From 95bf5649ac4cda8d21108b99dcd9fa8dbf467cf2 Mon Sep 17 00:00:00 2001 From: swithek <52840391+swithek@users.noreply.github.com> Date: Wed, 23 Sep 2026 16:39:52 +0200 Subject: [PATCH] add one-org mode --- README.md | 7 +- docker/env/auth-realtime.example.env | 4 +- docker/prod/README.md | 28 +- docker/prod/launcher/src/env.test.ts | 24 ++ docker/prod/launcher/src/env.ts | 13 +- e2e/AGENTS.md | 4 + e2e/docker-compose.prod.yaml | 22 +- e2e/helpers/auth.ts | 13 +- e2e/helpers/mailpit.ts | 80 +++- e2e/helpers/single-workspace.ts | 89 ++++ e2e/tests/prod-single-workspace.test.ts | 187 +++++++++ e2e/tests/settings.test.ts | 63 ++- server/auth-realtime/AGENTS.md | 22 +- server/auth-realtime/src/auth.test.ts | 387 ++++++++++++++++++ server/auth-realtime/src/auth.ts | 173 +++++++- server/auth-realtime/src/bootstrap.test.ts | 255 ++++++++++++ server/auth-realtime/src/bootstrap.ts | 153 +++++++ server/auth-realtime/src/core.test.ts | 18 + server/auth-realtime/src/core.ts | 7 + server/auth-realtime/src/db.test.ts | 153 ++++++- server/auth-realtime/src/db.ts | 74 +++- server/auth-realtime/src/env.test.ts | 26 +- server/auth-realtime/src/env.ts | 27 +- server/auth-realtime/src/index.ts | 22 + server/auth-realtime/src/routes.test.ts | 143 ++++++- server/auth-realtime/src/routes.ts | 49 ++- server/auth-realtime/src/test-helpers.ts | 6 + server/core/cmd/core/main.go | 1 + server/core/internal/email/email.go | 29 +- server/core/internal/email/email_test.go | 41 +- server/core/internal/email/templates.go | 2 +- .../email/templates/account_exists.html | 4 +- .../templates/email_change_confirmation.html | 4 +- .../email/templates/email_verification.html | 4 +- .../templates/organization_invitation.html | 6 +- .../email/templates/password_reset.html | 4 +- .../email/templates/signup_verification.html | 4 +- .../email/templates/user_deletion.html | 4 +- server/core/internal/email/templates_test.go | 7 +- .../server/internal/org/default_logo.png | Bin 0 -> 11234 bytes .../core/internal/server/internal/org/http.go | 71 +++- .../internal/server/internal/org/http_test.go | 272 ++++++++++++ server/core/internal/server/router.go | 1 + server/core/internal/server/router_test.go | 1 + .../AccountDeletionAction.nuxt.test.ts | 105 +++++ .../settings/AccountDeletionAction.vue | 44 +- .../settings/ActionModal.nuxt.test.ts | 21 + web/app/components/settings/ActionModal.vue | 5 +- .../settings/EmailChangeAction.nuxt.test.ts | 66 ++- .../components/settings/EmailChangeAction.vue | 42 +- .../WorkspaceInvitationAction.nuxt.test.ts | 11 + .../settings/WorkspaceInvitationAction.vue | 6 +- .../settings/WorkspaceSection.nuxt.test.ts | 18 +- .../components/settings/WorkspaceSection.vue | 14 +- web/app/components/test-helpers.ts | 3 + .../composables/api/useAuthAPI.nuxt.test.ts | 58 +++ web/app/composables/api/useAuthAPI.ts | 25 +- .../composables/useAuthSession.nuxt.test.ts | 39 +- web/app/composables/useAuthSession.ts | 7 + web/app/pages/login.nuxt.test.ts | 80 ++++ web/app/pages/login.vue | 41 +- web/app/pages/signup.vue | 17 +- web/app/utils/api/auth.ts | 10 + web/app/utils/api/organization.ts | 5 +- web/app/utils/auth.test.ts | 17 + web/app/utils/auth.ts | 15 + web/i18n/locales/en/onboarding.json | 5 + web/i18n/locales/en/settings.json | 14 +- 68 files changed, 3015 insertions(+), 157 deletions(-) create mode 100644 e2e/helpers/single-workspace.ts create mode 100644 e2e/tests/prod-single-workspace.test.ts create mode 100644 server/auth-realtime/src/bootstrap.test.ts create mode 100644 server/auth-realtime/src/bootstrap.ts create mode 100644 server/core/internal/server/internal/org/default_logo.png create mode 100644 web/app/pages/login.nuxt.test.ts diff --git a/README.md b/README.md index 4cc73ea0..1187c436 100644 --- a/README.md +++ b/README.md @@ -118,11 +118,10 @@ docker run -d --name oxynote \ ghcr.io/oxynote/oxynote:latest ``` -Open http://localhost:8080 and sign up. +Open http://localhost:8080 and log in with `admin@example.com` and +`oxynote-admin-1234`. -Everything else is one variable away and listed in -[docker/prod/README.md](docker/prod/README.md). To run from source, see -[CONTRIBUTING.md](CONTRIBUTING.md). +To run from source, see [CONTRIBUTING.md](CONTRIBUTING.md). ## What is in the box diff --git a/docker/env/auth-realtime.example.env b/docker/env/auth-realtime.example.env index 4ade7988..267826b6 100644 --- a/docker/env/auth-realtime.example.env +++ b/docker/env/auth-realtime.example.env @@ -37,7 +37,9 @@ OXYNOTE_AUTH_REALTIME_BETTER_AUTH_GOOGLE_CLIENT_SECRET= OXYNOTE_AUTH_REALTIME_BETTER_AUTH_SLACK_CLIENT_ID= OXYNOTE_AUTH_REALTIME_BETTER_AUTH_SLACK_CLIENT_SECRET= -# limits +# limits: -1 is none. Unset, one organization with any number of members. +# A limit of one organization creates it and its admin at boot and allows +# signup only to invited addresses. OXYNOTE_AUTH_REALTIME_MAX_ORGANIZATION_MEMBERS=5 OXYNOTE_AUTH_REALTIME_MAX_ORGANIZATIONS=50 # better-auth's own rate limiter is on unless this is set to "true". It diff --git a/docker/prod/README.md b/docker/prod/README.md index 917b4dc3..d033c3d3 100644 --- a/docker/prod/README.md +++ b/docker/prod/README.md @@ -84,9 +84,35 @@ Setting `OXYNOTE_SMTP_DSN` later brings the email flows back after a restart. Accounts created in the meantime have unverified addresses, so their next login sends a verification link first. +### Workspaces + +`OXYNOTE_MAX_ORGANIZATIONS` caps the number of workspaces and +`OXYNOTE_MAX_ORGANIZATION_MEMBERS` the members of each. `-1` removes a +limit. Members are unlimited by default. + +The workspace limit defaults to `1`, which runs the image as a single +workspace: + +- The first boot creates a workspace named Oxynote and its admin. Sign in as + `admin@example.com` with the password `oxynote-admin-1234`, then change + the email address, the password and the workspace name in the settings. + The admin's address receives no email, so a new address is confirmed by a + link sent to the new address only. +- Nobody can sign up without an invitation. The signup page sends other + visitors to the login page, and an invited address can create its account + from the invitation link. +- The last member cannot delete their account. + +The login page shows the default email and password until the admin +changes the password. Anyone who can reach the instance can sign in with +them until then, so change the password before exposing the instance. + +Any other value lets people sign up and create their own workspace until +the limit is reached. After that they can still sign up, but only to join a +workspace through an invitation. + ### Tuning -`OXYNOTE_MAX_ORGANIZATIONS`, `OXYNOTE_MAX_ORGANIZATION_MEMBERS`, `OXYNOTE_RATE_LIMIT_DISABLED` (`true`/`false`; the built-in limiter buckets by client IP — disable it behind a proxy that hides the original IP and rate limit there instead), `OXYNOTE_MAX_DOCUMENT_HISTORY_ENTRIES`, diff --git a/docker/prod/launcher/src/env.test.ts b/docker/prod/launcher/src/env.test.ts index ad7dc01a..50bb8b5a 100644 --- a/docker/prod/launcher/src/env.test.ts +++ b/docker/prod/launcher/src/env.test.ts @@ -490,12 +490,36 @@ describe("loadConfig", () => { expect(config.logLevel).toBe("WARN") }) + it("accepts -1 as no limit", ({ expect }) => { + const config = loadConfig( + completeEnv({ + OXYNOTE_MAX_ORGANIZATIONS: "-1", + OXYNOTE_MAX_ORGANIZATION_MEMBERS: "-1", + }), + ) + + expect(config.maxOrganizations).toBe("-1") + expect(config.maxOrganizationMembers).toBe("-1") + }) + it.for([ { name: "a word as a counter", input: { OXYNOTE_MAX_ORGANIZATIONS: "many" }, expected: "OXYNOTE_MAX_ORGANIZATIONS", }, + { + name: "zero as a limit", + input: { + OXYNOTE_MAX_ORGANIZATION_MEMBERS: "0", + }, + expected: "OXYNOTE_MAX_ORGANIZATION_MEMBERS", + }, + { + name: "a negative limit other than -1", + input: { OXYNOTE_MAX_ORGANIZATIONS: "-2" }, + expected: "OXYNOTE_MAX_ORGANIZATIONS", + }, { name: "a number as a retention", input: { diff --git a/docker/prod/launcher/src/env.ts b/docker/prod/launcher/src/env.ts index f5d04cf0..cd366e44 100644 --- a/docker/prod/launcher/src/env.ts +++ b/docker/prod/launcher/src/env.ts @@ -108,6 +108,15 @@ function count() { return z.string().regex(/^\d+$/, "must be a whole number").optional() } +// a limit kept as the string auth-realtime parses itself: -1 for none, or a +// positive integer. +function limit() { + return z + .string() + .regex(/^(-1|[1-9]\d*)$/, "must be -1 or a positive integer") + .optional() +} + // what the public origin falls back to. Caddy listens on 8080 inside the // container, so a container published as 8080:8080 is reachable here with // no variable set; anything else (a domain, another host port) needs @@ -392,8 +401,8 @@ const baseSchema = z.object({ OXYNOTE_AI_ASSISTANT_VERTEX_PROJECT_ID: z.string().optional(), OXYNOTE_AI_ASSISTANT_VERTEX_REGION: z.string().optional(), OXYNOTE_AI_ASSISTANT_VERTEX_SERVICE_ACCOUNT_JSON: z.string().optional(), - OXYNOTE_MAX_ORGANIZATIONS: count(), - OXYNOTE_MAX_ORGANIZATION_MEMBERS: count(), + OXYNOTE_MAX_ORGANIZATIONS: limit(), + OXYNOTE_MAX_ORGANIZATION_MEMBERS: limit(), OXYNOTE_RATE_LIMIT_DISABLED: flag(), OXYNOTE_MAX_DOCUMENT_HISTORY_ENTRIES: count(), OXYNOTE_DOCUMENT_HISTORY_RETENTION: goDuration, diff --git a/e2e/AGENTS.md b/e2e/AGENTS.md index 814d9428..7114a4be 100644 --- a/e2e/AGENTS.md +++ b/e2e/AGENTS.md @@ -95,6 +95,10 @@ sets no `OXYNOTE_DB_DSN`, so the image runs its embedded Postgres. A second instance, `oxynote-without-email` (`:19081`), sets no `OXYNOTE_SMTP_DSN`; only `prod-without-email.test.ts` drives it, through the origin in `helpers/without-email.ts`. +A third, `oxynote-single-workspace` (`:19082`), keeps the default workspace +limits; only `prod-single-workspace.test.ts` drives it, through +`helpers/single-workspace.ts`. Its tests share the one workspace and its +admin, so the file runs serially, without retries, read-only tests first. Compose builds nothing; the image comes from `make prod-build`, tagged `:e2e-prod`, and CI's cache reaches it through `PROD_BUILD_EXTRA`. A `probe` container (idle alpine on the private network) exists for the diff --git a/e2e/docker-compose.prod.yaml b/e2e/docker-compose.prod.yaml index 13fe5ee9..60a4962c 100644 --- a/e2e/docker-compose.prod.yaml +++ b/e2e/docker-compose.prod.yaml @@ -12,8 +12,9 @@ # - no named volumes, so every run starts from an empty database and an # empty data volume (the generated secrets are regenerated per run) # - no container_name, and its own ports (:19080 front door, :19081 the -# instance without email, :19025 mailpit), so it coexists with the dev -# stack and the e2e dev stack alike +# instance without email, :19082 the single-workspace instance, :19025 +# mailpit), so it coexists with the dev stack and the e2e dev stack +# alike # - env is inlined and committed; there is nothing secret to protect # - every image is pinned # - no OXYNOTE_DB_DSN, OXYNOTE_VALKEY_DSN or OXYNOTE_OBJECT_STORAGE_DSN, which @@ -71,6 +72,23 @@ services: - OXYNOTE_RATE_LIMIT_DISABLED=true stop_grace_period: 60s + # the same image with the workspace limits left at their defaults: one + # workspace, created with its admin at boot, and signup by invitation + # only. tests/prod-single-workspace.test.ts drives it through its own + # front door. Its tests share that one workspace, so no other test may + # use this instance. + oxynote-single-workspace: + image: ghcr.io/oxynote/oxynote:e2e-prod + ports: + - "19082:8080" + environment: + - OXYNOTE_PUBLIC_URL=http://localhost:19082 + - OXYNOTE_SMTP_DSN=smtp://mailpit:1025?tls=none + - OXYNOTE_EMAIL_FROM_ADDRESS=Oxynote + # the same limiter allowance as the instance above. + - OXYNOTE_RATE_LIMIT_DISABLED=true + stop_grace_period: 60s + # a sibling on the private network, and the only honest way to ask the # question the trust boundary answers: can a container that is not caddy # reach core, auth-realtime or the web server directly? The prod tests diff --git a/e2e/helpers/auth.ts b/e2e/helpers/auth.ts index 10177146..75ccc0cb 100644 --- a/e2e/helpers/auth.ts +++ b/e2e/helpers/auth.ts @@ -31,7 +31,10 @@ export function newCredentials(): Credentials { // bound its handler; visit() waits for the app instance, but under load // the handler can still land a beat later, and a click in that window // does nothing at all — no request, no error, just the same page. -async function revealForm(button: Locator, field: Locator): Promise { +export async function revealForm( + button: Locator, + field: Locator, +): Promise { await expect(async () => { await button.click() await expect(field).toBeVisible({ timeout: 1_000 }) @@ -47,7 +50,15 @@ export async function submitSignupForm( origin = BASE_URL, ): Promise { await visit(page, `${origin}/signup`) + await fillSignupForm(page, credentials) +} +// fillSignupForm is submitSignupForm for a signup page the browser is +// already on, such as one opened from an invitation. +export async function fillSignupForm( + page: Page, + credentials: Credentials, +): Promise { const email = page.getByPlaceholder( t("onboarding.signup.email-password-form.email-placeholder"), ) diff --git a/e2e/helpers/mailpit.ts b/e2e/helpers/mailpit.ts index b9dcbe80..12a4a8f5 100644 --- a/e2e/helpers/mailpit.ts +++ b/e2e/helpers/mailpit.ts @@ -8,6 +8,8 @@ const DELIVERY_TIMEOUT = 30_000 const VERIFICATION_LINK = /href="([^"]*\/api\/auth\/verify-email[^"]*)"/ const INVITE_LINK = /href="([^"]*\/accept-invite[^"]*)"/ const PASSWORD_RESET_LINK = /href="([^"]*\/api\/auth\/reset-password[^"]*)"/ +const ACCOUNT_DELETION_LINK = + /href="([^"]*\/api\/auth\/delete-user\/callback[^"]*)"/ // the account-exists notice links to the bare login page, which is what // separates it from the verification email in the same inbox — that // one's link only carries "/login" inside its callback parameter. @@ -40,6 +42,47 @@ export function fetchPasswordResetLink( return fetchLink(request, address, PASSWORD_RESET_LINK, "password reset") } +// fetchEmailChangeApprovalLink waits for the email asking the current +// address to approve a change of address, and hands back its link. That +// link has the same path as the signup verification in the same inbox. +// Only the request type inside its token tells the two apart. +export function fetchEmailChangeApprovalLink( + request: APIRequestContext, + address: string, +): Promise { + return fetchLink( + request, + address, + VERIFICATION_LINK, + "email change approval", + (link) => tokenRequestType(link) === "change-email-confirmation", + ) +} + +// fetchAccountDeletionLink waits for the email confirming an account +// deletion and hands back the link that completes it. +export function fetchAccountDeletionLink( + request: APIRequestContext, + address: string, +): Promise { + return fetchLink(request, address, ACCOUNT_DELETION_LINK, "account deletion") +} + +// deliveredTo counts the messages an address has received so far. +export async function deliveredTo( + request: APIRequestContext, + address: string, +): Promise { + const search = await request.get(`${MAILPIT_URL}/api/v1/search`, { + params: { query: `to:${address}` }, + }) + expect(search.ok()).toBe(true) + + const { messages } = (await search.json()) as { messages: unknown[] } + + return messages.length +} + // fetchAccountExistsLink waits for the notice a repeated signup sends to // the address's real owner and hands back its login link. export function fetchAccountExistsLink( @@ -58,13 +101,14 @@ async function fetchLink( address: string, pattern: RegExp, kind: string, + accept: (link: string) => boolean = () => true, ): Promise { const found = { link: "" } await expect .poll( async () => { - found.link = await findLink(request, address, pattern) + found.link = await findLink(request, address, pattern, accept) return found.link }, @@ -82,6 +126,7 @@ async function findLink( request: APIRequestContext, address: string, pattern: RegExp, + accept: (link: string) => boolean, ): Promise { const search = await request.get(`${MAILPIT_URL}/api/v1/search`, { params: { query: `to:${address}` }, @@ -99,14 +144,35 @@ async function findLink( const { HTML } = (await response.json()) as { HTML: string } const match = pattern.exec(HTML) - if (match?.[1]) { - // the template escapes the query separator, and following the - // link as written would hand the server a parameter called - // "amp;callbackURL" — leaving it with no redirect target, so - // it answers with raw JSON instead of returning to the app. - return match[1].replaceAll("&", "&") + if (!match?.[1]) { + continue + } + + // the template escapes the query separator, and following the + // link as written would hand the server a parameter called + // "amp;callbackURL" — leaving it with no redirect target, so it + // answers with raw JSON instead of returning to the app. + const link = match[1].replaceAll("&", "&") + + if (accept(link)) { + return link } } return "" } + +// the requestType better-auth writes into a verification link's token, a +// JWT. Empty when the link carries none. +function tokenRequestType(link: string): string { + const payload = new URL(link).searchParams.get("token")?.split(".")[1] + if (!payload) { + return "" + } + + const claims = JSON.parse(Buffer.from(payload, "base64url").toString()) as { + requestType?: unknown + } + + return typeof claims.requestType === "string" ? claims.requestType : "" +} diff --git a/e2e/helpers/single-workspace.ts b/e2e/helpers/single-workspace.ts new file mode 100644 index 00000000..e403b8a0 --- /dev/null +++ b/e2e/helpers/single-workspace.ts @@ -0,0 +1,89 @@ +import { + expect, + type APIRequestContext, + type APIResponse, + type Browser, + type BrowserContext, + type Page, +} from "@playwright/test" +import { fillSignupForm, submitLoginForm, type Credentials } from "./auth" +import { t } from "./i18n" +import { fetchVerificationLink } from "./mailpit" +import { visit } from "./page" + +// the prod stack's third instance of the image, started with the default +// workspace limits (oxynote-single-workspace in docker-compose.prod.yaml). +// Changing the port means changing that service. +export const SINGLE_WORKSPACE_URL = "http://localhost:19082" + +// the admin and workspace the instance creates at its first boot. The +// operator README documents these values. +export const DEFAULT_ADMIN: Credentials = { + email: "admin@example.com", + password: "oxynote-admin-1234", +} +export const DEFAULT_WORKSPACE_NAME = "Oxynote" + +// authPost calls one of better-auth's endpoints on the instance as the +// context's user. better-auth refuses a request without a trusted origin, +// which a browser would send on its own. +export function authPost( + request: APIRequestContext, + path: string, + data: Record = {}, +): Promise { + return request.post(`${SINGLE_WORKSPACE_URL}/auth-realtime/api/auth${path}`, { + data, + headers: { Origin: SINGLE_WORKSPACE_URL }, + }) +} + +// signedInContext opens a browser context already signed in with the given +// credentials. It signs in over the API, since the login page itself is +// what other tests drive. +export async function signedInContext( + browser: Browser, + credentials: Credentials, +): Promise { + const context = await browser.newContext() + const response = await authPost(context.request, "/sign-in/email", { + email: credentials.email, + password: credentials.password, + }) + expect(response.ok()).toBe(true) + + return context +} + +// joinThroughInvitation takes an invitee from the emailed invitation to a +// member of the workspace. It signs up from the invitation, which is the +// only way to reach the signup form here, then verifies, logs in and +// accepts. +export async function joinThroughInvitation( + page: Page, + request: APIRequestContext, + inviteLink: string, + credentials: Credentials, +): Promise { + await visit(page, inviteLink) + await page + .getByRole("button", { name: t("onboarding.accept-invite.signup-button") }) + .click() + await expect(page).toHaveURL(/\/signup\?next=/) + await fillSignupForm(page, credentials) + await expect(page).toHaveURL(/\/verify-email\?/, { timeout: 15_000 }) + + await page.goto(await fetchVerificationLink(request, credentials.email)) + await expect(page).toHaveURL(`${SINGLE_WORKSPACE_URL}/login?verified=true`) + + await submitLoginForm(page, credentials, SINGLE_WORKSPACE_URL) + // a fresh account has no workspace yet, so the login redirect lands on + // onboarding. The invitation is what gets them out of it. + await expect(page).toHaveURL(/\/welcome$/, { timeout: 15_000 }) + + await visit(page, inviteLink) + await page + .getByRole("button", { name: t("onboarding.accept-invite.accept-button") }) + .click() + await expect(page).toHaveURL(/-[a-z0-9]{20}$/, { timeout: 30_000 }) +} diff --git a/e2e/tests/prod-single-workspace.test.ts b/e2e/tests/prod-single-workspace.test.ts new file mode 100644 index 00000000..aac5844f --- /dev/null +++ b/e2e/tests/prod-single-workspace.test.ts @@ -0,0 +1,187 @@ +import { expect, test } from "@playwright/test" +import { newCredentials, revealForm, submitLoginForm } from "../helpers/auth" +import { inviteTeamMember } from "../helpers/collaboration" +import { waitForEditor } from "../helpers/editor" +import { t } from "../helpers/i18n" +import { + deliveredTo, + fetchAccountDeletionLink, + fetchVerificationLink, +} from "../helpers/mailpit" +import { visit } from "../helpers/page" +import { + authPost, + DEFAULT_ADMIN, + DEFAULT_WORKSPACE_NAME, + joinThroughInvitation, + signedInContext, + SINGLE_WORKSPACE_URL, +} from "../helpers/single-workspace" + +// the password the admin moves to partway through the file. +const NEW_ADMIN_PASSWORD = "e2e-Admin-Passw0rd!-new" + +// the instance has one workspace and one admin, and every test here shares +// them. The tests run in this order, read-only ones first, and later ones +// change the admin. A retry would rerun the group against an admin that is +// already changed, so there is none. +test.describe("a single-workspace instance", () => { + test.describe.configure({ mode: "serial", retries: 0 }) + + test("shows the default credentials on the login page, and they sign the admin in", async ({ + page, + }) => { + await visit(page, `${SINGLE_WORKSPACE_URL}/login`) + + await revealForm( + page.getByRole("button", { + name: t("onboarding.login.login-email-password"), + }), + page.getByText(t("onboarding.login.default-admin.intro")), + ) + await expect(page.getByRole("listitem")).toHaveText([ + DEFAULT_ADMIN.email, + DEFAULT_ADMIN.password, + ]) + + await submitLoginForm(page, DEFAULT_ADMIN, SINGLE_WORKSPACE_URL) + + await expect(page).toHaveURL(/-[a-z0-9]{20}$/, { timeout: 15_000 }) + await expect( + page.getByRole("button", { name: DEFAULT_WORKSPACE_NAME }), + ).toBeVisible() + }) + + test("sends a visitor from the signup page to the login page", async ({ + page, + }) => { + await visit(page, `${SINGLE_WORKSPACE_URL}/signup`) + + await expect(page).toHaveURL(`${SINGLE_WORKSPACE_URL}/login`) + }) + + test("refuses a signup without an invitation", async ({ request }) => { + const credentials = newCredentials() + + const response = await authPost(request, "/sign-up/email", { + email: credentials.email, + password: credentials.password, + name: "stranger", + }) + + expect(response.status()).toBe(400) + expect(await response.json()).toMatchObject({ + code: "SIGN_UP_DISABLED", + }) + }) + + test("keeps the last member from deleting their account", async ({ + browser, + request, + }) => { + const admin = await signedInContext(browser, DEFAULT_ADMIN) + + const response = await authPost(admin.request, "/delete-user", { + callbackURL: `${SINGLE_WORKSPACE_URL}/signup`, + }) + + expect(response.status()).toBe(403) + expect(await response.json()).toMatchObject({ + code: "LAST_ORGANIZATION_MEMBER", + }) + expect(await deliveredTo(request, DEFAULT_ADMIN.email)).toBe(0) + await admin.close() + }) + + test("lets an invited address join, and lets that member delete their account", async ({ + browser, + request, + }) => { + // an invitation, a signup, a verification, a login and an account + // deletion, each a cross-service round trip. + test.slow() + + const admin = await signedInContext(browser, DEFAULT_ADMIN) + const adminPage = await admin.newPage() + await visit(adminPage, `${SINGLE_WORKSPACE_URL}/`) + await waitForEditor(adminPage) + const invitee = newCredentials() + const inviteLink = await inviteTeamMember(adminPage, request, invitee.email) + + const inviteeContext = await browser.newContext() + const inviteePage = await inviteeContext.newPage() + await joinThroughInvitation(inviteePage, request, inviteLink, invitee) + + // with the admin still there, the member is not the last one. + const deletion = await authPost(inviteeContext.request, "/delete-user", { + callbackURL: `${SINGLE_WORKSPACE_URL}/signup`, + }) + expect(deletion.ok()).toBe(true) + await inviteePage.goto( + await fetchAccountDeletionLink(request, invitee.email), + ) + + const signIn = await authPost(request, "/sign-in/email", { + email: invitee.email, + password: invitee.password, + }) + expect(signIn.status()).toBe(401) + await inviteeContext.close() + await admin.close() + }) + + test("hides the default credentials once the admin changes the password", async ({ + browser, + page, + }) => { + const admin = await signedInContext(browser, DEFAULT_ADMIN) + const change = await authPost(admin.request, "/change-password", { + currentPassword: DEFAULT_ADMIN.password, + newPassword: NEW_ADMIN_PASSWORD, + }) + expect(change.ok()).toBe(true) + await admin.close() + + await visit(page, `${SINGLE_WORKSPACE_URL}/login`) + await revealForm( + page.getByRole("button", { + name: t("onboarding.login.login-email-password"), + }), + page.getByPlaceholder( + t("onboarding.login.email-password-form.email-placeholder"), + ), + ) + + await expect( + page.getByText(t("onboarding.login.default-admin.intro")), + ).toHaveCount(0) + }) + + test("sends the admin's new email one link, which completes the change", async ({ + browser, + request, + }) => { + const admin = await signedInContext(browser, { + email: DEFAULT_ADMIN.email, + password: NEW_ADMIN_PASSWORD, + }) + const newEmail = newCredentials().email + + const change = await authPost(admin.request, "/change-email", { + newEmail, + callbackURL: `${SINGLE_WORKSPACE_URL}/`, + }) + expect(change.ok()).toBe(true) + const adminPage = await admin.newPage() + await adminPage.goto(await fetchVerificationLink(request, newEmail)) + + const signIn = await authPost(request, "/sign-in/email", { + email: newEmail, + password: NEW_ADMIN_PASSWORD, + }) + expect(signIn.ok()).toBe(true) + expect(await deliveredTo(request, newEmail)).toBe(1) + expect(await deliveredTo(request, DEFAULT_ADMIN.email)).toBe(0) + await admin.close() + }) +}) diff --git a/e2e/tests/settings.test.ts b/e2e/tests/settings.test.ts index 944254ac..94902bfd 100644 --- a/e2e/tests/settings.test.ts +++ b/e2e/tests/settings.test.ts @@ -1,8 +1,13 @@ import { expect, test } from "@playwright/test" -import { newCredentials } from "../helpers/auth" +import { newCredentials, submitLoginForm } from "../helpers/auth" import { joinAsSecondUser } from "../helpers/collaboration" import { waitForEditor } from "../helpers/editor" import { t } from "../helpers/i18n" +import { + deliveredTo, + fetchEmailChangeApprovalLink, + fetchVerificationLink, +} from "../helpers/mailpit" import { visit } from "../helpers/page" import { openSettings } from "../helpers/settings" import { signUpWithWorkspace } from "../helpers/workspace" @@ -61,6 +66,62 @@ test.describe("settings", () => { ).toHaveValue("Fresh-Nickname") }) + test("moves the login to a new address once the old one approves and the new one verifies", async ({ + page, + browser, + request, + }) => { + const { credentials, workspace } = await signUpWithWorkspace(page, request) + const newEmail = newCredentials().email + + const settings = await openSettings(page, workspace.name) + await settings + .getByRole("button", { + name: t("settings.profile.email-change-button-screen-reader-hint"), + }) + .click() + const action = page.getByRole("dialog", { + name: t("settings.action-modals.email-change.title"), + }) + await action + .getByPlaceholder( + t("settings.action-modals.email-change.new-email-placeholder"), + ) + .fill(newEmail) + await action + .getByRole("button", { + name: t("settings.action-modals.email-change.submit-button"), + }) + .click() + await expect( + page.getByText( + t("settings.action-modals.email-change.success-message.title"), + ), + ).toBeVisible() + + // the old address approves first. The new one hears nothing until + // then. + const approval = await fetchEmailChangeApprovalLink( + request, + credentials.email, + ) + expect(await deliveredTo(request, newEmail)).toBe(0) + await page.goto(approval) + + await page.goto(await fetchVerificationLink(request, newEmail)) + expect(await deliveredTo(request, newEmail)).toBe(1) + + // a browser of its own proves the server took the new address + const context = await browser.newContext() + const fresh = await context.newPage() + await submitLoginForm(fresh, { + email: newEmail, + password: credentials.password, + }) + await expect(fresh).toHaveURL(/-[a-z0-9]{20}$/, { timeout: 15_000 }) + await context.close() + }) + test("lists a joined teammate among the members", async ({ page, request, diff --git a/server/auth-realtime/AGENTS.md b/server/auth-realtime/AGENTS.md index a76da662..956431f4 100644 --- a/server/auth-realtime/AGENTS.md +++ b/server/auth-realtime/AGENTS.md @@ -30,13 +30,14 @@ opens the pool, connects to Valkey (only when `OXYNOTE_AUTH_REALTIME_VALKEY_DSN` is set; otherwise better-auth runs with no secondary storage) and listens. Everything else is a factory taking what it needs. Modules: `env.ts` (zod config), `core.ts` (every call into core), -`db.ts` (the `Store`), `reporting.ts`, `logging.ts`, `headers.ts`, `auth.ts` -(`createAuth` + better-auth callbacks), `hocuspocus.ts` (`createHocuspocus`, -`createDocumentHooks`, `flushDocument`), `routes.ts` (`createRoutes` → Hono -app), `operations.ts` (pure: assistant edit ops on a Y.Doc), `ydocument.ts` -(pure: replacing Y.Doc content), `schema/` (ProseMirror schema mirroring -web's tiptap extensions), `sentry.ts` and `bundle.ts` (docker entry: sentry -+ index in one bundle). +`db.ts` (the `Store`), `bootstrap.ts` (the single-organization mode's +organization and admin, created before listening), `reporting.ts`, +`logging.ts`, `headers.ts`, `auth.ts` (`createAuth` + better-auth +callbacks), `hocuspocus.ts` (`createHocuspocus`, `createDocumentHooks`, +`flushDocument`), `routes.ts` (`createRoutes` → Hono app), `operations.ts` +(pure: assistant edit ops on a Y.Doc), `ydocument.ts` (pure: replacing Y.Doc +content), `schema/` (ProseMirror schema mirroring web's tiptap extensions), +`sentry.ts` and `bundle.ts` (docker entry: sentry + index in one bundle). - A module never reads `process.env`; it takes an `Env`. `loadEnv` runs once in `index.ts`. `src/sentry.ts` is the exception: node loads it via @@ -68,9 +69,10 @@ web's tiptap extensions), `sentry.ts` and `bundle.ts` (docker entry: sentry Every variable is declared in `src/env.ts` and zod-parsed once at boot. A missing, malformed or half-configured value is a boot error: URLs are -scheme-pinned to http/https (bare `z.url()` accepts `core:8080`), counters -are positive integers, booleans accept only `"true"`/`"false"`, a social -provider needs both credential halves or neither. Docker env files deliver +scheme-pinned to http/https (bare `z.url()` accepts `core:8080`), limits +are `-1` (read as `Infinity`) or positive integers, booleans accept only +`"true"`/`"false"`, a social provider needs both credential halves or +neither. Docker env files deliver unset variables as `""`; `loadEnv` drops those before parsing so a required one reports as missing. diff --git a/server/auth-realtime/src/auth.test.ts b/server/auth-realtime/src/auth.test.ts index 1f819b25..51256c0b 100644 --- a/server/auth-realtime/src/auth.test.ts +++ b/server/auth-realtime/src/auth.test.ts @@ -1,6 +1,7 @@ import { describe, it, vi } from "vitest" import { authMethods, + checkSignUpAllowed, confirmWithPassword, createAuth, createOrganizationHooks, @@ -8,6 +9,8 @@ import { electronCallbackOverride, invitationLink, organizationClaims, + refuseLastMemberDeletion, + sendChangeEmailConfirmation, toPublicAuthUrl, verificationTemplate, type SecondaryStorageClient, @@ -617,6 +620,291 @@ describe("confirmWithPassword", () => { ) }) +describe("checkSignUpAllowed", () => { + it("allows any signup with more than one organization allowed", async ({ + expect, + }) => { + const store = stubStore() + + await expect( + checkSignUpAllowed( + testEnv({ maxOrganizations: 100 }), + store, + "a@b.c", + true, + ), + ).resolves.toBeUndefined() + expect.soft(store.hasPendingInvitation).not.toHaveBeenCalled() + }) + + // the bootstrap creates the admin from the service's own code + it("allows a user created outside a request", async ({ expect }) => { + const store = stubStore() + store.hasPendingInvitation.mockResolvedValue(false) + + await expect( + checkSignUpAllowed( + testEnv({ maxOrganizations: 1 }), + store, + "a@b.c", + false, + ), + ).resolves.toBeUndefined() + expect.soft(store.hasPendingInvitation).not.toHaveBeenCalled() + }) + + it("allows an invited address", async ({ expect }) => { + const store = stubStore() + store.hasPendingInvitation.mockResolvedValue(true) + + await expect( + checkSignUpAllowed( + testEnv({ maxOrganizations: 1 }), + store, + "a@b.c", + true, + ), + ).resolves.toBeUndefined() + expect.soft(store.hasPendingInvitation).toHaveBeenCalledWith( + "a@b.c", + ) + }) + + it("refuses an uninvited address", async ({ expect }) => { + const store = stubStore() + store.hasPendingInvitation.mockResolvedValue(false) + + await expect( + checkSignUpAllowed( + testEnv({ maxOrganizations: 1 }), + store, + "a@b.c", + true, + ), + ).rejects.toMatchObject({ + statusCode: 400, + body: { code: "SIGN_UP_DISABLED" }, + }) + }) + + it("propagates a failed lookup", async ({ expect }) => { + const failure = new Error("connection terminated") + const store = stubStore() + store.hasPendingInvitation.mockRejectedValue(failure) + + await expect( + checkSignUpAllowed( + testEnv({ maxOrganizations: 1 }), + store, + "a@b.c", + true, + ), + ).rejects.toBe(failure) + }) +}) + +describe("refuseLastMemberDeletion", () => { + const signedIn = () => Promise.resolve({ id: "u1", email: "a@b.c" }) + + it.for([ + { name: "the deletion request", input: "/delete-user" }, + { + name: "the emailed callback", + input: "/delete-user/callback", + }, + ])( + "refuses $name from the only member", + async ({ input }, { expect }) => { + const store = stubStore() + store.organizationMemberCount.mockResolvedValue(1) + + await expect( + refuseLastMemberDeletion( + testEnv({ maxOrganizations: 1 }), + store, + input, + signedIn, + ), + ).rejects.toMatchObject({ + statusCode: 403, + body: { code: "LAST_ORGANIZATION_MEMBER" }, + }) + expect.soft( + store.userOrganizationId, + ).toHaveBeenCalledWith("u1") + expect.soft( + store.organizationMemberCount, + ).toHaveBeenCalledWith("org-1") + }, + ) + + it("lets a member go while others remain", async ({ expect }) => { + const store = stubStore() + store.organizationMemberCount.mockResolvedValue(2) + + await expect( + refuseLastMemberDeletion( + testEnv({ maxOrganizations: 1 }), + store, + "/delete-user", + signedIn, + ), + ).resolves.toBeUndefined() + }) + + it("lets a user without an organization go", async ({ expect }) => { + const store = stubStore() + store.userOrganizationId.mockResolvedValue(null) + + await expect( + refuseLastMemberDeletion( + testEnv({ maxOrganizations: 1 }), + store, + "/delete-user", + signedIn, + ), + ).resolves.toBeUndefined() + expect.soft( + store.organizationMemberCount, + ).not.toHaveBeenCalled() + }) + + it("leaves a request without a session to the endpoint", async ({ + expect, + }) => { + const store = stubStore() + + await expect( + refuseLastMemberDeletion( + testEnv({ maxOrganizations: 1 }), + store, + "/delete-user", + () => Promise.resolve(undefined), + ), + ).resolves.toBeUndefined() + expect.soft(store.userOrganizationId).not.toHaveBeenCalled() + }) + + it.for([ + { + name: "with more than one organization allowed", + input: { maxOrganizations: 100, path: "/delete-user" }, + }, + { + name: "on every other endpoint", + input: { maxOrganizations: 1, path: "/change-email" }, + }, + ])("checks nothing $name", async ({ input }, { expect }) => { + const store = stubStore() + const currentUser = vi.fn(signedIn) + + await expect( + refuseLastMemberDeletion( + testEnv({ + maxOrganizations: + input.maxOrganizations, + }), + store, + input.path, + currentUser, + ), + ).resolves.toBeUndefined() + expect.soft(currentUser).not.toHaveBeenCalled() + expect.soft(store.userOrganizationId).not.toHaveBeenCalled() + }) + + it("propagates a failed lookup", async ({ expect }) => { + const failure = new Error("connection terminated") + const store = stubStore() + store.organizationMemberCount.mockRejectedValue(failure) + + await expect( + refuseLastMemberDeletion( + testEnv({ maxOrganizations: 1 }), + store, + "/delete-user", + signedIn, + ), + ).rejects.toBe(failure) + }) +}) + +describe("sendChangeEmailConfirmation", () => { + it("asks the current address to approve the change", async ({ + expect, + }) => { + const core = stubCore() + + await sendChangeEmailConfirmation(testEnv(), core, { + user: { email: "old@b.c" }, + newEmail: "new@b.c", + url: "http://localhost:8080/api/auth/verify-email?token=tok&callbackURL=%2F", + }) + + expect(core.sendEmail).toHaveBeenCalledTimes(1) + expect(core.sendEmail).toHaveBeenCalledWith( + "email_change_confirmation", + { + email: "old@b.c", + link: "http://localhost:8080/auth-realtime/api/auth/verify-email?token=tok&callbackURL=%2F", + }, + ) + }) + + it("sends the bootstrap admin's new address the link that completes the change", async ({ + expect, + }) => { + const core = stubCore() + + await sendChangeEmailConfirmation(testEnv(), core, { + user: { email: "admin@example.com" }, + newEmail: "new@b.c", + url: "http://localhost:8080/api/auth/verify-email?token=tok&callbackURL=%2F", + }) + + expect(core.sendEmail).toHaveBeenCalledTimes(1) + const [template, data] = core.sendEmail.mock.calls[0] ?? [] + expect(template).toBe("email_verification") + expect(data?.email).toBe("new@b.c") + + const link = new URL(data?.link ?? "") + expect(link.origin + link.pathname).toBe( + "http://localhost:8080/auth-realtime/api/auth/verify-email", + ) + expect(link.searchParams.get("callbackURL")).toBe("/") + + const payload = (link.searchParams.get("token") ?? "").split( + ".", + )[1] + expect( + JSON.parse( + Buffer.from( + payload ?? "", + "base64url", + ).toString(), + ), + ).toMatchObject({ + email: "admin@example.com", + updateTo: "new@b.c", + requestType: "change-email-verification", + }) + }) + + it("propagates a failed send", async ({ expect }) => { + const failure = new Error("core unreachable") + const core = stubCore() + core.sendEmail.mockRejectedValue(failure) + + await expect( + sendChangeEmailConfirmation(testEnv(), core, { + user: { email: "old@b.c" }, + newEmail: "new@b.c", + url: "http://localhost:8080/api/auth/verify-email?token=tok", + }), + ).rejects.toBe(failure) + }) +}) + describe("createSecondaryStorage", () => { it("reads through to the redis client", async ({ expect }) => { const redis = stubRedis() @@ -1221,6 +1509,105 @@ describe("createAuth", () => { }) }) + describe("single-organization mode", () => { + it("refuses an uninvited signup at user creation", async ({ + expect, + }) => { + const store = stubStore() + store.hasPendingInvitation.mockResolvedValue(false) + const { auth } = buildAuth({ + env: testEnv({ maxOrganizations: 1 }), + store, + }) + + await expect( + auth.options.databaseHooks.user.create.before( + { email: "a@b.c" } as never, + {} as never, + ), + ).rejects.toMatchObject({ + body: { code: "SIGN_UP_DISABLED" }, + }) + }) + + it("lets this service create a user outside a request", async ({ + expect, + }) => { + const store = stubStore() + store.hasPendingInvitation.mockResolvedValue(false) + const { auth } = buildAuth({ + env: testEnv({ maxOrganizations: 1 }), + store, + }) + + await expect( + auth.options.databaseHooks.user.create.before( + { email: "admin@example.com" } as never, + undefined as never, + ), + ).resolves.toBeUndefined() + expect.soft( + store.hasPendingInvitation, + ).not.toHaveBeenCalled() + }) + + it("refuses to delete the last member's account", async ({ + expect, + }) => { + const store = stubStore() + store.organizationMemberCount.mockResolvedValue(1) + const { auth } = buildAuth({ + env: testEnv({ maxOrganizations: 1 }), + store, + }) + + await expect( + auth.options.hooks.before({ + path: "/delete-user", + body: {}, + context: { + session: { + user: { + id: "u1", + email: "a@b.c", + }, + }, + }, + } as never), + ).rejects.toMatchObject({ + body: { code: "LAST_ORGANIZATION_MEMBER" }, + }) + }) + + it.for([ + { name: "a member limit", input: 25, expected: 25 }, + // the limit is also a member list's page size in SQL + { + name: "no member limit", + input: Infinity, + expected: Number.MAX_SAFE_INTEGER, + }, + ])( + "hands better-auth $name as a finite number", + ({ input, expected }, { expect }) => { + const { auth } = buildAuth({ + env: testEnv({ + maxOrganizationMembers: input, + }), + }) + const plugin = auth.options.plugins.find( + (p) => p.id === "organization", + ) as unknown as { + options: { membershipLimit: number } + } + + expect(plugin.options.membershipLimit).toBe( + expected, + ) + }, + ) + }) + describe("password policy", () => { it("requires a verified address and a long passphrase", ({ expect, diff --git a/server/auth-realtime/src/auth.ts b/server/auth-realtime/src/auth.ts index 461bf7e2..93ad25a5 100644 --- a/server/auth-realtime/src/auth.ts +++ b/server/auth-realtime/src/auth.ts @@ -2,6 +2,7 @@ import { betterAuth } from "better-auth" import { APIError, createAuthMiddleware, + createEmailVerificationToken, getSessionFromCtx, } from "better-auth/api" import { jwt, organization } from "better-auth/plugins" @@ -9,6 +10,7 @@ import { electron } from "@better-auth/electron" import { mcp } from "@better-auth/mcp" import type { PostgresDialect } from "kysely" import { z } from "zod" +import { bootstrapAdmin } from "./bootstrap.js" import type { Store } from "./db.js" import type { CoreClient, EmailTemplate } from "./core.js" import type { Env, SocialProviderName } from "./env.js" @@ -341,6 +343,115 @@ export async function confirmWithPassword( return ctx.json({ status: true }) } +// a single-organization deployment has no open signup: a request creates +// an account only for an address with a pending invitation. A user created +// outside any request is the bootstrap's admin. +export async function checkSignUpAllowed( + env: Env, + store: Store, + email: string, + fromRequest: boolean, +): Promise { + if (env.maxOrganizations !== 1 || !fromRequest) { + return + } + + const allowed = await reported(() => store.hasPendingInvitation(email)) + if (allowed) { + return + } + + // not a 403: better-auth answers that on signup with a fake success. + throw APIError.from("BAD_REQUEST", { + code: "SIGN_UP_DISABLED", + message: "Sign-up is by invitation only", + }) +} + +// a single-organization deployment keeps at least one member, because +// nobody can sign up to take over an empty organization. The callback is +// checked too, since members may leave between the request and the link. +export async function refuseLastMemberDeletion( + env: Env, + store: Store, + path: string, + currentUser: () => Promise, +): Promise { + if ( + env.maxOrganizations !== 1 || + (path !== "/delete-user" && path !== "/delete-user/callback") + ) { + return + } + + // the endpoint's own session check answers a caller without one. + const user = await currentUser() + if (!user) { + return + } + + const last = await reported(async () => { + const organizationId = await store.userOrganizationId(user.id) + + return ( + organizationId !== null && + (await store.organizationMemberCount(organizationId)) <= + 1 + ) + }) + if (!last) { + return + } + + throw APIError.from("FORBIDDEN", { + code: "LAST_ORGANIZATION_MEMBER", + message: "The last member of the organization cannot be deleted", + }) +} + +// the first step of a change of address: the current address approves it, +// and better-auth then emails the new one through sendVerificationEmail. +// Sending the approval to the new address would ask it to approve its own +// claim. The bootstrap admin's address receives no mail, so the new +// address gets the link that completes the change instead, the one +// better-auth sends when no approval is configured. better-auth signs its +// tokens with the configured secret, so this one verifies like its own. +export async function sendChangeEmailConfirmation( + env: Env, + core: CoreClient, + { + user, + newEmail, + url, + }: { user: { email: string }; newEmail: string; url: string }, +): Promise { + if (user.email !== bootstrapAdmin.email) { + await core.sendEmail("email_change_confirmation", { + email: user.email, + link: toPublicAuthUrl(env, url), + }) + + return + } + + const link = new URL(toPublicAuthUrl(env, url)) + link.searchParams.set( + "token", + await createEmailVerificationToken( + env.betterAuthSecret, + user.email, + newEmail, + undefined, + { requestType: "change-email-verification" }, + ), + ) + + await core.sendEmail("email_verification", { + email: newEmail, + link: link.toString(), + }) +} + export function createSecondaryStorage(redis: SecondaryStorageClient) { return { get: (key: string) => reported(() => redis.get(key)), @@ -556,23 +667,13 @@ export function createAuth({ }, changeEmail: { enabled: true, - // the first step of a change of address: the current - // address approves it, and better-auth then emails the - // new one through sendVerificationEmail above. Sending - // this to the new address instead would ask it to - // approve its own claim. sendChangeEmailConfirmation: env.emailEnabled - ? async ({ user, url }) => { + ? async (data) => { await reported(() => - core.sendEmail( - "email_change_confirmation", - { - email: user.email, - link: toPublicAuthUrl( - env, - url, - ), - }, + sendChangeEmailConfirmation( + env, + core, + data, ), ) } @@ -647,7 +748,14 @@ export function createAuth({ organizationHooks.canCreateOrganization, disableOrganizationDeletion: true, // prevent clients from deleting the org organizationLimit: 1, - membershipLimit: env.maxOrganizationMembers, + // better-auth also passes the limit to the database + // as the page size of a member list, which cannot be + // Infinity. + membershipLimit: Number.isFinite( + env.maxOrganizationMembers, + ) + ? env.maxOrganizationMembers + : Number.MAX_SAFE_INTEGER, schema: { session: { fields: { @@ -865,16 +973,21 @@ export function createAuth({ ], hooks: { before: createAuthMiddleware(async (ctx) => { + const currentUser = async () => + (await getSessionFromCtx(ctx))?.user + + await refuseLastMemberDeletion( + env, + store, + ctx.path, + currentUser, + ) + if (!env.emailEnabled) { const answer = await confirmWithPassword( ctx, - async () => - ( - await getSessionFromCtx( - ctx, - ) - )?.user, + currentUser, ) if (answer !== undefined) { return answer @@ -888,6 +1001,22 @@ export function createAuth({ }), }, databaseHooks: { + user: { + create: { + // better-auth hands the hook the + // request's context. For a call from + // this service's own code it is + // undefined, though typed as null. + before: async (user, ctx) => { + await checkSignUpAllowed( + env, + store, + user.email, + Boolean(ctx), + ) + }, + }, + }, session: { create: { before: async (session) => diff --git a/server/auth-realtime/src/bootstrap.test.ts b/server/auth-realtime/src/bootstrap.test.ts new file mode 100644 index 00000000..3eaf2cca --- /dev/null +++ b/server/auth-realtime/src/bootstrap.test.ts @@ -0,0 +1,255 @@ +import { describe, it, vi } from "vitest" +import { + bootstrapSingleOrganization, + createDefaultAdminLookup, +} from "./bootstrap.js" +import { stubCore, stubLog, stubStore, testEnv } from "./test-helpers.js" + +// a single-organization deployment with no organization and no admin yet, +// unless a test says otherwise. +function deps( + overrides: { + maxOrganizations?: number + organizations?: number + existingAdmin?: string + } = {}, +) { + const store = stubStore() + store.totalOrganizationCount.mockResolvedValue( + overrides.organizations ?? 0, + ) + + const accounts = { + findUserByEmail: vi.fn().mockResolvedValue( + overrides.existingAdmin + ? { + user: { + id: overrides.existingAdmin, + }, + } + : null, + ), + createUser: vi.fn().mockResolvedValue({ id: "admin-1" }), + linkAccount: vi.fn().mockResolvedValue({}), + } + + return { + env: testEnv({ + maxOrganizations: overrides.maxOrganizations ?? 1, + }), + store, + core: stubCore(), + log: stubLog(), + accounts, + hashPassword: vi.fn().mockResolvedValue("hashed"), + createOrganization: vi.fn().mockResolvedValue({ id: "org-1" }), + } +} + +describe("bootstrapSingleOrganization", () => { + it("creates the admin, the organization and its logo", async ({ + expect, + }) => { + const d = deps() + + await bootstrapSingleOrganization(d) + + expect(d.accounts.createUser).toHaveBeenCalledWith( + { + email: "admin@example.com", + name: "Admin", + emailVerified: true, + }, + { method: "email-password" }, + ) + expect(d.hashPassword).toHaveBeenCalledWith( + "oxynote-admin-1234", + ) + expect(d.accounts.linkAccount).toHaveBeenCalledWith({ + userId: "admin-1", + providerId: "credential", + accountId: "admin-1", + password: "hashed", + }) + expect(d.createOrganization).toHaveBeenCalledWith({ + name: "Oxynote", + slug: "oxynote", + userId: "admin-1", + }) + expect(d.core.setDefaultOrganizationLogo).toHaveBeenCalledWith( + "org-1", + ) + expect.soft(d.log.warn).toHaveBeenCalledTimes(1) + }) + + it("reuses an admin left over from a failed run", async ({ + expect, + }) => { + const d = deps({ existingAdmin: "admin-0" }) + + await bootstrapSingleOrganization(d) + + expect(d.createOrganization).toHaveBeenCalledWith({ + name: "Oxynote", + slug: "oxynote", + userId: "admin-0", + }) + expect.soft(d.accounts.createUser).not.toHaveBeenCalled() + expect.soft(d.accounts.linkAccount).not.toHaveBeenCalled() + expect.soft(d.hashPassword).not.toHaveBeenCalled() + }) + + it.for([ + { + name: "more than one organization is allowed", + input: { maxOrganizations: 100 }, + }, + { + name: "an organization exists", + input: { organizations: 1 }, + }, + ])("does nothing when $name", async ({ input }, { expect }) => { + const d = deps(input) + + await bootstrapSingleOrganization(d) + + expect.soft(d.accounts.findUserByEmail).not.toHaveBeenCalled() + expect.soft(d.createOrganization).not.toHaveBeenCalled() + expect.soft( + d.core.setDefaultOrganizationLogo, + ).not.toHaveBeenCalled() + expect.soft(d.log.warn).not.toHaveBeenCalled() + }) + + it("propagates a failed organization creation before setting a logo", async ({ + expect, + }) => { + const failure = new Error("core unreachable") + const d = deps() + d.createOrganization.mockRejectedValue(failure) + + await expect(bootstrapSingleOrganization(d)).rejects.toBe( + failure, + ) + expect.soft( + d.core.setDefaultOrganizationLogo, + ).not.toHaveBeenCalled() + expect.soft(d.log.warn).not.toHaveBeenCalled() + }) + + it("propagates a failed logo upload", async ({ expect }) => { + const failure = new Error("core unreachable") + const d = deps() + d.core.setDefaultOrganizationLogo.mockRejectedValue(failure) + + await expect(bootstrapSingleOrganization(d)).rejects.toBe( + failure, + ) + expect.soft(d.log.warn).not.toHaveBeenCalled() + }) + + it("propagates a failed admin creation", async ({ expect }) => { + const failure = new Error("connection terminated") + const d = deps() + d.accounts.createUser.mockRejectedValue(failure) + + await expect(bootstrapSingleOrganization(d)).rejects.toBe( + failure, + ) + expect.soft(d.createOrganization).not.toHaveBeenCalled() + }) +}) + +describe("createDefaultAdminLookup", () => { + it.for([ + { name: "the default password", input: true }, + { name: "a changed password", input: false }, + ])("finds the admin with $name", async ({ input }, { expect }) => { + const store = stubStore() + store.credentialPasswordHash.mockResolvedValue("hash-1") + const verifyPassword = vi.fn().mockResolvedValue(input) + + expect( + await createDefaultAdminLookup({ + store, + verifyPassword, + })(), + ).toEqual({ defaultPassword: input }) + expect.soft(store.credentialPasswordHash).toHaveBeenCalledWith( + "admin@example.com", + ) + expect.soft(verifyPassword).toHaveBeenCalledWith({ + hash: "hash-1", + password: "oxynote-admin-1234", + }) + }) + + it("answers null without verifying once the default email is gone", async ({ + expect, + }) => { + const store = stubStore() + store.credentialPasswordHash.mockResolvedValue(null) + const verifyPassword = vi.fn().mockResolvedValue(true) + + expect( + await createDefaultAdminLookup({ + store, + verifyPassword, + })(), + ).toBeNull() + expect.soft(verifyPassword).not.toHaveBeenCalled() + }) + + it("verifies an unchanged hash only once", async ({ expect }) => { + const store = stubStore() + store.credentialPasswordHash.mockResolvedValue("hash-1") + const verifyPassword = vi.fn().mockResolvedValue(true) + const check = createDefaultAdminLookup({ + store, + verifyPassword, + }) + + await check() + const answer = await check() + + expect(answer).toEqual({ defaultPassword: true }) + expect.soft(store.credentialPasswordHash).toHaveBeenCalledTimes( + 2, + ) + expect.soft(verifyPassword).toHaveBeenCalledTimes(1) + }) + + it("verifies again once the password changes", async ({ expect }) => { + const store = stubStore() + store.credentialPasswordHash + .mockResolvedValueOnce("hash-1") + .mockResolvedValueOnce("hash-2") + const verifyPassword = vi + .fn() + .mockResolvedValueOnce(true) + .mockResolvedValueOnce(false) + const check = createDefaultAdminLookup({ + store, + verifyPassword, + }) + + await check() + const answer = await check() + + expect(answer).toEqual({ defaultPassword: false }) + expect.soft(verifyPassword).toHaveBeenCalledTimes(2) + }) + + it("propagates a failed lookup", async ({ expect }) => { + const failure = new Error("connection terminated") + const store = stubStore() + store.credentialPasswordHash.mockRejectedValue(failure) + + await expect( + createDefaultAdminLookup({ + store, + verifyPassword: vi.fn(), + })(), + ).rejects.toBe(failure) + }) +}) diff --git a/server/auth-realtime/src/bootstrap.ts b/server/auth-realtime/src/bootstrap.ts new file mode 100644 index 00000000..c82edc30 --- /dev/null +++ b/server/auth-realtime/src/bootstrap.ts @@ -0,0 +1,153 @@ +import type { CoreClient } from "./core.js" +import type { Store } from "./db.js" +import type { Env } from "./env.js" +import type { Logger } from "./logging.js" + +// the first account of a single-organization deployment. No mail reaches +// the address, because example.com is reserved (RFC 2606). The password +// follows the signup form's rules: 16 characters, a digit and a symbol. +export const bootstrapAdmin = { + email: "admin@example.com", + password: "oxynote-admin-1234", + name: "Admin", +} + +const bootstrapOrganization = { + name: "Oxynote", + slug: "oxynote", +} + +// the parts of better-auth's internal adapter that the bootstrap uses. +export interface AccountAdapter { + findUserByEmail(email: string): Promise<{ user: { id: string } } | null> + createUser( + user: { email: string; name: string; emailVerified: boolean }, + source: { method: "email-password" }, + ): Promise<{ id: string }> + linkAccount(account: { + userId: string + providerId: string + accountId: string + password: string + }): Promise +} + +export interface BootstrapDeps { + env: Env + store: Store + core: CoreClient + log: Logger + accounts: AccountAdapter + hashPassword: (password: string) => Promise + // better-auth's createOrganization. It runs the organization hooks + // and makes the user the owner. + createOrganization: (organization: { + name: string + slug: string + userId: string + }) => Promise<{ id: string }> +} + +// bootstrapSingleOrganization creates the organization and its admin when a +// single-organization deployment has none yet. It runs before the service +// listens, so no signup can race it. An admin left over from a failed run +// is reused. +export async function bootstrapSingleOrganization({ + env, + store, + core, + log, + accounts, + hashPassword, + createOrganization, +}: BootstrapDeps): Promise { + if (env.maxOrganizations !== 1) { + return + } + + if ((await store.totalOrganizationCount()) > 0) { + return + } + + const existing = await accounts.findUserByEmail(bootstrapAdmin.email) + let userId = existing?.user.id + + if (!userId) { + // the admin starts verified. Its address cannot receive a + // verification link. + const user = await accounts.createUser( + { + email: bootstrapAdmin.email, + name: bootstrapAdmin.name, + emailVerified: true, + }, + { method: "email-password" }, + ) + + await accounts.linkAccount({ + userId: user.id, + providerId: "credential", + accountId: user.id, + password: await hashPassword(bootstrapAdmin.password), + }) + + userId = user.id + } + + const organization = await createOrganization({ + ...bootstrapOrganization, + userId, + }) + + await core.setDefaultOrganizationLogo(organization.id) + + log.warn( + `created the ${bootstrapOrganization.name} organization. Sign in as ${bootstrapAdmin.email} with the default password and change both.`, + ) +} + +export interface DefaultAdmin { + // whether the account still uses the default password. + defaultPassword: boolean +} + +export interface DefaultAdminLookupDeps { + store: Store + verifyPassword: (data: { + hash: string + password: string + }) => Promise +} + +// createDefaultAdminLookup finds the bootstrap admin by its default email. +// It answers null once that account is gone or has a new email. Otherwise +// it tells whether the account still uses the default password. Checking a +// password is slow on purpose. So that answer is cached per stored hash, +// and checked again only when the hash changes. +export function createDefaultAdminLookup({ + store, + verifyPassword, +}: DefaultAdminLookupDeps): () => Promise { + let checked: { hash: string; matches: boolean } | undefined + + return async () => { + const hash = await store.credentialPasswordHash( + bootstrapAdmin.email, + ) + if (!hash) { + return null + } + + if (checked?.hash !== hash) { + checked = { + hash, + matches: await verifyPassword({ + hash, + password: bootstrapAdmin.password, + }), + } + } + + return { defaultPassword: checked.matches } + } +} diff --git a/server/auth-realtime/src/core.test.ts b/server/auth-realtime/src/core.test.ts index 20c41d23..862b3be5 100644 --- a/server/auth-realtime/src/core.test.ts +++ b/server/auth-realtime/src/core.test.ts @@ -108,6 +108,24 @@ describe("createCoreClient", () => { }) }) + describe("setDefaultOrganizationLogo", () => { + it("puts to the organization's default logo endpoint", async ({ + expect, + }) => { + const http = stubHttp() + + await createCoreClient( + BASE_URL, + http, + ).setDefaultOrganizationLogo("org-1") + + expect(http.put).toHaveBeenCalledTimes(1) + expect(http.put).toHaveBeenCalledWith( + "http://core:8080/api/x/organizations/org-1/logo/default", + ) + }) + }) + describe("fetchBranches", () => { it("returns the branch list core responded with", async ({ expect, diff --git a/server/auth-realtime/src/core.ts b/server/auth-realtime/src/core.ts index bf4ba102..279f10a1 100644 --- a/server/auth-realtime/src/core.ts +++ b/server/auth-realtime/src/core.ts @@ -93,6 +93,7 @@ export interface CoreClient { ): Promise initializeOrganization(organizationId: string): Promise teardownOrganization(organizationId: string): Promise + setDefaultOrganizationLogo(organizationId: string): Promise fetchBranches(documentId: string): Promise fetchBranchContent( documentId: string, @@ -157,6 +158,12 @@ export function createCoreClient( ) }, + async setDefaultOrganizationLogo(organizationId) { + await http.put( + `${internal}/organizations/${organizationId}/logo/default`, + ) + }, + async fetchBranches(documentId) { const response = await http.get( `${internal}/documents/${documentId}/branches`, diff --git a/server/auth-realtime/src/db.test.ts b/server/auth-realtime/src/db.test.ts index 43dc1808..8946dce9 100644 --- a/server/auth-realtime/src/db.test.ts +++ b/server/auth-realtime/src/db.test.ts @@ -9,6 +9,7 @@ function stubDb(row: unknown) { const chain = { select: vi.fn(() => chain), where: vi.fn(() => chain), + innerJoin: vi.fn(() => chain), executeTakeFirst: vi.fn().mockResolvedValue(row), executeTakeFirstOrThrow: vi.fn().mockResolvedValue(row), } @@ -24,10 +25,10 @@ function stubDb(row: unknown) { describe("createStore", () => { describe("totalOrganizationCount", () => { - it("returns the count the query answered with", async ({ + it("returns the count the query answered with as a number", async ({ expect, }) => { - const { store, selectFrom } = stubDb({ count: 7 }) + const { store, selectFrom } = stubDb({ count: "7" }) expect(await store.totalOrganizationCount()).toBe(7) expect(selectFrom).toHaveBeenCalledWith("organizations") @@ -46,6 +47,154 @@ describe("createStore", () => { }) }) + describe("organizationMemberCount", () => { + it("counts the organization's members as a number", async ({ + expect, + }) => { + const { store, chain, selectFrom } = stubDb({ + count: "3", + }) + + expect( + await store.organizationMemberCount("org-1"), + ).toBe(3) + expect(selectFrom).toHaveBeenCalledWith( + "organization_members", + ) + expect(chain.where).toHaveBeenCalledWith( + "fk_organization_id", + "=", + "org-1", + ) + }) + + it("propagates a failed query", async ({ expect }) => { + const failure = new Error("connection terminated") + const { store, chain } = stubDb(null) + chain.executeTakeFirstOrThrow.mockRejectedValue(failure) + + await expect( + store.organizationMemberCount("org-1"), + ).rejects.toBe(failure) + }) + }) + + describe("hasPendingInvitation", () => { + it("matches a pending, unexpired invitation on the lowercased address", async ({ + expect, + }) => { + const { store, chain, selectFrom } = stubDb({ + id: "inv-1", + }) + + expect( + await store.hasPendingInvitation( + "Jane@Example.com", + ), + ).toBe(true) + expect(selectFrom).toHaveBeenCalledWith( + "organization_invitations", + ) + expect(chain.where).toHaveBeenCalledWith( + "email", + "=", + "jane@example.com", + ) + expect(chain.where).toHaveBeenCalledWith( + "status", + "=", + "pending", + ) + expect(chain.where).toHaveBeenCalledWith( + "expires_at", + ">", + expect.any(Date), + ) + }) + + it("reports no invitation when none matches", async ({ + expect, + }) => { + const { store } = stubDb(undefined) + + expect( + await store.hasPendingInvitation( + "jane@example.com", + ), + ).toBe(false) + }) + + it("propagates a failed query", async ({ expect }) => { + const failure = new Error("connection terminated") + const { store, chain } = stubDb(undefined) + chain.executeTakeFirst.mockRejectedValue(failure) + + await expect( + store.hasPendingInvitation("jane@example.com"), + ).rejects.toBe(failure) + }) + }) + + describe("credentialPasswordHash", () => { + it("returns the hash of the user's password account", async ({ + expect, + }) => { + const { store, chain, selectFrom } = stubDb({ + password: "salt:hash", + }) + + expect( + await store.credentialPasswordHash( + "admin@example.com", + ), + ).toBe("salt:hash") + expect(selectFrom).toHaveBeenCalledWith("users") + expect(chain.innerJoin).toHaveBeenCalledWith( + "user_accounts", + "user_accounts.fk_user_id", + "users.id", + ) + expect(chain.where).toHaveBeenCalledWith( + "users.email", + "=", + "admin@example.com", + ) + expect(chain.where).toHaveBeenCalledWith( + "user_accounts.provider_id", + "=", + "credential", + ) + }) + + it.for([ + { name: "no such user", input: undefined }, + { + name: "an account without a password", + input: { password: null }, + }, + ])("returns null for $name", async ({ input }, { expect }) => { + const { store } = stubDb(input) + + expect( + await store.credentialPasswordHash( + "admin@example.com", + ), + ).toBeNull() + }) + + it("propagates a failed query", async ({ expect }) => { + const failure = new Error("connection terminated") + const { store, chain } = stubDb(undefined) + chain.executeTakeFirst.mockRejectedValue(failure) + + await expect( + store.credentialPasswordHash( + "admin@example.com", + ), + ).rejects.toBe(failure) + }) + }) + describe("userOrganizationId", () => { it("returns the organization the user belongs to", async ({ expect, diff --git a/server/auth-realtime/src/db.ts b/server/auth-realtime/src/db.ts index 520295ac..3cca61ab 100644 --- a/server/auth-realtime/src/db.ts +++ b/server/auth-realtime/src/db.ts @@ -6,8 +6,10 @@ import { Kysely, PostgresDialect } from "kysely" // query below touches. export interface Database { users: UsersTable + user_accounts: UserAccountsTable organizations: OrganizationsTable organization_members: OrganizationMembersTable + organization_invitations: OrganizationInvitationsTable oauth_consents: OAuthConsentsTable } @@ -16,6 +18,12 @@ export interface UsersTable { email: string } +export interface UserAccountsTable { + fk_user_id: string + provider_id: string + password: string | null +} + export interface OrganizationsTable { id: string } @@ -25,6 +33,13 @@ export interface OrganizationMembersTable { fk_organization_id: string } +export interface OrganizationInvitationsTable { + id: string + email: string + status: string + expires_at: Date +} + export interface OAuthConsentsTable { client_id: string fk_user_id: string @@ -36,6 +51,9 @@ export interface OAuthConsentsTable { // tested against four stubbed methods instead of a mocked chain. export interface Store { totalOrganizationCount(): Promise + organizationMemberCount(organizationId: string): Promise + hasPendingInvitation(email: string): Promise + credentialPasswordHash(email: string): Promise userOrganizationId(userId: string): Promise hasOAuthConsent(clientId: string, userId: string): Promise isOrganizationMember( @@ -58,10 +76,62 @@ export function createStore(db: Kysely): Store { async totalOrganizationCount() { const res = await db .selectFrom("organizations") - .select(db.fn.countAll().as("count")) + .select(db.fn.countAll().as("count")) .executeTakeFirstOrThrow() - return res.count + // pg hands a bigint count back as a string. + return Number(res.count) + }, + + async organizationMemberCount(organizationId) { + const res = await db + .selectFrom("organization_members") + .where( + "fk_organization_id", + "=", + organizationId, + ) + .select(db.fn.countAll().as("count")) + .executeTakeFirstOrThrow() + + // pg hands a bigint count back as a string. + return Number(res.count) + }, + + // better-auth stores both invitation and user addresses + // lowercased. + async hasPendingInvitation(email) { + const res = await db + .selectFrom("organization_invitations") + .where("email", "=", email.toLowerCase()) + .where("status", "=", "pending") + .where("expires_at", ">", new Date()) + .select("id") + .executeTakeFirst() + + return res !== undefined + }, + + // the user's stored password hash. Null when the user does not + // exist or has no password, like a social login. + async credentialPasswordHash(email) { + const res = await db + .selectFrom("users") + .innerJoin( + "user_accounts", + "user_accounts.fk_user_id", + "users.id", + ) + .where("users.email", "=", email) + .where( + "user_accounts.provider_id", + "=", + "credential", + ) + .select("user_accounts.password") + .executeTakeFirst() + + return res?.password ?? null }, // a user belongs to at most one organization, which is what diff --git a/server/auth-realtime/src/env.test.ts b/server/auth-realtime/src/env.test.ts index 96b6cdcb..ca2bb39e 100644 --- a/server/auth-realtime/src/env.test.ts +++ b/server/auth-realtime/src/env.test.ts @@ -170,12 +170,14 @@ describe("loadEnv", () => { }) }) - describe("counters", () => { - it("falls back to the defaults when unset", ({ expect }) => { + describe("limits", () => { + it("falls back to one organization with unlimited members when unset", ({ + expect, + }) => { const env = loadEnv(completeEnv()) - expect(env.maxOrganizations).toBe(100) - expect(env.maxOrganizationMembers).toBe(5) + expect(env.maxOrganizations).toBe(1) + expect(env.maxOrganizationMembers).toBe(Infinity) }) it("reads the configured values", ({ expect }) => { @@ -192,11 +194,25 @@ describe("loadEnv", () => { expect(env.maxOrganizationMembers).toBe(25) }) + it("reads -1 as no limit", ({ expect }) => { + const env = loadEnv( + completeEnv({ + OXYNOTE_AUTH_REALTIME_MAX_ORGANIZATIONS: + "-1", + OXYNOTE_AUTH_REALTIME_MAX_ORGANIZATION_MEMBERS: + "-1", + }), + ) + + expect(env.maxOrganizations).toBe(Infinity) + expect(env.maxOrganizationMembers).toBe(Infinity) + }) + it.for([ { name: "a word", input: "many" }, { name: "a fraction", input: "2.5" }, { name: "zero", input: "0" }, - { name: "a negative count", input: "-1" }, + { name: "a negative count other than -1", input: "-2" }, ])( "rejects $name as an organization limit", ({ input }, { expect }) => { diff --git a/server/auth-realtime/src/env.ts b/server/auth-realtime/src/env.ts index 1b14bf7a..24b5e389 100644 --- a/server/auth-realtime/src/env.ts +++ b/server/auth-realtime/src/env.ts @@ -38,7 +38,11 @@ export interface Env { socialProviders: Partial< Record > + // Infinity when unlimited. A limit of one organization is the + // single-organization mode: it is created at boot, and only invited + // users may sign up. maxOrganizations: number + // Infinity when unlimited. maxOrganizationMembers: number rateLimitEnabled: boolean // false on a deployment with no email relay, where no link better-auth @@ -54,17 +58,20 @@ function httpUrl() { return z.url({ protocol: /^https?$/ }) } -// an unset counter falls back to its default; a present one must be a -// positive integer, so a typo is a boot error instead of a NaN limit that -// compares false against every count. -function counter(fallback: number) { +// a limit is -1 for none or a positive integer, read as Infinity and the +// number respectively. Anything else is a boot error. +function limit(fallback: number) { return z .string() + .regex(/^(-1|[1-9]\d*)$/, "must be -1 or a positive integer") .optional() - .transform((value) => - value === undefined ? fallback : Number(value), - ) - .pipe(z.number().int().positive()) + .transform((value) => { + if (value === undefined) { + return fallback + } + + return value === "-1" ? Infinity : Number(value) + }) } // an unset address falls back to all interfaces on the default port; a @@ -148,8 +155,8 @@ const schema = z httpUrl(), OXYNOTE_AUTH_REALTIME_TRUSTED_ORIGINS: z.string().optional(), OXYNOTE_AUTH_REALTIME_MCP_RESOURCE: httpUrl(), - OXYNOTE_AUTH_REALTIME_MAX_ORGANIZATIONS: counter(100), - OXYNOTE_AUTH_REALTIME_MAX_ORGANIZATION_MEMBERS: counter(5), + OXYNOTE_AUTH_REALTIME_MAX_ORGANIZATIONS: limit(1), + OXYNOTE_AUTH_REALTIME_MAX_ORGANIZATION_MEMBERS: limit(Infinity), // better-auth's limiter buckets by client IP, and sign-in and // sign-up share one allowance of three requests per ten // seconds. That is wrong for any caller whose traffic arrives diff --git a/server/auth-realtime/src/index.ts b/server/auth-realtime/src/index.ts index 8c13bd2e..e26af67f 100644 --- a/server/auth-realtime/src/index.ts +++ b/server/auth-realtime/src/index.ts @@ -7,6 +7,10 @@ import { loadEnv } from "./env.js" import { createDatabase } from "./db.js" import { createCoreClient } from "./core.js" import { createAuth } from "./auth.js" +import { + bootstrapSingleOrganization, + createDefaultAdminLookup, +} from "./bootstrap.js" import { createHocuspocus } from "./hocuspocus.js" import { createLogger } from "./logging.js" import { createRoutes } from "./routes.js" @@ -38,6 +42,19 @@ if (env.valkeyDsn) { } const auth = createAuth({ env, store, dialect, redis, core, log }) +const authContext = await auth.$context + +// before listening, so no signup can race the organization's creation. +await bootstrapSingleOrganization({ + env, + store, + core, + log, + accounts: authContext.internalAdapter, + hashPassword: (password) => authContext.password.hash(password), + createOrganization: (organization) => + auth.api.createOrganization({ body: organization }), +}) const { instance: hocuspocus, @@ -63,6 +80,11 @@ app.route( hocuspocus, flushDocument, resetConnections, + findDefaultAdmin: createDefaultAdminLookup({ + store, + verifyPassword: (data) => + authContext.password.verify(data), + }), core, }), ) diff --git a/server/auth-realtime/src/routes.test.ts b/server/auth-realtime/src/routes.test.ts index 1ad35fd0..a78c9bf5 100644 --- a/server/auth-realtime/src/routes.test.ts +++ b/server/auth-realtime/src/routes.test.ts @@ -1,7 +1,11 @@ import { describe, it, vi, type Mock } from "vitest" import * as Y from "yjs" import { SignJWT, exportJWK, generateKeyPair, type JWTPayload } from "jose" -import { createRoutes, type DocumentRegistry } from "./routes.js" +import { + createRoutes, + type DocumentRegistry, + type RouteDeps, +} from "./routes.js" import { isSystemContext, replaceYdocContent } from "./ydocument.js" import { fragmentXml, @@ -65,6 +69,7 @@ function build( store?: StubStore hocuspocus?: DocumentRegistry flushDocument?: Mock<(documentName: string) => Promise> + findDefaultAdmin?: Mock core?: StubCore } = {}, ) { @@ -74,6 +79,8 @@ function build( const flushDocument: Mock<(documentName: string) => Promise> = overrides.flushDocument ?? vi.fn().mockResolvedValue(undefined) const resetConnections = vi.fn<(documentName: string) => void>() + const findDefaultAdmin: Mock = + overrides.findDefaultAdmin ?? vi.fn().mockResolvedValue(null) const app = createRoutes({ env: overrides.env ?? ENV, auth, @@ -81,10 +88,19 @@ function build( hocuspocus: overrides.hocuspocus ?? stubRegistry().registry, flushDocument, resetConnections, + findDefaultAdmin, core, }) - return { app, auth, core, store, flushDocument, resetConnections } + return { + app, + auth, + core, + store, + flushDocument, + resetConnections, + findDefaultAdmin, + } } // the order in which the flushes and the core call happened, which is @@ -219,6 +235,9 @@ describe("createRoutes", () => { expect(await res.json()).toEqual({ methods: ["email-password", "github"], emailEnabled: true, + singleOrganization: false, + maxOrganizationMembers: 5, + defaultAdmin: null, }) }) @@ -235,6 +254,107 @@ describe("createRoutes", () => { expect(await res.json()).toEqual({ methods: ["email-password"], emailEnabled: false, + singleOrganization: false, + maxOrganizationMembers: 5, + defaultAdmin: null, + }) + }) + + it("reports single-organization mode and no member limit as null", async ({ + expect, + }) => { + const { app } = build({ + env: testEnv({ + maxOrganizations: 1, + maxOrganizationMembers: Infinity, + }), + }) + + const res = await app.request("/auth-config") + + expect(await res.json()).toEqual({ + methods: ["email-password"], + emailEnabled: true, + singleOrganization: true, + maxOrganizationMembers: null, + defaultAdmin: null, + }) + }) + + it.for([ + { + name: "with the default password", + input: true, + expected: "oxynote-admin-1234", + }, + { + name: "without the default password", + input: false, + expected: null, + }, + ])( + "publishes the default admin $name", + async ({ input, expected }, { expect }) => { + const findDefaultAdmin = vi + .fn() + .mockResolvedValue({ + defaultPassword: input, + }) + const { app } = build({ + env: testEnv({ maxOrganizations: 1 }), + findDefaultAdmin, + }) + + const res = await app.request("/auth-config") + + expect(await res.json()).toMatchObject({ + defaultAdmin: { + email: "admin@example.com", + password: expected, + }, + }) + expect.soft( + findDefaultAdmin, + ).toHaveBeenCalledTimes(1) + }, + ) + + it("never looks for the default admin with more than one organization allowed", async ({ + expect, + }) => { + const findDefaultAdmin = vi + .fn() + .mockResolvedValue({ defaultPassword: true }) + const { app } = build({ findDefaultAdmin }) + + const res = await app.request("/auth-config") + + expect(await res.json()).toMatchObject({ + defaultAdmin: null, + }) + expect.soft(findDefaultAdmin).not.toHaveBeenCalled() + }) + + it("still answers, without the admin, when the lookup fails", async ({ + expect, + }) => { + const { app } = build({ + env: testEnv({ maxOrganizations: 1 }), + findDefaultAdmin: vi + .fn() + .mockRejectedValue( + new Error( + "connection terminated", + ), + ), + }) + + const res = await app.request("/auth-config") + + expect(res.status).toBe(200) + expect(await res.json()).toMatchObject({ + methods: ["email-password"], + defaultAdmin: null, }) }) }) @@ -268,6 +388,25 @@ describe("createRoutes", () => { expect(await res.json()).toEqual({ availableSlots: 10 }) }) + + it("reports null slots without an organization limit", async ({ + expect, + }) => { + const store = stubStore() + const { app } = build({ + store, + env: testEnv({ maxOrganizations: Infinity }), + }) + + const res = await app.request("/organizations/stats") + + expect(await res.json()).toEqual({ + availableSlots: null, + }) + expect.soft( + store.totalOrganizationCount, + ).not.toHaveBeenCalled() + }) }) describe("POST /documents/:documentId/branches", () => { diff --git a/server/auth-realtime/src/routes.ts b/server/auth-realtime/src/routes.ts index 75e2210e..74240445 100644 --- a/server/auth-realtime/src/routes.ts +++ b/server/auth-realtime/src/routes.ts @@ -13,6 +13,7 @@ import type { } from "./core.js" import type { Env } from "./env.js" import { authMethods } from "./auth.js" +import { bootstrapAdmin, type DefaultAdmin } from "./bootstrap.js" import { toAxiosHeaders } from "./headers.js" import { bestEffort } from "./reporting.js" import { replaceYdocContent, systemOrigin } from "./ydocument.js" @@ -54,6 +55,8 @@ export interface RouteDeps { // drops every client's socket to a document so each reconnects and // authenticates again. resetConnections: (documentName: string) => void + // the bootstrap admin, while it still has its default email. + findDefaultAdmin: () => Promise core: CoreClient } @@ -113,6 +116,7 @@ export function createRoutes({ hocuspocus, flushDocument, resetConnections, + findDefaultAdmin, core, }: RouteDeps): Hono { const app = new Hono() @@ -223,16 +227,55 @@ export function createRoutes({ }) // public auth configuration for the login/signup pages: which social - // providers are configured, and whether emailed links can arrive at - // all. Lives outside the better-auth /auth/** namespace. - app.get("/auth-config", (c) => { + // providers are configured, whether emailed links can arrive at all, + // and the organization limits. Null is no member limit. defaultAdmin + // is set while the bootstrap admin keeps its default email. Its + // password is set only while that still works, so the login page can + // show it. Lives outside the better-auth /auth/** namespace. + app.get("/auth-config", async (c) => { + let defaultAdmin: { + email: string + password: string | null + } | null = null + + // if the lookup fails, leave the admin out. The login page still + // needs the rest of this answer. + if (env.maxOrganizations === 1) { + try { + const admin = await findDefaultAdmin() + + if (admin) { + defaultAdmin = { + email: bootstrapAdmin.email, + password: admin.defaultPassword + ? bootstrapAdmin.password + : null, + } + } + } catch (error) { + Sentry.captureException(error) + } + } + return c.json({ methods: authMethods(env), emailEnabled: env.emailEnabled, + singleOrganization: env.maxOrganizations === 1, + maxOrganizationMembers: Number.isFinite( + env.maxOrganizationMembers, + ) + ? env.maxOrganizationMembers + : null, + defaultAdmin, }) }) + // null slots is no organization limit. app.get("/organizations/stats", async (c) => { + if (!Number.isFinite(env.maxOrganizations)) { + return c.json({ availableSlots: null }) + } + const count = await store.totalOrganizationCount() return c.json({ availableSlots: env.maxOrganizations - count }) }) diff --git a/server/auth-realtime/src/test-helpers.ts b/server/auth-realtime/src/test-helpers.ts index 088bbfbd..8127d245 100644 --- a/server/auth-realtime/src/test-helpers.ts +++ b/server/auth-realtime/src/test-helpers.ts @@ -15,6 +15,9 @@ export type StubStore = { export function stubStore(): StubStore { return { totalOrganizationCount: vi.fn().mockResolvedValue(0), + organizationMemberCount: vi.fn().mockResolvedValue(2), + hasPendingInvitation: vi.fn().mockResolvedValue(true), + credentialPasswordHash: vi.fn().mockResolvedValue(null), userOrganizationId: vi.fn().mockResolvedValue("org-1"), hasOAuthConsent: vi.fn().mockResolvedValue(true), isOrganizationMember: vi.fn().mockResolvedValue(true), @@ -39,6 +42,9 @@ export function stubCore(): StubCore { sendEmail: vi.fn().mockResolvedValue(undefined), initializeOrganization: vi.fn().mockResolvedValue(undefined), teardownOrganization: vi.fn().mockResolvedValue(undefined), + setDefaultOrganizationLogo: vi + .fn() + .mockResolvedValue(undefined), fetchBranches: vi.fn().mockResolvedValue([]), fetchBranchContent: vi.fn().mockResolvedValue({ documentName: "Doc", diff --git a/server/core/cmd/core/main.go b/server/core/cmd/core/main.go index 1bdf561c..05b9cdd5 100644 --- a/server/core/cmd/core/main.go +++ b/server/core/cmd/core/main.go @@ -276,6 +276,7 @@ func main() { //nolint:maintidx // main performs linear wiring of all components Password: buildinfo.Getenv("EMAIL_SMTP_PASSWORD"), TLS: email.TLSMode(buildinfo.Getenv("EMAIL_SMTP_TLS")), FromAddress: buildinfo.Getenv("EMAIL_FROM_ADDRESS"), + PublicURL: buildinfo.Getenv("SERVER_PUBLIC_URL"), }, ) if err != nil { diff --git a/server/core/internal/email/email.go b/server/core/internal/email/email.go index 9f134739..50fdab0a 100644 --- a/server/core/internal/email/email.go +++ b/server/core/internal/email/email.go @@ -6,6 +6,8 @@ import ( "context" "fmt" "log/slog" + "maps" + "net/url" "strconv" "time" @@ -38,6 +40,9 @@ const _sendTimeout = 30 * time.Second // _linkKey is the template argument carrying the action URL. const _linkKey = "link" +// _hostKey is the template argument carrying the instance's host. +const _hostKey = "host" + // Config holds SMTP connection settings for the email sender. type Config struct { // Host is the SMTP server host. When empty, email sending is @@ -61,6 +66,10 @@ type Config struct { // FromAddress is the address emails are sent from, in // "Name
" or plain "address" form. FromAddress string + + // PublicURL is the instance's public URL. Its host is named in the + // footer of every email. + PublicURL string } // _maxSendRetries caps the retries of one delivery. @@ -75,21 +84,28 @@ type Sender struct { // field so tests can substitute a faster one. backoffStrategy func() backoff.BackOff - supv *xync.Supervisor - fromEmail string + supv *xync.Supervisor + fromEmail string + publicHost string } // NewSender creates a fresh instance of email sender. // An empty cfg.Host yields a sender with sending disabled: every email // is logged instead of sent. func NewSender(log *slog.Logger, cfg Config) (*Sender, error) { + publicURL, err := url.Parse(cfg.PublicURL) + if err != nil || publicURL.Host == "" { + return nil, fmt.Errorf("invalid public url %q", cfg.PublicURL) + } + sender := &Sender{ log: log, backoffStrategy: func() backoff.BackOff { return backoff.NewExponentialBackOff() }, - supv: xync.NewSupervisor(), - fromEmail: cfg.FromAddress, + supv: xync.NewSupervisor(), + fromEmail: cfg.FromAddress, + publicHost: publicURL.Host, } if cfg.Host == "" { @@ -151,7 +167,10 @@ func (s *Sender) send(toEmail, subject string, tmpl Template, args map[string]st return } - body, err := render(tmpl, args) + vars := map[string]string{_hostKey: s.publicHost} + maps.Copy(vars, args) + + body, err := render(tmpl, vars) if err != nil { s.log.Error( "cannot render email template", diff --git a/server/core/internal/email/email_test.go b/server/core/internal/email/email_test.go index c92a5136..bfcb0e45 100644 --- a/server/core/internal/email/email_test.go +++ b/server/core/internal/email/email_test.go @@ -25,11 +25,24 @@ func Test_NewSender(t *testing.T) { NilClient bool Err error }{ + "Error returned by invalid public url": { + Cfg: Config{ + PublicURL: "::not-a-url", + }, + Err: assert.AnError, + }, + "Error returned by public url without a host": { + Cfg: Config{ + PublicURL: "/core", + }, + Err: assert.AnError, + }, "Error returned by invalid port": { Cfg: Config{ - Host: "localhost", - Port: "not-a-port", - TLS: TLSModeNone, + Host: "localhost", + Port: "not-a-port", + TLS: TLSModeNone, + PublicURL: "https://notes.example.com/core", }, Err: assert.AnError, }, @@ -38,22 +51,25 @@ func Test_NewSender(t *testing.T) { Host: "localhost", // out of the valid port range, so the client option // fails inside mail.NewClient rather than in Atoi. - Port: "99999", - TLS: TLSModeNone, + Port: "99999", + TLS: TLSModeNone, + PublicURL: "https://notes.example.com/core", }, Err: assert.AnError, }, "Error returned by invalid tls mode": { Cfg: Config{ - Host: "localhost", - Port: "1025", - TLS: "ssl3", + Host: "localhost", + Port: "1025", + TLS: "ssl3", + PublicURL: "https://notes.example.com/core", }, Err: assert.AnError, }, "Successful creation without a host": { Cfg: Config{ FromAddress: "Oxynote ", + PublicURL: "https://notes.example.com/core", }, NilClient: true, }, @@ -63,6 +79,7 @@ func Test_NewSender(t *testing.T) { Port: "1025", TLS: TLSModeNone, FromAddress: "Oxynote ", + PublicURL: "https://notes.example.com/core", }, }, "Successful creation with starttls config and auth": { @@ -73,6 +90,7 @@ func Test_NewSender(t *testing.T) { Password: "pass", TLS: TLSModeStartTLS, FromAddress: "team@oxynote.io", + PublicURL: "https://notes.example.com/core", }, }, "Successful creation with implicit tls config": { @@ -81,6 +99,7 @@ func Test_NewSender(t *testing.T) { Port: "465", TLS: TLSModeTLS, FromAddress: "team@oxynote.io", + PublicURL: "https://notes.example.com/core", }, }, } @@ -102,6 +121,7 @@ func Test_NewSender(t *testing.T) { assert.Equal(t, log, sender.log) assert.NotNil(t, sender.backoffStrategy) assert.Equal(t, c.Cfg.FromAddress, sender.fromEmail) + assert.Equal(t, "notes.example.com", sender.publicHost) if c.NilClient { assert.Nil(t, sender.client) @@ -249,8 +269,8 @@ func Test_Sender_Send(t *testing.T) { "Organization invitation": { Template: TemplateOrganizationInvitation, Data: Data{Email: "user@example.com", Organization: "Acme", Link: "https://example.com/join"}, - Subject: "Join Acme on Oxynote", - Contains: []string{"https://example.com/join", "Acme"}, + Subject: "You're invited to the Acme workspace", + Contains: []string{"https://example.com/join", "Acme", "Sent by Oxynote at notes.example.com"}, }, "User deletion confirmation": { Template: TemplateUserDeletion, @@ -341,6 +361,7 @@ func stubSender(client *clientMock) *Sender { backoffStrategy: func() backoff.BackOff { return &backoff.ZeroBackOff{} }, supv: xync.NewSupervisor(), fromEmail: "Oxynote ", + publicHost: "notes.example.com", } } diff --git a/server/core/internal/email/templates.go b/server/core/internal/email/templates.go index f77322ce..9add2e5e 100644 --- a/server/core/internal/email/templates.go +++ b/server/core/internal/email/templates.go @@ -58,7 +58,7 @@ var _specs = map[Template]spec{ args: linkArgs, }, TemplateOrganizationInvitation: { - subjectFrom: func(d Data) string { return fmt.Sprintf("Join %s on Oxynote", d.Organization) }, + subjectFrom: func(d Data) string { return fmt.Sprintf("You're invited to the %s workspace", d.Organization) }, args: func(d Data) map[string]string { return map[string]string{ _linkKey: d.Link, diff --git a/server/core/internal/email/templates/account_exists.html b/server/core/internal/email/templates/account_exists.html index e509fc40..fe339767 100644 --- a/server/core/internal/email/templates/account_exists.html +++ b/server/core/internal/email/templates/account_exists.html @@ -152,7 +152,7 @@

If this wasn’t you, you can safely ignore this email — no new account was created. If you’ve forgotten your password, you can reset it from the login page. If you’re worried about your account, reach out to us at hello@oxynote.io.

+ >

If this wasn’t you, you can safely ignore this email — no new account was created. If you’ve forgotten your password, you can reset it from the login page.

@@ -164,7 +164,7 @@

Stay safe,

Oxynote Team

+ >

Sent by Oxynote at {{.host}}

diff --git a/server/core/internal/email/templates/email_change_confirmation.html b/server/core/internal/email/templates/email_change_confirmation.html index 684d89dc..4832b483 100644 --- a/server/core/internal/email/templates/email_change_confirmation.html +++ b/server/core/internal/email/templates/email_change_confirmation.html @@ -152,7 +152,7 @@

If you didn’t ask for this, ignore this email and your address stays as it is. Reach out to us at hello@oxynote.io so we can help secure your account.

+ >

If you didn’t ask for this, ignore this email and your address stays as it is.

@@ -164,7 +164,7 @@

Thanks for taking a moment to confirm — we appreciate it,

Oxynote Team

+ >

Sent by Oxynote at {{.host}}

diff --git a/server/core/internal/email/templates/email_verification.html b/server/core/internal/email/templates/email_verification.html index 5b9f6dc0..0aa1b25d 100644 --- a/server/core/internal/email/templates/email_verification.html +++ b/server/core/internal/email/templates/email_verification.html @@ -152,7 +152,7 @@

If you didn’t make this change, please reach out to us right away at hello@oxynote.io so we can help secure your account.

+ >

If you didn’t make this change, ignore this email and your address stays as it is.

@@ -164,7 +164,7 @@

Thanks for taking a moment to confirm — we appreciate it,

Oxynote Team

+ >

Sent by Oxynote at {{.host}}

diff --git a/server/core/internal/email/templates/organization_invitation.html b/server/core/internal/email/templates/organization_invitation.html index 30e83212..0fc66876 100644 --- a/server/core/internal/email/templates/organization_invitation.html +++ b/server/core/internal/email/templates/organization_invitation.html @@ -114,7 +114,7 @@

Hi there,

You’ve been invited to join {{.organization}} on Oxynote.

Click the button below to accept the invitation and get started:

+ >

Hi there,

You’ve been invited to the {{.organization}} workspace.

Click the button below to accept the invitation and get started:

@@ -152,7 +152,7 @@

If you weren’t expecting this invite, feel free to ignore this email — nothing will happen unless you accept it.

If you have any questions, just reach out to us at hello@oxynote.io. We’re happy to help.

+ >

If you weren’t expecting this invite, feel free to ignore this email — nothing will happen unless you accept it.

@@ -164,7 +164,7 @@

Looking forward to having you on board,

Oxynote Team

+ >

Sent by Oxynote at {{.host}}

diff --git a/server/core/internal/email/templates/password_reset.html b/server/core/internal/email/templates/password_reset.html index 87ac1612..fe79c6f6 100644 --- a/server/core/internal/email/templates/password_reset.html +++ b/server/core/internal/email/templates/password_reset.html @@ -152,7 +152,7 @@

If you didn’t request a password reset, you can safely ignore this email — your password will stay the same. If you’re worried about your account, reach out to us at hello@oxynote.io.

+ >

If you didn’t request a password reset, you can safely ignore this email — your password will stay the same.

@@ -164,7 +164,7 @@

Stay safe,

Oxynote Team

+ >

Sent by Oxynote at {{.host}}

diff --git a/server/core/internal/email/templates/signup_verification.html b/server/core/internal/email/templates/signup_verification.html index eec44944..f40abda1 100644 --- a/server/core/internal/email/templates/signup_verification.html +++ b/server/core/internal/email/templates/signup_verification.html @@ -152,7 +152,7 @@

If you didn’t create an Oxynote account, you can safely ignore this email — the account won’t be activated. If you have questions, reach out to us at hello@oxynote.io.

+ >

If you didn’t create an Oxynote account, you can safely ignore this email — the account won’t be activated.

@@ -164,7 +164,7 @@

Stay safe,

Oxynote Team

+ >

Sent by Oxynote at {{.host}}

diff --git a/server/core/internal/email/templates/user_deletion.html b/server/core/internal/email/templates/user_deletion.html index ad2f48e5..9980377e 100644 --- a/server/core/internal/email/templates/user_deletion.html +++ b/server/core/internal/email/templates/user_deletion.html @@ -158,7 +158,7 @@

If you didn’t request this, you can safely ignore this email — nothing will happen.

If you have any questions or just want to talk it through, feel free to reach out to us at hello@oxynote.io. We’re happy to help.

+ >

If you didn’t request this, you can safely ignore this email — nothing will happen.

@@ -170,7 +170,7 @@

Thanks for being part of Oxynote,

Oxynote Team

+ >

Sent by Oxynote at {{.host}}

diff --git a/server/core/internal/email/templates_test.go b/server/core/internal/email/templates_test.go index f6939445..9c4717f0 100644 --- a/server/core/internal/email/templates_test.go +++ b/server/core/internal/email/templates_test.go @@ -1,6 +1,7 @@ package email import ( + "maps" "testing" "github.com/oxynote/oxynote/server/core/pkg/testutil" @@ -109,7 +110,10 @@ func Test_render(t *testing.T) { t.Run(cn, func(t *testing.T) { t.Parallel() - res, err := render(c.Template, c.Args) + args := map[string]string{"host": "notes.example.com"} + maps.Copy(args, c.Args) + + res, err := render(c.Template, args) testutil.AssertEqualError(t, c.Err, err) if err != nil { @@ -127,6 +131,7 @@ func Test_render(t *testing.T) { assert.Contains(t, res, `src="cid:logo.png"`, "rendered body must reference the embedded logo") assert.NotContains(t, res, "cdn.prod.website-files.com", "rendered body references a remote CDN image") + assert.Contains(t, res, "Sent by Oxynote at notes.example.com") for _, want := range c.Contains { assert.Contains(t, res, want) diff --git a/server/core/internal/server/internal/org/default_logo.png b/server/core/internal/server/internal/org/default_logo.png new file mode 100644 index 0000000000000000000000000000000000000000..c8a8f20f7b93c0f7b0f0a6116a02eed2b4cc5837 GIT binary patch literal 11234 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 Tvw0o}{G8O#IsWFD*`5Cf", { concurrent: false }, () => { expect(wrapper.emitted("close")).toBeUndefined() }) }) + describe("in a single-workspace instance", { concurrent: false }, () => { + beforeEach(() => { + seedAuthConfig({ singleOrganization: true }) + }) + + it("offers the last member only a way to close the modal", async ({ + expect, + }) => { + seedAuthOrganization({ members: [{ id: "m1" }] }) + + const wrapper = await mountAction() + + expect(wrapper.text()).toContain( + t( + "settings.action-modals.account-deletion.description-last-member-kept", + ), + ) + expect(wrapper.text()).not.toContain( + t( + "settings.action-modals.account-deletion.description-last-org-member", + ), + ) + expect(wrapper.findAll("button").map((b) => b.text())).toEqual([ + t("settings.action-modals.account-deletion.close-button"), + ]) + }) + + it("deletes nothing when the last member's form is submitted anyway", async ({ + expect, + }) => { + seedAuthOrganization({ members: [{ id: "m1" }] }) + const calls = mockAuthEndpoint("delete-user", () => ({ + success: true, + })) + const wrapper = await mountAction() + + await confirmDeletion(wrapper) + + expect(calls).toHaveLength(0) + expect(wrapper.emitted("close")).toBeUndefined() + }) + + it("asks the last member for no password when the server sends no email", async ({ + expect, + }) => { + seedAuthConfig({ singleOrganization: true, emailEnabled: false }) + seedAuthAccounts(["credential"]) + seedAuthOrganization({ members: [{ id: "m1" }] }) + + const wrapper = await mountAction() + + expect(wrapper.find("input[type='password']").exists()).toBe(false) + expect(wrapper.findAll("button").map((b) => b.text())).toEqual([ + t("settings.action-modals.account-deletion.close-button"), + ]) + }) + + it("lets a member of a shared workspace delete their account", async ({ + expect, + }) => { + seedAuthOrganization({ members: [{ id: "m1" }, { id: "m2" }] }) + const calls = mockAuthEndpoint("delete-user", () => ({ + success: true, + })) + const wrapper = await mountAction() + + await confirmDeletion(wrapper) + + expect(wrapper.text()).not.toContain( + t( + "settings.action-modals.account-deletion.description-last-member-kept", + ), + ) + expect(calls).toHaveLength(1) + expect(wrapper.emitted("close")).toHaveLength(1) + }) + + it("explains a refusal for the last member and stays open", async ({ + expect, + }) => { + seedAuthOrganization({ members: [{ id: "m1" }, { id: "m2" }] }) + mockAuthEndpoint("delete-user", (_call, event) => { + setResponseStatus(event, 403) + + return { + code: "LAST_ORGANIZATION_MEMBER", + message: "The last member of the organization cannot be deleted", + } + }) + const wrapper = await mountAction() + + await confirmDeletion(wrapper) + + expect(raisedToasts()).toMatchObject([ + { + type: "error", + description: t( + "settings.action-modals.account-deletion.errors.last-member.description", + ), + }, + ]) + expect(navigateToMock).not.toHaveBeenCalled() + expect(wrapper.emitted("close")).toBeUndefined() + }) + }) }) diff --git a/web/app/components/settings/AccountDeletionAction.vue b/web/app/components/settings/AccountDeletionAction.vue index 65b71af2..4a268eea 100644 --- a/web/app/components/settings/AccountDeletionAction.vue +++ b/web/app/components/settings/AccountDeletionAction.vue @@ -13,7 +13,7 @@ const emit = defineEmits<{ const { fetchAuthSession, fetchOrganization, hasPassword, deleteUser } = useAuthSession() -const { isEmailEnabled } = useAuthAPI() +const { isEmailEnabled, isSingleOrganization } = useAuthAPI() const config = useRuntimeConfig() const { t } = useI18n({ useScope: "global" }) @@ -53,8 +53,17 @@ const lastOrgMemeber = computed(() => { return org.members.length <= 1 }) +// a single-workspace instance has no signup to repopulate an empty +// workspace, so the server refuses to delete its last member. +const deletionBlocked = computed( + () => isSingleOrganization.value && lastOrgMemeber.value, +) const onSubmit = form.handleSubmit(async (values) => { + if (deletionBlocked.value) { + return + } + loading.value = true await delay(300) // show loading spinner for at least a moment @@ -78,6 +87,18 @@ const onSubmit = form.handleSubmit(async (values) => { return } + if (error.code === "LAST_ORGANIZATION_MEMBER") { + showToastMessage( + "error", + t("settings.action-modals.account-deletion.errors.last-member.title"), + t( + "settings.action-modals.account-deletion.errors.last-member.description", + ), + ) + + return + } + if (error.code === "CREDENTIAL_ACCOUNT_NOT_FOUND") { showToastMessage( "error", @@ -136,7 +157,17 @@ const onSubmit = form.handleSubmit(async (values) => { ) }} -
+
+ {{ + $t( + "settings.action-modals.account-deletion.description-last-member-kept", + ) + }} +
+
{{ $t( "settings.action-modals.account-deletion.description-last-org-member", @@ -146,7 +177,7 @@ const onSubmit = form.handleSubmit(async (values) => {
{
{ class="text-2sm" @click="emit('close')" > - {{ $t("settings.action-modals.account-deletion.cancel-button") }} + {{ + deletionBlocked + ? $t("settings.action-modals.account-deletion.close-button") + : $t("settings.action-modals.account-deletion.cancel-button") + }}
diff --git a/web/app/components/settings/ActionModal.nuxt.test.ts b/web/app/components/settings/ActionModal.nuxt.test.ts index 2ac6a4e8..671e70b0 100644 --- a/web/app/components/settings/ActionModal.nuxt.test.ts +++ b/web/app/components/settings/ActionModal.nuxt.test.ts @@ -13,6 +13,7 @@ import { emitFrom, mockAuthOrganization, seedAuthAccounts, + seedAuthConfig, seedAuthSession, t, teleportedButton, @@ -59,6 +60,7 @@ describe("", { concurrent: false }, () => { clearQueryCache() seedAuthSession({ id: "u1", email: "ada@oxynote.test", name: "ada" }) seedAuthAccounts(["credential"]) + seedAuthConfig() mockAuthOrganization({ id: "org-1", name: "Acme", @@ -145,6 +147,25 @@ describe("", { concurrent: false }, () => { ) }) + it("keeps the invitation title for any number of members without a limit", async ({ + expect, + }) => { + seedAuthConfig({ maxOrganizationMembers: null }) + mockAuthOrganization({ + id: "org-1", + members: Array.from({ length: 5 }, (_, i) => ({ id: `m${i}` })), + invitations: [], + }) + + await mountModal({ modelValue: "workspace-invitation" }) + + expect(dialogText()).not.toContain( + t( + "settings.action-modals.workspace-invitation.title-max-members-reached", + ), + ) + }) + it("names the member in the removal modal", async ({ expect }) => { await mountModal({ modelValue: "workspace-member-removal", opts: MEMBER }) diff --git a/web/app/components/settings/ActionModal.vue b/web/app/components/settings/ActionModal.vue index 90de37b4..481c2c0c 100644 --- a/web/app/components/settings/ActionModal.vue +++ b/web/app/components/settings/ActionModal.vue @@ -40,16 +40,17 @@ const open = computed({ }) const { t } = useI18n({ useScope: "global" }) const { fetchOrganization, hasPassword } = useAuthSession() +const { maxOrganizationMembers } = useAuthAPI() const isMaxMembersReached = computed(() => { const org = fetchOrganization.state.value.data?.data - if (!org) { + if (!org || maxOrganizationMembers.value === null) { return false } return ( org.members.length + org.invitations.filter((inv) => inv.status === "pending").length >= - ORGANIZATION_MAX_MEMBERS + maxOrganizationMembers.value ) }) diff --git a/web/app/components/settings/EmailChangeAction.nuxt.test.ts b/web/app/components/settings/EmailChangeAction.nuxt.test.ts index 1754fd0d..85b61439 100644 --- a/web/app/components/settings/EmailChangeAction.nuxt.test.ts +++ b/web/app/components/settings/EmailChangeAction.nuxt.test.ts @@ -48,10 +48,14 @@ describe("", { concurrent: false }, () => { afterEach(disposeMockEndpoints) - it("explains what changing the email does", async ({ expect }) => { + it("explains that the current address approves the change first", async ({ + expect, + }) => { const wrapper = await mountAction() - expect(wrapper.text()).toContain("Changing your email address") + expect(wrapper.text()).toContain( + t("settings.action-modals.email-change.description"), + ) }) it("sends the new address to the server with a verification callback", async ({ @@ -77,7 +81,16 @@ describe("", { concurrent: false }, () => { await submitEmail(wrapper, "new@oxynote.test") - expect(toast.custom).toHaveBeenCalledTimes(1) + expect(raisedToasts()).toMatchObject([ + { + type: "success", + title: t("settings.action-modals.email-change.success-message.title"), + description: t( + "settings.action-modals.email-change.success-message.description", + { current: "ada@oxynote.test", email: "new@oxynote.test" }, + ), + }, + ]) expect(wrapper.emitted("close")).toHaveLength(1) }) @@ -130,6 +143,53 @@ describe("", { concurrent: false }, () => { expect(wrapper.emitted("close")).toHaveLength(1) }) + describe("for the default admin", { concurrent: false }, () => { + beforeEach(() => { + seedAuthConfig({ + singleOrganization: true, + defaultAdmin: { email: "admin@example.com", password: null }, + }) + }) + + it("explains that only the new address gets a link", async ({ expect }) => { + seedAuthSession({ id: "u1", email: "admin@example.com", name: "Admin" }) + + const wrapper = await mountAction() + + expect(wrapper.text()).toContain( + t("settings.action-modals.email-change.description-default-admin"), + ) + }) + + it("confirms that the verification link went to the new address", async ({ + expect, + }) => { + seedAuthSession({ id: "u1", email: "admin@example.com", name: "Admin" }) + mockAuthEndpoint("change-email", () => ({ status: true })) + const wrapper = await mountAction() + + await submitEmail(wrapper, "boss@oxynote.test") + + expect(raisedToasts()).toMatchObject([ + { + type: "success", + description: t( + "settings.action-modals.email-change.success-message-default-admin.description", + { email: "boss@oxynote.test" }, + ), + }, + ]) + }) + + it("keeps the approval step for every other member", async ({ expect }) => { + const wrapper = await mountAction() + + expect(wrapper.text()).toContain( + t("settings.action-modals.email-change.description"), + ) + }) + }) + describe("when the server sends no email", { concurrent: false }, () => { beforeEach(() => { seedAuthConfig({ emailEnabled: false }) diff --git a/web/app/components/settings/EmailChangeAction.vue b/web/app/components/settings/EmailChangeAction.vue index b51053ab..70158b33 100644 --- a/web/app/components/settings/EmailChangeAction.vue +++ b/web/app/components/settings/EmailChangeAction.vue @@ -21,13 +21,23 @@ const form = useForm({ validationSchema: formSchema, }) const { fetchAuthSession, hasPassword, changeEmail } = useAuthSession() -const { isEmailEnabled } = useAuthAPI() +const { isEmailEnabled, defaultAdmin } = useAuthAPI() const config = useRuntimeConfig() const { t } = useI18n({ useScope: "global" }) const loading = ref(false) +const currentEmail = computed( + () => fetchAuthSession.state.value.data?.data?.user.email ?? "", +) +// no mail reaches the default admin's email. So its change skips the +// approval by the current email, and only the new email gets a link. +const isDefaultAdmin = computed( + () => + defaultAdmin.value !== null && + defaultAdmin.value.email === currentEmail.value, +) const onSubmit = form.handleSubmit(async (values) => { - if (values.email === fetchAuthSession.state.value.data?.data?.user.email) { + if (values.email === currentEmail.value) { emit("close") return } @@ -72,13 +82,29 @@ const onSubmit = form.handleSubmit(async (values) => { return } + // read before the refetch. The session may already carry the new email. + const skippedApproval = isDefaultAdmin.value + const previousEmail = currentEmail.value + await fetchAuthSession.refetch() - if (isEmailEnabled.value) { + if (isEmailEnabled.value && skippedApproval) { + showToastMessage( + "success", + t( + "settings.action-modals.email-change.success-message-default-admin.title", + ), + t( + "settings.action-modals.email-change.success-message-default-admin.description", + { email: values.email }, + ), + ) + } else if (isEmailEnabled.value) { showToastMessage( "success", t("settings.action-modals.email-change.success-message.title"), t("settings.action-modals.email-change.success-message.description", { + current: previousEmail, email: values.email, }), ) @@ -102,9 +128,13 @@ const onSubmit = form.handleSubmit(async (values) => {
{{ - isEmailEnabled - ? $t("settings.action-modals.email-change.description") - : $t("settings.action-modals.email-change.description-without-email") + !isEmailEnabled + ? $t("settings.action-modals.email-change.description-without-email") + : isDefaultAdmin + ? $t( + "settings.action-modals.email-change.description-default-admin", + ) + : $t("settings.action-modals.email-change.description") }}
", { concurrent: false }, () => { clearQueryCache() vi.mocked(toast.custom).mockReset() seedAuthSession({ id: "u1", email: "ada@oxynote.test", name: "Ada" }) + seedAuthConfig() }) afterEach(disposeMockEndpoints) @@ -202,6 +203,16 @@ describe("", { concurrent: false }, () => { expect(wrapper.text()).not.toContain("maximum number of members") }) + it("keeps inviting available without a member limit", async ({ expect }) => { + seedAuthConfig({ maxOrganizationMembers: null }) + seedWorkspace(50) + + const wrapper = await mountAction() + + expect(wrapper.find("input").exists()).toBe(true) + expect(wrapper.text()).not.toContain("maximum number of members") + }) + it("keeps inviting available while the organization is unknown", async ({ expect, }) => { diff --git a/web/app/components/settings/WorkspaceInvitationAction.vue b/web/app/components/settings/WorkspaceInvitationAction.vue index 92db4f62..787aceeb 100644 --- a/web/app/components/settings/WorkspaceInvitationAction.vue +++ b/web/app/components/settings/WorkspaceInvitationAction.vue @@ -21,19 +21,19 @@ const form = useForm({ }) const { fetchAuthSession, fetchOrganization, inviteOrganizationMember } = useAuthSession() -const { isEmailEnabled } = useAuthAPI() +const { isEmailEnabled, maxOrganizationMembers } = useAuthAPI() const { t } = useI18n({ useScope: "global" }) const loading = ref(false) const isMaxMembersReached = computed(() => { const org = fetchOrganization.state.value.data?.data - if (!org) { + if (!org || maxOrganizationMembers.value === null) { return false } return ( org.members.length + org.invitations.filter((inv) => inv.status === "pending").length >= - ORGANIZATION_MAX_MEMBERS + maxOrganizationMembers.value ) }) diff --git a/web/app/components/settings/WorkspaceSection.nuxt.test.ts b/web/app/components/settings/WorkspaceSection.nuxt.test.ts index 60b522ed..d7efc1a0 100644 --- a/web/app/components/settings/WorkspaceSection.nuxt.test.ts +++ b/web/app/components/settings/WorkspaceSection.nuxt.test.ts @@ -300,7 +300,10 @@ describe("", { concurrent: false }, () => { }) describe("member list", { concurrent: false }, () => { - beforeEach(clearTeleportedOverlays) + beforeEach(() => { + clearTeleportedOverlays() + seedAuthConfig() + }) it("lists the joined members", async ({ expect }) => { seedWorkspace({ members: [member("u1", "ada"), member("u2", "linus")] }) @@ -312,6 +315,19 @@ describe("", { concurrent: false }, () => { expect(wrapper.text()).toContain("Members (2/5)") }) + it("counts the members without a maximum when there is no limit", async ({ + expect, + }) => { + seedAuthConfig({ maxOrganizationMembers: null }) + seedWorkspace({ members: [member("u1", "ada"), member("u2", "linus")] }) + + const { wrapper } = await mountSection() + + expect(wrapper.text()).toContain( + t("settings.workspace.members-label-unlimited", { current: 2 }), + ) + }) + it("shows when a member joined", async ({ expect }) => { seedWorkspace() diff --git a/web/app/components/settings/WorkspaceSection.vue b/web/app/components/settings/WorkspaceSection.vue index a5ffb10a..6de66721 100644 --- a/web/app/components/settings/WorkspaceSection.vue +++ b/web/app/components/settings/WorkspaceSection.vue @@ -15,7 +15,7 @@ const emit = defineEmits<{ const { fetchOrganization, fetchAuthSession, updateOrganization } = useAuthSession() -const { isEmailEnabled } = useAuthAPI() +const { isEmailEnabled, maxOrganizationMembers } = useAuthAPI() const { t } = useI18n({ useScope: "global" }) const { uploadOrganizationLogo } = useOrganizationAPI() const config = useRuntimeConfig() @@ -324,13 +324,23 @@ async function copyInvitationLink(member: OrganizationMember) {
+ + + - + = {}) { seedQueryData(["auth", "config"], { methods: ["email-password"], emailEnabled: true, + singleOrganization: false, + maxOrganizationMembers: 5, + defaultAdmin: null, ...config, }) } diff --git a/web/app/composables/api/useAuthAPI.nuxt.test.ts b/web/app/composables/api/useAuthAPI.nuxt.test.ts index 3b247735..64222a94 100644 --- a/web/app/composables/api/useAuthAPI.nuxt.test.ts +++ b/web/app/composables/api/useAuthAPI.nuxt.test.ts @@ -67,4 +67,62 @@ describe("useAuthAPI", { concurrent: false }, () => { expect(api.isEmailEnabled.value).toBe(false) }) }) + describe("organization limits", () => { + it("hides the default admin outside single-workspace mode", async ({ + expect, + }) => { + mockEndpoint( + "GET", + "http://test.local/auth-realtime/api/auth-config", + () => ({ + singleOrganization: false, + defaultAdmin: { email: "admin@example.com", password: null }, + }), + ) + const api = makeAuthAPI() + + await api.fetchAuthConfig.refresh() + + expect(api.defaultAdmin.value).toBeNull() + }) + + it("assumes open signup and no member limit until the config arrives", async ({ + expect, + }) => { + mockEndpoint( + "GET", + "http://test.local/auth-realtime/api/auth-config", + () => ({ singleOrganization: true, maxOrganizationMembers: 5 }), + ) + const api = makeAuthAPI() + + expect(api.isSingleOrganization.value).toBe(false) + expect(api.maxOrganizationMembers.value).toBeNull() + + // drains the eager load so it cannot land in the next test + await api.fetchAuthConfig.refresh() + }) + + it("follows the server's answer once it arrives", async ({ expect }) => { + mockEndpoint( + "GET", + "http://test.local/auth-realtime/api/auth-config", + () => ({ + singleOrganization: true, + maxOrganizationMembers: 5, + defaultAdmin: { email: "admin@example.com", password: null }, + }), + ) + const api = makeAuthAPI() + + await api.fetchAuthConfig.refresh() + + expect(api.isSingleOrganization.value).toBe(true) + expect(api.maxOrganizationMembers.value).toBe(5) + expect(api.defaultAdmin.value).toEqual({ + email: "admin@example.com", + password: null, + }) + }) + }) }) diff --git a/web/app/composables/api/useAuthAPI.ts b/web/app/composables/api/useAuthAPI.ts index e486ce7f..80d0a259 100644 --- a/web/app/composables/api/useAuthAPI.ts +++ b/web/app/composables/api/useAuthAPI.ts @@ -1,4 +1,4 @@ -const AUTH_QUERY_KEYS = { +export const AUTH_QUERY_KEYS = { config: ["auth", "config"] as const, } @@ -24,5 +24,26 @@ export default function () { () => fetchAuthConfig.state.value.data?.emailEnabled ?? true, ) - return { fetchAuthConfig, isEmailEnabled } + // until the config arrives the instance is assumed to allow signup and + // any number of members; the server enforces its own limits either way. + const isSingleOrganization = computed( + () => fetchAuthConfig.state.value.data?.singleOrganization ?? false, + ) + const maxOrganizationMembers = computed( + () => fetchAuthConfig.state.value.data?.maxOrganizationMembers ?? null, + ) + + const defaultAdmin = computed(() => + isSingleOrganization.value + ? (fetchAuthConfig.state.value.data?.defaultAdmin ?? null) + : null, + ) + + return { + defaultAdmin, + fetchAuthConfig, + isEmailEnabled, + isSingleOrganization, + maxOrganizationMembers, + } } diff --git a/web/app/composables/useAuthSession.nuxt.test.ts b/web/app/composables/useAuthSession.nuxt.test.ts index 8e27248a..e141acb3 100644 --- a/web/app/composables/useAuthSession.nuxt.test.ts +++ b/web/app/composables/useAuthSession.nuxt.test.ts @@ -1,4 +1,11 @@ -import { beforeEach, describe, it, vi } from "vitest" +import { afterEach, beforeEach, describe, it, vi } from "vitest" +import { + clearQueryCache, + disposeMockEndpoints, + mockEndpoint, + runInApp, +} from "./api/test-helpers" +import useAuthAPI from "./api/useAuthAPI" import useAuthSession from "./useAuthSession" const { authClientStub, createAuthClientMock, allStubFns } = vi.hoisted(() => { @@ -344,6 +351,8 @@ describe("useAuthSession", { concurrent: false }, () => { }) describe("safeSignOut", () => { + afterEach(disposeMockEndpoints) + it("returns the auth error without touching the cache", async ({ expect, }) => { @@ -375,6 +384,34 @@ describe("useAuthSession", { concurrent: false }, () => { const fresh = makeAuthSession() expect(fresh.fetchAuthSession.data.value).toBeUndefined() }) + + it("fetches the auth config again, with the admin's new password", async ({ + expect, + }) => { + clearQueryCache() + let defaultAdmin = { + email: "admin@example.com", + password: "oxynote-admin-1234" as string | null, + } + const calls = mockEndpoint( + "GET", + "http://test.local/auth-realtime/api/auth-config", + () => ({ singleOrganization: true, defaultAdmin }), + ) + const api = runInApp(() => useAuthAPI()) + await api.fetchAuthConfig.refresh() + defaultAdmin = { email: "admin@example.com", password: null } + authClientStub.signOut.mockResolvedValue({ data: {}, error: null }) + const auth = makeAuthSession() + + await auth.safeSignOut() + + expect(calls).toHaveLength(2) + expect(api.defaultAdmin.value).toEqual({ + email: "admin@example.com", + password: null, + }) + }) }) it.for(DELEGATION_CASES)( diff --git a/web/app/composables/useAuthSession.ts b/web/app/composables/useAuthSession.ts index e95bde88..7c6949a6 100644 --- a/web/app/composables/useAuthSession.ts +++ b/web/app/composables/useAuthSession.ts @@ -1,3 +1,5 @@ +import { AUTH_QUERY_KEYS } from "./api/useAuthAPI" + const AUTH_SESSION_QUERY_KEYS = { root: ["auth", "session"] as const, organization: ["auth", "organization"] as const, @@ -136,6 +138,11 @@ export default function () { queryCache.remove(e) }) + // the login page shows the default admin password from the auth + // config. The cached config can be up to five minutes old, so fetch + // it again. "all" also refetches it when no page uses it. + await queryCache.invalidateQueries({ key: AUTH_QUERY_KEYS.config }, "all") + return res } diff --git a/web/app/pages/login.nuxt.test.ts b/web/app/pages/login.nuxt.test.ts new file mode 100644 index 00000000..57f4fe25 --- /dev/null +++ b/web/app/pages/login.nuxt.test.ts @@ -0,0 +1,80 @@ +import { mountSuspended } from "@nuxt/test-utils/runtime" +import { beforeEach, describe, it } from "vitest" +import { clearQueryCache } from "~/composables/api/test-helpers" +import Login from "./login.vue" +import { findButtonByText, seedAuthConfig, t } from "../components/test-helpers" + +// the query cache is an app-wide singleton every mount in the file shares +describe("", { concurrent: false }, () => { + beforeEach(clearQueryCache) + + it("shows the default admin credentials above the email field while they still sign in", async ({ + expect, + }) => { + seedAuthConfig({ + singleOrganization: true, + defaultAdmin: { + email: "admin@example.com", + password: "oxynote-admin-1234", + }, + }) + + const wrapper = await openEmailPasswordForm() + + expect(wrapper.findAll("li").map((li) => li.text())).toEqual([ + "admin@example.com", + "oxynote-admin-1234", + ]) + expect(wrapper.findAll("code").map((c) => c.element.textContent)).toEqual([ + "admin@example.com", + "oxynote-admin-1234", + ]) + + const form = wrapper.get("form").element + const box = form.firstElementChild + expect(box?.textContent).toContain("oxynote-admin-1234") + expect( + box?.nextElementSibling?.querySelector("input[type='email']"), + ).not.toBeNull() + }) + + it("shows no credentials once the default admin has changed the password", async ({ + expect, + }) => { + seedAuthConfig({ + singleOrganization: true, + defaultAdmin: { email: "admin@example.com", password: null }, + }) + + const wrapper = await openEmailPasswordForm() + + expect(wrapper.text()).not.toContain("admin@example.com") + }) + + it("shows no credentials outside single-workspace mode", async ({ + expect, + }) => { + seedAuthConfig({ + singleOrganization: false, + defaultAdmin: { + email: "admin@example.com", + password: "oxynote-admin-1234", + }, + }) + + const wrapper = await openEmailPasswordForm() + + expect(wrapper.text()).not.toContain("admin@example.com") + }) +}) + +async function openEmailPasswordForm() { + const wrapper = await mountSuspended(Login) + + await findButtonByText( + wrapper, + t("onboarding.login.login-email-password"), + ).trigger("click") + + return wrapper +} diff --git a/web/app/pages/login.vue b/web/app/pages/login.vue index a1d6883a..78e42334 100644 --- a/web/app/pages/login.vue +++ b/web/app/pages/login.vue @@ -25,7 +25,8 @@ const { requestPasswordReset, setupSignInRedirect, } = useAuthSession() -const { fetchAuthConfig, isEmailEnabled } = useAuthAPI() +const { fetchAuthConfig, isEmailEnabled, isSingleOrganization, defaultAdmin } = + useAuthAPI() const route = useRoute() const config = useRuntimeConfig() @@ -50,6 +51,15 @@ const resetEmail = ref("") const enabledMethods = computed( () => fetchAuthConfig.state.value.data?.methods ?? [], ) +// shown only while the default password still works. +const defaultAdminLogin = computed(() => { + const admin = defaultAdmin.value + if (!admin?.password) { + return null + } + + return { email: admin.email, password: admin.password } +}) const noMethodsConfigured = computed( () => fetchAuthConfig.state.value.status === "success" && @@ -349,6 +359,28 @@ function methodVariant(method: AuthMethod) { class="flex w-full flex-col gap-3" @submit="onEmailPasswordSubmit" > +
+ +
+

{{ $t("onboarding.login.default-admin.intro") }}

+
    +
  • + + {{ defaultAdminLogin.email }} + +
  • +
  • + + {{ defaultAdminLogin.password }} + +
  • +
+

{{ $t("onboarding.login.default-admin.outro") }}

+
+
+
+ {{ $t("onboarding.login.no-account.invitation-only") }} +
{ + const { fetchAuthConfig } = useAuthAPI() + const { data } = await fetchAuthConfig.refresh() + + if (data?.singleOrganization && !isInvitationRedirect(to.query.next)) { + return navigateTo("/", { replace: true }) + } + }, }) const { t } = useI18n({ useScope: "global" }) @@ -93,10 +103,9 @@ const conditionsKeypath = computed(() => { return null }) const newAccountsAllowed = computed(() => { - return ( - fetchOrganizationStats.state.value.data && - fetchOrganizationStats.state.value.data.availableSlots > 0 - ) + const slots = fetchOrganizationStats.state.value.data?.availableSlots + + return slots === null || (slots !== undefined && slots > 0) }) let redirectTimeout: ReturnType | undefined diff --git a/web/app/utils/api/auth.ts b/web/app/utils/api/auth.ts index 1da0014f..dadcf492 100644 --- a/web/app/utils/api/auth.ts +++ b/web/app/utils/api/auth.ts @@ -3,4 +3,14 @@ export type AuthMethod = "email-password" | "github" | "google" | "slack" export interface AuthConfig { methods: AuthMethod[] emailEnabled: boolean + // if true, the whole instance is one workspace: it exists from the start, and + // only invited users sign up. + singleOrganization: boolean + // null when a workspace takes any number of members. + maxOrganizationMembers: number | null + // the account that still has the default admin email. The server finds + // it by that email. So a new email makes the whole value null. The + // password is null once the admin changes it. Always null outside + // single-workspace mode. + defaultAdmin: { email: string; password: string | null } | null } diff --git a/web/app/utils/api/organization.ts b/web/app/utils/api/organization.ts index abd8ae25..3205962c 100644 --- a/web/app/utils/api/organization.ts +++ b/web/app/utils/api/organization.ts @@ -1,5 +1,4 @@ -export const ORGANIZATION_MAX_MEMBERS = 5 - export interface OrganizationStats { - availableSlots: number + // null when any number of workspaces may be created. + availableSlots: number | null } diff --git a/web/app/utils/auth.test.ts b/web/app/utils/auth.test.ts index f053a3f9..5695f65b 100644 --- a/web/app/utils/auth.test.ts +++ b/web/app/utils/auth.test.ts @@ -1,6 +1,7 @@ import { describe, it } from "vitest" import { acceptInvitationUrl, + isInvitationRedirect, postAuthDocumentUrl, postEmailVerificationUrl, } from "./auth" @@ -58,6 +59,22 @@ describe("acceptInvitationUrl", () => { }) }) +describe("isInvitationRedirect", () => { + it.for([ + { + name: "an encoded accept-invite path", + input: encodeURIComponent("/accept-invite?id=inv-1"), + expected: true, + }, + { name: "another page", input: "%2Fdocs%2Fabc", expected: false }, + { name: "a malformed encoding", input: "%E0%A4%A", expected: false }, + { name: "no next path", input: undefined, expected: false }, + { name: "a repeated query parameter", input: ["a", "b"], expected: false }, + ])("answers $expected for $name", ({ input, expected }, { expect }) => { + expect(isInvitationRedirect(input)).toBe(expected) + }) +}) + describe("postEmailVerificationUrl", () => { it("builds the verification url with the email encoded", ({ expect }) => { expect(postEmailVerificationUrl("https://app.test", "a+b@test.io")).toBe( diff --git a/web/app/utils/auth.ts b/web/app/utils/auth.ts index 24b8be52..88a1c06f 100644 --- a/web/app/utils/auth.ts +++ b/web/app/utils/auth.ts @@ -41,6 +41,21 @@ export function acceptInvitationUrl( return `${baseUrl || ""}/accept-invite?${query.toString()}` } +// whether a login or signup page was opened on the way to accepting an +// invitation. The next path arrives URI-encoded, and a malformed one is no +// invitation. +export function isInvitationRedirect(next: unknown): boolean { + if (typeof next !== "string") { + return false + } + + try { + return decodeURIComponent(next).startsWith("/accept-invite") + } catch { + return false + } +} + export function postEmailVerificationUrl( baseUrl: string | undefined, email: string, diff --git a/web/i18n/locales/en/onboarding.json b/web/i18n/locales/en/onboarding.json index 7504f8d2..7de42ca0 100644 --- a/web/i18n/locales/en/onboarding.json +++ b/web/i18n/locales/en/onboarding.json @@ -65,8 +65,13 @@ "reset": "Reset it" } }, + "default-admin": { + "intro": "Log in with the default credentials:", + "outro": "Then change both in the settings." + }, "no-account": { "main": "Don't have an account? {sign-up} or {learn-more}.", + "invitation-only": "Don't have an account? Ask a workspace member to invite you.", "placeholders": { "sign-up": "Sign up", "learn-more": "learn more" diff --git a/web/i18n/locales/en/settings.json b/web/i18n/locales/en/settings.json index e8bf6a68..601c0f1a 100644 --- a/web/i18n/locales/en/settings.json +++ b/web/i18n/locales/en/settings.json @@ -6,7 +6,8 @@ "action-modals": { "email-change": { "title": "Change Email", - "description": "Changing your email address will update the address used for notifications and other account-related actions. We'll send a verification email to your new address.", + "description": "Changing your email address will update the address used for notifications and other account-related actions. We'll first send an approval link to your current address, then a verification link to your new one.", + "description-default-admin": "Changing your email address will update the address used for notifications and other account-related actions. We'll send a verification email to your new address.", "description-without-email": "Changing your email address will update the address used for notifications and other account-related actions. Confirm the change with your current password.", "new-email-label": "Your new email address", "new-email-placeholder": "New email address", @@ -15,6 +16,10 @@ "submit-button": "Change Email", "cancel-button": "Cancel", "success-message": { + "title": "Approval email sent", + "description": "We've sent an approval link to {current}. Once you approve, we'll send a verification link to {email}" + }, + "success-message-default-admin": { "title": "Verification email sent", "description": "We've sent a verification link to {email}. Once you click the link, your email address will be updated" }, @@ -79,10 +84,12 @@ "description": "This action is irreversible and will permanently delete all your data. A confirmation email will be sent to your registered email address to finalize the account deletion process.", "description-without-email": "This action is irreversible and will permanently delete all your data. Confirm it with your current password.", "description-last-org-member": "You are the last member of this workspace. Deleting your account will also delete the workspace and all its data.", + "description-last-member-kept": "You are the last member of this workspace, and it needs at least one. Invite someone before deleting your account.", "password-label": "Your current password", "password-placeholder": "Current password", "confirm-button": "Delete Account", "cancel-button": "Cancel", + "close-button": "Close", "success-message": { "confirmation-link-sent": { "title": "Confirmation email sent", @@ -98,6 +105,10 @@ "title": "Account couldn't be deleted", "description": "An error occurred while trying to delete your account. Please try again" }, + "last-member": { + "title": "Account couldn't be deleted", + "description": "You are the last member of this workspace, and it needs at least one" + }, "password-required": "Please enter your current password", "invalid-password": "Incorrect password", "no-password": { @@ -416,6 +427,7 @@ "url-change-button-screen-reader-hint": "Change Workspace URL", "invitation-button": "Invite", "members-label": "Members ({current}/{max})", + "members-label-unlimited": "Members ({current})", "members-option-button-screen-reader-hint": "Manage Member", "joined-label": "Joined", "invited-label": "Invited",