Explorar o código

One workshop per self-hosted install, and a leaner admin overview (#391)

Bernt Christian Egeland hai 2 semanas
pai
achega
1c078d2016
Modificáronse 43 ficheiros con 561 adicións e 192 borrados
  1. 13 8
      e2e/specs/auth/sign-up.spec.ts
  2. 89 0
      e2e/specs/auth/single-workshop.spec.ts
  3. 13 14
      e2e/specs/auth/tenancy.spec.ts
  4. 12 9
      e2e/specs/settings/marine.spec.ts
  5. 16 17
      e2e/specs/tech/api.spec.ts
  6. 13 0
      e2e/support/cloud.ts
  7. 111 0
      e2e/support/db.ts
  8. 1 2
      messages/de/admin.json
  9. 5 0
      messages/de/onboarding.json
  10. 1 2
      messages/en/admin.json
  11. 5 0
      messages/en/onboarding.json
  12. 1 2
      messages/es/admin.json
  13. 5 0
      messages/es/onboarding.json
  14. 1 2
      messages/fr/admin.json
  15. 5 0
      messages/fr/onboarding.json
  16. 1 2
      messages/it/admin.json
  17. 5 0
      messages/it/onboarding.json
  18. 1 2
      messages/lt/admin.json
  19. 5 0
      messages/lt/onboarding.json
  20. 1 2
      messages/nb/admin.json
  21. 5 0
      messages/nb/onboarding.json
  22. 1 2
      messages/nl/admin.json
  23. 5 0
      messages/nl/onboarding.json
  24. 1 2
      messages/pl/admin.json
  25. 5 0
      messages/pl/onboarding.json
  26. 1 2
      messages/pt-BR/admin.json
  27. 5 0
      messages/pt-BR/onboarding.json
  28. 1 2
      messages/ru/admin.json
  29. 5 0
      messages/ru/onboarding.json
  30. 1 2
      messages/tr/admin.json
  31. 5 0
      messages/tr/onboarding.json
  32. 0 36
      src/__tests__/features/admin/plan-pricing.test.ts
  33. 67 1
      src/__tests__/lib/features.test.ts
  34. 0 1
      src/app/(authenticated)/admin/page.tsx
  35. 12 1
      src/app/(public)/onboarding/page.tsx
  36. 5 19
      src/features/admin/Actions/getAdminStats.ts
  37. 2 9
      src/features/admin/Components/admin-overview.tsx
  38. 6 0
      src/features/onboarding/Actions/createOnboardingOrg.ts
  39. 45 0
      src/features/onboarding/Components/SingleWorkshopNotice.tsx
  40. 17 14
      src/features/team/Actions/createNewOrganization.ts
  41. 18 14
      src/features/team/Actions/teamActions.ts
  42. 50 2
      src/lib/features.ts
  43. 0 23
      src/lib/plan-pricing.ts

+ 13 - 8
e2e/specs/auth/sign-up.spec.ts

@@ -2,8 +2,13 @@ import { expect, type Page, test } from '@playwright/test'
 import { linkIn, waitForMail } from '../../support/mail'
 
 /**
- * How people arrive: a stranger who opens a workshop of their own, and a
- * colleague who was invited into one that exists.
+ * How people arrive: a stranger, and a colleague who was invited into the
+ * workshop that exists.
+ *
+ * A self-hosted install runs one workshop, so the stranger does not open a
+ * second one: they are told to ask its owner for an invitation
+ * (single-workshop.spec.ts holds the rest of that). The colleague's path is
+ * the one that matters on such an install.
  *
  * Both start signed out. The colleague follows the link out of the invitation
  * mail itself, caught by the harness's mail sink, so the address the app
@@ -22,16 +27,16 @@ async function fillSignUp(page: Page, name: string, email: string, password: str
   await page.getByRole('button', { name: /create account/i }).click()
 }
 
-test('a new account is walked through onboarding into a workshop of its own', async ({ page }) => {
+test('a new account reaches onboarding, where the install says it has its workshop', async ({
+  page,
+}) => {
   await page.goto('/auth/sign-up')
   await fillSignUp(page, 'E2E Founder', `e2e-founder-${stamp}@example.com`, `E2e-pass-${stamp}`)
 
   await page.waitForURL(/\/onboarding/, { timeout: 30_000 })
-  await page.locator('#workshopName').fill(`E2E Garage ${stamp}`)
-  await page.locator('form button[type="submit"]').click()
-
-  await page.waitForURL((url) => !/^\/(auth|onboarding)/.test(url.pathname), { timeout: 30_000 })
-  await expect(page.getByText(`E2E Garage ${stamp}`).first()).toBeVisible()
+  await expect(
+    page.getByRole('heading', { name: 'This installation already has its workshop' })
+  ).toBeVisible()
 })
 
 test('an invited colleague signs up straight into the workshop', async ({ page, browser }) => {

+ 89 - 0
e2e/specs/auth/single-workshop.spec.ts

@@ -0,0 +1,89 @@
+import { expect, test } from '@playwright/test'
+import { deletePersonWithEmail, organizationCount, personWithEmail } from '../../support/db'
+import { settle } from '../../support/hydration'
+
+/**
+ * A self-hosted install runs one workshop.
+ *
+ * The seeded workshop is that one. A stranger who signs up afterwards is not
+ * opening a second workshop: they are told to ask its owner for an
+ * invitation, and the owner's own "Add New Company" is refused with the
+ * pointer to the licence. Both are proved on the count of workshops in the
+ * database, not only on what the screen says. Lifting the limit with a
+ * licence is a signed token from torqvoice.com, which this harness cannot
+ * mint, so that half lives in src/__tests__/lib/features.test.ts.
+ */
+
+test.describe.configure({ mode: 'serial' })
+
+const stamp = Date.now()
+const STRANGER = `e2e-stranger-${stamp}@example.com`
+const PASSWORD = `E2e-pass-${stamp}`
+
+let workshops = 0
+
+test.beforeAll(async () => {
+  workshops = await organizationCount()
+  expect(workshops, 'the install already has its workshop').toBeGreaterThanOrEqual(1)
+})
+
+test.afterAll(async () => {
+  await deletePersonWithEmail(STRANGER)
+})
+
+test.describe('a stranger signing up', () => {
+  test.use({ storageState: { cookies: [], origins: [] } })
+
+  test('is told to ask for an invitation instead of opening a workshop', async ({ page }) => {
+    await page.goto('/auth/sign-up')
+    await settle(page)
+    await page.locator('#name').fill('E2E Stranger')
+    await page.locator('#email').fill(STRANGER)
+    await page.locator('#password').fill(PASSWORD)
+    await page.locator('#terms').click()
+    await page.locator('form button[type="submit"]').click()
+    await page.waitForURL(/\/onboarding/, { timeout: 30_000 })
+
+    await expect(
+      page.getByRole('heading', { name: 'This installation already has its workshop' })
+    ).toBeVisible()
+    await expect(page.getByText(STRANGER)).toBeVisible()
+    await expect(page.locator('#workshopName')).toHaveCount(0)
+
+    // The account exists, so an invitation can reach it; the workshop count did not move.
+    expect((await personWithEmail(STRANGER)).users).toBe(1)
+    expect(await organizationCount()).toBe(workshops)
+
+    await page.getByRole('button', { name: 'Sign out' }).click()
+    await page.waitForURL(/\/auth\/sign-in/, { timeout: 15_000 })
+  })
+
+  test('is sent back to the notice on every visit until invited', async ({ page }) => {
+    await page.goto('/auth/sign-in')
+    await settle(page)
+    await page.locator('#email').fill(STRANGER)
+    await page.locator('#password').fill(PASSWORD)
+    await page.getByRole('button', { name: 'Sign In', exact: true }).click()
+    await page.waitForURL(/\/onboarding/, { timeout: 30_000 })
+    await expect(
+      page.getByRole('heading', { name: 'This installation already has its workshop' })
+    ).toBeVisible()
+  })
+})
+
+test.describe('the owner', () => {
+  test('cannot add a second company without a licence', async ({ page }) => {
+    await page.goto('/settings/company')
+    await settle(page)
+    const dialog = page.getByRole('dialog', { name: 'Add New Company' })
+    await expect(async () => {
+      await page.getByRole('button', { name: 'Add New Company' }).click()
+      await expect(dialog).toBeVisible({ timeout: 2_000 })
+    }).toPass({ timeout: 30_000 })
+    await dialog.locator('#settings-org-name').fill(`E2E Second Garage ${stamp}`)
+    await dialog.getByRole('button', { name: 'Create Company' }).click()
+
+    await expect(page.getByText('This installation runs one workshop')).toBeVisible()
+    expect(await organizationCount()).toBe(workshops)
+  })
+})

+ 13 - 14
e2e/specs/auth/tenancy.spec.ts

@@ -11,6 +11,7 @@ import {
   organizationIdFor,
   seededTenantFixtures,
   type TenantFixtures,
+  plantWorkshop,
 } from '../../support/db'
 import { shareLink } from '../../support/work-order'
 
@@ -48,7 +49,7 @@ let sharedInvoice = ''
 /** A file that genuinely belongs to the first workshop's own job. */
 let theirFileUrl = ''
 
-/** Signs the outsider in, opening their workshop on the first run. */
+/** Signs the outsider in. */
 async function signInAsOutsider(page: Page) {
   await page.goto('/auth/sign-in')
   await page.locator('#email').fill(OUTSIDER)
@@ -79,19 +80,17 @@ test.beforeAll(async ({ browser }) => {
 })
 
 test.describe('a second workshop', () => {
-  test('is opened by a stranger signing up', async ({ page }) => {
-    await page.goto('/auth/sign-up')
-    await page.locator('#name').fill('E2E Outsider')
-    await page.locator('#email').fill(OUTSIDER)
-    await page.locator('#password').fill(PASSWORD)
-    await page.locator('#terms').click()
-    await page.getByRole('button', { name: /create account/i }).click()
-
-    await page.waitForURL(/\/onboarding/, { timeout: 30_000 })
-    await page.locator('#workshopName').fill(`E2E Outsider Garage ${stamp}`)
-    await page.locator('form button[type="submit"]').click()
-    await page.waitForURL((url) => !/^\/(auth|onboarding)/.test(url.pathname), { timeout: 30_000 })
-
+  test('exists beside the first, with its own owner signed in', async ({ page }) => {
+    // A self-hosted install opens one workshop and sends every later
+    // sign-up to ask for an invitation (single-workshop.spec.ts), so the
+    // outsider's workshop is planted rather than signed up.
+    await plantWorkshop({
+      name: 'E2E Outsider',
+      email: OUTSIDER,
+      password: PASSWORD,
+      workshopName: `E2E Outsider Garage ${stamp}`,
+    })
+    await signInAsOutsider(page)
     await expect(page.getByText(`E2E Outsider Garage ${stamp}`).first()).toBeVisible()
   })
 

+ 12 - 9
e2e/specs/settings/marine.spec.ts

@@ -1,10 +1,10 @@
 import { expect, type Locator, type Page, test } from '@playwright/test'
-import { completeOnboarding, signUpWithPassword } from '../../support/cloud'
+import { signInWithPassword } from '../../support/cloud'
 import {
   connectRegistry,
   customerIdNamed,
   disconnectRegistry,
-  organizationIdFor,
+  plantWorkshop,
   userIdFor,
   workshopSetting,
 } from '../../support/db'
@@ -27,7 +27,7 @@ import { newWorkOrder, saveWorkOrder, seededVehicleUrl } from '../../support/wor
  * about vessels.
  */
 
-// Signing up and onboarding a workshop in a hook takes longer than a test.
+// Planting and signing into a workshop in a hook takes longer than a test.
 test.describe.configure({ mode: 'serial', timeout: 180_000 })
 
 const stamp = Date.now()
@@ -53,18 +53,21 @@ let vesselUrl = ''
 test.use({ storageState: STATE })
 
 test.beforeAll(async ({ browser }) => {
-  const context = await browser.newContext({ storageState: { cookies: [], origins: [] } })
-  const page = await context.newPage()
-  await signUpWithPassword(page, {
+  // A workshop of its own, planted: a self-hosted install opens one workshop
+  // and would send a sign-up to ask for an invitation instead.
+  const planted = await plantWorkshop({
     name: 'E2E Marine Owner',
     email: EMAIL,
     password: `E2e-pass-${stamp}`,
+    workshopName: `E2E Marina ${stamp}`,
   })
-  await completeOnboarding(page, `E2E Marina ${stamp}`, { sampleData: false })
+  organizationId = planted.organizationId
+
+  const context = await browser.newContext({ storageState: { cookies: [], origins: [] } })
+  const page = await context.newPage()
+  await signInWithPassword(page, { email: EMAIL, password: `E2e-pass-${stamp}` })
   await context.storageState({ path: STATE })
   await context.close()
-
-  organizationId = await organizationIdFor(EMAIL)
 })
 
 test.afterAll(async () => {

+ 16 - 17
e2e/specs/tech/api.spec.ts

@@ -1,5 +1,11 @@
 import { expect, type APIRequestContext, type Page, test } from '@playwright/test'
-import { foreignServiceRecordId, organizationIdFor, seededTenantFixtures } from '../../support/db'
+import {
+  foreignServiceRecordId,
+  organizationIdFor,
+  plantJob,
+  plantWorkshop,
+  seededTenantFixtures,
+} from '../../support/db'
 import { settle } from '../../support/hydration'
 
 /**
@@ -89,23 +95,16 @@ test.beforeAll(async ({ browser, playwright, baseURL }) => {
   // A job in this workshop that will not be assigned to the new technician.
   someoneElsesJob = seeded.serviceRecordId
 
-  // A second workshop, for the cross-workshop refusals. Signing up gives it a
-  // few work orders of its own, which is what makes it a useful target.
-  const outsider = await browser.newContext({ storageState: { cookies: [], origins: [] } })
-  const outsiderPage = await outsider.newPage()
-  await outsiderPage.goto('/auth/sign-up')
-  await outsiderPage.locator('#name').fill('E2E Tech Outsider')
-  await outsiderPage.locator('#email').fill(OUTSIDER)
-  await outsiderPage.locator('#password').fill(OUTSIDER_PASSWORD)
-  await outsiderPage.locator('#terms').click()
-  await outsiderPage.getByRole('button', { name: /create account/i }).click()
-  await outsiderPage.waitForURL(/\/onboarding/, { timeout: 30_000 })
-  await outsiderPage.locator('#workshopName').fill(`E2E Tech Outsider Garage ${stamp}`)
-  await outsiderPage.locator('form button[type="submit"]').click()
-  await outsiderPage.waitForURL((url) => !/^\/(auth|onboarding)/.test(url.pathname), {
-    timeout: 30_000,
+  // A second workshop, for the cross-workshop refusals, planted with a job
+  // of its own: a self-hosted install opens one workshop, so a sign-up would
+  // be told to ask for an invitation instead of opening this one.
+  const outsider = await plantWorkshop({
+    name: 'E2E Tech Outsider',
+    email: OUTSIDER,
+    password: OUTSIDER_PASSWORD,
+    workshopName: `E2E Tech Outsider Garage ${stamp}`,
   })
-  await outsider.close()
+  await plantJob(outsider.organizationId, outsider.userId, `E2E Tech Outsider Job ${stamp}`)
 
   foreignJob = await foreignServiceRecordId(await organizationIdFor(OUTSIDER))
   expect(foreignJob).not.toBe(someoneElsesJob)

+ 13 - 0
e2e/support/cloud.ts

@@ -89,6 +89,19 @@ export async function signUpWithPassword(
   await page.waitForURL(/\/onboarding/, { timeout: 30_000 })
 }
 
+/** Signs in with a password, from the sign-in page, and waits to be inside the app. */
+export async function signInWithPassword(
+  page: Page,
+  person: { email: string; password: string }
+): Promise<void> {
+  await page.goto('/auth/sign-in')
+  await settle(page)
+  await page.locator('#email').fill(person.email)
+  await page.locator('#password').fill(person.password)
+  await page.getByRole('button', { name: 'Sign In', exact: true }).click()
+  await page.waitForURL((url) => !/^\/(auth|onboarding)/.test(url.pathname), { timeout: 30_000 })
+}
+
 /** Names the workshop and finishes onboarding, with or without the sample data. */
 export async function completeOnboarding(
   page: Page,

+ 111 - 0
e2e/support/db.ts

@@ -1,3 +1,4 @@
+import { randomBytes, scryptSync } from 'node:crypto'
 import { Client } from 'pg'
 
 /**
@@ -1109,3 +1110,113 @@ export async function deviceCountFor(email: string, needle: string): Promise<num
     return Number(result.rows[0].n)
   })
 }
+
+/**
+ * Matches better-auth's scrypt parameters, the same way the seed does, so a
+ * password written here is accepted by the sign-in form.
+ */
+function hashPassword(password: string): string {
+  const N = 16384
+  const r = 16
+  const p = 1
+  const salt = randomBytes(16).toString('hex')
+  const key = scryptSync(password.normalize('NFKC'), salt, 64, { N, r, p, maxmem: 128 * N * r * 2 })
+  return `${salt}:${key.toString('hex')}`
+}
+
+export interface PlantedWorkshop {
+  userId: string
+  organizationId: string
+}
+
+/**
+ * A second tenant, put straight into the database.
+ *
+ * A self-hosted install opens one workshop; every later sign-up is told to
+ * ask for an invitation. A spec that needs a second, separate workshop to
+ * prove isolation therefore cannot sign one up and has to plant it: a
+ * verified person with a password, and a workshop they own.
+ */
+export async function plantWorkshop(input: {
+  name: string
+  email: string
+  password: string
+  workshopName: string
+}): Promise<PlantedWorkshop> {
+  return withDb(async (db) => {
+    const user = await db.query<{ id: string }>(
+      `insert into users (id, name, email, "emailVerified", "termsAcceptedAt", "createdAt", "updatedAt")
+       values (md5(random()::text || clock_timestamp()::text), $1, $2, true, now(), now(), now())
+       returning id`,
+      [input.name, input.email.toLowerCase()]
+    )
+    const userId = user.rows[0].id
+    await db.query(
+      `insert into accounts (id, "accountId", "providerId", "userId", password, "createdAt", "updatedAt")
+       values (md5(random()::text || clock_timestamp()::text), $1, 'credential', $1, $2, now(), now())`,
+      [userId, hashPassword(input.password)]
+    )
+    const org = await db.query<{ id: string }>(
+      `insert into organizations (id, name, "createdAt", "updatedAt")
+       values (md5(random()::text || clock_timestamp()::text), $1, now(), now())
+       returning id`,
+      [input.workshopName]
+    )
+    const organizationId = org.rows[0].id
+    await db.query(
+      `insert into organization_members (id, role, "userId", "organizationId")
+       values (md5(random()::text || clock_timestamp()::text), 'owner', $1, $2)`,
+      [userId, organizationId]
+    )
+    return { userId, organizationId }
+  })
+}
+
+/** Removes a person and, through the cascade, their memberships and sessions. */
+export async function deletePersonWithEmail(email: string): Promise<void> {
+  await withDb((db) => db.query(`delete from users where lower(email) = lower($1)`, [email]))
+}
+
+/** How many workshops the install has. */
+export async function organizationCount(): Promise<number> {
+  return withDb(async (db) => {
+    const result = await db.query<{ count: string }>(
+      `select count(*)::text as count from organizations`
+    )
+    return Number(result.rows[0].count)
+  })
+}
+
+/**
+ * One customer, one vehicle and one work order in a workshop, for a spec
+ * that needs a job to point at. A planted workshop has none of the sample
+ * data onboarding would have given it.
+ */
+export async function plantJob(
+  organizationId: string,
+  userId: string,
+  title: string
+): Promise<{ serviceRecordId: string; vehicleId: string }> {
+  return withDb(async (db) => {
+    const customer = await db.query<{ id: string }>(
+      `insert into customers (id, name, "userId", "organizationId", "updatedAt")
+       values (md5(random()::text || clock_timestamp()::text), $1, $2, $3, now())
+       returning id`,
+      [`${title} customer`, userId, organizationId]
+    )
+    const vehicle = await db.query<{ id: string }>(
+      `insert into vehicles (id, make, model, year, "userId", "organizationId", "customerId", "updatedAt")
+       values (md5(random()::text || clock_timestamp()::text), 'E2E', $1, 2020, $2, $3, $4, now())
+       returning id`,
+      [title, userId, organizationId, customer.rows[0].id]
+    )
+    const vehicleId = vehicle.rows[0].id
+    const job = await db.query<{ id: string }>(
+      `insert into service_records (id, title, "vehicleId", "organizationId", "updatedAt")
+       values (md5(random()::text || clock_timestamp()::text), $1, $2, $3, now())
+       returning id`,
+      [title, vehicleId, organizationId]
+    )
+    return { serviceRecordId: job.rows[0].id, vehicleId }
+  })
+}

+ 1 - 2
messages/de/admin.json

@@ -24,8 +24,7 @@
   "overview": {
     "totalUsers": "Benutzer gesamt",
     "organizations": "Organisationen",
-    "activeSubscriptions": "Aktive Abonnements",
-    "monthlyRevenue": "Monatlicher Umsatz"
+    "activeSubscriptions": "Aktive Abonnements"
   },
   "users": {
     "searchPlaceholder": "Nach Name oder E-Mail suchen...",

+ 5 - 0
messages/de/onboarding.json

@@ -85,5 +85,10 @@
     "removeConfirmDescription": "Damit werden die bei der Einrichtung erstellten Beispielkunden, Fahrzeuge, Aufträge, das Angebot und die Inspektion gelöscht. Ihre eigenen Daten bleiben unberührt.",
     "removed": "Beispieldaten entfernt",
     "removeFailed": "Beispieldaten konnten nicht entfernt werden"
+  },
+  "singleWorkshop": {
+    "title": "Diese Installation hat bereits ihre Werkstatt",
+    "description": "Torqvoice betreibt hier eine Werkstatt, und sie ist bereits eingerichtet. Um darin zu arbeiten, bitten Sie den Inhaber, {email} unter Einstellungen, dann Team einzuladen.",
+    "signOut": "Abmelden"
   }
 }

+ 1 - 2
messages/en/admin.json

@@ -24,8 +24,7 @@
   "overview": {
     "totalUsers": "Total Users",
     "organizations": "Organizations",
-    "activeSubscriptions": "Active Subscriptions",
-    "monthlyRevenue": "Monthly Revenue"
+    "activeSubscriptions": "Active Subscriptions"
   },
   "users": {
     "searchPlaceholder": "Search by name or email...",

+ 5 - 0
messages/en/onboarding.json

@@ -85,5 +85,10 @@
     "removeConfirmDescription": "This deletes the sample customers, vehicles, work orders, quote and inspection created during setup. Your own data is not affected.",
     "removed": "Sample data removed",
     "removeFailed": "Could not remove sample data"
+  },
+  "singleWorkshop": {
+    "title": "This installation already has its workshop",
+    "description": "Torqvoice here runs one workshop, and it has been set up. To work in it, ask its owner to invite {email} from Settings, then Team.",
+    "signOut": "Sign out"
   }
 }

+ 1 - 2
messages/es/admin.json

@@ -24,8 +24,7 @@
   "overview": {
     "totalUsers": "Usuarios totales",
     "organizations": "Organizaciones",
-    "activeSubscriptions": "Suscripciones activas",
-    "monthlyRevenue": "Ingresos mensuales"
+    "activeSubscriptions": "Suscripciones activas"
   },
   "users": {
     "searchPlaceholder": "Buscar por nombre o correo electrónico...",

+ 5 - 0
messages/es/onboarding.json

@@ -85,5 +85,10 @@
     "removeConfirmDescription": "Esto elimina los clientes, vehículos, órdenes de trabajo, el presupuesto y la inspección de ejemplo creados durante la configuración. Sus propios datos no se ven afectados.",
     "removed": "Datos de ejemplo eliminados",
     "removeFailed": "No se pudieron eliminar los datos de ejemplo"
+  },
+  "singleWorkshop": {
+    "title": "Esta instalación ya tiene su taller",
+    "description": "Torqvoice aquí gestiona un taller, y ya está configurado. Para trabajar en él, pide a su propietario que invite a {email} desde Configuración, luego Equipo.",
+    "signOut": "Cerrar sesión"
   }
 }

+ 1 - 2
messages/fr/admin.json

@@ -24,8 +24,7 @@
   "overview": {
     "totalUsers": "Utilisateurs totaux",
     "organizations": "Organisations",
-    "activeSubscriptions": "Abonnements actifs",
-    "monthlyRevenue": "Revenus mensuels"
+    "activeSubscriptions": "Abonnements actifs"
   },
   "users": {
     "searchPlaceholder": "Rechercher par nom ou e-mail...",

+ 5 - 0
messages/fr/onboarding.json

@@ -85,5 +85,10 @@
     "removeConfirmDescription": "Cette action supprime les clients, véhicules, ordres de réparation, le devis et l'inspection d'exemple créés lors de la configuration. Vos propres données ne sont pas concernées.",
     "removed": "Données d'exemple supprimées",
     "removeFailed": "Impossible de supprimer les données d'exemple"
+  },
+  "singleWorkshop": {
+    "title": "Cette installation a déjà son atelier",
+    "description": "Torqvoice gère ici un seul atelier, et il est déjà configuré. Pour y travailler, demandez à son propriétaire d'inviter {email} depuis Paramètres, puis Équipe.",
+    "signOut": "Se déconnecter"
   }
 }

+ 1 - 2
messages/it/admin.json

@@ -24,8 +24,7 @@
   "overview": {
     "totalUsers": "Utenti totali",
     "organizations": "Organizzazioni",
-    "activeSubscriptions": "Abbonamenti attivi",
-    "monthlyRevenue": "Fatturato mensile"
+    "activeSubscriptions": "Abbonamenti attivi"
   },
   "users": {
     "searchPlaceholder": "Cerca per nome o email...",

+ 5 - 0
messages/it/onboarding.json

@@ -85,5 +85,10 @@
     "removeConfirmDescription": "Verranno eliminati i clienti, i veicoli, gli ordini di lavoro, il preventivo e l'ispezione di esempio creati durante la configurazione. I Suoi dati non vengono toccati.",
     "removed": "Dati di esempio rimossi",
     "removeFailed": "Impossibile rimuovere i dati di esempio"
+  },
+  "singleWorkshop": {
+    "title": "Questa installazione ha già la sua officina",
+    "description": "Torqvoice qui gestisce un'unica officina, già configurata. Per lavorarci, chiedi al proprietario di invitare {email} da Impostazioni, poi Team.",
+    "signOut": "Esci"
   }
 }

+ 1 - 2
messages/lt/admin.json

@@ -24,8 +24,7 @@
   "overview": {
     "totalUsers": "Iš viso naudotojų",
     "organizations": "Organizacijos",
-    "activeSubscriptions": "Aktyvios prenumeratos",
-    "monthlyRevenue": "Mėnesio pajamos"
+    "activeSubscriptions": "Aktyvios prenumeratos"
   },
   "users": {
     "searchPlaceholder": "Ieškoti pagal vardą arba el. paštą...",

+ 5 - 0
messages/lt/onboarding.json

@@ -85,5 +85,10 @@
     "removeConfirmDescription": "Bus ištrinti pavyzdiniai klientai, automobiliai, darbo užsakymai, pasiūlymas ir apžiūra, sukurti per pradinį nustatymą. Jūsų pačių duomenys nebus paliesti.",
     "removed": "Pavyzdiniai duomenys pašalinti",
     "removeFailed": "Nepavyko pašalinti pavyzdinių duomenų"
+  },
+  "singleWorkshop": {
+    "title": "Ši instaliacija jau turi savo dirbtuvę",
+    "description": "Torqvoice čia aptarnauja vieną dirbtuvę, ir ji jau sukurta. Norėdami joje dirbti, paprašykite savininko pakviesti {email} per Nustatymai, tada Komanda.",
+    "signOut": "Atsijungti"
   }
 }

+ 1 - 2
messages/nb/admin.json

@@ -24,8 +24,7 @@
   "overview": {
     "totalUsers": "Totalt antall brukere",
     "organizations": "Organisasjoner",
-    "activeSubscriptions": "Aktive abonnementer",
-    "monthlyRevenue": "Månedlig inntekt"
+    "activeSubscriptions": "Aktive abonnementer"
   },
   "users": {
     "searchPlaceholder": "Søk etter navn eller e-post...",

+ 5 - 0
messages/nb/onboarding.json

@@ -85,5 +85,10 @@
     "removeConfirmDescription": "Dette sletter eksempelkundene, kjøretøyene, arbeidsordrene, tilbudet og kontrollen som ble opprettet under oppsettet. Dine egne data berøres ikke.",
     "removed": "Eksempeldata fjernet",
     "removeFailed": "Kunne ikke fjerne eksempeldata"
+  },
+  "singleWorkshop": {
+    "title": "Denne installasjonen har allerede verkstedet sitt",
+    "description": "Torqvoice kjører ett verksted her, og det er satt opp. For å jobbe i det, be eieren invitere {email} fra Innstillinger, deretter Team.",
+    "signOut": "Logg ut"
   }
 }

+ 1 - 2
messages/nl/admin.json

@@ -24,8 +24,7 @@
   "overview": {
     "totalUsers": "Totaal aantal gebruikers",
     "organizations": "Organisaties",
-    "activeSubscriptions": "Actieve abonnementen",
-    "monthlyRevenue": "Maandelijkse omzet"
+    "activeSubscriptions": "Actieve abonnementen"
   },
   "users": {
     "searchPlaceholder": "Zoeken op naam of e-mail...",

+ 5 - 0
messages/nl/onboarding.json

@@ -85,5 +85,10 @@
     "removeConfirmDescription": "Hiermee verwijdert u de voorbeeldklanten, voertuigen, werkorders, de offerte en de inspectie die bij de installatie zijn aangemaakt. Uw eigen gegevens blijven onaangetast.",
     "removed": "Voorbeeldgegevens verwijderd",
     "removeFailed": "Kon de voorbeeldgegevens niet verwijderen"
+  },
+  "singleWorkshop": {
+    "title": "Deze installatie heeft haar werkplaats al",
+    "description": "Torqvoice draait hier één werkplaats en die is al ingericht. Om erin te werken vraag je de eigenaar om {email} uit te nodigen via Instellingen, dan Team.",
+    "signOut": "Uitloggen"
   }
 }

+ 1 - 2
messages/pl/admin.json

@@ -24,8 +24,7 @@
   "overview": {
     "totalUsers": "Łączna liczba użytkowników",
     "organizations": "Organizacje",
-    "activeSubscriptions": "Aktywne subskrypcje",
-    "monthlyRevenue": "Miesięczny przychód"
+    "activeSubscriptions": "Aktywne subskrypcje"
   },
   "users": {
     "searchPlaceholder": "Szukaj po nazwie lub e-mailu...",

+ 5 - 0
messages/pl/onboarding.json

@@ -85,5 +85,10 @@
     "removeConfirmDescription": "Spowoduje to usunięcie przykładowych klientów, pojazdów, zleceń, wyceny i przeglądu utworzonych podczas konfiguracji. Twoje własne dane pozostaną nietknięte.",
     "removed": "Dane przykładowe usunięte",
     "removeFailed": "Nie udało się usunąć danych przykładowych"
+  },
+  "singleWorkshop": {
+    "title": "Ta instalacja ma już swój warsztat",
+    "description": "Torqvoice prowadzi tu jeden warsztat i został on już skonfigurowany. Aby w nim pracować, poproś właściciela o zaproszenie {email} w Ustawieniach, potem Zespół.",
+    "signOut": "Wyloguj się"
   }
 }

+ 1 - 2
messages/pt-BR/admin.json

@@ -24,8 +24,7 @@
   "overview": {
     "totalUsers": "Total de Usuários",
     "organizations": "Organizações",
-    "activeSubscriptions": "Assinaturas Ativas",
-    "monthlyRevenue": "Receita Mensal"
+    "activeSubscriptions": "Assinaturas Ativas"
   },
   "users": {
     "searchPlaceholder": "Buscar por nome ou e-mail...",

+ 5 - 0
messages/pt-BR/onboarding.json

@@ -85,5 +85,10 @@
     "removeConfirmDescription": "Isso exclui os clientes, veículos, ordens de serviço, o orçamento e a inspeção de exemplo criados durante a configuração. Seus próprios dados não são afetados.",
     "removed": "Dados de exemplo removidos",
     "removeFailed": "Não foi possível remover os dados de exemplo"
+  },
+  "singleWorkshop": {
+    "title": "Esta instalação já tem sua oficina",
+    "description": "O Torqvoice aqui roda uma oficina, e ela já foi configurada. Para trabalhar nela, peça ao proprietário que convide {email} em Configurações, depois Equipe.",
+    "signOut": "Sair"
   }
 }

+ 1 - 2
messages/ru/admin.json

@@ -24,8 +24,7 @@
   "overview": {
     "totalUsers": "Всего пользователей",
     "organizations": "Организации",
-    "activeSubscriptions": "Активные подписки",
-    "monthlyRevenue": "Ежемесячный доход"
+    "activeSubscriptions": "Активные подписки"
   },
   "users": {
     "searchPlaceholder": "Поиск по имени или эл. почте...",

+ 5 - 0
messages/ru/onboarding.json

@@ -85,5 +85,10 @@
     "removeConfirmDescription": "Будут удалены примеры клиентов, автомобилей, заказ-нарядов, смета и осмотр, созданные при настройке. Ваши собственные данные не пострадают.",
     "removed": "Демонстрационные данные удалены",
     "removeFailed": "Не удалось удалить демонстрационные данные"
+  },
+  "singleWorkshop": {
+    "title": "У этой установки уже есть своя мастерская",
+    "description": "Здесь Torqvoice обслуживает одну мастерскую, и она уже настроена. Чтобы работать в ней, попросите владельца пригласить {email} в разделе Настройки, затем Команда.",
+    "signOut": "Выйти"
   }
 }

+ 1 - 2
messages/tr/admin.json

@@ -24,8 +24,7 @@
   "overview": {
     "totalUsers": "Toplam Kullanıcılar",
     "organizations": "Organizasyonlar",
-    "activeSubscriptions": "Aktif Abonelikler",
-    "monthlyRevenue": "Aylık Gelir"
+    "activeSubscriptions": "Aktif Abonelikler"
   },
   "users": {
     "searchPlaceholder": "Ad veya e-posta ile ara...",

+ 5 - 0
messages/tr/onboarding.json

@@ -85,5 +85,10 @@
     "removeConfirmDescription": "Bu işlem kurulum sırasında oluşturulan örnek müşterileri, araçları, iş emirlerini, teklifi ve muayeneyi siler. Kendi verileriniz etkilenmez.",
     "removed": "Örnek veriler kaldırıldı",
     "removeFailed": "Örnek veriler kaldırılamadı"
+  },
+  "singleWorkshop": {
+    "title": "Bu kurulumun atölyesi zaten var",
+    "description": "Torqvoice burada tek bir atölye çalıştırıyor ve o kurulmuş durumda. İçinde çalışmak için sahibinden {email} adresini Ayarlar, ardından Ekip bölümünden davet etmesini isteyin.",
+    "signOut": "Çıkış yap"
   }
 }

+ 0 - 36
src/__tests__/features/admin/plan-pricing.test.ts

@@ -1,36 +0,0 @@
-/**
- * The admin overview card is labelled monthly revenue, but plan prices are
- * stored per billing interval and the plans sold today all bill yearly. Adding
- * those prices up untouched showed a year's income as a month's.
- */
-
-import { describe, it, expect } from 'vitest'
-import { monthlyPlanPrice } from '@/lib/plan-pricing'
-
-describe('monthlyPlanPrice', () => {
-  it('leaves a monthly price alone', () => {
-    expect(monthlyPlanPrice(99, 'month')).toBe(99)
-  })
-
-  it('spreads a yearly price across twelve months', () => {
-    expect(monthlyPlanPrice(140, 'year')).toBeCloseTo(11.67, 2)
-  })
-
-  it('treats a missing interval as monthly, matching the column default', () => {
-    expect(monthlyPlanPrice(99, null)).toBe(99)
-    expect(monthlyPlanPrice(99, undefined)).toBe(99)
-  })
-
-  it('ignores the casing an interval was written in', () => {
-    expect(monthlyPlanPrice(120, 'YEAR')).toBe(10)
-  })
-
-  it('handles quarterly and weekly billing', () => {
-    expect(monthlyPlanPrice(30, 'quarter')).toBe(10)
-    expect(monthlyPlanPrice(12, 'week')).toBe(52)
-  })
-
-  it('returns zero for a price that is not a number', () => {
-    expect(monthlyPlanPrice(Number.NaN, 'year')).toBe(0)
-  })
-})

+ 67 - 1
src/__tests__/lib/features.test.ts

@@ -4,6 +4,8 @@ vi.mock('@/lib/db', () => ({
   db: {
     subscription: { findUnique: vi.fn() },
     appSetting: { findMany: vi.fn() },
+    organization: { count: vi.fn() },
+    organizationMember: { count: vi.fn(), findMany: vi.fn() },
   },
 }))
 
@@ -16,7 +18,12 @@ vi.mock('@/lib/license/revalidate', () => ({
 }))
 
 import { db } from '@/lib/db'
-import { getFeatures, PLAN_FEATURES } from '@/lib/features'
+import {
+  getFeatures,
+  getMaxOrganizations,
+  organizationAllowance,
+  PLAN_FEATURES,
+} from '@/lib/features'
 import { verifyLicenseToken } from '@/lib/license/token'
 import { scheduleLicenseSelfHeal } from '@/lib/license/revalidate'
 
@@ -211,3 +218,62 @@ describe('getFeatures — self-hosted mode', () => {
     expect(mockSelfHeal).not.toHaveBeenCalled()
   })
 })
+
+describe('the workshop limit on a self-hosted install', () => {
+  beforeEach(() => {
+    vi.stubEnv('TORQVOICE_MODE', 'self-hosted')
+  })
+
+  it('is one workshop without a licence, counted across the install', async () => {
+    mockFindMany.mockResolvedValue([])
+    vi.mocked(db.organization.count).mockResolvedValue(1)
+    expect(await getMaxOrganizations('user-1')).toBe(1)
+    expect(await organizationAllowance('user-1')).toEqual({ allowed: false, current: 1, max: 1 })
+    // The count is the install's, not the person's: a newcomer who owns
+    // nothing is still refused once the workshop exists.
+    expect(db.organizationMember.count).not.toHaveBeenCalled()
+  })
+
+  it('opens the first workshop', async () => {
+    mockFindMany.mockResolvedValue([])
+    vi.mocked(db.organization.count).mockResolvedValue(0)
+    expect(await organizationAllowance('user-1')).toEqual({ allowed: true, current: 0, max: 1 })
+  })
+
+  it('is lifted by a valid licence token on any organization', async () => {
+    mockFindMany.mockResolvedValue([
+      { organizationId: 'org-1', value: 'tvl1.stale' },
+      { organizationId: 'org-2', value: 'tvl1.good' },
+    ] as never)
+    mockVerify.mockImplementation((token) =>
+      verification(token === 'tvl1.good' ? 'valid' : 'stale')
+    )
+    vi.mocked(db.organization.count).mockResolvedValue(2)
+    expect(await getMaxOrganizations('user-1')).toBe(PLAN_FEATURES['white-label'].maxOrganizations)
+    expect((await organizationAllowance('user-1')).allowed).toBe(true)
+  })
+
+  it('does not count a token that fails to verify', async () => {
+    mockFindMany.mockResolvedValue([{ organizationId: 'org-1', value: 'tvl1.forged' }] as never)
+    mockVerify.mockReturnValue(verification('invalid'))
+    expect(await getMaxOrganizations('user-1')).toBe(1)
+  })
+
+  it('plan features say one workshop without a licence and many with one', async () => {
+    mockFindMany.mockResolvedValue([])
+    expect((await getFeatures('org-1')).maxOrganizations).toBe(1)
+  })
+})
+
+describe('the workshop limit on the cloud', () => {
+  it('counts what the person owns against the plan', async () => {
+    vi.stubEnv('TORQVOICE_MODE', 'cloud')
+    vi.mocked(db.organizationMember.count).mockResolvedValue(1)
+    // No owned organizations with a plan to look up: the free allowance applies.
+    vi.mocked(db.organizationMember.findMany).mockResolvedValue([])
+    const allowance = await organizationAllowance('user-1')
+    expect(allowance.max).toBe(PLAN_FEATURES.free.maxOrganizations)
+    expect(allowance.current).toBe(1)
+    expect(db.organization.count).not.toHaveBeenCalled()
+  })
+})

+ 0 - 1
src/app/(authenticated)/admin/page.tsx

@@ -8,7 +8,6 @@ export default async function AdminPage() {
     totalUsers: 0,
     totalOrganizations: 0,
     totalActiveSubscriptions: 0,
-    totalRevenue: 0,
   }
 
   return <AdminOverview stats={stats} />

+ 12 - 1
src/app/(public)/onboarding/page.tsx

@@ -4,6 +4,8 @@ import { safeRedirectPath } from '@/lib/safe-redirect'
 import { auth } from '@/lib/auth'
 import { db } from '@/lib/db'
 import { OnboardingForm } from '@/features/onboarding/Components/OnboardingForm'
+import { SingleWorkshopNotice } from '@/features/onboarding/Components/SingleWorkshopNotice'
+import { organizationAllowance } from '@/lib/features'
 
 export default async function OnboardingPage({
   searchParams,
@@ -28,13 +30,22 @@ export default async function OnboardingPage({
     redirect('/')
   }
 
+  // A self-hosted install runs one workshop unless licensed for more. Someone
+  // who signed up after it was opened is not starting a second one; they are
+  // joining the first, which takes an invitation from its owner.
+  const allowance = await organizationAllowance(session.user.id)
+
   return (
     <div className="grid-bg flex min-h-screen items-center justify-center p-4">
       <div className="absolute inset-0 overflow-hidden">
         <div className="absolute -top-40 -right-40 h-96 w-96 rounded-full bg-primary/10 blur-3xl" />
         <div className="absolute -bottom-40 -left-40 h-96 w-96 rounded-full bg-primary/5 blur-3xl" />
       </div>
-      <OnboardingForm redirectTo={redirectTo} />
+      {allowance.allowed ? (
+        <OnboardingForm redirectTo={redirectTo} />
+      ) : (
+        <SingleWorkshopNotice email={session.user.email} />
+      )}
     </div>
   )
 }

+ 5 - 19
src/features/admin/Actions/getAdminStats.ts

@@ -2,33 +2,19 @@
 
 import { withSuperAdmin } from '@/lib/with-super-admin'
 import { db } from '@/lib/db'
-import { monthlyPlanPrice } from '@/lib/plan-pricing'
 
 export async function getAdminStats() {
   return withSuperAdmin(async () => {
-    const [totalUsers, totalOrganizations, totalActiveSubscriptions, activeSubscriptions] =
-      await Promise.all([
-        db.user.count(),
-        db.organization.count(),
-        db.subscription.count({ where: { status: 'active' } }),
-        db.subscription.findMany({
-          where: { status: 'active' },
-          select: { plan: { select: { price: true, interval: true } } },
-        }),
-      ])
-
-    // Yearly plans are the common case, so their price has to come down to a
-    // month before it can be added to the monthly total.
-    const totalRevenue = activeSubscriptions.reduce(
-      (sum, sub) => sum + monthlyPlanPrice(sub.plan.price, sub.plan.interval),
-      0
-    )
+    const [totalUsers, totalOrganizations, totalActiveSubscriptions] = await Promise.all([
+      db.user.count(),
+      db.organization.count(),
+      db.subscription.count({ where: { status: 'active' } }),
+    ])
 
     return {
       totalUsers,
       totalOrganizations,
       totalActiveSubscriptions,
-      totalRevenue,
     }
   })
 }

+ 2 - 9
src/features/admin/Components/admin-overview.tsx

@@ -3,13 +3,12 @@
 import Link from 'next/link'
 import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
 import { useTranslations } from 'next-intl'
-import { Building2, CreditCard, DollarSign, Users } from 'lucide-react'
+import { Building2, CreditCard, Users } from 'lucide-react'
 
 type AdminStats = {
   totalUsers: number
   totalOrganizations: number
   totalActiveSubscriptions: number
-  totalRevenue: number
 }
 
 export function AdminOverview({ stats }: { stats: AdminStats }) {
@@ -34,16 +33,10 @@ export function AdminOverview({ stats }: { stats: AdminStats }) {
       icon: CreditCard,
       href: '/admin/organizations',
     },
-    {
-      title: t('overview.monthlyRevenue'),
-      value: `$${stats.totalRevenue.toFixed(2)}`,
-      icon: DollarSign,
-      href: '/admin/organizations',
-    },
   ]
 
   return (
-    <div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
+    <div className="grid gap-4 sm:grid-cols-3">
       {cards.map((card) => (
         <Link key={card.title} href={card.href}>
           <Card className="transition-colors hover:bg-muted/50 cursor-pointer">

+ 6 - 0
src/features/onboarding/Actions/createOnboardingOrg.ts

@@ -15,6 +15,7 @@ import { CHECKLIST_DISMISSED_KEY, SAMPLE_DATA_IDS_KEY } from '../Lib/onboardingK
 import { SETTING_KEYS } from '@/features/settings/Schema/settingsSchema'
 import { safeTimeZone } from '@/lib/timezone'
 import type { ActionResult } from '@/lib/with-auth'
+import { organizationAllowance, SINGLE_WORKSHOP_MESSAGE } from '@/lib/features'
 
 export async function createOnboardingOrg(
   input: unknown
@@ -35,6 +36,11 @@ export async function createOnboardingOrg(
       return { success: false, error: 'You already belong to an organization' }
     }
 
+    const allowance = await organizationAllowance(session.user.id)
+    if (!allowance.allowed) {
+      return { success: false, error: SINGLE_WORKSHOP_MESSAGE }
+    }
+
     const org = await db.organization.create({
       data: { name: data.workshopName },
     })

+ 45 - 0
src/features/onboarding/Components/SingleWorkshopNotice.tsx

@@ -0,0 +1,45 @@
+'use client'
+
+import { useRouter } from 'next/navigation'
+import { useTranslations } from 'next-intl'
+import { Building2, LogOut } from 'lucide-react'
+import { signOut } from '@/lib/auth-client'
+import { Button } from '@/components/ui/button'
+import { AuthLogo } from '@/components/auth-logo'
+
+/**
+ * Shown instead of the workshop form when this install already has its one
+ * workshop and holds no licence for more. The person is signed in and has
+ * nowhere to go until the owner invites them, so the only action is to
+ * sign out and come back through the invitation.
+ */
+export function SingleWorkshopNotice({ email }: { email: string }) {
+  const t = useTranslations('onboarding.singleWorkshop')
+  const tc = useTranslations('common')
+  const router = useRouter()
+
+  const handleSignOut = async () => {
+    await signOut()
+    router.push('/auth/sign-in')
+    router.refresh()
+  }
+
+  return (
+    <div className="glass relative z-10 w-full max-w-md rounded-2xl p-8 shadow-2xl">
+      <div className="mb-6 text-center">
+        <div className="mb-4 inline-flex items-center gap-2">
+          <AuthLogo alt={tc('brandName')} />
+        </div>
+        <div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-primary/10">
+          <Building2 className="h-6 w-6 text-primary" />
+        </div>
+        <h1 className="text-2xl font-bold tracking-tight">{t('title')}</h1>
+        <p className="mt-2 text-sm text-muted-foreground">{t('description', { email })}</p>
+      </div>
+      <Button type="button" variant="outline" className="w-full" onClick={handleSignOut}>
+        <LogOut className="mr-2 h-4 w-4" />
+        {t('signOut')}
+      </Button>
+    </div>
+  )
+}

+ 17 - 14
src/features/team/Actions/createNewOrganization.ts

@@ -3,7 +3,12 @@
 import { cookies } from 'next/headers'
 import { db } from '@/lib/db'
 import { withAuth } from '@/lib/with-auth'
-import { isCloudMode, getMaxOrganizations, FeatureGatedError } from '@/lib/features'
+import {
+  isCloudMode,
+  organizationAllowance,
+  FeatureGatedError,
+  SINGLE_WORKSHOP_MESSAGE,
+} from '@/lib/features'
 import { demoGuard } from '@/lib/demo'
 import { createOrganizationSchema } from '../Schema/teamSchema'
 import { revalidatePath } from 'next/cache'
@@ -16,19 +21,17 @@ export async function createNewOrganization(input: unknown) {
       demoGuard()
       const data = createOrganizationSchema.parse(input)
 
-      if (isCloudMode()) {
-        const ownedCount = await db.organizationMember.count({
-          where: { userId, role: 'owner' },
-        })
-        const maxOrgs = await getMaxOrganizations(userId)
-
-        if (ownedCount >= maxOrgs) {
-          throw new FeatureGatedError(
-            'maxOrganizations',
-            `You have reached the maximum number of organizations (${maxOrgs}) for your plan. Upgrade to create more.`,
-            maxOrgs
-          )
-        }
+      const allowance = await organizationAllowance(userId)
+      if (!allowance.allowed) {
+        // On the cloud this is a plan limit and the client offers the way to
+        // a plan; on a self-hosted install it is the licence, and the message
+        // says where that lives.
+        if (!isCloudMode()) throw new Error(SINGLE_WORKSHOP_MESSAGE)
+        throw new FeatureGatedError(
+          'maxOrganizations',
+          `You have reached the maximum number of organizations (${allowance.max}) for your plan. Upgrade to create more.`,
+          allowance.max
+        )
       }
 
       const org = await db.$transaction(async (tx) => {

+ 18 - 14
src/features/team/Actions/teamActions.ts

@@ -12,7 +12,13 @@ import { revalidatePath } from 'next/cache'
 import { PermissionAction, PermissionSubject } from '@/lib/permissions'
 import { setTechnicianStanding } from '../Lib/technicianStanding'
 import { revokeTechnicianCredentials } from '../Lib/revokeTechnicianCredentials'
-import { getFeatures, getMaxOrganizations, isCloudMode, FeatureGatedError } from '@/lib/features'
+import {
+  getFeatures,
+  isCloudMode,
+  organizationAllowance,
+  FeatureGatedError,
+  SINGLE_WORKSHOP_MESSAGE,
+} from '@/lib/features'
 import { demoGuard } from '@/lib/demo'
 import { canInvite } from '../Lib/invitationRules'
 import { createAndSendInvitation } from '../Lib/createInvitation'
@@ -78,19 +84,17 @@ export async function createOrganization(input: unknown) {
       demoGuard()
       const data = createOrganizationSchema.parse(input)
 
-      if (isCloudMode()) {
-        const ownedCount = await db.organizationMember.count({
-          where: { userId, role: 'owner' },
-        })
-        const maxOrgs = await getMaxOrganizations(userId)
-
-        if (ownedCount >= maxOrgs) {
-          throw new FeatureGatedError(
-            'maxOrganizations',
-            `You have reached the maximum number of organizations (${maxOrgs}) for your plan. Upgrade to create more.`,
-            maxOrgs
-          )
-        }
+      const allowance = await organizationAllowance(userId)
+      if (!allowance.allowed) {
+        // On the cloud this is a plan limit and the client offers the way to
+        // a plan; on a self-hosted install it is the licence, and the message
+        // says where that lives.
+        if (!isCloudMode()) throw new Error(SINGLE_WORKSHOP_MESSAGE)
+        throw new FeatureGatedError(
+          'maxOrganizations',
+          `You have reached the maximum number of organizations (${allowance.max}) for your plan. Upgrade to create more.`,
+          allowance.max
+        )
       }
 
       const org = await db.$transaction(async (tx) => {

+ 50 - 2
src/lib/features.ts

@@ -218,15 +218,38 @@ export const getFeatures = cache(async (organizationId: string): Promise<PlanFea
     ...PLAN_FEATURES['white-label'],
     brandingRemoved: hasLicense,
     customPlatformName: hasLicense,
+    // One workshop per install without a licence. A self-hosting workshop
+    // needs one; running many is what the white-label licence is for.
+    maxOrganizations: hasLicense ? PLAN_FEATURES['white-label'].maxOrganizations : 1,
   }
 })
 
+/**
+ * Whether any organization on this install holds a valid licence token.
+ * The licence is what turns a single-workshop install into a multi-workshop
+ * one, so it is checked across the install, not per person.
+ */
+export async function installHasLicense(): Promise<boolean> {
+  const tokens = await db.appSetting.findMany({
+    where: { key: 'license.token', organizationId: { not: null } },
+    select: { organizationId: true, value: true },
+  })
+  return tokens.some(
+    (row) =>
+      row.organizationId !== null &&
+      verifyLicenseToken(row.value, row.organizationId).status === 'valid'
+  )
+}
+
 /**
  * Returns the max organizations a user is allowed based on their best plan
- * across all orgs they own. In self-hosted mode, returns unlimited.
+ * across all orgs they own. In self-hosted mode the limit is one for the
+ * whole install, lifted by a valid white-label licence.
  */
 export async function getMaxOrganizations(userId: string): Promise<number> {
-  if (!isCloudMode()) return 999999
+  if (!isCloudMode()) {
+    return (await installHasLicense()) ? PLAN_FEATURES['white-label'].maxOrganizations : 1
+  }
 
   const ownedOrgs = await db.organizationMember.findMany({
     where: { userId, role: 'owner' },
@@ -245,6 +268,31 @@ export async function getMaxOrganizations(userId: string): Promise<number> {
   return best
 }
 
+export type OrganizationAllowance = {
+  allowed: boolean
+  /** what counts against the limit: organizations owned (cloud) or on the install (self-hosted) */
+  current: number
+  max: number
+}
+
+/**
+ * Whether one more organization may be created. On the cloud the limit is
+ * the plan's and counts what this person owns; on a self-hosted install it
+ * counts every organization there is, so a second sign-up cannot open a
+ * second workshop on an install licensed for one.
+ */
+export async function organizationAllowance(userId: string): Promise<OrganizationAllowance> {
+  const max = await getMaxOrganizations(userId)
+  const current = isCloudMode()
+    ? await db.organizationMember.count({ where: { userId, role: 'owner' } })
+    : await db.organization.count()
+  return { allowed: current < max, current, max }
+}
+
+/** The refusal an install licensed for one workshop gives, worded for a self-hoster. */
+export const SINGLE_WORKSHOP_MESSAGE =
+  'This installation runs one workshop. A white-label licence from torqvoice.com allows more; see Settings, then License.'
+
 /**
  * Thrown when the plan refuses an action. `withAuth` turns it into a typed
  * `gated` field on the result, so the client can show an upgrade prompt with

+ 0 - 23
src/lib/plan-pricing.ts

@@ -1,23 +0,0 @@
-/**
- * A plan's price is stored per billing interval, not per month. Most plans bill
- * yearly, so summing prices straight out of the table and calling the result
- * monthly revenue overstates it by a factor of twelve.
- */
-export function monthlyPlanPrice(price: number, interval: string | null | undefined): number {
-  if (!Number.isFinite(price)) return 0
-
-  switch ((interval ?? 'month').toLowerCase()) {
-    case 'year':
-    case 'yearly':
-    case 'annual':
-      return price / 12
-    case 'quarter':
-    case 'quarterly':
-      return price / 3
-    case 'week':
-    case 'weekly':
-      return (price * 52) / 12
-    default:
-      return price
-  }
-}