add one-org mode

This commit is contained in:
swithek 2026-09-23 16:39:52 +02:00
parent ce554d07f1
commit 95bf5649ac
68 changed files with 3015 additions and 157 deletions

View File

@ -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

View File

@ -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

View File

@ -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`,

View File

@ -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: {

View File

@ -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,

View File

@ -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

View File

@ -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 <team@oxynote.io>
# 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

View File

@ -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<void> {
export async function revealForm(
button: Locator,
field: Locator,
): Promise<void> {
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<void> {
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<void> {
const email = page.getByPlaceholder(
t("onboarding.signup.email-password-form.email-placeholder"),
)

View File

@ -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<string> {
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<string> {
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<number> {
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<string> {
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<string> {
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("&amp;", "&")
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("&amp;", "&")
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 : ""
}

View File

@ -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<string, unknown> = {},
): Promise<APIResponse> {
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<BrowserContext> {
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<void> {
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 })
}

View File

@ -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()
})
})

View File

@ -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,

View File

@ -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.

View File

@ -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,

View File

@ -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<void> {
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<SessionUser | undefined>,
): Promise<void> {
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<void> {
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) =>

View File

@ -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)
})
})

View File

@ -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<unknown>
}
export interface BootstrapDeps {
env: Env
store: Store
core: CoreClient
log: Logger
accounts: AccountAdapter
hashPassword: (password: string) => Promise<string>
// 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<void> {
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<boolean>
}
// 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<DefaultAdmin | null> {
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 }
}
}

View File

@ -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,

View File

@ -93,6 +93,7 @@ export interface CoreClient {
): Promise<void>
initializeOrganization(organizationId: string): Promise<void>
teardownOrganization(organizationId: string): Promise<void>
setDefaultOrganizationLogo(organizationId: string): Promise<void>
fetchBranches(documentId: string): Promise<BranchSummary[]>
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`,

View File

@ -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,

View File

@ -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<number>
organizationMemberCount(organizationId: string): Promise<number>
hasPendingInvitation(email: string): Promise<boolean>
credentialPasswordHash(email: string): Promise<string | null>
userOrganizationId(userId: string): Promise<string | null>
hasOAuthConsent(clientId: string, userId: string): Promise<boolean>
isOrganizationMember(
@ -58,10 +76,62 @@ export function createStore(db: Kysely<Database>): Store {
async totalOrganizationCount() {
const res = await db
.selectFrom("organizations")
.select(db.fn.countAll<number>().as("count"))
.select(db.fn.countAll<string>().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<string>().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

View File

@ -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 }) => {

View File

@ -38,7 +38,11 @@ export interface Env {
socialProviders: Partial<
Record<SocialProviderName, SocialProviderCredentials>
>
// 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

View File

@ -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,
}),
)

View File

@ -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<void>>
findDefaultAdmin?: Mock<RouteDeps["findDefaultAdmin"]>
core?: StubCore
} = {},
) {
@ -74,6 +79,8 @@ function build(
const flushDocument: Mock<(documentName: string) => Promise<void>> =
overrides.flushDocument ?? vi.fn().mockResolvedValue(undefined)
const resetConnections = vi.fn<(documentName: string) => void>()
const findDefaultAdmin: Mock<RouteDeps["findDefaultAdmin"]> =
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", () => {

View File

@ -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<DefaultAdmin | null>
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 })
})

View File

@ -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",

View File

@ -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 {

View File

@ -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 <address>" 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",

View File

@ -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 <team@oxynote.io>",
PublicURL: "https://notes.example.com/core",
},
NilClient: true,
},
@ -63,6 +79,7 @@ func Test_NewSender(t *testing.T) {
Port: "1025",
TLS: TLSModeNone,
FromAddress: "Oxynote <team@oxynote.io>",
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 <team@oxynote.io>",
publicHost: "notes.example.com",
}
}

View File

@ -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,

View File

@ -152,7 +152,7 @@
<div
style="font-family:Verdana, Helvetica, Arial, sans-serif;font-size:14px;letter-spacing:normal;line-height:1;text-align:left;color:#000000;"
><p class="text-build-content" style="margin: 10px 0; margin-top: 10px; margin-bottom: 10px;"><span style="color:#000000;font-family:Arial, sans-serif">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.</span></p></div>
><p class="text-build-content" style="margin: 10px 0; margin-top: 10px; margin-bottom: 10px;"><span style="color:#000000;font-family:Arial, sans-serif">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.</span></p></div>
</td>
</tr>
@ -164,7 +164,7 @@
<div
style="font-family:Verdana, Helvetica, Arial, sans-serif;font-size:14px;letter-spacing:normal;line-height:1;text-align:left;color:#000000;"
><p class="text-build-content" style="margin: 10px 0; margin-top: 10px;"><span style="color:#000000;font-family:Arial, sans-serif">Stay safe,</span></p><p class="text-build-content" style="margin: 10px 0; margin-bottom: 10px;"><span style="color:#000000;font-family:Arial, sans-serif">Oxynote Team</span></p></div>
><p class="text-build-content" style="margin: 10px 0; margin-top: 10px; margin-bottom: 10px;"><span style="color:#666666;font-family:Arial, sans-serif;font-size:13px">Sent by Oxynote at {{.host}}</span></p></div>
</td>
</tr>

View File

@ -152,7 +152,7 @@
<div
style="font-family:Verdana, Helvetica, Arial, sans-serif;font-size:14px;letter-spacing:normal;line-height:1;text-align:left;color:#000000;"
><p class="text-build-content" style="margin: 10px 0; margin-top: 10px; margin-bottom: 10px;"><span style="color:#000000;font-family:Arial, sans-serif">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.</span></p></div>
><p class="text-build-content" style="margin: 10px 0; margin-top: 10px; margin-bottom: 10px;"><span style="color:#000000;font-family:Arial, sans-serif">If you didn’t ask for this, ignore this email and your address stays as it is.</span></p></div>
</td>
</tr>
@ -164,7 +164,7 @@
<div
style="font-family:Verdana, Helvetica, Arial, sans-serif;font-size:14px;letter-spacing:normal;line-height:1;text-align:left;color:#000000;"
><p class="text-build-content" style="margin: 10px 0; margin-top: 10px;"><span style="color:#000000;font-family:Arial, sans-serif">Thanks for taking a moment to confirm — we appreciate it,</span></p><p class="text-build-content" style="margin: 10px 0; margin-bottom: 10px;"><span style="color:#000000;font-family:Arial, sans-serif">Oxynote Team</span></p></div>
><p class="text-build-content" style="margin: 10px 0; margin-top: 10px; margin-bottom: 10px;"><span style="color:#666666;font-family:Arial, sans-serif;font-size:13px">Sent by Oxynote at {{.host}}</span></p></div>
</td>
</tr>

View File

@ -152,7 +152,7 @@
<div
style="font-family:Verdana, Helvetica, Arial, sans-serif;font-size:14px;letter-spacing:normal;line-height:1;text-align:left;color:#000000;"
><p class="text-build-content" style="margin: 10px 0; margin-top: 10px; margin-bottom: 10px;"><span style="color:#000000;font-family:Arial, sans-serif">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.</span></p></div>
><p class="text-build-content" style="margin: 10px 0; margin-top: 10px; margin-bottom: 10px;"><span style="color:#000000;font-family:Arial, sans-serif">If you didn’t make this change, ignore this email and your address stays as it is.</span></p></div>
</td>
</tr>
@ -164,7 +164,7 @@
<div
style="font-family:Verdana, Helvetica, Arial, sans-serif;font-size:14px;letter-spacing:normal;line-height:1;text-align:left;color:#000000;"
><p class="text-build-content" style="margin: 10px 0; margin-top: 10px;"><span style="color:#000000;font-family:Arial, sans-serif">Thanks for taking a moment to confirm — we appreciate it,</span></p><p class="text-build-content" style="margin: 10px 0; margin-bottom: 10px;"><span style="color:#000000;font-family:Arial, sans-serif">Oxynote Team</span></p></div>
><p class="text-build-content" style="margin: 10px 0; margin-top: 10px; margin-bottom: 10px;"><span style="color:#666666;font-family:Arial, sans-serif;font-size:13px">Sent by Oxynote at {{.host}}</span></p></div>
</td>
</tr>

View File

@ -114,7 +114,7 @@
<div
style="font-family:Verdana, Helvetica, Arial, sans-serif;font-size:14px;letter-spacing:normal;line-height:1;text-align:left;color:#000000;"
><p class="text-build-content" style="margin: 10px 0; margin-top: 10px;"><span style="color:#000000;font-family:Arial, sans-serif">Hi there,</span></p><p class="text-build-content" style="margin: 10px 0;"><span style="color:#000000;font-family:Arial, sans-serif">You’ve been invited to join <b>{{.organization}}</b> on Oxynote.</span></p><p class="text-build-content" style="margin: 10px 0; margin-bottom: 10px;"><span style="color:#000000;font-family:Arial, sans-serif">Click the button below to accept the invitation and get started:</span></p></div>
><p class="text-build-content" style="margin: 10px 0; margin-top: 10px;"><span style="color:#000000;font-family:Arial, sans-serif">Hi there,</span></p><p class="text-build-content" style="margin: 10px 0;"><span style="color:#000000;font-family:Arial, sans-serif">You’ve been invited to the <b>{{.organization}}</b> workspace.</span></p><p class="text-build-content" style="margin: 10px 0; margin-bottom: 10px;"><span style="color:#000000;font-family:Arial, sans-serif">Click the button below to accept the invitation and get started:</span></p></div>
</td>
</tr>
@ -152,7 +152,7 @@
<div
style="font-family:Verdana, Helvetica, Arial, sans-serif;font-size:14px;letter-spacing:normal;line-height:1;text-align:left;color:#000000;"
><p class="text-build-content" style="margin: 10px 0; margin-top: 10px;"><span style="color:#000000;font-family:Arial, sans-serif">If you weren’t expecting this invite, feel free to ignore this email — nothing will happen unless you accept it.</span></p><p class="text-build-content" style="margin: 10px 0; margin-bottom: 10px;"><span style="color:#000000;font-family:Arial, sans-serif">If you have any questions, just reach out to us at hello@oxynote.io. We’re happy to help.</span></p></div>
><p class="text-build-content" style="margin: 10px 0; margin-top: 10px; margin-bottom: 10px;"><span style="color:#000000;font-family:Arial, sans-serif">If you weren’t expecting this invite, feel free to ignore this email — nothing will happen unless you accept it.</span></p></div>
</td>
</tr>
@ -164,7 +164,7 @@
<div
style="font-family:Verdana, Helvetica, Arial, sans-serif;font-size:14px;letter-spacing:normal;line-height:1;text-align:left;color:#000000;"
><p class="text-build-content" style="margin: 10px 0; margin-top: 10px;"><span style="color:#000000;font-family:Arial, sans-serif">Looking forward to having you on board,</span></p><p class="text-build-content" style="margin: 10px 0; margin-bottom: 10px;"><span style="color:#000000;font-family:Arial, sans-serif">Oxynote Team</span></p></div>
><p class="text-build-content" style="margin: 10px 0; margin-top: 10px; margin-bottom: 10px;"><span style="color:#666666;font-family:Arial, sans-serif;font-size:13px">Sent by Oxynote at {{.host}}</span></p></div>
</td>
</tr>

View File

@ -152,7 +152,7 @@
<div
style="font-family:Verdana, Helvetica, Arial, sans-serif;font-size:14px;letter-spacing:normal;line-height:1;text-align:left;color:#000000;"
><p class="text-build-content" style="margin: 10px 0; margin-top: 10px; margin-bottom: 10px;"><span style="color:#000000;font-family:Arial, sans-serif">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.</span></p></div>
><p class="text-build-content" style="margin: 10px 0; margin-top: 10px; margin-bottom: 10px;"><span style="color:#000000;font-family:Arial, sans-serif">If you didn’t request a password reset, you can safely ignore this email — your password will stay the same.</span></p></div>
</td>
</tr>
@ -164,7 +164,7 @@
<div
style="font-family:Verdana, Helvetica, Arial, sans-serif;font-size:14px;letter-spacing:normal;line-height:1;text-align:left;color:#000000;"
><p class="text-build-content" style="margin: 10px 0; margin-top: 10px;"><span style="color:#000000;font-family:Arial, sans-serif">Stay safe,</span></p><p class="text-build-content" style="margin: 10px 0; margin-bottom: 10px;"><span style="color:#000000;font-family:Arial, sans-serif">Oxynote Team</span></p></div>
><p class="text-build-content" style="margin: 10px 0; margin-top: 10px; margin-bottom: 10px;"><span style="color:#666666;font-family:Arial, sans-serif;font-size:13px">Sent by Oxynote at {{.host}}</span></p></div>
</td>
</tr>

View File

@ -152,7 +152,7 @@
<div
style="font-family:Verdana, Helvetica, Arial, sans-serif;font-size:14px;letter-spacing:normal;line-height:1;text-align:left;color:#000000;"
><p class="text-build-content" style="margin: 10px 0; margin-top: 10px; margin-bottom: 10px;"><span style="color:#000000;font-family:Arial, sans-serif">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.</span></p></div>
><p class="text-build-content" style="margin: 10px 0; margin-top: 10px; margin-bottom: 10px;"><span style="color:#000000;font-family:Arial, sans-serif">If you didn’t create an Oxynote account, you can safely ignore this email — the account won’t be activated.</span></p></div>
</td>
</tr>
@ -164,7 +164,7 @@
<div
style="font-family:Verdana, Helvetica, Arial, sans-serif;font-size:14px;letter-spacing:normal;line-height:1;text-align:left;color:#000000;"
><p class="text-build-content" style="margin: 10px 0; margin-top: 10px;"><span style="color:#000000;font-family:Arial, sans-serif">Stay safe,</span></p><p class="text-build-content" style="margin: 10px 0; margin-bottom: 10px;"><span style="color:#000000;font-family:Arial, sans-serif">Oxynote Team</span></p></div>
><p class="text-build-content" style="margin: 10px 0; margin-top: 10px; margin-bottom: 10px;"><span style="color:#666666;font-family:Arial, sans-serif;font-size:13px">Sent by Oxynote at {{.host}}</span></p></div>
</td>
</tr>

View File

@ -158,7 +158,7 @@
<div
style="font-family:Verdana, Helvetica, Arial, sans-serif;font-size:14px;letter-spacing:normal;line-height:1;text-align:left;color:#000000;"
><p class="text-build-content" style="margin: 10px 0; margin-top: 10px;"><span style="color:#000000;font-family:Arial, sans-serif">If you didn’t request this, you can safely ignore this email — nothing will happen.</span></p><p class="text-build-content" style="margin: 10px 0; margin-bottom: 10px;"><span style="color:#000000;font-family:Arial, sans-serif">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.</span></p></div>
><p class="text-build-content" style="margin: 10px 0; margin-top: 10px; margin-bottom: 10px;"><span style="color:#000000;font-family:Arial, sans-serif">If you didn’t request this, you can safely ignore this email — nothing will happen.</span></p></div>
</td>
</tr>
@ -170,7 +170,7 @@
<div
style="font-family:Verdana, Helvetica, Arial, sans-serif;font-size:14px;letter-spacing:normal;line-height:1;text-align:left;color:#000000;"
><p class="text-build-content" style="margin: 10px 0; margin-top: 10px;"><span style="color:#000000;font-family:Arial, sans-serif">Thanks for being part of Oxynote,</span></p><p class="text-build-content" style="margin: 10px 0; margin-bottom: 10px;"><span style="color:#000000;font-family:Arial, sans-serif">Oxynote Team</span></p></div>
><p class="text-build-content" style="margin: 10px 0; margin-top: 10px; margin-bottom: 10px;"><span style="color:#666666;font-family:Arial, sans-serif;font-size:13px">Sent by Oxynote at {{.host}}</span></p></div>
</td>
</tr>

View File

@ -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)

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

View File

@ -3,6 +3,8 @@ package org
import (
"context"
_ "embed"
"fmt"
"log/slog"
"net/http"
@ -23,6 +25,12 @@ import (
"github.com/rs/xid"
)
// _defaultLogo is the Oxynote logo an organization can be given before it
// uploads its own.
//
//go:embed default_logo.png
var _defaultLogo []byte
// ErrNoOrganizationMembers is returned when an organization has no members.
var ErrNoOrganizationMembers = errutil.New(http.StatusBadRequest, "organization.no_members", "organization has no members")
@ -182,28 +190,12 @@ func (h *Handler) UploadOrganizationLogo(w http.ResponseWriter, r *http.Request)
return
}
logoFolder := orgCore.LogoFolder(session.ActiveOrganizationID)
err = h.storer.Upload(r.Context(), logoFolder, session.ActiveOrganizationID, data, contentType)
logoLocation, err := h.storeLogo(r.Context(), session.ActiveOrganizationID, data, contentType)
if err != nil {
httpserver.RespondError(h.log, w, err)
return
}
logoLocation := httpserver.CacheBust(h.publicURL + orgCore.LogoPath)
err = h.db.UpdateOrganizationLogo(r.Context(), session.ActiveOrganizationID, logoLocation)
if err != nil {
derr := h.storer.Delete(r.Context(), logoFolder, session.ActiveOrganizationID)
if derr != nil {
h.log.Error("deleting object after DB failure", slog.String("error", derr.Error()))
}
httpserver.RespondError(h.log, w, err)
return
}
httpserver.Respond(
h.log,
w,
@ -213,6 +205,51 @@ func (h *Handler) UploadOrganizationLogo(w http.ResponseWriter, r *http.Request)
)
}
// SetDefaultOrganizationLogo gives the organization the Oxynote logo.
func (h *Handler) SetDefaultOrganizationLogo(w http.ResponseWriter, r *http.Request) {
id, err := h.extractOrganizationParameter(r)
if err != nil {
httpserver.RespondError(h.log, w, err)
return
}
if _, err = h.storeLogo(r.Context(), id, _defaultLogo, "image/png"); err != nil {
httpserver.RespondError(h.log, w, err)
return
}
httpserver.Respond(
h.log,
w,
nil,
http.StatusNoContent,
)
}
// storeLogo stores the organization's logo and points the organization at
// it, returning the logo's location. The object is removed again when the
// organization cannot be updated.
func (h *Handler) storeLogo(ctx context.Context, organizationID string, data []byte, contentType string) (string, error) {
logoFolder := orgCore.LogoFolder(organizationID)
if err := h.storer.Upload(ctx, logoFolder, organizationID, data, contentType); err != nil {
return "", fmt.Errorf("uploading logo: %w", err)
}
logoLocation := httpserver.CacheBust(h.publicURL + orgCore.LogoPath)
if err := h.db.UpdateOrganizationLogo(ctx, organizationID, logoLocation); err != nil {
derr := h.storer.Delete(ctx, logoFolder, organizationID)
if derr != nil {
h.log.Error("deleting object after DB failure", slog.String("error", derr.Error()))
}
return "", fmt.Errorf("updating organization logo: %w", err)
}
return logoLocation, nil
}
// RetrieveOrganizationLogo handles the retrieval of an organization's logo.
func (h *Handler) RetrieveOrganizationLogo(w http.ResponseWriter, r *http.Request) {
session, ok := auth.RequireSession(h.log, w, r)

View File

@ -653,6 +653,278 @@ func Test_Handler_UploadOrganizationLogo(t *testing.T) {
}
}
func Test_Handler_SetDefaultOrganizationLogo(t *testing.T) {
type check func(*testing.T, *DBMock, *StorerMock, *httptest.ResponseRecorder)
checks := func(cc ...check) []check { return cc }
hasResp := func(code int, body string) check {
return func(t *testing.T, _ *DBMock, _ *StorerMock, rec *httptest.ResponseRecorder) {
assert.Equal(t, code, rec.Code)
if body == "" {
assert.Zero(t, rec.Body.Len(), rec.Body.String())
return
}
assert.JSONEq(t, body, rec.Body.String())
}
}
wasUploadCalled := func(count int) check {
return func(t *testing.T, _ *DBMock, storer *StorerMock, _ *httptest.ResponseRecorder) {
ff := storer.UploadCalls()
require.Len(t, ff, count)
if count == 0 {
return
}
assert.Equal(t, "organizations/org2/logo", ff[0].Folder)
assert.Equal(t, "org2", ff[0].ID)
assert.Equal(t, _defaultLogo, ff[0].Data)
assert.Equal(t, "image/png", ff[0].ContentType)
}
}
wasUpdateLogoCalled := func(count int) check {
return func(t *testing.T, db *DBMock, _ *StorerMock, _ *httptest.ResponseRecorder) {
ff := db.UpdateOrganizationLogoCalls()
require.Len(t, ff, count)
if count == 0 {
return
}
assert.Equal(t, "org2", ff[0].OrganizationID)
assert.Regexp(t, `^loc/api/organizations/logo\?v=\d{14}$`, ff[0].Logo)
}
}
cc := map[string]struct {
DB *DBMock
Storer *StorerMock
OmitID bool
Checks []check
}{
"Missing organization ID parameter": {
DB: &DBMock{},
Storer: &StorerMock{},
OmitID: true,
Checks: checks(
hasResp(http.StatusNotFound, `{"code":"general","message":"not found"}`),
wasUploadCalled(0),
wasUpdateLogoCalled(0),
),
},
"Error returned by Storer.Upload": {
DB: &DBMock{},
Storer: &StorerMock{
UploadFunc: func(context.Context, string, string, []byte, string) error {
return assert.AnError
},
},
Checks: checks(
hasResp(http.StatusInternalServerError, `{"code":"general","message":"internal server error"}`),
wasUploadCalled(1),
wasUpdateLogoCalled(0),
),
},
"Successful update": {
DB: &DBMock{},
Storer: &StorerMock{},
Checks: checks(
hasResp(http.StatusNoContent, ""),
wasUploadCalled(1),
wasUpdateLogoCalled(1),
),
},
}
for cn, c := range cc {
t.Run(cn, func(t *testing.T) {
t.Parallel()
hdl := Handler{
log: slog.New(slog.DiscardHandler),
db: c.DB,
storer: c.Storer,
publicURL: "loc",
}
req := httptest.NewRequest(http.MethodPut, "http://test.com/", http.NoBody)
ctx := req.Context()
if !c.OmitID {
ctx = testutil.AddChiCtx(ctx, "organizationId", "org2")
}
rec := httptest.NewRecorder()
hdl.SetDefaultOrganizationLogo(rec, req.WithContext(ctx))
for _, ch := range c.Checks {
ch(t, c.DB, c.Storer, rec)
}
})
}
}
func Test_Handler_storeLogo(t *testing.T) {
type check func(*testing.T, *DBMock, *StorerMock, string, *bytes.Buffer)
checks := func(cc ...check) []check { return cc }
hasLocation := func(pattern string) check {
return func(t *testing.T, _ *DBMock, _ *StorerMock, location string, _ *bytes.Buffer) {
assert.Regexp(t, pattern, location)
}
}
hasLog := func(line string) check {
return func(t *testing.T, _ *DBMock, _ *StorerMock, _ string, buf *bytes.Buffer) {
if line == "" {
assert.Empty(t, buf.String())
return
}
assert.Contains(t, buf.String(), line)
}
}
wasUploadCalled := func(count int) check {
return func(t *testing.T, _ *DBMock, storer *StorerMock, _ string, _ *bytes.Buffer) {
ff := storer.UploadCalls()
require.Len(t, ff, count)
if count == 0 {
return
}
assert.Equal(t, "organizations/org1/logo", ff[0].Folder)
assert.Equal(t, "org1", ff[0].ID)
assert.Equal(t, _testPNG, ff[0].Data)
assert.Equal(t, "image/png", ff[0].ContentType)
}
}
wasUpdateLogoCalled := func(count int) check {
return func(t *testing.T, db *DBMock, _ *StorerMock, _ string, _ *bytes.Buffer) {
ff := db.UpdateOrganizationLogoCalls()
require.Len(t, ff, count)
if count == 0 {
return
}
assert.Equal(t, "org1", ff[0].OrganizationID)
assert.Regexp(t, `^loc/api/organizations/logo\?v=\d{14}$`, ff[0].Logo)
}
}
wasDeleteCalled := func(count int) check {
return func(t *testing.T, _ *DBMock, storer *StorerMock, _ string, _ *bytes.Buffer) {
ff := storer.DeleteCalls()
require.Len(t, ff, count)
if count == 0 {
return
}
assert.Equal(t, "organizations/org1/logo", ff[0].Folder)
assert.Equal(t, "org1", ff[0].ID)
}
}
cc := map[string]struct {
DB *DBMock
Storer *StorerMock
Err error
Checks []check
}{
"Error returned by Storer.Upload": {
DB: &DBMock{},
Storer: &StorerMock{
UploadFunc: func(context.Context, string, string, []byte, string) error {
return assert.AnError
},
},
Err: assert.AnError,
Checks: checks(
wasUploadCalled(1),
wasUpdateLogoCalled(0),
wasDeleteCalled(0),
),
},
"Error returned by DB.UpdateOrganizationLogo": {
DB: &DBMock{
UpdateOrganizationLogoFunc: func(context.Context, string, string) error {
return assert.AnError
},
},
Storer: &StorerMock{},
Err: assert.AnError,
Checks: checks(
hasLog(""),
wasUploadCalled(1),
wasUpdateLogoCalled(1),
wasDeleteCalled(1),
),
},
"Error returned by Storer.Delete after DB.UpdateOrganizationLogo": {
DB: &DBMock{
UpdateOrganizationLogoFunc: func(context.Context, string, string) error {
return assert.AnError
},
},
Storer: &StorerMock{
DeleteFunc: func(context.Context, string, string) error {
return assert.AnError
},
},
Err: assert.AnError,
Checks: checks(
hasLog("deleting object after DB failure"),
wasDeleteCalled(1),
),
},
"Successful store": {
DB: &DBMock{},
Storer: &StorerMock{},
Checks: checks(
hasLocation(`^loc/api/organizations/logo\?v=\d{14}$`),
hasLog(""),
wasUploadCalled(1),
wasUpdateLogoCalled(1),
wasDeleteCalled(0),
),
},
}
for cn, c := range cc {
t.Run(cn, func(t *testing.T) {
t.Parallel()
var buf bytes.Buffer
hdl := Handler{
log: slog.New(slog.NewTextHandler(&buf, nil)),
db: c.DB,
storer: c.Storer,
publicURL: "loc",
}
location, err := hdl.storeLogo(context.Background(), "org1", _testPNG, "image/png")
testutil.AssertEqualError(t, c.Err, err)
for _, ch := range c.Checks {
ch(t, c.DB, c.Storer, location, &buf)
}
})
}
}
func Test_Handler_RetrieveOrganizationLogo(t *testing.T) {
type check func(*testing.T, *StorerMock, *httptest.ResponseRecorder)

View File

@ -143,6 +143,7 @@ func (s *Server) internalRouter() chi.Router {
r.Post("/organizations/{organizationId}/initialize", s.handlers.organization.InitializeOrganization)
r.Post("/organizations/{organizationId}/teardown", s.handlers.organization.TeardownOrganization)
r.Put("/organizations/{organizationId}/logo/default", s.handlers.organization.SetDefaultOrganizationLogo)
r.Route("/documents/{documentId}", func(sr chi.Router) {
sr.Get("/branches", s.handlers.document.FetchDocumentBranchesUnsafe)

View File

@ -80,6 +80,7 @@ func Test_Server_httpRouter(t *testing.T) {
// the service-to-service surface stays where it is.
assert.True(t, routes["POST /api/x/organizations/{organizationId}/teardown"])
assert.True(t, routes["PUT /api/x/organizations/{organizationId}/logo/default"])
assert.True(t, routes["PUT /api/x/documents/{documentId}/branch/{branchId}/"])
assert.True(t, routes["POST /api/x/email"])

View File

@ -292,4 +292,109 @@ describe("<AccountDeletionAction>", { 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()
})
})
})

View File

@ -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) => {
)
}}
</ShadcnUiDialogDescription>
<div v-if="lastOrgMemeber" class="text-2sm font-medium text-foreground">
<div v-if="deletionBlocked" class="text-2sm font-medium text-foreground">
{{
$t(
"settings.action-modals.account-deletion.description-last-member-kept",
)
}}
</div>
<div
v-else-if="lastOrgMemeber"
class="text-2sm font-medium text-foreground"
>
{{
$t(
"settings.action-modals.account-deletion.description-last-org-member",
@ -146,7 +177,7 @@ const onSubmit = form.handleSubmit(async (values) => {
</div>
<form class="mt-5 flex flex-col gap-5" @submit="onSubmit">
<ShadcnUiFormField
v-if="passwordRequired"
v-if="passwordRequired && !deletionBlocked"
v-slot="{ componentField }"
name="password"
class="w-full"
@ -177,6 +208,7 @@ const onSubmit = form.handleSubmit(async (values) => {
</ShadcnUiFormField>
<div class="flex gap-2 self-stretch">
<ShadcnUiButton
v-if="!deletionBlocked"
type="submit"
variant="destructive"
size="sm"
@ -198,7 +230,11 @@ 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")
}}
</ShadcnUiButton>
</div>
</form>

View File

@ -13,6 +13,7 @@ import {
emitFrom,
mockAuthOrganization,
seedAuthAccounts,
seedAuthConfig,
seedAuthSession,
t,
teleportedButton,
@ -59,6 +60,7 @@ describe("<ActionModal>", { 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("<ActionModal>", { 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 })

View File

@ -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
)
})

View File

@ -48,10 +48,14 @@ describe("<EmailChangeAction>", { 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("<EmailChangeAction>", { 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("<EmailChangeAction>", { 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 })

View File

@ -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) => {
<div class="flex flex-col">
<ShadcnUiDialogDescription class="text-2sm">
{{
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")
}}
</ShadcnUiDialogDescription>
<form

View File

@ -63,6 +63,7 @@ describe("<WorkspaceInvitationAction>", { 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("<WorkspaceInvitationAction>", { 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,
}) => {

View File

@ -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
)
})

View File

@ -300,7 +300,10 @@ describe("<WorkspaceSection>", { 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("<WorkspaceSection>", { 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()

View File

@ -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) {
<div class="flex w-full flex-col gap-0.75">
<div class="flex items-center justify-between">
<i18n-t
v-if="maxOrganizationMembers === null"
scope="global"
keypath="settings.workspace.members-label-unlimited"
tag="div"
class="text-2base"
>
<template #current>{{ members.length }}</template>
</i18n-t>
<i18n-t
v-else
scope="global"
keypath="settings.workspace.members-label"
tag="div"
class="text-2base"
>
<template #current>{{ members.length }}</template>
<template #max>{{ ORGANIZATION_MAX_MEMBERS }}</template>
<template #max>{{ maxOrganizationMembers }}</template>
</i18n-t>
<ShadcnUiButton

View File

@ -90,6 +90,9 @@ export function seedAuthConfig(config: Partial<AuthConfig> = {}) {
seedQueryData(["auth", "config"], {
methods: ["email-password"],
emailEnabled: true,
singleOrganization: false,
maxOrganizationMembers: 5,
defaultAdmin: null,
...config,
})
}

View File

@ -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,
})
})
})
})

View File

@ -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,
}
}

View File

@ -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)(

View File

@ -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
}

View File

@ -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("<login>", { 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
}

View File

@ -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"
>
<div
v-if="defaultAdminLogin"
class="flex items-start gap-2 rounded-md bg-accent px-3 py-2.5 text-2sm text-accent-foreground"
>
<Icon name="mingcute:key-2-line" class="mt-1 size-3.5 shrink-0" />
<div class="flex flex-col gap-1.5 leading-relaxed">
<p>{{ $t("onboarding.login.default-admin.intro") }}</p>
<ul class="list-disc pl-4">
<li>
<code class="inline-code break-all">
{{ defaultAdminLogin.email }}
</code>
</li>
<li>
<code class="inline-code break-all">
{{ defaultAdminLogin.password }}
</code>
</li>
</ul>
<p>{{ $t("onboarding.login.default-admin.outro") }}</p>
</div>
</div>
<ShadcnUiFormField
v-slot="{ componentField, meta }"
name="email"
@ -547,7 +579,14 @@ function methodVariant(method: AuthMethod) {
</ShadcnUiButton>
</template>
</i18n-t>
<div
v-if="isSingleOrganization && !isInvitationRedirect(route.query.next)"
class="text-center text-xs text-accent-foreground"
>
{{ $t("onboarding.login.no-account.invitation-only") }}
</div>
<i18n-t
v-else
scope="global"
keypath="onboarding.login.no-account.main"
tag="div"

View File

@ -10,6 +10,16 @@ import { cn } from "~/lib/utils"
definePageMeta({
name: "signup",
skipAuth: true,
// a single-workspace instance has no open signup: only an invitee on
// the way to the invitation creates an account here.
middleware: async (to) => {
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<typeof setTimeout> | undefined

View File

@ -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
}

View File

@ -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
}

View File

@ -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(

View File

@ -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,

View File

@ -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"

View File

@ -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",