Skip to content

Commit

Permalink
Merge pull request #4941 from FlowFuse/allow-team-trial-creation
Browse files Browse the repository at this point in the history
Allow trial team to be manually created
  • Loading branch information
knolleary authored Dec 19, 2024
2 parents 449189a + 420c7ae commit cf9bfbe
Show file tree
Hide file tree
Showing 14 changed files with 336 additions and 84 deletions.
7 changes: 7 additions & 0 deletions forge/db/models/Team.js
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,13 @@ module.exports = {
}]
})
},
countForUser: async function (User) {
return M.TeamMember.count({
where: {
UserId: User.id
}
})
},
forUser: async function (User) {
return M.TeamMember.findAll({
where: {
Expand Down
2 changes: 1 addition & 1 deletion forge/lib/userTeam.js
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ async function completeUserSignup (app, user) {
// return
}
// only create a personal team if no other teams exist
if (!((await app.db.models.Team.forUser(user)).length)) {
if ((await app.db.models.Team.countForUser(user)) === 0) {
let teamTypeId = app.settings.get('user:team:auto-create:teamType')

if (!teamTypeId) {
Expand Down
73 changes: 69 additions & 4 deletions forge/routes/api/team.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
const crypto = require('crypto')

const { Op } = require('sequelize')

const { Roles } = require('../../lib/roles')
Expand Down Expand Up @@ -395,7 +397,8 @@ module.exports = async function (app) {
properties: {
name: { type: 'string' },
type: { type: 'string' },
slug: { type: 'string' }
slug: { type: 'string' },
trial: { type: 'boolean' }
}
},
response: {
Expand Down Expand Up @@ -429,6 +432,34 @@ module.exports = async function (app) {
return
}

let trialMode = false
if (app.license.active() && app.billing && request.body.trial) {
// Check this user is allowed to create a trial team of this type.
// Rules:
// 1. teamType must have trial mode enabled
const teamTrialActive = await teamType.getProperty('trial.active', false)
if (!teamTrialActive) {
reply.code(400).send({ code: 'invalid_request', error: 'trial mode not available' })
return
}
// 2. user must have no existing teams
const existingTeamCount = await app.db.models.Team.countForUser(request.session.User)
if (existingTeamCount > 0) {
reply.code(400).send({ code: 'invalid_request', error: 'trial mode not available' })
return
}
// 3. user must be < 1 week old
const delta = Date.now() - request.session.User.createdAt.getTime()
if (delta > 1000 * 60 * 60 * 24 * 7) {
reply.code(400).send({ code: 'invalid_request', error: 'trial mode not available' })
return
}
trialMode = true
} else if (request.body.trial) {
reply.code(400).send({ code: 'invalid_request', error: 'trial mode not available' })
return
}

let team

try {
Expand All @@ -444,9 +475,43 @@ module.exports = async function (app) {
const teamView = app.db.views.Team.team(team)

if (app.license.active() && app.billing) {
const session = await app.billing.createSubscriptionSession(team, request.session.User)
app.auditLog.Team.billing.session.created(request.session.User, null, team, session)
teamView.billingURL = session.url
if (trialMode) {
await app.billing.setupTrialTeamSubscription(team, request.session.User)
// In trial mode, we may also auto-create their first application and instance
if (app.settings.get('user:team:auto-create:instanceType')) {
const instanceTypeId = app.settings.get('user:team:auto-create:instanceType')
const instanceType = await app.db.models.ProjectType.byId(instanceTypeId)
const instanceStack = await instanceType?.getDefaultStack() || (await instanceType.getProjectStacks())?.[0]
const instanceTemplate = await app.db.models.ProjectTemplate.findOne({ where: { active: true } })
if (!instanceType) {
app.log.warn(`Unable to create Trial Instance in team ${team.hashid}: Instance type with id ${instanceTypeId} from 'user:team:auto-create:instanceType' not found`)
} else if (!instanceStack) {
app.log.warn(`Unable to create Trial Instance in team ${team.hashid}: Unable to find a stack for use with instance type ${instanceTypeId}`)
} else if (!instanceTemplate) {
app.log.warn(`Unable to create Trial Instance in team ${team.hashid}: Unable to find the default instance template`)
} else {
const applicationName = `${request.session.User.name}'s Application`
const application = await app.db.models.Application.create({
name: applicationName.charAt(0).toUpperCase() + applicationName.slice(1),
TeamId: team.id
})
await app.auditLog.Team.application.created(request.session.User, null, team, application)
await app.auditLog.Application.application.created(request.session.User, null, application)

const safeTeamName = team.name.toLowerCase().replace(/[\W_]/g, '-')
const safeUserName = request.session.User.username.toLowerCase().replace(/[\W_]/g, '-')

const instanceProperties = {
name: `${safeTeamName}-${safeUserName}-${crypto.randomBytes(4).toString('hex')}`
}
await app.db.controllers.Project.create(team, application, request.session.User, instanceType, instanceStack, instanceTemplate, instanceProperties)
}
}
} else {
const session = await app.billing.createSubscriptionSession(team, request.session.User)
app.auditLog.Team.billing.session.created(request.session.User, null, team, session)
teamView.billingURL = session.url
}
}

reply.send(teamView)
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/components/PageHeader.vue
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
<router-link :to="homeLink">
<img class="ff-logo" src="/ff-logo--wordmark--dark.png">
</router-link>
<global-search v-if="hasAMinimumTeamRoleOf(Roles.Viewer)" />
<global-search v-if="teams.length > 0 && hasAMinimumTeamRoleOf(Roles.Viewer)" />
<!-- Mobile: Toggle(User Options) -->
<div class="flex ff-mobile-navigation-right" data-el="mobile-nav-right">
<NotificationsButton class="ff-header--mobile-notificationstoggle" :class="{'active': mobileTeamSelectionOpen}" />
Expand Down
34 changes: 34 additions & 0 deletions frontend/src/components/TeamTypeSelection.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
<template>
<div>
<div class="flex gap-6 justify-center relative z-10 flex-wrap">
<team-type-tile v-for="type in types" :key="type.id" :team-type="type" />
</div>
</div>
</template>

<script>
import { mapState } from 'vuex'
import teamTypesApi from '../api/teamTypes.js'
import TeamTypeTile from './TeamTypeTile.vue'
export default {
name: 'TeamTypeSelection',
components: {
'team-type-tile': TeamTypeTile
},
data () {
return {
types: []
}
},
computed: {
...mapState('account', ['user'])
},
async created () {
const { types } = await teamTypesApi.getTeamTypes()
this.types = types.sort((a, b) => a.order - b.order)
}
}
</script>
143 changes: 143 additions & 0 deletions frontend/src/components/TeamTypeTile.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
<template>
<div class="ff-team-type-tile">
<div v-if="isTrial(teamType)" class="trial-ribbon">
<label>{{ teamType.properties.trial.duration }} Days Free Trial</label>
</div>
<div class="space-y-2">
<img class="w-36 m-auto" src="../images/empty-states/application-instances.png">
<div class="flex flex-col gap-5">
<div class="flex justify-between items-center text-2xl">
<label class="font-medium">{{ teamType.name }}</label>
<span v-if="pricing?.value">
{{ pricing.value }} <span class="text-xs">/{{ pricing.interval }}</span>
</span>
</div>
<ff-markdown-viewer :content="teamType.description" />
</div>
</div>
<template v-if="enableCTA">
<ff-button v-if="isTrial(teamType)" kind="primary" class="w-full mt-4" :to="`/team/create?teamType=${teamType.id}`">
Start Free Trial
</ff-button>
<ff-button v-else-if="isManualBilling(teamType)" kind="secondary" class="w-full mt-4" @click="contactFF(teamType)">
Contact FlowFuse
</ff-button>
<ff-button v-else kind="secondary" class="w-full mt-4" :to="`/team/create?teamType=${teamType.id}`">
Select
</ff-button>
</template>
</div>
</template>

<script>
import { mapState } from 'vuex'
import BillingAPI from '../api/billing.js'
import Alerts from '../services/alerts.js'
export default {
name: 'TeamTypeTile',
props: {
teamType: {
type: Object,
required: true
},
enableCTA: {
type: Boolean,
default: true
}
},
computed: {
...mapState('account', ['user', 'teams']),
pricing: function () {
const billing = this.teamType.properties?.billing.description?.split('/')
const price = {}
if (typeof billing !== 'undefined') {
price.value = billing[0]
price.interval = billing[1]
}
return price
}
},
methods: {
isTrial (teamType) {
// A team trial can be offered if:
// 1. User has no other teams
return this.teams.length === 0 &&
// 2. User is less than a week old
(Date.now() - (new Date(this.user.createdAt)).getTime()) < 1000 * 60 * 60 * 24 * 7 &&
// 3. TeamType meta data says so
teamType.properties?.trial?.active
},
isManualBilling (teamType) {
return teamType.properties?.billing?.requireContact
},
contactFF (teamType) {
BillingAPI.sendTeamTypeContact(this.user, teamType, 'Create Team').then(() => {
Alerts.emit('A message has been sent to our team. We will contact you soon regarding your request. In the mean time, feel free to choose another plan to get started.', 'confirmation', 20000)
}).catch(err => {
Alerts.emit('Something went wrong with the request. Please try again or contact support for help.', 'warning', 15000)
console.error('Failed to submit hubspot form: ', err)
})
}
}
}
</script>
<style lang="scss">
.ff-team-type-tile {
position: relative;
border-radius: 6px;
border: 2px solid $ff-grey-300;
background: white;
box-shadow: 0px 4px 8px 0px rgba(0, 0, 0, 0.25);
padding: 24px;
width: 100%;
max-width: 300px;
display: flex;
flex-direction: column;
justify-content: space-between;
ul {
list-style: disc;
padding-left: 16px;
li {
margin-bottom: 6px;
}
}
}
.trial-ribbon {
--ribbon-overlap: 8px;
display: flex;
justify-content: center;
align-items: center;
height: 30px;
left: calc(-1 * var(--ribbon-overlap));
line-height: 1.3;
width: calc(100% + 2 * var(--ribbon-overlap));
margin: 0;
position: absolute;
top: 8px;
color: white;
// text-shadow: 0 1px 1px #111;
border-top: 1px solid #363636;
border-bottom: 1px solid #202020;
background: $ff-red-500;
// background: linear-gradient($ff-red-500 0%, $ff-red-700 100%);
border-radius: 2px 2px 0 0;
box-shadow: 0 1px 2px rgba(0,0,0,0.3);
}
.trial-ribbon::before,
.trial-ribbon::after {
content: '';
display: block;
width: 0;
height: 0;
position: absolute;
bottom: calc((-2 * var(--ribbon-overlap)) - 1px);
z-index: -10;
border: var(--ribbon-overlap) solid;
border-color: $ff-red-900 transparent transparent transparent;
}
.trial-ribbon::before {left: 0;}
.trial-ribbon::after {right: 0;}
</style>
19 changes: 13 additions & 6 deletions frontend/src/pages/Home.vue
Original file line number Diff line number Diff line change
@@ -1,13 +1,20 @@
<template>
<main>
<main class="min-h-full">
<template v-if="pending">
<div class="flex-grow flex flex-col items-center justify-center mx-auto text-gray-600 opacity-50">
<FlowFuseLogo class="max-w-xs mx-auto w-full" />
</div>
</template>
<template v-else-if="teams.length === 0">
<NoTeamsUser />
</template>
<ff-page v-else-if="teams.length === 0">
<template #header>
<ff-page-header title="Choose Team Type">
<template #context>
Choose which team type you'd like to get started with.
</template>
</ff-page-header>
</template>
<TeamTypeSelection />
</ff-page>
</main>
</template>

Expand All @@ -17,13 +24,13 @@ import { mapGetters, mapState } from 'vuex'
import FlowFuseLogo from '../components/Logo.vue'
import NoTeamsUser from './account/NoTeamsUser.vue'
import TeamTypeSelection from '../components/TeamTypeSelection.vue'
export default {
name: 'HomePage',
components: {
FlowFuseLogo,
NoTeamsUser
TeamTypeSelection
},
data () {
return {
Expand Down
44 changes: 0 additions & 44 deletions frontend/src/pages/account/NoTeamsUser.vue

This file was deleted.

Loading

0 comments on commit cf9bfbe

Please sign in to comment.