diff --git a/package.json b/package.json index 4d7bfde..792f5c7 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@interval/server", "description": "Interval Server is the central server for Interval apps", - "version": "1.0.1", + "version": "1.1.0-dev.0+internal", "license": "MIT", "engines": { "node": ">=16" @@ -12,6 +12,7 @@ "bin": { "interval-server": "dist/src/entry.js" }, + "files": ["dist/"], "scripts": { "lint": "eslint src", "seed": "prisma db seed", @@ -22,7 +23,7 @@ "build": "rm -rf dist && prisma generate && yarn server:build && yarn wss:build && yarn client:build", "pkg": "./scripts/pkg.sh", "pub": "./scripts/pub.sh", - "start": "NODE_ENV=production ./dist/src/entry.js", + "start": "NODE_ENV=production node ./dist/src/entry.js", "docker": "./scripts/docker-build.sh", "client:build": "vite build", "client:clean": "rm -r node_modules/.vite", diff --git a/src/entry.ts b/src/entry.ts index 617c93c..438eb6e 100644 --- a/src/entry.ts +++ b/src/entry.ts @@ -165,7 +165,16 @@ program .name('interval-server') .description('Interval Server is the central server for Interval apps') .option('-v, --verbose', 'verbose output') - .addCommand(new Command('start').description('starts Interval Server')) + .addCommand( + new Command('start') + .description('starts Interval Server') + .addOption( + new Option( + '--internal-actions', + 'start interval internal actions along with the server' + ) + ) + ) .addCommand( new Command('db-init').addOption( new Option( @@ -177,34 +186,50 @@ program const [cmd, ...args] = program.parse().args async function main() { - if (cmd === 'start') { - const envVars = (await import('./env')).default - // start the internal web socket server - import('./wss/index') + switch (cmd) { + case 'start': { + const envVars = (await import('./env')).default + // start the internal web socket server + import('./wss/index') - const app = express() + const app = express() - const mainAppServer = (await import('./server/index')).default + const mainAppServer = (await import('./server/index')).default - app.use(mainAppServer) + app.use(mainAppServer) - const server = http.createServer(app) + const server = http.createServer(app) - const wss = new WebSocketServer({ server, path: '/websocket' }) - const { setupWebSocketServer } = await import('./wss/wss') - setupWebSocketServer(wss) + const wss = new WebSocketServer({ server, path: '/websocket' }) + const { setupWebSocketServer } = await import('./wss/wss') + setupWebSocketServer(wss) - server.listen(Number(envVars.PORT), () => { - logger.info( - `πŸ“‘ Interval Server listening at http://localhost:${envVars.PORT}` - ) - }) - } else if (cmd === 'db-init') { - logger.info('Initializing a database...') - initDb({ skipCreate: args.includes('--skip-create') }).catch(() => { - logger.error(`Failed to initialize database.`) - process.exit(1) - }) + server.listen(Number(envVars.PORT), () => { + logger.info( + `πŸ“‘ Interval Server listening at http://localhost:${envVars.PORT}` + ) + + if (args.includes('--internal-actions')) { + import('./interval').catch(err => { + logger.error('Failed starting internal actions:', err) + }) + } + }) + + break + } + case 'internal': { + await import('./interval') + break + } + case 'db-init': { + logger.info('Initializing a database...') + initDb({ skipCreate: args.includes('--skip-create') }).catch(() => { + logger.error('Failed to initialize database.') + process.exit(1) + }) + break + } } } diff --git a/src/env.ts b/src/env.ts index 629d219..1ac15de 100644 --- a/src/env.ts +++ b/src/env.ts @@ -16,6 +16,9 @@ const schema = z.object({ WSS_API_SECRET: z.string(), AUTH_COOKIE_SECRET: z.string(), + // for internal interval host + INTERVAL_KEY: z.string().optional(), + GIT_COMMIT: z.string().optional(), PORT: z.string().optional().default('3000'), diff --git a/src/interval/helpers/findUsers.ts b/src/interval/helpers/findUsers.ts new file mode 100644 index 0000000..b754a6f --- /dev/null +++ b/src/interval/helpers/findUsers.ts @@ -0,0 +1,21 @@ +import prisma from '~/server/prisma' +export default async function findUsers(query: string) { + return prisma.user.findMany({ + where: { + OR: [ + { + firstName: { + mode: 'insensitive', + contains: query, + }, + }, + { + lastName: { + mode: 'insensitive', + contains: query, + }, + }, + ], + }, + }) +} diff --git a/src/interval/helpers/orgRowMenuItems.ts b/src/interval/helpers/orgRowMenuItems.ts new file mode 100644 index 0000000..4bf6837 --- /dev/null +++ b/src/interval/helpers/orgRowMenuItems.ts @@ -0,0 +1,45 @@ +import { MenuItem } from '@interval/sdk/dist/types' +import { Organization, OrganizationSSO } from '@prisma/client' + +export default function orgRowMenuItems( + row: Organization & { sso?: Pick | null } +): MenuItem[] { + return [ + { + label: 'Browse app structure', + action: 'organizations/app_structure', + params: { org: row.slug }, + }, + { + label: 'Change slug', + action: 'organizations/change_slug', + params: { org: row.slug }, + }, + { + label: 'Enable SSO', + action: 'organizations/create_org_sso', + params: { org: row.slug }, + }, + { + label: 'Disable SSO', + action: 'organizations/disable_org_sso', + params: { org: row.slug }, + disabled: row.sso === null, + }, + { + label: 'Toggle feature flag', + action: 'organizations/org_feature_flag', + params: { org: row.slug }, + }, + { + label: 'Transfer owner', + action: 'organizations/transfer_ownership', + params: { org: row.slug }, + }, + { + label: 'Create transaction history export', + action: 'organizations/create_transaction_history_export', + params: { org: row.slug }, + }, + ] +} diff --git a/src/interval/helpers/renderUserResult.ts b/src/interval/helpers/renderUserResult.ts new file mode 100644 index 0000000..5809d6a --- /dev/null +++ b/src/interval/helpers/renderUserResult.ts @@ -0,0 +1,8 @@ +import { User } from '@prisma/client' + +export default function renderUserResult(user: User) { + return { + label: [user.firstName, user.lastName].join(' '), + description: user.email, + } +} diff --git a/src/interval/helpers/requireOrg.ts b/src/interval/helpers/requireOrg.ts new file mode 100644 index 0000000..08f0eda --- /dev/null +++ b/src/interval/helpers/requireOrg.ts @@ -0,0 +1,21 @@ +import { ctx } from '@interval/sdk' +import prisma from '~/server/prisma' +import selectOrganization from './selectOrganization' + +export default async function requireOrg(paramName = 'org') { + if (paramName in ctx.params) { + const org = await prisma.organization.findFirst({ + where: { slug: String(ctx.params[paramName]) }, + include: { + sso: true, + environments: true, + }, + }) + + if (!org) throw new Error('Org not found') + + return org + } + + return selectOrganization() +} diff --git a/src/interval/helpers/requireParam.ts b/src/interval/helpers/requireParam.ts new file mode 100644 index 0000000..5a7f3c8 --- /dev/null +++ b/src/interval/helpers/requireParam.ts @@ -0,0 +1,9 @@ +import { ctx } from '@interval/sdk' + +export default function requireParam(key: string) { + if (ctx.params[key] === undefined) { + throw new Error(`Missing required param: ${key}`) + } + + return String(ctx.params[key]) +} diff --git a/src/interval/helpers/searchForOrganization.ts b/src/interval/helpers/searchForOrganization.ts new file mode 100644 index 0000000..51550f3 --- /dev/null +++ b/src/interval/helpers/searchForOrganization.ts @@ -0,0 +1,40 @@ +import { io } from '@interval/sdk' +import { Prisma } from '@prisma/client' + +import prisma from '~/server/prisma' + +export default async function searchForOrganization() { + return io.search>( + 'Select organization', + { + async onSearch(query) { + return prisma.organization.findMany({ + where: { + OR: [ + { + name: { + search: query, + mode: 'insensitive', + }, + }, + { + name: { + contains: query, + mode: 'insensitive', + }, + }, + ], + }, + include: { + owner: true, + }, + }) + }, + renderResult: org => ({ + value: org.id, + label: org.name, + description: `Owner: ${org.owner.firstName} ${org.owner.lastName} (${org.owner.email}), Slug: ${org.slug}`, + }), + } + ) +} diff --git a/src/interval/helpers/selectOrganization.ts b/src/interval/helpers/selectOrganization.ts new file mode 100644 index 0000000..39c2ae5 --- /dev/null +++ b/src/interval/helpers/selectOrganization.ts @@ -0,0 +1,22 @@ +import prisma from '~/server/prisma' +import searchForOrganization from './searchForOrganization' + +export default async function selectOrganization() { + const selected = await searchForOrganization() + + const org = await prisma.organization.findUnique({ + where: { + id: selected.id, + }, + include: { + sso: true, + environments: true, + }, + }) + + if (!org) { + throw new Error("Organization doesn't exist?") + } + + return org +} diff --git a/src/interval/helpers/selectUser.ts b/src/interval/helpers/selectUser.ts new file mode 100644 index 0000000..d156fee --- /dev/null +++ b/src/interval/helpers/selectUser.ts @@ -0,0 +1,10 @@ +import { io } from '@interval/sdk' +import findUsers from './findUsers' +import renderUserResult from './renderUserResult' + +export default function selectUser() { + return io.search('Select a user', { + onSearch: async q => await findUsers(q), + renderResult: renderUserResult, + }) +} diff --git a/src/interval/helpers/staticQuery.ts b/src/interval/helpers/staticQuery.ts new file mode 100644 index 0000000..73ba08c --- /dev/null +++ b/src/interval/helpers/staticQuery.ts @@ -0,0 +1,9 @@ +// Don't use this with any query strings that aren't static + +import { io } from '@interval/sdk' +import prisma from '~/server/prisma' + +export default async function staticQuery(query: string) { + const rows: Record[] = await prisma.$queryRawUnsafe(query) + await io.display.table('Rows', { data: rows }) +} diff --git a/src/interval/index.ts b/src/interval/index.ts new file mode 100644 index 0000000..4cf10ec --- /dev/null +++ b/src/interval/index.ts @@ -0,0 +1,17 @@ +import path from 'path' +import { Interval } from '@interval/sdk' +import env from '../env' + +if (!env.INTERVAL_KEY) { + throw new Error( + 'Environment variable INTERVAL_KEY required for internal actions.' + ) +} + +const interval = new Interval({ + endpoint: `${env.APP_URL}/websocket`, + apiKey: env.INTERVAL_KEY, + routesDirectory: path.resolve(__dirname, 'routes'), +}) + +interval.listen() diff --git a/src/interval/routes/organizations/app_structure.ts b/src/interval/routes/organizations/app_structure.ts new file mode 100644 index 0000000..b795223 --- /dev/null +++ b/src/interval/routes/organizations/app_structure.ts @@ -0,0 +1,165 @@ +import { getName } from '~/utils/actions' +import { ctx, io, Action } from '@interval/sdk' +import prisma from '~/server/prisma' +import { + getAllActionGroups, + getAllActions, + reconstructActionGroups, +} from '~/server/utils/actions' +import { actionScheduleToDescriptiveString } from '~/utils/actionSchedule' +import relativeTime from '~/utils/date' +import requireOrg from '../../helpers/requireOrg' + +export default new Action({ + unlisted: false, + handler: async () => { + const org = await requireOrg() + + const prodEnv = org.environments.find(env => env.slug === null) + + if (!prodEnv) { + throw new Error( + `Could not find production environment for organization ${org.name}` + ) + } + + const slugPrefix = ctx.params.slugPrefix + ? String(ctx.params.slugPrefix) + : undefined + + const [actions, actionGroups] = await Promise.all([ + getAllActions({ + organizationId: org.id, + organizationEnvironmentId: prodEnv.id, + developerId: null, + }), + getAllActionGroups({ + organizationId: org.id, + organizationEnvironmentId: prodEnv.id, + developerId: null, + }), + ]) + + const onlineActions = actions.filter( + act => + act.hostInstances.some(hi => hi.status === 'ONLINE') || + act.httpHosts.some(hh => hh.status === 'ONLINE') + ) + + const router = reconstructActionGroups({ + slugPrefix, + actionGroups: Array.from(actionGroups.values()), + actions: onlineActions, + canConfigureActions: true, + mode: 'live', + }) + + const items: { + name: string + description?: string + slug: string + status?: string + permissions?: string + schedule?: string + transactionCount?: number + lastRun?: string + }[] = [] + + for (const group of router.groups) { + items.push({ + name: `πŸ“ ${group.name}`, + slug: group.slug, + }) + } + + const currentLevelActions = router.actions.filter(a => + slugPrefix ? a.slug.startsWith(slugPrefix) : true + ) + + const transactions = await prisma.transaction.findMany({ + where: { + action: { + id: { + in: currentLevelActions.map(a => a.id), + }, + }, + }, + orderBy: { + createdAt: 'desc', + }, + }) + + if (slugPrefix) { + items.push({ + name: 'πŸ“ ../', + slug: '', + }) + } + + for (const action of currentLevelActions) { + const actionTransactions = transactions.filter( + tx => tx.actionId === action.id + ) + + const mostRecent = actionTransactions.length + ? actionTransactions[0] + : null + + items.push({ + name: getName(action), + slug: action.slug, + status: + action.hostInstances.find(hi => hi.status === 'ONLINE') || + action.httpHosts.find(hi => hi.status === 'ONLINE') + ? 'Online' + : 'Offline', + transactionCount: actionTransactions.length, + lastRun: mostRecent ? relativeTime(mostRecent.createdAt) : '', + description: action.description ?? '', + permissions: action.metadata?.availability ?? '', + schedule: action.schedules + ?.map(actionScheduleToDescriptiveString) + .join(' + '), + }) + } + + const tableTitle = [ + 'Dashboard', + ...router.groupBreadcrumbs.map(g => g.name), + ].join(' > ') + + await io.group( + [ + io.display.table(tableTitle, { + data: items, + columns: [ + { + label: 'Name', + renderCell: row => ({ + label: row.name, + action: row.name.includes('πŸ“') + ? 'organizations/app_structure' + : undefined, + params: { org: org.slug, slugPrefix: row.slug }, + }), + }, + 'slug', + 'description', + 'transactionCount', + 'lastRun', + 'status', + 'permissions', + 'schedule', + ], + }), + ], + { + continueButton: { + label: 'Done', + }, + } + ) + }, + name: 'View app structure', + description: 'View groups & actions for an organization', +}) diff --git a/src/interval/routes/organizations/change_slug.ts b/src/interval/routes/organizations/change_slug.ts new file mode 100644 index 0000000..4a9e88f --- /dev/null +++ b/src/interval/routes/organizations/change_slug.ts @@ -0,0 +1,60 @@ +import { io, Action } from '@interval/sdk' +import prisma from '~/server/prisma' +import { dashboardL1Paths } from '~/server/utils/routes' +import { isOrgSlugValid } from '~/utils/validate' +import requireOrg from '../../helpers/requireOrg' + +export default new Action({ + unlisted: true, + handler: async () => { + const org = await requireOrg() + + let newSlug: string = org.slug + let exists = false + + do { + newSlug = await io.input.text('New slug', { + defaultValue: newSlug, + helpText: !isOrgSlugValid(newSlug) + ? "Sorry, that slug isn't valid." + : exists + ? 'Sorry, that slug is taken, please select another.' + : undefined, + }) + + exists = + !!(await prisma.organization.findUnique({ + where: { + slug: newSlug, + }, + })) || dashboardL1Paths.has(newSlug) + } while (exists || !isOrgSlugValid(newSlug)) + + const confirmed = await io.confirm( + `Are you sure you want to change the slug for ${org.name} from "${org.slug}" to "${newSlug}"?` + ) + + if (!confirmed) { + return { + Status: 'Not confirmed, nothing changed.', + } + } + + const newOrg = await prisma.organization.update({ + where: { + id: org.id, + }, + data: { + slug: newSlug, + }, + }) + + return { + Status: 'Slug changed', + 'Previous slug': org.slug, + 'New slug': newOrg.slug, + } + }, + name: 'Change slug', + description: 'Changes the slug for an organization', +}) diff --git a/src/interval/routes/organizations/create_org.ts b/src/interval/routes/organizations/create_org.ts new file mode 100644 index 0000000..7cdf7ba --- /dev/null +++ b/src/interval/routes/organizations/create_org.ts @@ -0,0 +1,53 @@ +import { io, Action } from '@interval/sdk' +import prisma from '~/server/prisma' +import { createOrganization } from '~/server/utils/organizations' +import { isOrgSlugValid } from '~/utils/validate' +import findUsers from '../../helpers/findUsers' +import renderUserResult from '../../helpers/renderUserResult' + +export default new Action({ + unlisted: false, + handler: async () => { + const initialUsers = await findUsers('') + const [selectedOwner, name, initialSlug] = await io.group([ + io.search('Select owner', { + initialResults: initialUsers, + onSearch: async q => findUsers(q), + renderResult: renderUserResult, + }), + io.input.text('Organization name'), + io.input.text('Organization slug'), + ]) + + let slug: string | undefined + let exists = false + + do { + if (slug) { + slug = await io.input.text('Organization slug', { + helpText: `${ + exists + ? 'Sorry, that slug already exists' + : 'Please enter a valid slug' + }. You entered "${slug}".`, + }) + } else { + slug = initialSlug + } + + exists = !!(await prisma.organization.findFirst({ + where: { + slug, + }, + })) + } while (!isOrgSlugValid(slug) || exists) + + return createOrganization({ + name, + slug, + ownerId: selectedOwner.id, + }) + }, + name: 'Create organization', + description: 'Create a new organization with an arbitrary owner.', +}) diff --git a/src/interval/routes/organizations/create_org_sso.ts b/src/interval/routes/organizations/create_org_sso.ts new file mode 100644 index 0000000..2b2749d --- /dev/null +++ b/src/interval/routes/organizations/create_org_sso.ts @@ -0,0 +1,158 @@ +import { io, Action } from '@interval/sdk' +import prisma from '~/server/prisma' +import { getPrimaryRole, EXPOSED_ROLES } from '~/utils/permissions' +import { userAccessPermissionToString } from '~/utils/text' +import { isEmail } from '~/utils/validate' +import requireOrg from '../../helpers/requireOrg' + +export default new Action({ + name: 'Enable SSO', + description: + 'Creates necessary record for an organization that wants to enable SSO (either full or "fake").', + handler: async () => { + const identityConfirmed = await io.confirmIdentity( + 'Please confirm you are able to enable SSO for an organization.' + ) + + if (!identityConfirmed) { + throw new Error('Identity not confirmed.') + } + + const org = await requireOrg() + + if (org.sso) { + const shouldContinue = await io.confirm( + `${org.name} already has SSO enabled, do you want to continue?`, + { + helpText: + 'Cancelling will not alter the existing configuration, continuing will allow you to update it.', + } + ) + + if (!shouldContinue) return 'Canceled' + } + + const [, domain] = await io.group([ + io.display.markdown(` + As part of setting up SSO for an organization, we'll let **anyone** with an email the organization's domain access their Interval dashboard. + + For example, for Interval employees, we have the email domain interval.com. + `), + io.input + .text('Enter the email domain', { + defaultValue: org.sso?.domain, + placeholder: 'company.com', + helpText: 'Everything after, but not including, the @ symbol.', + }) + .validate(pendingDomain => { + if ( + pendingDomain.includes('@') || + !isEmail(`example@${pendingDomain}`) + ) { + return 'Please enter a valid domain. Note: this is not a complete URL.' + } + }), + ]) + + // TODO: Optional values? + // https://github.com/interval/interval2/issues/85 + const [, hasWorkOSId] = await io.group([ + io.display.markdown(` + We have two kinds of SSO: + - The "real" SSO works through WorkOS. This is for companies using Interval whose employees login through Okta, Google SAML, etc. + - The "fake" SSO skips WorkOS and just allows anyone at the email domain to access the company's Interval dashboard. + + If you aren't sure which type of SSO to enable, reach out to our point of contact at the organization. + + `), + io.input.boolean(`Do you want to enable "real" SSO for ${org.name}`, { + helpText: + 'Enable this if they use Okta, SAML, etc. Leave this unchecked otherwise.', + }), + ]) + + let workosOrganizationId: string | undefined + + if (hasWorkOSId) { + ;[, workosOrganizationId] = await io.group([ + io.display.markdown(` + In order to setup "real" SSO through WorkOS, we need to create an ID in their dashboard. + + 1. Go to the [WorkOS dashboard](https://dashboard.workos.com). + 2. Make sure you have chosen the **Production** environment in their sidebar. + 3. Click "Organizations." + 4. Click "Create Organization." + + Use the following values for their Create Organization form: + - Organization: **${org.name}** + - User Email Domains: **${domain}** + - Authentication settings: **Allow authentication only from Organization email domains** + + 5. Submit the form by clicking "Create Organization." + 6. ${org.name} should now be visible in the WorkOS Organizations list. Click ${org.name}. + 7. Copy the Organization ID. This is a long string like "org_XYZ..." + + Once you have Organization ID on your clipboard, paste it below... + + `), + io.input.text('WorkOS Organization ID', { + defaultValue: org.sso?.workosOrganizationId ?? undefined, + }), + ]) + } + + const defaultRole = + getPrimaryRole(org.sso?.defaultUserPermissions ?? ['ACTION_RUNNER']) ?? + 'ACTION_RUNNER' + + const [role] = await io.group( + [ + io.select.single('Default user access role', { + defaultValue: { + value: defaultRole, + label: userAccessPermissionToString(defaultRole), + }, + helpText: + 'When new users are added to the organization through SSO, this is the role they will be assigned in Interval. This may be overridden by WorkOS SSO roles, if present.', + options: EXPOSED_ROLES.map(role => ({ + value: role, + label: userAccessPermissionToString(role), + })), + }), + ], + { + continueButton: { + label: org.sso ? 'Update SSO settings' : `Enable SSO for ${org.name}`, + }, + } + ) + + const createdSSO = await prisma.organizationSSO.upsert({ + where: { + organizationId: org.id, + }, + update: { + domain, + workosOrganizationId, + defaultUserPermissions: [role.value], + }, + create: { + organizationId: org.id, + domain, + workosOrganizationId, + defaultUserPermissions: [role.value], + }, + }) + + return { + 'Change type': org.sso ? 'SSO updated' : 'SSO enabled', + 'SSO Type': hasWorkOSId ? 'Real (WorkOS)' : 'Fake (no WorkOS)', + 'Interval Organization ID': org.id, + 'Interval OrganizationSSO ID': createdSSO.id, + 'WorkOS Organization ID': workosOrganizationId, + 'Default user permissions': JSON.stringify( + createdSSO.defaultUserPermissions + ), + } + }, +}) diff --git a/src/interval/routes/organizations/disable_org_sso.ts b/src/interval/routes/organizations/disable_org_sso.ts new file mode 100644 index 0000000..6051e99 --- /dev/null +++ b/src/interval/routes/organizations/disable_org_sso.ts @@ -0,0 +1,74 @@ +import { io, Action } from '@interval/sdk' +import prisma from '~/server/prisma' +import requireOrg from '../../helpers/requireOrg' + +export default new Action({ + name: 'Disable SSO', + description: 'Disables SSO for an organization.', + handler: async () => { + const identityConfirmed = await io.confirmIdentity( + 'Please confirm you are able to enable SSO for an organization.' + ) + + if (!identityConfirmed) { + throw new Error('Identity not confirmed.') + } + + const org = await requireOrg() + + if (!org.sso) { + throw new Error('No SSO enabled for this organization.') + } + + await io.display.metadata('SSO configuration', { + layout: 'grid', + data: [ + { + label: 'Interval Organization ID', + value: org.sso.organizationId, + }, + { + label: 'Domain', + value: org.sso.domain, + }, + { + label: 'WorkOS Organization Id', + value: org.sso.workosOrganizationId, + }, + { + label: 'Default user permissions', + value: JSON.stringify(org.sso.defaultUserPermissions), + }, + ], + }) + + const confirmed = await io.confirm( + 'Are you sure you want to delete SSO for this organization?', + { + helpText: 'This cannot be undone, it will have to be enabled again.', + } + ) + + if (!confirmed) { + return 'Not confirmed, nothing to do' + } + + await prisma.organizationSSO.delete({ + where: { + id: org.sso.id, + }, + }) + + return { + 'Deleted SSO Type': org.sso.workosOrganizationId + ? 'Real (WorkOS)' + : 'Fake (no WorkOS)', + 'Interval Organization ID': org.id, + 'Interval OrganizationSSO ID': org.sso.id, + 'WorkOS Organization ID': org.sso.workosOrganizationId, + 'Default user permissions': JSON.stringify( + org.sso.defaultUserPermissions + ), + } + }, +}) diff --git a/src/interval/routes/organizations/index.ts b/src/interval/routes/organizations/index.ts new file mode 100644 index 0000000..3d985ba --- /dev/null +++ b/src/interval/routes/organizations/index.ts @@ -0,0 +1,128 @@ +import { io, Layout, Page } from '@interval/sdk' +import { Prisma } from '@prisma/client' +import prisma from '~/server/prisma' +import orgRowMenuItems from '../../helpers/orgRowMenuItems' + +export default new Page({ + name: 'Organizations', + handler: async () => { + const thisSunday = new Date() + thisSunday.setDate(thisSunday.getDate() - thisSunday.getDay()) + thisSunday.setHours(0, 0, 0) + + const [totalOrgs, newOrgs] = await Promise.all([ + prisma.organization.count(), + prisma.organization.count({ + where: { + createdAt: { + gte: thisSunday, + }, + }, + }), + ]) + + return new Layout({ + title: 'Organizations', + menuItems: [ + { + label: 'Create organization', + action: 'organizations/create_org', + }, + ], + children: [ + io.display.metadata('', { + data: [ + { + label: 'Total organizations', + value: totalOrgs, + }, + { + label: 'New this week', + value: newOrgs, + }, + ], + }), + io.display.table('All organizations', { + getData: async state => { + const where: Prisma.OrganizationWhereInput | undefined = + state.queryTerm + ? { + OR: [ + { + id: state.queryTerm, + }, + { + name: { + contains: state.queryTerm, + mode: 'insensitive', + }, + }, + { + slug: { + contains: state.queryTerm, + mode: 'insensitive', + }, + }, + ], + } + : undefined + + const orderBy = + state.sortColumn && state.sortDirection + ? { + [state.sortColumn]: state.sortDirection, + } + : undefined + + const [data, totalRecords] = await Promise.all([ + prisma.organization.findMany({ + where, + orderBy, + include: { + sso: { + select: { + id: true, + workosOrganizationId: true, + }, + }, + scimDirectory: { + select: { + id: true, + workosDirectoryId: true, + }, + }, + }, + take: state.pageSize, + skip: state.offset, + }), + prisma.organization.count({ + where, + }), + ]) + + return { data, totalRecords } + }, + columns: [ + { + label: 'Name', + accessorKey: 'name', + }, + { + label: 'Slug', + accessorKey: 'slug', + }, + { + label: 'SSO', + renderCell: row => row.sso?.workosOrganizationId, + }, + { + label: 'SCIM Directory', + renderCell: row => row.scimDirectory?.workosDirectoryId, + }, + ], + rowMenuItems: orgRowMenuItems, + }), + ], + }) + }, +}) diff --git a/src/interval/routes/organizations/org_feature_flag.ts b/src/interval/routes/organizations/org_feature_flag.ts new file mode 100644 index 0000000..34c8ed5 --- /dev/null +++ b/src/interval/routes/organizations/org_feature_flag.ts @@ -0,0 +1,61 @@ +import { io, Action } from '@interval/sdk' +import prisma from '~/server/prisma' +import { UX_FEATURE_FLAGS } from '~/utils/featureFlags' +import { featureFlagToString } from '~/utils/text' +import requireOrg from '../../helpers/requireOrg' + +export default new Action({ + unlisted: false, + handler: async () => { + const organization = await requireOrg() + + const organizationFeatureFlags = + await prisma.organizationFeatureFlag.findMany({ + where: { + organizationId: organization.id, + }, + }) + + const options = UX_FEATURE_FLAGS.map(flag => ({ + label: featureFlagToString(flag), + value: flag, + })) + + const defaultValue = options.filter(opt => + organizationFeatureFlags.some( + off => off.flag === opt.value && off.enabled + ) + ) + + const selected = await io.select.multiple('Enabled feature flags', { + options, + defaultValue, + }) + + const flags = {} + for (const flag of UX_FEATURE_FLAGS) { + const enabled = selected.some(opt => opt.value === flag) + await prisma.organizationFeatureFlag.upsert({ + where: { + organizationId_flag: { + organizationId: organization.id, + flag, + }, + }, + update: { + enabled, + }, + create: { + organizationId: organization.id, + flag, + enabled, + }, + }) + flags[flag] = enabled ? 'βœ…' : '❌' + } + + return flags + }, + name: 'Toggle feature flag', + description: 'Toggles features flag for an organization.', +}) diff --git a/src/interval/routes/organizations/transfer_ownership.ts b/src/interval/routes/organizations/transfer_ownership.ts new file mode 100644 index 0000000..e72bc7f --- /dev/null +++ b/src/interval/routes/organizations/transfer_ownership.ts @@ -0,0 +1,62 @@ +import { io, Action } from '@interval/sdk' +import prisma from '~/server/prisma' +import findUsers from '../../helpers/findUsers' +import renderUserResult from '../../helpers/renderUserResult' +import requireOrg from '../../helpers/requireOrg' + +export default new Action({ + unlisted: false, + handler: async () => { + const organization = await requireOrg() + + function notCurrentOwner(user: { id: string }): boolean { + return user.id !== organization.ownerId + } + + const initialUsers = await findUsers('') + const newOwner = await io.search('Select new owner', { + initialResults: initialUsers.filter(notCurrentOwner), + onSearch: async q => (await findUsers(q)).filter(notCurrentOwner), + renderResult: renderUserResult, + }) + + if ( + !(await io.confirm( + `Are you sure you want to transfer ${organization.name} to ${newOwner.firstName} ${newOwner.lastName}?` + )) + ) { + return + } + + const org = await prisma.organization.update({ + where: { + id: organization.id, + }, + data: { + owner: { connect: { id: newOwner.id } }, + }, + }) + + await prisma.userOrganizationAccess.upsert({ + where: { + userId_organizationId: { + userId: newOwner.id, + organizationId: org.id, + }, + }, + update: { + permissions: ['ADMIN'], + }, + create: { + user: { connect: { id: newOwner.id } }, + organization: { connect: { id: org.id } }, + permissions: ['ADMIN'], + }, + }) + + return org + }, + name: 'Transfer owner', + description: + 'Transfer ownership of an existing organization to another user.', +}) diff --git a/src/interval/routes/toggle_global_feature_flags.ts b/src/interval/routes/toggle_global_feature_flags.ts new file mode 100644 index 0000000..54e36b9 --- /dev/null +++ b/src/interval/routes/toggle_global_feature_flags.ts @@ -0,0 +1,90 @@ +import { io, Action } from '@interval/sdk' +import { ConfiguredFeatureFlag } from '@prisma/client' +import prisma from '~/server/prisma' +import { FEATURE_FLAG_DEFAULTS } from '~/utils/featureFlags' +import { featureFlagToString } from '~/utils/text' + +export default new Action({ + handler: async () => { + const flags: Partial> = { + ...FEATURE_FLAG_DEFAULTS, + } + { + const globalFlags = await prisma.globalFeatureFlag.findMany() + for (const ff of globalFlags) { + flags[ff.flag] = ff.enabled + } + } + const enabledFlags = flags as Record + + const options = Object.keys(enabledFlags).map(flag => ({ + label: featureFlagToString(flag as ConfiguredFeatureFlag), + value: flag, + })) + options.sort((a, b) => { + if (a.label < b.label) return -1 + if (a.label > b.label) return 1 + return 0 + }) + + const selected = await io.select.multiple('Enabled feature flags', { + options, + defaultValue: options.filter( + o => enabledFlags[o.value as ConfiguredFeatureFlag] + ), + }) + + const changes: Partial> = {} + for (const opt of selected) { + const flag = opt.value as ConfiguredFeatureFlag + if (!enabledFlags[flag]) { + changes[flag] = true + } + } + + for (const [flag, enabled] of Object.entries(enabledFlags)) { + if (enabled && !selected.find(ff => ff.value === flag)) { + changes[flag] = false + } + } + + if (Object.keys(changes).length === 0) return + + const confirmed = await io.confirm( + 'Are you sure you want to change the following flags?', + { + helpText: Object.entries(changes) + .map(([flag, enabled]) => `${enabled ? 'βœ…' : '❌'} ${flag}`) + .join('; '), + } + ) + + if (!confirmed) return + + for (const [flag, enabled] of Object.entries(changes)) { + await prisma.globalFeatureFlag.upsert({ + create: { + flag: flag as ConfiguredFeatureFlag, + enabled, + }, + update: { + enabled, + }, + where: { + flag: flag as ConfiguredFeatureFlag, + }, + }) + } + + const ret = {} + for (const [flag, enabled] of Object.entries(enabledFlags)) { + ret[flag] = enabled ? 'βœ…' : '❌' + } + for (const [flag, enabled] of Object.entries(changes)) { + ret[flag] = enabled ? 'βœ…' : '❌' + } + + return ret + }, + name: 'πŸ‡ΊπŸ‡³ Toggle global feature flags', +}) diff --git a/src/interval/routes/users/delete.ts b/src/interval/routes/users/delete.ts new file mode 100644 index 0000000..69b4c58 --- /dev/null +++ b/src/interval/routes/users/delete.ts @@ -0,0 +1,137 @@ +import { io, Action } from '@interval/sdk' +import { PrismaPromise, User } from '@prisma/client' +import prisma from '~/server/prisma' +import findUsers from '../../helpers/findUsers' +import renderUserResult from '../../helpers/renderUserResult' +import requireParam from '../../helpers/requireParam' + +export default new Action({ + unlisted: true, + handler: async () => { + const userId = requireParam('id') + + const user = await prisma.user.findUnique({ + where: { id: userId }, + include: { + organizations: true, + apiKeys: true, + }, + }) + + if (!user) { + throw new Error('User not found') + } + + await io.group([ + io.display.markdown(` + ## Delete this user + ${user.firstName} ${user.lastName} (${user.email}) + + ## This action completely deletes a user from our database + ${ + user.organizations.length > 0 + ? `- This user owns ${user.organizations.length} organizations. You'll be prompted to reassign ownership of those orgs on the next screen.` + : '' + } + ${ + user.apiKeys.length > 0 + ? `- This user has ${user.apiKeys.length} API keys. You'll be prompted to reassign ownership of those keys on the next screen.` + : '' + } + `), + ]) + + const userList = await prisma.user.findMany({ + where: { id: { not: userId } }, + }) + + const notUser = + (except: { id: string | number }) => (user: { id: string }) => + user.id !== String(except.id) + + async function pickNewOwner( + objectId: string, + objectName: string, + user: User + ) { + const [, newOwner] = await io.group([ + io.display.markdown( + `## ${user.firstName} ${user.lastName} owns ${objectName} (${objectId})` + ), + io.search(`Select a new owner for ${objectName}`, { + initialResults: userList.filter(notUser(user)), + onSearch: async q => { + const l = await findUsers(q) + return l.filter(notUser(user)) + }, + renderResult: renderUserResult, + }), + ]) + return newOwner + } + + const updateOperations: PrismaPromise[] = [] + + for (const org of user.organizations) { + const newOwner = await pickNewOwner(org.id, org.name, user) + + updateOperations.push( + prisma.organization.update({ + where: { id: org.id }, + data: { owner: { connect: { id: String(newOwner.id) } } }, + }) + ) + } + + for (const key of user.apiKeys) { + const newOwner = await pickNewOwner( + key.id, + `${key.label || ''} (${key.usageEnvironment})`, + user + ) + + updateOperations.push( + prisma.apiKey.update({ + where: { id: key.id }, + data: { user: { connect: { id: String(newOwner.id) } } }, + }) + ) + } + + const isConfirmed = await io.confirm( + `Are you sure you want to delete ${user.firstName} ${user.lastName}?` + ) + + if (!isConfirmed) return + + await prisma.$transaction(updateOperations) + + await prisma.userAccessGroupMembership.deleteMany({ + where: { userOrganizationAccess: { userId } }, + }) + + const toDelete = await prisma.userOrganizationAccess.findMany({ + where: { userId }, + }) + + await prisma.userOrganizationAccess.deleteMany({ + where: { userId }, + }) + + await prisma.userPasswordResetToken.deleteMany({ + where: { userId }, + }) + + await prisma.userSession.deleteMany({ + where: { userId }, + }) + + await prisma.transaction.deleteMany({ + where: { ownerId: userId }, + }) + + await prisma.user.delete({ where: { id: userId } }) + }, + name: 'Delete', + description: 'Deletes a user account and all linked records.', +}) diff --git a/src/interval/routes/users/index.ts b/src/interval/routes/users/index.ts new file mode 100644 index 0000000..121c27d --- /dev/null +++ b/src/interval/routes/users/index.ts @@ -0,0 +1,134 @@ +import { io, Layout, Page } from '@interval/sdk' +import { Prisma } from '@prisma/client' +import prisma from '~/server/prisma' + +export default new Page({ + name: 'Users', + handler: async () => { + const thisSunday = new Date() + thisSunday.setDate(thisSunday.getDate() - thisSunday.getDay()) + thisSunday.setHours(0, 0, 0) + + const [totalUsers, newUsers] = await Promise.all([ + prisma.user.count(), + prisma.user.count({ + where: { + createdAt: { + gte: thisSunday, + }, + }, + }), + ]) + + return new Layout({ + title: 'Users', + children: [ + io.display.metadata('', { + data: [ + { + label: 'Total users', + value: totalUsers, + }, + { + label: 'New this week', + value: newUsers, + }, + ], + }), + io.display.table('All users', { + columns: [ + { + label: 'id', + accessorKey: 'id', + renderCell: row => ({ + label: row.id, + action: 'users/show', + params: { + email: row.email, + }, + }), + }, + 'firstName', + 'email', + 'defaultNotificationMethod', + 'timeZoneName', + 'createdAt', + 'updatedAt', + 'deletedAt', + ], + getData: async state => { + const where: Prisma.UserWhereInput | undefined = state.queryTerm + ? { + OR: [ + { + id: state.queryTerm, + }, + { + firstName: { + contains: state.queryTerm, + mode: 'insensitive', + }, + }, + { + lastName: { + contains: state.queryTerm, + mode: 'insensitive', + }, + }, + { + email: { + contains: state.queryTerm, + mode: 'insensitive', + }, + }, + ], + } + : undefined + + const orderBy = + state.sortColumn && state.sortDirection + ? { + [state.sortColumn]: state.sortDirection, + } + : undefined + + const [data, totalRecords] = await Promise.all([ + prisma.user.findMany({ + where, + orderBy, + take: state.pageSize, + skip: state.offset, + }), + prisma.user.count({ + where, + }), + ]) + + return { data, totalRecords } + }, + rowMenuItems: row => [ + { + label: 'Disable and drop connections', + route: 'dump_wss_state/clients/disable_user_drop_connections', + params: { id: row.id }, + theme: 'danger', + disabled: !!row.deletedAt, + }, + { + label: 'Reenable user account', + route: 'users/reenable', + params: { id: row.id }, + disabled: !row.deletedAt, + }, + { + label: 'Permanently delete', + route: 'users/delete', + params: { id: row.id }, + theme: 'danger', + }, + ], + }), + ], + }) + }, +}) diff --git a/src/interval/routes/users/reenable.ts b/src/interval/routes/users/reenable.ts new file mode 100644 index 0000000..2391f10 --- /dev/null +++ b/src/interval/routes/users/reenable.ts @@ -0,0 +1,49 @@ +import { io, ctx, Action } from '@interval/sdk' +import prisma from '~/server/prisma' + +export default new Action({ + name: 'Reenable user account', + unlisted: true, + handler: async () => { + if (!ctx.params.id || typeof ctx.params.id !== 'string') { + throw new Error('Invalid user id param.') + } + + const identityConfirmed = await io.confirmIdentity( + 'Confirm you can do this' + ) + + if (!identityConfirmed) { + throw new Error('Unauthorized.') + } + + const user = await prisma.user.findUniqueOrThrow({ + where: { + id: ctx.params.id, + }, + }) + + if (!user.deletedAt) { + throw new Error('User is already enabled.') + } + + const confirmed = await io.confirm( + `Reenable user ${user.firstName} ${user.lastName} (${user.email})?` + ) + + if (!confirmed) { + return 'Not confirmed, nothing to do.' + } + + const { password, mfaId, ...rest } = await prisma.user.update({ + where: { + id: user.id, + }, + data: { + deletedAt: null, + }, + }) + + return rest + }, +}) diff --git a/src/interval/routes/users/show.ts b/src/interval/routes/users/show.ts new file mode 100644 index 0000000..3fb652f --- /dev/null +++ b/src/interval/routes/users/show.ts @@ -0,0 +1,205 @@ +import { ctx, io, Layout, Page } from '@interval/sdk' +import { z } from 'zod' +import prisma from '~/server/prisma' +import { EXPOSED_ROLES } from '~/utils/permissions' +import requireOrg from '../../helpers/requireOrg' +import selectUser from '../../helpers/selectUser' + +async function getUserAndOrg() { + const organization = await requireOrg('organizationSlug') + + const { userId } = z + .object({ + userId: z.string().optional(), + }) + .parse(ctx.params) + + const user = userId + ? await prisma.user.findUniqueOrThrow({ where: { id: userId } }) + : await selectUser() + + return { + user, + organization, + } +} + +const userPage = new Page({ + name: 'User', + unlisted: true, + actions: { + remove_from_org: { + name: 'Remove from organization', + handler: async () => { + const { user, organization } = await getUserAndOrg() + + let isConfirmed = await io.confirmIdentity( + `Before removing a user, please confirm your identity.` + ) + + if (!isConfirmed) return 'Identity not confirmed' + + isConfirmed = await io.confirm( + `Remove ${user.firstName} ${user.lastName} from ${organization.name}?` + ) + + if (!isConfirmed) return 'Cancelled' + + await prisma.userOrganizationAccess.delete({ + where: { + userId_organizationId: { + userId: user.id, + organizationId: organization.id, + }, + }, + }) + + return { + organizationId: organization.id, + userId: user.id, + } + }, + }, + add_to_org: { + name: 'Add to organization', + handler: async () => { + const { user, organization } = await getUserAndOrg() + + const role = await io.select.single('Role', { + options: EXPOSED_ROLES, + }) + + const isConfirmed = await io.confirmIdentity( + `Before adding a user, please confirm your identity.` + ) + + if (!isConfirmed) return 'Identity not confirmed' + + const createdRow = await prisma.userOrganizationAccess.create({ + data: { + userId: user.id, + organizationId: organization.id, + permissions: [role], + }, + }) + + return createdRow as any + }, + }, + }, + handler: async () => { + const user = await prisma.user.findUniqueOrThrow({ + where: { + email: String(ctx.params.email), + }, + include: { + userOrganizationAccess: { + include: { + organization: true, + }, + }, + organizations: true, + transactions: { + include: { + action: true, + }, + orderBy: { + createdAt: 'desc', + }, + }, + }, + }) + + const basicInfo = Object.entries(user) + .map(([k, v]) => ({ + label: String(k), + value: v, + })) + .filter( + row => + !['password'].includes(row.label) && typeof row.value !== 'object' + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ) as any + + return new Layout({ + title: `${user.firstName} ${user.lastName}`, + menuItems: [ + { + label: 'Add to organization', + action: 'users/show/add_to_org', + params: { + userId: user.id, + }, + }, + { + label: 'Disable and drop connections', + route: 'dump_wss_state/clients/disable_user_drop_connections', + params: { id: user.id }, + theme: 'danger', + disabled: !!user.deletedAt, + }, + { + label: 'Reenable user account', + route: 'users/reenable', + params: { id: user.id }, + disabled: !user.deletedAt, + }, + { + label: 'Permanently delete', + route: 'users/delete', + params: { id: user.id }, + theme: 'danger', + }, + ], + children: [ + io.display.metadata('Details', { + layout: 'list', + data: basicInfo, + }), + io.display.table(`Organizations ${user.firstName} belongs to`, { + data: user.userOrganizationAccess.map(oa => ({ + ...oa.organization, + role: oa.permissions.toString(), + })), + columns: ['id', 'name', 'slug', 'role'], + rowMenuItems: row => [ + { + label: 'Remove from org', + action: 'users/show/remove_from_org', + params: { + organizationSlug: row.slug, + userId: user.id, + }, + }, + ], + }), + io.display.table(`Organizations owned by ${user.firstName}`, { + data: user.organizations, + columns: ['id', 'name', 'slug'], + rowMenuItems: row => [ + { + label: 'Remove from org', + action: 'users/show/remove_from_org', + params: { + organizationSlug: row.slug, + userId: user.id, + }, + }, + ], + }), + io.display.table('Recent transactions', { + data: user.transactions.map(t => ({ + id: t.id, + actionSlug: t.action.slug, + actionName: t.action.name, + status: t.status, + createdAt: t.createdAt, + completedAt: t.completedAt, + })), + }), + ], + }) + }, +}) + +export default userPage diff --git a/src/interval/routes/workos/replay_event.ts b/src/interval/routes/workos/replay_event.ts new file mode 100644 index 0000000..3feb218 --- /dev/null +++ b/src/interval/routes/workos/replay_event.ts @@ -0,0 +1,40 @@ +import { Action, io } from '@interval/sdk' +import { Webhook } from '@workos-inc/node' +import { handleWebhook } from '~/server/api/workosWebhooks' + +import { workos } from '~/server/auth' + +export default new Action({ + name: 'Replay event', + handler: async () => { + if (!workos) { + throw new Error( + 'WorkOS credentials not found, WorkOS integration not enabled.' + ) + } + + const confirmed = await io.confirmIdentity('Who are you') + if (!confirmed) throw new Error('Unauthorized') + + const eventId = await io.input.text('Event ID you want to replay') + + const prevId = await io.input.text( + 'ID for the previous event in the queue, for validation' + ) + + const events = await workos.events.listEvents({ + limit: 1, + after: prevId, + }) + + const event = events.data[0] + + await io.display.object('Event', { + data: event, + }) + + if (event.id !== eventId) throw new Error('IDs do not match') + + await handleWebhook(event as unknown as Webhook) + }, +})