mirror of
https://github.com/oxynote/oxynote.git
synced 2026-10-17 02:49:18 +02:00
add one-org mode
This commit is contained in:
parent
ce554d07f1
commit
95bf5649ac
|
|
@ -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
|
||||
|
||||
|
|
|
|||
4
docker/env/auth-realtime.example.env
vendored
4
docker/env/auth-realtime.example.env
vendored
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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`,
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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("&", "&")
|
||||
if (!match?.[1]) {
|
||||
continue
|
||||
}
|
||||
|
||||
// the template escapes the query separator, and following the
|
||||
// link as written would hand the server a parameter called
|
||||
// "amp;callbackURL" — leaving it with no redirect target, so it
|
||||
// answers with raw JSON instead of returning to the app.
|
||||
const link = match[1].replaceAll("&", "&")
|
||||
|
||||
if (accept(link)) {
|
||||
return link
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
// the requestType better-auth writes into a verification link's token, a
|
||||
// JWT. Empty when the link carries none.
|
||||
function tokenRequestType(link: string): string {
|
||||
const payload = new URL(link).searchParams.get("token")?.split(".")[1]
|
||||
if (!payload) {
|
||||
return ""
|
||||
}
|
||||
|
||||
const claims = JSON.parse(Buffer.from(payload, "base64url").toString()) as {
|
||||
requestType?: unknown
|
||||
}
|
||||
|
||||
return typeof claims.requestType === "string" ? claims.requestType : ""
|
||||
}
|
||||
|
|
|
|||
89
e2e/helpers/single-workspace.ts
Normal file
89
e2e/helpers/single-workspace.ts
Normal 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 })
|
||||
}
|
||||
187
e2e/tests/prod-single-workspace.test.ts
Normal file
187
e2e/tests/prod-single-workspace.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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) =>
|
||||
|
|
|
|||
255
server/auth-realtime/src/bootstrap.test.ts
Normal file
255
server/auth-realtime/src/bootstrap.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
153
server/auth-realtime/src/bootstrap.ts
Normal file
153
server/auth-realtime/src/bootstrap.ts
Normal 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 }
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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`,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 }) => {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
BIN
server/core/internal/server/internal/org/default_logo.png
Normal file
BIN
server/core/internal/server/internal/org/default_logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 11 KiB |
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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"])
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}) => {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
80
web/app/pages/login.nuxt.test.ts
Normal file
80
web/app/pages/login.nuxt.test.ts
Normal 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
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user