make email sending optional

This commit is contained in:
swithek 2026-09-15 21:18:02 +02:00
parent 8dc5392065
commit cc162bad2b
54 changed files with 2454 additions and 278 deletions

View File

@ -99,8 +99,8 @@ make run / start / stop # dev stack foreground / background / stop
make dev # backend containers + web dev server on :3000
make check-env / sync-env # report / reconcile *.local.env against templates
make lint / check-lint # fix / verify lint, format and types everywhere
make prod-build / prod-run / prod-stop # all-in-one image on :8080 (mailpit :8025)
make e2e-{dev,prod}[-stack-build|-stack-stop] # dev stack :18080, prod image :19080
make prod-build / prod-run[-no-email] / prod-stop # all-in-one image on :8080 (mailpit :8025)
make e2e-{dev,prod}[-stack-build|-stack-stop] # dev stack :18080, prod image :19080 (no email :19081)
```
`make prod-publish` is release-only (`.github/workflows/release.yml`).

View File

@ -12,6 +12,7 @@ E2E_DEV_COMPOSE := docker compose -f e2e/docker-compose.dev.yaml $(E2E_DEV_COMPO
E2E_PROD_COMPOSE := docker compose -f e2e/docker-compose.prod.yaml
PROD_COMPOSE := docker compose -f docker/prod/docker-compose.example.yaml \
-f docker/prod/docker-compose.local.yaml
PROD_NO_EMAIL_COMPOSE := docker compose -f docker/prod/docker-compose.example.yaml
# a tag names the stack an image belongs to. The e2e targets override these
# to :e2e-dev and :e2e-prod so a run cannot replace the image `make start`
@ -185,13 +186,20 @@ prod-publish:
# build the image, then run the example deployment against it on
# http://localhost:8080 (ctrl-c stops it). The local override adds the mail
# sink the example only describes in a comment: signup requires a verified
# address, so without it the verification link is only logged. Read the
# delivered mail at http://localhost:8025.
# sink the example only describes in a comment, so the product runs with
# email. Read the delivered mail at http://localhost:8025.
.PHONY: prod-run
prod-run: prod-build
OXYNOTE_IMAGE_TAG=$(PROD_IMAGE_TAG) $(PROD_COMPOSE) up
# the same without the local override: the example as it stands, which runs
# the product without email. Both targets share one compose project and its
# data volume, so prod-stop stops either, and --remove-orphans takes down a
# mailpit an earlier prod-run left behind.
.PHONY: prod-run-no-email
prod-run-no-email: prod-build
OXYNOTE_IMAGE_TAG=$(PROD_IMAGE_TAG) $(PROD_NO_EMAIL_COMPOSE) up --remove-orphans
.PHONY: prod-stop
prod-stop:
$(PROD_COMPOSE) down

View File

@ -120,50 +120,9 @@ docker run -d --name oxynote \
Open http://localhost:8080 and sign up.
<details>
<summary>Want email? This compose file starts Mailpit next to Oxynote</summary>
```yaml
name: oxynote
services:
oxynote:
image: ghcr.io/oxynote/oxynote:latest
ports:
- "8080:8080"
environment:
# point these at a real relay to have mail delivered; here they go
# to the mailpit service below.
- OXYNOTE_SMTP_DSN=smtp://mailpit:1025?tls=none
- OXYNOTE_EMAIL_FROM_ADDRESS=Oxynote <team@example.com>
volumes:
- oxynote_data:/oxynote/data
depends_on:
mailpit:
condition: service_started
# the image shuts its services down in order, flushing open documents
# last; docker's default 10s grace would kill that mid-flush.
stop_grace_period: 60s
restart: unless-stopped
# catches every mail Oxynote sends and shows it at
# http://localhost:8025.
mailpit:
image: axllent/mailpit:v1.31.0
ports:
- "8025:8025"
restart: unless-stopped
volumes:
oxynote_data:
```
</details>
Everything else, from email to object storage, the GitHub and Slack
apps, social login and Rubber Duck, is one variable away and
listed in [docker/prod/README.md](docker/prod/README.md). To run from
source, see [CONTRIBUTING.md](CONTRIBUTING.md).
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).
## What is in the box

View File

@ -46,3 +46,9 @@ OXYNOTE_AUTH_REALTIME_MAX_ORGANIZATIONS=50
# (a test run, or a proxy that hides the original IP) and rate-limit
# upstream instead.
OXYNOTE_AUTH_REALTIME_RATE_LIMIT_DISABLED=false
# email
# "true" where core has no SMTP relay: signup skips verification, password
# reset is refused, and email changes and account deletions are confirmed
# with the password instead of an emailed link.
OXYNOTE_AUTH_REALTIME_EMAIL_DISABLED=false

View File

@ -15,7 +15,7 @@ volume, as does the search index, which core rebuilds from Postgres when
missing). `docker-compose.example.yaml` is the canonical deployment and
`README.md` the operator reference. `docker-compose.local.yaml` is the
override `make prod-run` layers on it to build the image locally and add a
mailpit, since signup needs a verified address.
mailpit, since the example runs without email.
- **`make prod-build`** (repo root) builds it. Core's binary must come from
goreleaser, never `go build`; the target stages it into `.build/` for the

View File

@ -20,9 +20,10 @@ docker compose -f my-deployment.yaml up -d
From a repository checkout, `make prod-run` builds the image locally
(goreleaser builds the core binary first — a bare `docker build` is not
supported) and runs this example on `http://localhost:8080`, with
`docker-compose.local.yaml` layered on top so signup mail lands in a mailpit
at `http://localhost:8025` instead of being logged. `make prod-stop` stops
it.
`docker-compose.local.yaml` layered on top to add an email sender: a mailpit
that shows the delivered mail at `http://localhost:8025`. `make
prod-run-no-email` runs the example without that override, as a deployment
without an email sender. `make prod-stop` stops either.
The image serves plain HTTP on port **8080** and assumes it is reached at
`http://localhost:8080` unless told otherwise, so publish the container as
@ -56,13 +57,33 @@ cleanly. An incomplete group fails the boot with the missing name.
| --- | --- |
| Valkey/Redis | `OXYNOTE_VALKEY_DSN` (`redis[s]://[user:pass@]host:port[/db]`, credentials supported). Without it the assistant keeps its conversations in the core process and sessions are not cached outside Postgres. |
| Object storage | `OXYNOTE_OBJECT_STORAGE_DSN` — an S3-compatible store as one URL: `http(s)://ACCESS_KEY:SECRET_KEY@host:port/bucket[?region=...]` (the bucket defaults to `oxynote` and is created if missing). Without it uploaded images are kept on the data volume, which is the right choice for a single-node deployment. |
| Email | `OXYNOTE_SMTP_DSN` (`smtp[s]://[user:pass@]host:port[?tls=none\|starttls\|tls]`), `OXYNOTE_EMAIL_FROM_ADDRESS`. Without email, verification mails are logged instead of sent. |
| Email | `OXYNOTE_SMTP_DSN` (`smtp[s]://[user:pass@]host:port[?tls=none\|starttls\|tls]`), `OXYNOTE_EMAIL_FROM_ADDRESS`. Without it the product runs without email, as described below. |
| GitHub App | `OXYNOTE_GITHUB_APP_ID`, `OXYNOTE_GITHUB_APP_SLUG`, `OXYNOTE_GITHUB_APP_SIGNATURE_SECRET`; mount the app's private key at `/oxynote/github/private-key.pem` |
| Slack app | `OXYNOTE_SLACK_APP_CLIENT_ID`, `OXYNOTE_SLACK_APP_CLIENT_SECRET`, `OXYNOTE_SLACK_APP_SIGNATURE_SECRET` |
| Social login | `OXYNOTE_SOCIAL_LOGIN_{GITHUB,GOOGLE,SLACK}_CLIENT_ID` + `_CLIENT_SECRET` (both halves per provider) |
| AI assistant | `OXYNOTE_AI_ASSISTANT_PROVIDER` (`anthropic`, `openai`, `google`, `ollama`, `openrouter`) plus the vendor's credentials, detailed in [docs/ai.md](../../docs/ai.md): `OXYNOTE_AI_ASSISTANT_API_KEY`, `_MODEL`, `_BASE_URL`, `_MAX_TOKENS`, `_REQUEST_TIMEOUT`, `_SUMMARY_MODEL`, `_AZURE_API_VERSION`, `_BEDROCK_{REGION,ACCESS_KEY,SECRET_ACCESS_KEY,SESSION_TOKEN}`, `_VERTEX_{PROJECT_ID,REGION,SERVICE_ACCOUNT_JSON}` |
| URL watching | `OXYNOTE_CHANGE_DETECTION_URL`, `OXYNOTE_CHANGE_DETECTION_API_KEY` (a changedetection.io instance) |
### Without an email sender
Without `OXYNOTE_SMTP_DSN` nothing is emailed, and the flows that would wait
for a link work without one:
- Signup skips email verification and goes straight to workspace creation.
- Invitations are still created, but their links have to be passed on by
hand. Workspace settings offer **Copy invitation link** on each pending
invitation, and show a notice about it.
- Changing the email address or deleting the account asks for the current
password. Accounts that sign in only through a social login have no
password, so they can do neither.
- Password reset is switched off, and there is no other way to recover a
forgotten password. The login page tells users to contact their
administrator.
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.
### Tuning
`OXYNOTE_MAX_ORGANIZATIONS`, `OXYNOTE_MAX_ORGANIZATION_MEMBERS`,

View File

@ -1,7 +1,7 @@
# `make prod-run` from the repository root builds the image and runs this
# file against it, with docker-compose.local.yaml layered on top to add the
# mail sink a browser trial needs. The full variable reference lives in
# README.md next to this file.
# file against it, with docker-compose.local.yaml layered on top to add a
# mail sink, so the email flows can be tried as well. The full variable
# reference lives in README.md next to this file.
name: oxynote-prod
@ -10,12 +10,13 @@ services:
image: ghcr.io/oxynote/oxynote:${OXYNOTE_IMAGE_TAG:-latest}
ports:
- "8080:8080"
environment:
# optional: outgoing email (signup verification, invites). Point it
# at a real relay, or uncomment the mailpit service below for a
# trial run and use these two lines:
- OXYNOTE_SMTP_DSN=smtp://mailpit:1025
- OXYNOTE_EMAIL_FROM_ADDRESS=Oxynote <team@example.com>
# optional: outgoing email (signup verification, invitations, password
# reset). Without it the product runs without email, as README.md
# describes. Point it at a real relay, or uncomment the mailpit service
# below for a trial run and use these lines:
# environment:
# - OXYNOTE_SMTP_DSN=smtp://mailpit:1025
# - OXYNOTE_EMAIL_FROM_ADDRESS=Oxynote <team@example.com>
volumes:
# the embedded database, the generated secrets, every uploaded image
# and the search index live here. Losing this volume loses every
@ -28,12 +29,14 @@ services:
stop_grace_period: 60s
restart: unless-stopped
# a trial-run mail sink: uncomment it together with the two OXYNOTE_SMTP
# variables above and read the delivered mail at http://localhost:8025.
mailpit:
image: axllent/mailpit:v1.31.0
ports:
- "8025:8025"
# a trial-run mail sink: uncomment it together with the email variables
# above and read the delivered mail at http://localhost:8025. It accepts
# mail for any address and shows it to anyone who opens it, so it has no
# place in a real deployment.
# mailpit:
# image: axllent/mailpit:v1.31.0
# ports:
# - "8025:8025"
volumes:
oxynote_data:

View File

@ -3,11 +3,11 @@
# deployment operators copy — docker-compose.example.yaml is that file, and
# it stays as documented.
#
# the one thing a local run cannot do without is mail: signup requires a
# verified address, so without a relay the verification link is only ever
# logged. This starts the mail sink the example already describes in a
# comment and points the image at it; the delivered mail is read at
# http://localhost:8025.
# it adds mail. The example runs without an email sender, which switches off
# signup verification and password reset; this starts the mail sink the
# example describes in a comment and points the image at it, so a local run
# goes through the same flows as a deployment with a relay. The delivered
# mail is read at http://localhost:8025.
name: oxynote-prod

View File

@ -289,6 +289,9 @@ describe("buildChildEnvs", () => {
const envs = build()
expect(envs.core.OXYNOTE_CORE_EMAIL_SMTP_HOST).toBe("")
expect(
envs.authRealtime.OXYNOTE_AUTH_REALTIME_EMAIL_DISABLED,
).toBe("true")
expect(envs.core.OXYNOTE_CORE_GITHUB_APP_ID).toBe("")
expect(envs.core.OXYNOTE_CORE_SLACK_CLIENT_ID).toBe("")
expect(envs.core.OXYNOTE_CORE_ASSISTANT_PROVIDER).toBe("")
@ -344,6 +347,9 @@ describe("buildChildEnvs", () => {
expect(envs.core.OXYNOTE_CORE_EMAIL_FROM_ADDRESS).toBe(
"Oxynote <team@example.com>",
)
expect(
envs.authRealtime.OXYNOTE_AUTH_REALTIME_EMAIL_DISABLED,
).toBe("false")
expect(envs.core.OXYNOTE_CORE_GITHUB_APP_ID).toBe("123")
expect(envs.core.OXYNOTE_CORE_GITHUB_PRIVATE_KEY_PATH).toBe(
"/oxynote/github/private-key.pem",

View File

@ -200,6 +200,11 @@ export function buildChildEnvs(
config.maxOrganizationMembers,
OXYNOTE_AUTH_REALTIME_RATE_LIMIT_DISABLED:
config.rateLimitDisabled ? "true" : "false",
// core sends every email, so without a relay nothing auth-realtime
// asks for can arrive; it has to know at boot, because
// better-auth's verification options are fixed then.
OXYNOTE_AUTH_REALTIME_EMAIL_DISABLED:
config.smtp === undefined ? "true" : "false",
OXYNOTE_AUTH_REALTIME_LOG_LEVEL: config.logLevel,
OXYNOTE_AUTH_REALTIME_BETTER_AUTH_GITHUB_CLIENT_ID:
config.socialLogin.github?.clientId ?? "",

View File

@ -81,7 +81,8 @@ differing from `docker/docker-compose.dev.yaml` in:
polls it through the front door, treating any response under 500 as
ready.
Mailpit makes the auth flows testable: verification is mandatory, so tests
Mailpit makes the auth flows testable: with an email sender, verification
is mandatory, so tests
fetch the real message via mailpit's REST API and follow the link. **Neither
stack runs valkey or an object store**; that is the configuration most
installs run. A test that needs either says why.
@ -90,7 +91,10 @@ installs run. A test that needs either says why.
`docker-compose.prod.yaml` is `docker/prod/docker-compose.example.yaml`
retuned as a fixture (`:19080`, `:19025`, inlined env, pinned images). It
sets no `OXYNOTE_DB_DSN`, so the image runs its embedded Postgres.
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`.
Compose builds nothing; the image comes from `make prod-build`, tagged
`:e2e-prod`, and CI's cache reaches it through `PROD_BUILD_EXTRA`. A `probe`
container (idle alpine on the private network) exists for the

View File

@ -11,8 +11,9 @@
# the same fixture rules apply:
# - 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, :19025
# mailpit), so it coexists with the dev stack and the e2e dev stack alike
# - 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
# - 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
@ -54,6 +55,22 @@ services:
# last, and docker's default 10s grace would SIGKILL that mid-flush.
stop_grace_period: 60s
# the same image with no email sender, which is all it takes to run the
# product without email: the launcher derives the switch from the missing
# OXYNOTE_SMTP_DSN. tests/prod-without-email.test.ts drives it through its
# own front door; every other test stays on the instance above.
oxynote-without-email:
image: ghcr.io/oxynote/oxynote:e2e-prod
ports:
- "19081:8080"
environment:
- OXYNOTE_PUBLIC_URL=http://localhost:19081
- OXYNOTE_MAX_ORGANIZATION_MEMBERS=5
# the same two allowances as the instance above, for the same reasons.
- OXYNOTE_MAX_ORGANIZATIONS=100000
- OXYNOTE_RATE_LIMIT_DISABLED=true
stop_grace_period: 60s
# a sibling on the private network, and the only honest way to ask the
# question the trust boundary answers: can a container that is not caddy
# reach core, auth-realtime or the web server directly? The prod tests

View File

@ -39,12 +39,14 @@ async function revealForm(button: Locator, field: Locator): Promise<void> {
}
// submitSignupForm walks the email-password branch of the signup page and
// leaves the browser wherever the app sends it.
// leaves the browser wherever the app sends it. The origin is the stack's
// front door unless a test drives another instance.
export async function submitSignupForm(
page: Page,
credentials: Credentials,
origin = BASE_URL,
): Promise<void> {
await visit(page, "/signup")
await visit(page, `${origin}/signup`)
const email = page.getByPlaceholder(
t("onboarding.signup.email-password-form.email-placeholder"),
@ -71,12 +73,14 @@ export async function submitSignupForm(
}
// submitLoginForm walks the email-password branch of the login page and
// leaves the browser wherever the app sends it.
// leaves the browser wherever the app sends it. The origin is the stack's
// front door unless a test drives another instance.
export async function submitLoginForm(
page: Page,
credentials: Credentials,
origin = BASE_URL,
): Promise<void> {
await visit(page, "/login")
await visit(page, `${origin}/login`)
const email = page.getByPlaceholder(
t("onboarding.login.email-password-form.email-placeholder"),

View File

@ -0,0 +1,30 @@
import { expect, type Page } from "@playwright/test"
import { newCredentials, submitSignupForm, type Credentials } from "./auth"
import { waitForEditor } from "./editor"
import { newWorkspace, submitWorkspaceForm, type Workspace } from "./workspace"
// the prod stack's second instance of the image, started with no email
// sender (oxynote-without-email in docker-compose.prod.yaml). Changing the
// port means changing that service.
export const WITHOUT_EMAIL_URL = "http://localhost:19081"
// signUpWithWorkspaceWithoutEmail is signUpWithWorkspace for that instance.
// Signup signs the new account straight in, so there is no link to follow
// and no login step before the workspace is created.
export async function signUpWithWorkspaceWithoutEmail(
page: Page,
): Promise<{ credentials: Credentials; workspace: Workspace }> {
const credentials = newCredentials()
await submitSignupForm(page, credentials, WITHOUT_EMAIL_URL)
await expect(page).toHaveURL(`${WITHOUT_EMAIL_URL}/welcome`, {
timeout: 15_000,
})
const workspace = newWorkspace()
await submitWorkspaceForm(page, workspace)
await expect(page).toHaveURL(/-[a-z0-9]{20}$/, { timeout: 30_000 })
await waitForEditor(page)
return { credentials, workspace }
}

View File

@ -26,6 +26,27 @@ test.describe("login", () => {
await expect(page.getByText(credentials.email)).toBeVisible()
})
test("takes a returning user back to their workspace", async ({
page,
request,
browser,
}) => {
const { credentials, workspace } = await signUpWithWorkspace(page, request)
const context = await browser.newContext()
const returning = await context.newPage()
await submitLoginForm(returning, credentials)
// the login redirect ends on the workspace's first document, a cold
// document load in a browser that has never opened it
await expect(returning).toHaveURL(
new RegExp(`/${workspace.slug}/.+-[a-z0-9]{20}$`),
{ timeout: 30_000 },
)
await context.close()
})
test("rejects an unknown email and password", async ({ page }) => {
await submitLoginForm(page, newCredentials())

View File

@ -0,0 +1,245 @@
import { expect, test, type Page } from "@playwright/test"
import {
newCredentials,
submitLoginForm,
submitSignupForm,
} from "../helpers/auth"
import { sidebarDocument } from "../helpers/editor"
import { t } from "../helpers/i18n"
import { visit } from "../helpers/page"
import { openSettings } from "../helpers/settings"
import {
signUpWithWorkspaceWithoutEmail,
WITHOUT_EMAIL_URL,
} from "../helpers/without-email"
// confirms a deletion from settings with the given password and leaves the
// browser wherever the app sends it.
async function confirmAccountDeletion(
page: Page,
workspaceName: string,
password: string,
): Promise<void> {
const settings = await openSettings(page, workspaceName)
await settings
.getByRole("button", {
name: t("settings.profile.account-deletion-button"),
})
.click()
const action = page.getByRole("dialog", {
name: t("settings.action-modals.account-deletion.title"),
})
await action
.getByLabel(t("settings.action-modals.account-deletion.password-label"))
.fill(password)
await action
.getByRole("button", {
name: t("settings.action-modals.account-deletion.confirm-button"),
})
.click()
}
// the image started with no email sender. The launcher derives that mode
// from the missing OXYNOTE_SMTP_DSN, which only the all-in-one image does.
test.describe("without an email sender", () => {
test("signs a new account straight in", async ({ page }) => {
await submitSignupForm(page, newCredentials(), WITHOUT_EMAIL_URL)
await expect(page).toHaveURL(`${WITHOUT_EMAIL_URL}/welcome`, {
timeout: 15_000,
})
})
test("refuses to send a password reset link", async ({ page, request }) => {
const credentials = newCredentials()
await submitSignupForm(page, credentials, WITHOUT_EMAIL_URL)
await expect(page).toHaveURL(`${WITHOUT_EMAIL_URL}/welcome`, {
timeout: 15_000,
})
// the login page offers no reset at all, so the request goes
// straight to the endpoint it would have called
const response = await request.post(
`${WITHOUT_EMAIL_URL}/auth-realtime/api/auth/request-password-reset`,
{
headers: { Origin: WITHOUT_EMAIL_URL },
data: {
email: credentials.email,
redirectTo: `${WITHOUT_EMAIL_URL}/reset-password`,
},
},
)
expect(response.status()).toBe(400)
expect((await response.json()) as unknown).toMatchObject({
code: "RESET_PASSWORD_DISABLED",
})
})
test("takes a teammate in through a copied invitation link", async ({
page,
browser,
}) => {
// two signups, a workspace and an invitation run before the first
// assertion
test.slow()
await page.context().grantPermissions(["clipboard-read", "clipboard-write"])
const { workspace } = await signUpWithWorkspaceWithoutEmail(page)
const invitee = newCredentials()
await openSettings(page, workspace.name)
await page
.getByRole("button", {
name: t("settings.workspace.invitation-button"),
exact: true,
})
.click()
await page
.getByPlaceholder(
t("settings.action-modals.workspace-invitation.email-placeholder"),
)
.fill(invitee.email)
await page
.getByRole("button", {
name: t("settings.action-modals.workspace-invitation.submit-button"),
})
.click()
await page
.getByRole("row")
.filter({ hasText: invitee.email })
.getByRole("button", {
name: t("settings.workspace.members-option-button-screen-reader-hint"),
})
.click()
await page
.getByRole("menuitem", {
name: t("settings.workspace.member-options.copy-link.title"),
})
.click()
await expect(
page.getByText(t("settings.workspace.invitation-link-copied")),
).toBeVisible()
const link = await page.evaluate(() => navigator.clipboard.readText())
const context = await browser.newContext()
const other = await context.newPage()
await submitSignupForm(other, invitee, WITHOUT_EMAIL_URL)
// a fresh account has no workspace yet, so signup lands on
// onboarding — the invitation is what gets them out of it
await expect(other).toHaveURL(`${WITHOUT_EMAIL_URL}/welcome`, {
timeout: 15_000,
})
await visit(other, link)
await other
.getByRole("button", {
name: t("onboarding.accept-invite.accept-button"),
})
.click()
await expect(other).toHaveURL(new RegExp(`/${workspace.slug}/`), {
timeout: 30_000,
})
await expect(sidebarDocument(other, "Welcome to Oxynote!")).toBeVisible()
await context.close()
})
test("moves the login to a new address once the password confirms it", async ({
page,
browser,
}) => {
const { credentials, workspace } =
await signUpWithWorkspaceWithoutEmail(page)
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
.getByPlaceholder(
t("settings.action-modals.email-change.password-placeholder"),
)
.fill(credentials.password)
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-without-email.title",
),
),
).toBeVisible()
// a browser of its own proves the server took the new address,
// not just the form
const context = await browser.newContext()
const fresh = await context.newPage()
await submitLoginForm(
fresh,
{ email: newEmail, password: credentials.password },
WITHOUT_EMAIL_URL,
)
await expect(fresh).toHaveURL(/-[a-z0-9]{20}$/, { timeout: 30_000 })
await context.close()
})
test("deletes the account once the password confirms it", async ({
page,
}) => {
const { credentials, workspace } =
await signUpWithWorkspaceWithoutEmail(page)
await confirmAccountDeletion(page, workspace.name, credentials.password)
await expect(page).toHaveURL(`${WITHOUT_EMAIL_URL}/signup?deletion=success`)
await submitLoginForm(page, credentials, WITHOUT_EMAIL_URL)
await expect(
page.getByText(t("onboarding.login.errors.invalid-credentials")),
).toBeVisible()
})
test("keeps the account when the deletion password is wrong", async ({
page,
browser,
}) => {
const { credentials, workspace } =
await signUpWithWorkspaceWithoutEmail(page)
await confirmAccountDeletion(page, workspace.name, "not-the-Passw0rd!")
await expect(
page.getByText(
t("settings.action-modals.account-deletion.errors.invalid-password"),
),
).toBeVisible()
const context = await browser.newContext()
const fresh = await context.newPage()
await submitLoginForm(fresh, credentials, WITHOUT_EMAIL_URL)
await expect(fresh).toHaveURL(/-[a-z0-9]{20}$/, { timeout: 30_000 })
await context.close()
})
})

View File

@ -1,6 +1,7 @@
import { describe, it, vi } from "vitest"
import {
authMethods,
confirmWithPassword,
createAuth,
createOrganizationHooks,
createSecondaryStorage,
@ -8,6 +9,7 @@ import {
invitationLink,
organizationClaims,
toPublicAuthUrl,
verificationTemplate,
type SecondaryStorageClient,
} from "./auth.js"
import { createDatabase, type Store } from "./db.js"
@ -71,6 +73,16 @@ function buildAuth(
return { auth, core, log }
}
// an email callback is left out when no email can be sent, so its type
// admits undefined even on an instance built with email enabled.
function configured<T>(callback: T | undefined): T {
if (!callback) {
throw new Error("callback is not configured")
}
return callback
}
describe("authMethods", () => {
it("always offers email and password", ({ expect }) => {
expect(authMethods(testEnv())).toEqual(["email-password"])
@ -116,6 +128,51 @@ describe("toPublicAuthUrl", () => {
})
})
describe("verificationTemplate", () => {
// better-auth's verification tokens are JWTs; only the payload is read
function token(payload: Record<string, unknown>): string {
const body = Buffer.from(JSON.stringify(payload)).toString(
"base64url",
)
return `header.${body}.signature`
}
it("sends the new-address wording for a change of address", ({
expect,
}) => {
expect(
verificationTemplate(
token({
email: "old@b.c",
updateTo: "new@b.c",
requestType:
"change-email-verification",
}),
),
).toBe("email_verification")
})
it.for([
{
name: "a signup token, which names no other address",
input: "header.eyJlbWFpbCI6ImFAYi5jIn0.signature",
},
{ name: "a token with no payload at all", input: "opaque" },
{
name: "a payload that is not JSON",
input: "header.@@@.signature",
},
])(
"sends the activation wording for $name",
({ input }, { expect }) => {
expect(verificationTemplate(input)).toBe(
"signup_verification",
)
},
)
})
describe("electronCallbackOverride", () => {
const electronContext = {
path: "/sign-in/social",
@ -347,6 +404,219 @@ describe("createOrganizationHooks", () => {
})
})
describe("confirmWithPassword", () => {
const signedIn = () =>
Promise.resolve({ id: "u1", email: "ada@oxynote.test" })
// an email change by an account whose password checks out, to an
// address nobody holds, unless a test says otherwise.
function passwordContext(
overrides: {
path?: string
body?: Record<string, unknown>
account?: { password?: string | null } | null
verifies?: boolean
holder?: unknown
} = {},
) {
const internalAdapter = {
findCredentialAccount: vi
.fn()
.mockResolvedValue(
overrides.account === undefined
? { password: "hash" }
: overrides.account,
),
findUserByEmail: vi
.fn()
.mockResolvedValue(overrides.holder ?? null),
updateUser: vi.fn().mockResolvedValue({}),
}
const password = {
verify: vi
.fn()
.mockResolvedValue(overrides.verifies ?? true),
}
const json = vi.fn((body: { status: boolean }) => body)
return {
ctx: {
path: overrides.path ?? "/change-email",
body: overrides.body ?? {
newEmail: "new@oxynote.test",
password: "secret",
},
context: { internalAdapter, password },
json,
},
internalAdapter,
password,
json,
}
}
it("leaves every other endpoint alone", async ({ expect }) => {
const { ctx, internalAdapter } = passwordContext({
path: "/sign-in/email",
})
const currentUser = vi.fn(signedIn)
const answer = await confirmWithPassword(ctx, currentUser)
expect(answer).toBeUndefined()
expect.soft(currentUser).not.toHaveBeenCalled()
expect.soft(
internalAdapter.findCredentialAccount,
).not.toHaveBeenCalled()
})
it("leaves a caller without a session to the endpoint", async ({
expect,
}) => {
const { ctx, internalAdapter } = passwordContext()
const answer = await confirmWithPassword(ctx, () =>
Promise.resolve(undefined),
)
expect(answer).toBeUndefined()
expect.soft(
internalAdapter.findCredentialAccount,
).not.toHaveBeenCalled()
expect.soft(internalAdapter.updateUser).not.toHaveBeenCalled()
})
it("refuses an account that has no password", async ({ expect }) => {
const { ctx, internalAdapter, password } = passwordContext({
account: null,
})
await expect(
confirmWithPassword(ctx, signedIn),
).rejects.toMatchObject({
body: { code: "CREDENTIAL_ACCOUNT_NOT_FOUND" },
})
expect.soft(
internalAdapter.findCredentialAccount,
).toHaveBeenCalledWith("u1")
expect.soft(password.verify).not.toHaveBeenCalled()
expect.soft(internalAdapter.updateUser).not.toHaveBeenCalled()
})
it("refuses a request that carries no password", async ({ expect }) => {
const { ctx, internalAdapter, password } = passwordContext({
body: { newEmail: "new@oxynote.test" },
})
await expect(
confirmWithPassword(ctx, signedIn),
).rejects.toMatchObject({ body: { code: "INVALID_PASSWORD" } })
expect.soft(password.verify).not.toHaveBeenCalled()
expect.soft(internalAdapter.updateUser).not.toHaveBeenCalled()
})
it("refuses a wrong password", async ({ expect }) => {
const { ctx, internalAdapter, password } = passwordContext({
verifies: false,
})
await expect(
confirmWithPassword(ctx, signedIn),
).rejects.toMatchObject({ body: { code: "INVALID_PASSWORD" } })
expect.soft(password.verify).toHaveBeenCalledWith({
hash: "hash",
password: "secret",
})
expect.soft(internalAdapter.updateUser).not.toHaveBeenCalled()
})
it("lets a deletion through to better-auth once the password checks out", async ({
expect,
}) => {
const { ctx, internalAdapter, password, json } =
passwordContext({
path: "/delete-user",
body: { password: "secret" },
})
const answer = await confirmWithPassword(ctx, signedIn)
expect(answer).toBeUndefined()
expect.soft(password.verify).toHaveBeenCalledTimes(1)
expect.soft(
internalAdapter.findUserByEmail,
).not.toHaveBeenCalled()
expect.soft(internalAdapter.updateUser).not.toHaveBeenCalled()
expect.soft(json).not.toHaveBeenCalled()
})
it("stores the new address unverified once the password checks out", async ({
expect,
}) => {
const { ctx, internalAdapter, password } = passwordContext({
body: {
newEmail: "New@Oxynote.test",
password: "secret",
},
})
const answer = await confirmWithPassword(ctx, signedIn)
expect(answer).toEqual({ status: true })
expect.soft(password.verify).toHaveBeenCalledTimes(1)
expect.soft(
internalAdapter.findUserByEmail,
).toHaveBeenCalledWith("new@oxynote.test")
expect.soft(internalAdapter.updateUser).toHaveBeenCalledWith(
"u1",
{
email: "new@oxynote.test",
emailVerified: false,
},
)
})
it("refuses an address another account holds", async ({ expect }) => {
const { ctx, internalAdapter } = passwordContext({
holder: { user: { id: "u2" } },
})
await expect(
confirmWithPassword(ctx, signedIn),
).rejects.toMatchObject({
body: { code: "USER_ALREADY_EXISTS_USE_ANOTHER_EMAIL" },
})
expect.soft(internalAdapter.updateUser).not.toHaveBeenCalled()
})
it.for([
{ name: "a malformed address", input: "not-an-email" },
{ name: "the current address", input: "ada@oxynote.test" },
])(
"leaves $name to the endpoint's own validation",
async ({ input }, { expect }) => {
const { ctx, internalAdapter, json } = passwordContext({
body: { newEmail: input, password: "secret" },
})
const answer = await confirmWithPassword(ctx, signedIn)
expect(answer).toBeUndefined()
expect.soft(
internalAdapter.findUserByEmail,
).not.toHaveBeenCalled()
expect.soft(
internalAdapter.updateUser,
).not.toHaveBeenCalled()
expect.soft(json).not.toHaveBeenCalled()
},
)
})
describe("createSecondaryStorage", () => {
it("reads through to the redis client", async ({ expect }) => {
const redis = stubRedis()
@ -575,7 +845,9 @@ describe("createAuth", () => {
}) => {
const { auth, core } = buildAuth()
await auth.options.emailAndPassword.sendResetPassword({
await configured(
auth.options.emailAndPassword.sendResetPassword,
)({
user: { email: "a@b.c" },
url: "http://localhost:8080/api/auth/reset/tok",
token: "tok",
@ -616,13 +888,14 @@ describe("createAuth", () => {
}) => {
const { auth, core } = buildAuth()
await auth.options.emailVerification.sendVerificationEmail(
{
user: { email: "a@b.c" },
url: "http://localhost:8080/api/auth/verify/tok",
token: "tok",
} as never,
)
await configured(
auth.options.emailVerification
.sendVerificationEmail,
)({
user: { email: "a@b.c" },
url: "http://localhost:8080/api/auth/verify/tok",
token: "tok",
} as never)
expect(core.sendEmail).toHaveBeenCalledWith(
"signup_verification",
@ -633,25 +906,59 @@ describe("createAuth", () => {
)
})
it("confirms a change of address at the new address", async ({
// the first step of a change of address asks the current
// address to approve it; sending this to the new address would
// ask it to approve its own claim
it("asks the current address to approve a change of address", async ({
expect,
}) => {
const { auth, core } = buildAuth()
await auth.options.user.changeEmail.sendChangeEmailConfirmation(
await configured(
auth.options.user.changeEmail
.sendChangeEmailConfirmation,
)({
user: { email: "old@b.c" },
newEmail: "new@b.c",
url: "http://localhost:8080/api/auth/change/tok",
token: "tok",
} as never)
expect(core.sendEmail).toHaveBeenCalledWith(
"email_change_confirmation",
{
user: { email: "old@b.c" },
newEmail: "new@b.c",
url: "http://localhost:8080/api/auth/change/tok",
token: "tok",
} as never,
email: "old@b.c",
link: "http://localhost:8080/auth-realtime/api/auth/change/tok",
},
)
})
// the second step, which better-auth sends through the same
// callback as a signup activation
it("asks the new address to verify itself", async ({
expect,
}) => {
const { auth, core } = buildAuth()
await configured(
auth.options.emailVerification
.sendVerificationEmail,
)({
user: { email: "new@b.c" },
url: "http://localhost:8080/api/auth/verify/tok",
token: `header.${Buffer.from(
JSON.stringify({
email: "old@b.c",
updateTo: "new@b.c",
}),
).toString("base64url")}.signature`,
} as never)
expect(core.sendEmail).toHaveBeenCalledWith(
"email_verification",
{
email: "new@b.c",
link: "http://localhost:8080/auth-realtime/api/auth/change/tok",
link: "http://localhost:8080/auth-realtime/api/auth/verify/tok",
},
)
})
@ -661,13 +968,14 @@ describe("createAuth", () => {
}) => {
const { auth, core } = buildAuth()
await auth.options.user.deleteUser.sendDeleteAccountVerification(
{
user: { email: "a@b.c" },
url: "http://localhost:8080/api/auth/delete/tok",
token: "tok",
} as never,
)
await configured(
auth.options.user.deleteUser
.sendDeleteAccountVerification,
)({
user: { email: "a@b.c" },
url: "http://localhost:8080/api/auth/delete/tok",
token: "tok",
} as never)
expect(core.sendEmail).toHaveBeenCalledWith(
"user_deletion",
@ -687,15 +995,44 @@ describe("createAuth", () => {
const { auth } = buildAuth({ core })
await expect(
auth.options.emailAndPassword.sendResetPassword(
{
user: { email: "a@b.c" },
url: "http://localhost:8080/api/auth/reset/tok",
token: "tok",
} as never,
),
configured(
auth.options.emailAndPassword
.sendResetPassword,
)({
user: { email: "a@b.c" },
url: "http://localhost:8080/api/auth/reset/tok",
token: "tok",
} as never),
).rejects.toBe(failure)
})
// nothing can reach an inbox, so no flow may wait on one
it("sends nothing and verifies nothing when email is disabled", ({
expect,
}) => {
const { auth } = buildAuth({
env: testEnv({ emailEnabled: false }),
})
expect(
auth.options.emailAndPassword
.requireEmailVerification,
).toBe(false)
expect(
auth.options.emailVerification.sendOnSignUp,
).toBe(false)
expect(
auth.options.emailVerification
.sendVerificationEmail,
).toBeUndefined()
expect(
auth.options.emailAndPassword.sendResetPassword,
).toBeUndefined()
expect(
auth.options.user.deleteUser
.sendDeleteAccountVerification,
).toBeUndefined()
})
})
// core scopes every request by the session's organization, so a
@ -808,6 +1145,82 @@ describe("createAuth", () => {
})
})
describe("before hook", () => {
// the request context as better-auth hands it to the hook, with the
// session already resolved, which is where getSessionFromCtx reads
// it from before it would look at any cookie.
function changeEmailRequest(updateUser = vi.fn()) {
return {
path: "/change-email",
body: {
newEmail: "new@oxynote.test",
password: "secret",
},
context: {
session: {
user: {
id: "u1",
email: "ada@oxynote.test",
},
},
internalAdapter: {
findCredentialAccount: vi
.fn()
.mockResolvedValue({
password: "hash",
}),
findUserByEmail: vi
.fn()
.mockResolvedValue(
null,
),
updateUser,
},
password: {
verify: vi
.fn()
.mockResolvedValue(
true,
),
},
},
}
}
it("changes the address itself once the password checks out when email is disabled", async ({
expect,
}) => {
const { auth } = buildAuth({
env: testEnv({ emailEnabled: false }),
})
const updateUser = vi.fn().mockResolvedValue({})
const answer = await auth.options.hooks.before(
changeEmailRequest(updateUser) as never,
)
expect(answer).toEqual({ status: true })
expect.soft(updateUser).toHaveBeenCalledWith("u1", {
email: "new@oxynote.test",
emailVerified: false,
})
})
it("leaves an address change to better-auth while email is enabled", async ({
expect,
}) => {
const { auth } = buildAuth()
const updateUser = vi.fn().mockResolvedValue({})
const answer = await auth.options.hooks.before(
changeEmailRequest(updateUser) as never,
)
expect(answer).toBeUndefined()
expect.soft(updateUser).not.toHaveBeenCalled()
})
})
describe("password policy", () => {
it("requires a verified address and a long passphrase", ({
expect,

View File

@ -1,11 +1,16 @@
import { betterAuth } from "better-auth"
import { createAuthMiddleware } from "better-auth/api"
import {
APIError,
createAuthMiddleware,
getSessionFromCtx,
} from "better-auth/api"
import { jwt, organization } from "better-auth/plugins"
import { electron } from "@better-auth/electron"
import { mcp } from "@better-auth/mcp"
import type { PostgresDialect } from "kysely"
import { z } from "zod"
import type { Store } from "./db.js"
import type { CoreClient } from "./core.js"
import type { CoreClient, EmailTemplate } from "./core.js"
import type { Env, SocialProviderName } from "./env.js"
import type { Logger, LogLevel } from "./logging.js"
import { reported } from "./reporting.js"
@ -69,6 +74,36 @@ export function toPublicAuthUrl(env: Env, url: string): string {
return url.replace(env.authOrigin, env.publicAuthBaseUrl)
}
// the activation link a signup sends and the link that moves an account to
// a new address both arrive at sendVerificationEmail, and only the second
// one's token names the address it switches to. A token that cannot be read
// is treated as a signup, the flow every account goes through first.
export function verificationTemplate(token: string): EmailTemplate {
const payload = token.split(".")[1]
if (!payload) {
return "signup_verification"
}
let decoded: unknown
try {
decoded = JSON.parse(
Buffer.from(payload, "base64url").toString(),
)
} catch {
return "signup_verification"
}
const updateTo =
decoded && typeof decoded === "object"
? (decoded as { updateTo?: unknown }).updateTo
: undefined
return typeof updateTo === "string" && updateTo
? "email_verification"
: "signup_verification"
}
export interface SocialSignInContext {
path: string
query?: Record<string, unknown> | null
@ -201,6 +236,111 @@ export function createOrganizationHooks({
}
}
// the part of better-auth's endpoint context confirmWithPassword reads.
// Declared structurally so a test can drive it with plain stubs.
export interface PasswordConfirmationContext {
path: string
body?: Record<string, unknown> | null
context: {
internalAdapter: {
findCredentialAccount(
userId: string,
): Promise<{ password?: string | null } | null>
findUserByEmail(email: string): Promise<unknown>
updateUser(
userId: string,
data: { email: string; emailVerified: boolean },
): Promise<unknown>
}
password: {
verify(data: {
hash: string
password: string
}): Promise<boolean>
}
}
json(body: { status: boolean }): unknown
}
export interface SessionUser {
id: string
email: string
}
// better-auth confirms an email change or an account deletion through an
// emailed link. Without email the current password confirms it instead,
// checked before either endpoint runs; an account without a password
// (social login only) has nothing to confirm with and is refused. Resolves
// to the hook's own answer, or undefined to let the endpoint run.
export async function confirmWithPassword(
ctx: PasswordConfirmationContext,
currentUser: () => Promise<SessionUser | undefined>,
): Promise<unknown> {
if (ctx.path !== "/change-email" && ctx.path !== "/delete-user") {
return undefined
}
// the endpoint's own session check answers a caller without one.
const user = await currentUser()
if (!user) {
return undefined
}
const account = await ctx.context.internalAdapter.findCredentialAccount(
user.id,
)
if (!account?.password) {
throw APIError.from("FORBIDDEN", {
code: "CREDENTIAL_ACCOUNT_NOT_FOUND",
message: "Credential account not found",
})
}
const password = ctx.body?.password
if (
typeof password !== "string" ||
!(await ctx.context.password.verify({
hash: account.password,
password,
}))
) {
throw APIError.from("BAD_REQUEST", {
code: "INVALID_PASSWORD",
message: "Invalid password",
})
}
// with no deletion email configured, better-auth deletes the account
// itself.
if (ctx.path === "/delete-user") {
return undefined
}
// better-auth changes an address without a link only for an
// unverified account, so the change is made here, and the new address
// is stored unverified because nothing has proven it. A malformed or
// unchanged address is left to the endpoint's own validation.
const parsed = z.email().safeParse(ctx.body?.newEmail)
const newEmail = parsed.success ? parsed.data.toLowerCase() : undefined
if (!newEmail || newEmail === user.email) {
return undefined
}
if (await ctx.context.internalAdapter.findUserByEmail(newEmail)) {
throw APIError.from("UNPROCESSABLE_ENTITY", {
code: "USER_ALREADY_EXISTS_USE_ANOTHER_EMAIL",
message: "User already exists. Use another email.",
})
}
await ctx.context.internalAdapter.updateUser(user.id, {
email: newEmail,
emailVerified: false,
})
return ctx.json({ status: true })
}
export function createSecondaryStorage(redis: SecondaryStorageClient) {
return {
get: (key: string) => reported(() => redis.get(key)),
@ -306,7 +446,9 @@ export function createAuth({
},
emailAndPassword: {
enabled: true,
requireEmailVerification: true,
// without email no verification link can arrive, so a new
// account is signed in straight away.
requireEmailVerification: env.emailEnabled,
minPasswordLength: 16,
maxPasswordLength: 128,
// a reset always happens outside an authenticated session
@ -316,17 +458,28 @@ export function createAuth({
// revokes its other sessions via revokeOtherSessions on
// the client call instead.
revokeSessionsOnPasswordReset: true,
sendResetPassword: async ({ user, url }) => {
await reported(() =>
core.sendEmail("password_reset", {
email: user.email,
link: toPublicAuthUrl(env, url),
}),
)
},
// duplicate signups get better-auth's synthetic success so
// the browser can't probe which emails have accounts. The
// real owner is told through their inbox instead.
// with no sender better-auth refuses a reset request
// (RESET_PASSWORD_DISABLED) instead of promising a link.
sendResetPassword: env.emailEnabled
? async ({ user, url }) => {
await reported(() =>
core.sendEmail(
"password_reset",
{
email: user.email,
link: toPublicAuthUrl(
env,
url,
),
},
),
)
}
: undefined,
// where verification is required, duplicate signups get
// better-auth's synthetic success so the browser can't probe
// which emails have accounts. The real owner is told through
// their inbox instead.
onExistingUserSignUp: async ({ user }) => {
await reported(() =>
core.sendEmail("account_exists", {
@ -337,23 +490,35 @@ export function createAuth({
},
},
emailVerification: {
sendOnSignUp: true,
sendOnSignUp: env.emailEnabled,
// re-send the verification link when an unverified user
// tries to log in — the login page forwards them to the
// check-your-inbox page, which would otherwise lie about
// an email being sent.
sendOnSignIn: true,
// signup/sign-in verification gets its own
// account-activation template; the change-email flow
// below keeps the "new email address" one.
sendVerificationEmail: async ({ user, url }) => {
await reported(() =>
core.sendEmail("signup_verification", {
email: user.email,
link: toPublicAuthUrl(env, url),
}),
)
},
// signup/sign-in activation and the change-email link both
// come through here, so the token decides which wording is
// sent. With no sender, better-auth's change-email endpoint
// has no link to offer either, and confirmWithPassword takes
// the change over.
sendVerificationEmail: env.emailEnabled
? async ({ user, url, token }) => {
await reported(() =>
core.sendEmail(
verificationTemplate(
token,
),
{
email: user.email,
link: toPublicAuthUrl(
env,
url,
),
},
),
)
}
: undefined,
},
rateLimit: {
enabled: env.rateLimitEnabled,
@ -391,43 +556,48 @@ export function createAuth({
},
changeEmail: {
enabled: true,
sendChangeEmailConfirmation: async ({
newEmail,
url,
}) => {
await reported(() =>
core.sendEmail(
"email_verification",
{
email: newEmail,
link: toPublicAuthUrl(
env,
url,
// 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 }) => {
await reported(() =>
core.sendEmail(
"email_change_confirmation",
{
email: user.email,
link: toPublicAuthUrl(
env,
url,
),
},
),
},
),
)
},
)
}
: undefined,
},
deleteUser: {
enabled: true,
sendDeleteAccountVerification: async ({
user,
url,
}) => {
await reported(() =>
core.sendEmail(
"user_deletion",
{
email: user.email,
link: toPublicAuthUrl(
env,
url,
// with no sender better-auth deletes the account at once,
// after confirmWithPassword has checked the password.
sendDeleteAccountVerification: env.emailEnabled
? async ({ user, url }) => {
await reported(() =>
core.sendEmail(
"user_deletion",
{
email: user.email,
link: toPublicAuthUrl(
env,
url,
),
},
),
},
),
)
},
)
}
: undefined,
},
},
account: {
@ -694,14 +864,28 @@ export function createAuth({
}),
],
hooks: {
before: createAuthMiddleware((ctx) =>
Promise.resolve(
electronCallbackOverride(
ctx,
env.frontendUrl,
),
),
),
before: createAuthMiddleware(async (ctx) => {
if (!env.emailEnabled) {
const answer =
await confirmWithPassword(
ctx,
async () =>
(
await getSessionFromCtx(
ctx,
)
)?.user,
)
if (answer !== undefined) {
return answer
}
}
return electronCallbackOverride(
ctx,
env.frontendUrl,
)
}),
},
databaseHooks: {
session: {

View File

@ -6,6 +6,7 @@ import type { AxiosHeaders } from "axios"
// dropped email.
export type EmailTemplate =
| "email_verification"
| "email_change_confirmation"
| "password_reset"
| "account_exists"
| "signup_verification"

View File

@ -249,6 +249,26 @@ describe("loadEnv", () => {
})
})
describe("email", () => {
it.for([
{ name: "unset", input: undefined, expected: true },
{ name: '"false"', input: "false", expected: true },
{ name: '"true"', input: "true", expected: false },
])(
"is enabled=$expected when the disable flag is $name",
({ input, expected }, { expect }) => {
const env = loadEnv(
completeEnv({
OXYNOTE_AUTH_REALTIME_EMAIL_DISABLED:
input,
}),
)
expect(env.emailEnabled).toBe(expected)
},
)
})
describe("trusted origins", () => {
it("splits the list on commas", ({ expect }) => {
const env = loadEnv(completeEnv())

View File

@ -41,6 +41,9 @@ export interface Env {
maxOrganizations: number
maxOrganizationMembers: number
rateLimitEnabled: boolean
// false on a deployment with no email relay, where no link better-auth
// sends can reach an inbox.
emailEnabled: boolean
logLevel: LogLevel
}
@ -155,6 +158,11 @@ const schema = z
// does not pass the original IP through — so it can be
// switched off and left to whatever sits in front.
OXYNOTE_AUTH_REALTIME_RATE_LIMIT_DISABLED: flag(false),
// core logs what it cannot send, so this is set where core has no
// SMTP relay: signup then skips verification, password reset is
// refused, and email changes and account deletions are confirmed
// with the password instead of a link.
OXYNOTE_AUTH_REALTIME_EMAIL_DISABLED: flag(false),
// the floor for better-auth's and hocuspocus's output too.
OXYNOTE_AUTH_REALTIME_LOG_LEVEL: z
.enum(["DEBUG", "INFO", "WARN", "ERROR"])
@ -280,6 +288,7 @@ export function loadEnv(source: Record<string, string | undefined>): Env {
values.OXYNOTE_AUTH_REALTIME_MAX_ORGANIZATION_MEMBERS,
rateLimitEnabled:
!values.OXYNOTE_AUTH_REALTIME_RATE_LIMIT_DISABLED,
emailEnabled: !values.OXYNOTE_AUTH_REALTIME_EMAIL_DISABLED,
logLevel: values.OXYNOTE_AUTH_REALTIME_LOG_LEVEL,
}
}

View File

@ -218,6 +218,23 @@ describe("createRoutes", () => {
expect(res.status).toBe(200)
expect(await res.json()).toEqual({
methods: ["email-password", "github"],
emailEnabled: true,
})
})
it("tells the pages when no email can be delivered", async ({
expect,
}) => {
const { app } = build({
env: testEnv({ emailEnabled: false }),
})
const res = await app.request("/auth-config")
expect(res.status).toBe(200)
expect(await res.json()).toEqual({
methods: ["email-password"],
emailEnabled: false,
})
})
})

View File

@ -223,11 +223,13 @@ export function createRoutes({
})
// public auth configuration for the login/signup pages: which social
// providers are configured (and, later, other signup-related
// capability flags). Lives outside the better-auth /auth/**
// namespace.
// providers are configured, and whether emailed links can arrive at
// all. Lives outside the better-auth /auth/** namespace.
app.get("/auth-config", (c) => {
return c.json({ methods: authMethods(env) })
return c.json({
methods: authMethods(env),
emailEnabled: env.emailEnabled,
})
})
app.get("/organizations/stats", async (c) => {

View File

@ -109,6 +109,7 @@ export function testEnv(overrides: Partial<Env> = {}): Env {
maxOrganizations: 100,
maxOrganizationMembers: 5,
rateLimitEnabled: true,
emailEnabled: true,
logLevel: "INFO",
...overrides,
}

View File

@ -252,6 +252,19 @@ func (s *Sender) SendEmailVerification(eml, link string) {
)
}
// SendEmailChangeConfirmation sends an email asking the current address to
// approve a change of address, with the approval link.
func (s *Sender) SendEmailChangeConfirmation(eml, link string) {
s.send(
eml,
"Approve your email address change",
TemplateEmailChangeConfirmation,
map[string]string{
_linkKey: link,
},
)
}
// SendOrganizationInvitation sends an email regarding
// organization invitation with the token, embedded into a full URL, to the
// specified email address.

View File

@ -237,6 +237,22 @@ func Test_Sender_SendEmailVerification(t *testing.T) {
assert.Contains(t, msgBody(t, msg), "https://example.com/verify")
}
func Test_Sender_SendEmailChangeConfirmation(t *testing.T) {
t.Parallel()
client := &clientMock{}
s := stubSender(client)
s.SendEmailChangeConfirmation("user@example.com", "https://example.com/approve")
s.supv.Wait()
msg := sentMsg(t, client)
assert.Equal(t, "user@example.com", msgTo(t, msg))
assert.Equal(t, []string{"Approve your email address change"}, msg.GetGenHeader(mail.HeaderSubject))
assert.Contains(t, msgBody(t, msg), "https://example.com/approve")
}
func Test_Sender_SendOrganizationInvitation(t *testing.T) {
t.Parallel()

View File

@ -14,6 +14,10 @@ const (
// verification template.
TemplateEmailVerification Template = "email_verification"
// TemplateEmailChangeConfirmation specifies the template asking the
// current address to approve a change of address.
TemplateEmailChangeConfirmation Template = "email_change_confirmation"
// TemplateOrganizationInvitation specifies the organization
// invitation template.
TemplateOrganizationInvitation Template = "organization_invitation"

View File

@ -0,0 +1,197 @@
<!doctype html>
<html lang="en-US" dir="ltr" xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office">
<head>
<title></title>
<!--[if !mso]><!-->
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<!--<![endif]-->
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<style type="text/css">
#outlook a { padding:0; }
body { margin:0;padding:0;-webkit-text-size-adjust:100%;-ms-text-size-adjust:100%; }
table, td { border-collapse:collapse;mso-table-lspace:0pt;mso-table-rspace:0pt; }
img { border:0;height:auto;line-height:100%; outline:none;text-decoration:none;-ms-interpolation-mode:bicubic; }
p { display:block;margin:13px 0; }
</style>
<!--[if mso]>
<noscript>
<xml>
<o:OfficeDocumentSettings>
<o:AllowPNG/>
<o:PixelsPerInch>96</o:PixelsPerInch>
</o:OfficeDocumentSettings>
</xml>
</noscript>
<![endif]-->
<!--[if lte mso 11]>
<style type="text/css">
.outlook-group-fix { width:100% !important; }
</style>
<![endif]-->
<style type="text/css">
@media only screen and (min-width:480px) {
.column-per-100 { width:100% !important; max-width: 100%; }
}
</style>
<style media="screen and (min-width:480px)">
.moz-text-html .column-per-100 { width:100% !important; max-width: 100%; }
</style>
<style type="text/css">
[owa] .column-per-100 { width:100% !important; max-width: 100%; }
</style>
</head>
<body style="word-spacing:normal;background-color:#ffffff;">
<div
style="background-color:#ffffff;" role="main" lang="en-US" dir="ltr"
>
<table
align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background:#ffffff;background-color:#ffffff;width:100%;"
>
<tbody>
<tr>
<td>
<!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:600px;" width="600" bgcolor="#ffffff" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div style="margin:0px auto;max-width:600px;">
<table
align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;"
>
<tbody>
<tr>
<td
style="direction:ltr;font-size:0px;padding:20px 0px 20px 0px;text-align:center;"
>
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:600px;" ><![endif]-->
<div
class="column-per-100 outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;"
>
<table
border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%"
>
<tbody>
<tr>
<td
align="center" style="font-size:0px;padding:0px 25px 20px 25px;word-break:break-word;"
>
<table
border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-collapse:collapse;border-spacing:0px;"
>
<tbody>
<tr>
<td style="width:300px;">
<img
alt="Oxynote Logo" src="cid:logo.png" style="border:none;display:block;outline:none;text-decoration:none;height:84px;width:100%;font-size:13px;" title="" width="300" height="84"
/>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
<tr>
<td
align="left" style="font-size:0px;padding:10px 25px;padding-top:0px;padding-bottom:0px;word-break:break-word;"
>
<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">Someone asked to move your Oxynote account to a different email address. This message went to your current address, so nothing changes until you approve it.</span></p><p class="text-build-content" style="margin: 10px 0; margin-bottom: 10px;"><span style="color:#000000;font-family:Arial, sans-serif">To approve the change, click the button below. We will then email the new address to confirm it belongs to you:</span></p></div>
</td>
</tr>
<tr>
<td
align="center" vertical-align="middle" style="font-size:0px;padding:10px 25px;word-break:break-word;"
>
<table
border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-collapse:separate;line-height:100%;"
>
<tbody>
<tr>
<td
align="center" bgcolor="#253D61" role="presentation" style="border:none;border-radius:3px;cursor:auto;mso-padding-alt:10px 25px;background:#253D61;" valign="middle"
>
<a
href="{{.link}}" style="display:inline-block;background:#253D61;color:#ffffff;font-family:Verdana, Helvetica, Arial, sans-serif;font-size:14px;font-weight:normal;line-height:120%;margin:0;text-decoration:none;text-transform:none;padding:10px 25px;mso-padding-alt:0px;border-radius:3px;" target="_blank"
>
<span style="color:#ffffff;font-family:Arial, sans-serif;">Approve email address change</span>
</a>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
<tr>
<td
align="left" style="font-size:0px;padding:10px 25px;padding-top:0px;padding-bottom:0px;word-break:break-word;"
>
<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>
</td>
</tr>
<tr>
<td
align="left" style="font-size:0px;padding:10px 25px;padding-top:0px;padding-bottom:0px;word-break:break-word;"
>
<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>
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
</body>
</html>

View File

@ -28,6 +28,15 @@ func Test_render(t *testing.T) {
"https://example.com/verify?t=abc",
},
},
"Email change confirmation": {
Template: TemplateEmailChangeConfirmation,
Args: map[string]string{
"link": "https://example.com/approve?t=abc",
},
Contains: []string{
"https://example.com/approve?t=abc",
},
},
"Organization invitation": {
Template: TemplateOrganizationInvitation,
Args: map[string]string{

View File

@ -20,6 +20,9 @@ var _ Sender = &SenderMock{}
// SendAccountExistsFunc: func(eml string, link string) {
// panic("mock out the SendAccountExists method")
// },
// SendEmailChangeConfirmationFunc: func(eml string, link string) {
// panic("mock out the SendEmailChangeConfirmation method")
// },
// SendEmailVerificationFunc: func(eml string, link string) {
// panic("mock out the SendEmailVerification method")
// },
@ -45,6 +48,9 @@ type SenderMock struct {
// SendAccountExistsFunc mocks the SendAccountExists method.
SendAccountExistsFunc func(eml string, link string)
// SendEmailChangeConfirmationFunc mocks the SendEmailChangeConfirmation method.
SendEmailChangeConfirmationFunc func(eml string, link string)
// SendEmailVerificationFunc mocks the SendEmailVerification method.
SendEmailVerificationFunc func(eml string, link string)
@ -69,6 +75,13 @@ type SenderMock struct {
// Link is the link argument value.
Link string
}
// SendEmailChangeConfirmation holds details about calls to the SendEmailChangeConfirmation method.
SendEmailChangeConfirmation []struct {
// Eml is the eml argument value.
Eml string
// Link is the link argument value.
Link string
}
// SendEmailVerification holds details about calls to the SendEmailVerification method.
SendEmailVerification []struct {
// Eml is the eml argument value.
@ -108,6 +121,7 @@ type SenderMock struct {
}
}
lockSendAccountExists sync.RWMutex
lockSendEmailChangeConfirmation sync.RWMutex
lockSendEmailVerification sync.RWMutex
lockSendOrganizationInvitation sync.RWMutex
lockSendPasswordReset sync.RWMutex
@ -151,6 +165,42 @@ func (mock *SenderMock) SendAccountExistsCalls() []struct {
return calls
}
// SendEmailChangeConfirmation calls SendEmailChangeConfirmationFunc.
func (mock *SenderMock) SendEmailChangeConfirmation(eml string, link string) {
callInfo := struct {
Eml string
Link string
}{
Eml: eml,
Link: link,
}
mock.lockSendEmailChangeConfirmation.Lock()
mock.calls.SendEmailChangeConfirmation = append(mock.calls.SendEmailChangeConfirmation, callInfo)
mock.lockSendEmailChangeConfirmation.Unlock()
if mock.SendEmailChangeConfirmationFunc == nil {
return
}
mock.SendEmailChangeConfirmationFunc(eml, link)
}
// SendEmailChangeConfirmationCalls gets all the calls that were made to SendEmailChangeConfirmation.
// Check the length with:
//
// len(mockedSender.SendEmailChangeConfirmationCalls())
func (mock *SenderMock) SendEmailChangeConfirmationCalls() []struct {
Eml string
Link string
} {
var calls []struct {
Eml string
Link string
}
mock.lockSendEmailChangeConfirmation.RLock()
calls = mock.calls.SendEmailChangeConfirmation
mock.lockSendEmailChangeConfirmation.RUnlock()
return calls
}
// SendEmailVerification calls SendEmailVerificationFunc.
func (mock *SenderMock) SendEmailVerification(eml string, link string) {
callInfo := struct {

View File

@ -50,6 +50,8 @@ func (h *Handler) SendEmail(w http.ResponseWriter, r *http.Request) {
switch req.Template {
case emailCore.TemplateEmailVerification:
h.sender.SendEmailVerification(req.Data.Email, req.Data.Link)
case emailCore.TemplateEmailChangeConfirmation:
h.sender.SendEmailChangeConfirmation(req.Data.Email, req.Data.Link)
case emailCore.TemplatePasswordReset:
h.sender.SendPasswordReset(req.Data.Email, req.Data.Link)
case emailCore.TemplateAccountExists:
@ -76,6 +78,10 @@ type Sender interface {
// verification email.
SendEmailVerification(eml, link string)
// SendEmailChangeConfirmation should send the email asking the
// current address to approve a change of address.
SendEmailChangeConfirmation(eml, link string)
// SendPasswordReset should send a password reset email.
SendPasswordReset(eml, link string)

View File

@ -48,6 +48,7 @@ func Test_Handler_SendEmail(t *testing.T) {
wasNoSendCalled := func() check {
return func(t *testing.T, sender *SenderMock, _ *httptest.ResponseRecorder) {
assert.Empty(t, sender.SendEmailVerificationCalls())
assert.Empty(t, sender.SendEmailChangeConfirmationCalls())
assert.Empty(t, sender.SendPasswordResetCalls())
assert.Empty(t, sender.SendAccountExistsCalls())
assert.Empty(t, sender.SendSignupVerificationCalls())
@ -70,6 +71,20 @@ func Test_Handler_SendEmail(t *testing.T) {
}
}
wasSendEmailChangeConfirmationCalled := func(count int, eml, link string) check {
return func(t *testing.T, sender *SenderMock, _ *httptest.ResponseRecorder) {
ff := sender.SendEmailChangeConfirmationCalls()
require.Len(t, ff, count)
if count == 0 {
return
}
assert.Equal(t, eml, ff[0].Eml)
assert.Equal(t, link, ff[0].Link)
}
}
wasSendPasswordResetCalled := func(count int, eml, link string) check {
return func(t *testing.T, sender *SenderMock, _ *httptest.ResponseRecorder) {
ff := sender.SendPasswordResetCalls()
@ -166,6 +181,13 @@ func Test_Handler_SendEmail(t *testing.T) {
wasSendEmailVerificationCalled(1, "user@example.com", "https://example.com/verify"),
),
},
"Email change confirmation sent": {
Body: `{"template":"email_change_confirmation","data":{"email":"user@example.com","link":"https://example.com/approve"}}`,
Checks: checks(
hasResp(http.StatusNoContent, ""),
wasSendEmailChangeConfirmationCalled(1, "user@example.com", "https://example.com/approve"),
),
},
"Password reset sent": {
Body: `{"template":"password_reset","data":{"email":"user@example.com","link":"https://example.com/reset"}}`,
Checks: checks(

View File

@ -1,3 +1,5 @@
import { mockNuxtImport } from "@nuxt/test-utils/runtime"
import { setResponseStatus } from "h3"
import { afterEach, beforeEach, describe, it, vi } from "vitest"
import { toast } from "vue-sonner"
import {
@ -9,7 +11,11 @@ import {
findButtonByText,
mockAuthEndpoint,
mountUnderDialogRoot,
raisedToasts,
seedAuthAccounts,
seedAuthConfig,
seedAuthOrganization,
settleActionSubmit,
settleMutations,
t,
} from "../test-helpers"
@ -18,6 +24,9 @@ vi.mock("vue-sonner", () => ({
toast: { custom: vi.fn(), dismiss: vi.fn() },
}))
const navigateToMock = vi.hoisted(() => vi.fn())
mockNuxtImport("navigateTo", () => navigateToMock)
function mountAction() {
return mountUnderDialogRoot(AccountDeletionAction)
}
@ -25,12 +34,11 @@ function mountAction() {
async function confirmDeletion(
wrapper: Awaited<ReturnType<typeof mountAction>>,
) {
await findButtonByText(
wrapper,
t("settings.action-modals.account-deletion.confirm-button"),
).trigger("click")
await vi.advanceTimersByTimeAsync(300)
await settleMutations()
// the confirm button submits the form, which is what lets Enter in the
// password field delete the account. happy-dom does not raise a submit
// event from a click on a submit button, so the form is submitted here
await wrapper.get("form").trigger("submit")
await settleActionSubmit()
}
// the query cache and the vue-sonner module mock are app-wide singletons
@ -40,6 +48,7 @@ describe("<AccountDeletionAction>", { concurrent: false }, () => {
beforeEach(() => {
clearQueryCache()
vi.mocked(toast.custom).mockReset()
navigateToMock.mockReset()
vi.useFakeTimers()
})
@ -113,11 +122,12 @@ describe("<AccountDeletionAction>", { concurrent: false }, () => {
mockAuthEndpoint("delete-user", () => ({ success: true }))
const wrapper = await mountAction()
await findButtonByText(
wrapper,
t("settings.action-modals.account-deletion.confirm-button"),
).trigger("click")
await nextTick()
await wrapper.get("form").trigger("submit")
// vee-validate's scheduler needs a turn of the clock before the
// submit handler runs; 100ms leaves the spinner's own delay(300)
// pending, which is the state under test
await vi.advanceTimersByTimeAsync(100)
await settleMutations()
expect(
findButtonByText(
@ -133,6 +143,21 @@ describe("<AccountDeletionAction>", { concurrent: false }, () => {
).toBeDefined()
})
// a submit button is what makes Enter in the password field confirm the
// deletion, the way every other dialog in settings behaves
it("confirms through a submit button rather than a click handler", async ({
expect,
}) => {
const wrapper = await mountAction()
expect(
findButtonByText(
wrapper,
t("settings.action-modals.account-deletion.confirm-button"),
).attributes("type"),
).toBe("submit")
})
it("warns and stays open when the deletion is refused", async ({
expect,
}) => {
@ -161,4 +186,110 @@ describe("<AccountDeletionAction>", { concurrent: false }, () => {
expect(calls).toHaveLength(0)
expect(wrapper.emitted("close")).toHaveLength(1)
})
describe("when the server sends no email", { concurrent: false }, () => {
beforeEach(() => {
seedAuthConfig({ emailEnabled: false })
seedAuthAccounts(["credential"])
})
it("asks for the current password instead of promising an email", async ({
expect,
}) => {
const wrapper = await mountAction()
expect(wrapper.find("input[type='password']").exists()).toBe(true)
expect(wrapper.text()).toContain(
t("settings.action-modals.account-deletion.description-without-email"),
)
})
it("deletes the account with the password and lands on signup", async ({
expect,
}) => {
const calls = mockAuthEndpoint("delete-user", () => ({
success: true,
message: "User deleted",
}))
const wrapper = await mountAction()
await wrapper.get("input[type='password']").setValue("correct-horse-1!")
await confirmDeletion(wrapper)
expect(calls).toHaveLength(1)
expect(calls[0]?.body).toMatchObject({ password: "correct-horse-1!" })
expect(navigateToMock).toHaveBeenCalledTimes(1)
expect(navigateToMock).toHaveBeenCalledWith({
path: "/signup",
query: { deletion: "success" },
})
expect(toast.custom).not.toHaveBeenCalled()
expect(wrapper.emitted("close")).toHaveLength(1)
})
it("asks for the password before deleting anything", async ({ expect }) => {
const calls = mockAuthEndpoint("delete-user", () => ({
success: true,
}))
const wrapper = await mountAction()
await confirmDeletion(wrapper)
expect(wrapper.text()).toContain(
t("settings.action-modals.account-deletion.errors.password-required"),
)
expect(calls).toHaveLength(0)
expect(navigateToMock).not.toHaveBeenCalled()
expect(wrapper.emitted("close")).toBeUndefined()
})
it("shows a wrong password under the field and stays open", async ({
expect,
}) => {
mockAuthEndpoint("delete-user", (_call, event) => {
setResponseStatus(event, 400)
return { code: "INVALID_PASSWORD", message: "Invalid password" }
})
const wrapper = await mountAction()
await wrapper.get("input[type='password']").setValue("wrong-password")
await confirmDeletion(wrapper)
expect(wrapper.text()).toContain(
t("settings.action-modals.account-deletion.errors.invalid-password"),
)
expect(navigateToMock).not.toHaveBeenCalled()
expect(wrapper.emitted("close")).toBeUndefined()
})
it("explains why an account without a password cannot be deleted", async ({
expect,
}) => {
seedAuthAccounts(["github"])
mockAuthEndpoint("delete-user", (_call, event) => {
setResponseStatus(event, 403)
return {
code: "CREDENTIAL_ACCOUNT_NOT_FOUND",
message: "Credential account not found",
}
})
const wrapper = await mountAction()
await confirmDeletion(wrapper)
expect(wrapper.find("input[type='password']").exists()).toBe(false)
expect(raisedToasts()).toMatchObject([
{
type: "error",
description: t(
"settings.action-modals.account-deletion.errors.no-password.description",
),
},
])
expect(navigateToMock).not.toHaveBeenCalled()
expect(wrapper.emitted("close")).toBeUndefined()
})
})
})

View File

@ -1,14 +1,49 @@
<script lang="ts" setup>
import { toTypedSchema } from "@vee-validate/zod"
import { useForm } from "vee-validate"
import * as z from "zod"
// for some reason this isn't auto imported
import { FormField as ShadcnUiFormField } from "@/components/shadcn/ui/form"
import { cn } from "~/lib/utils"
import { showToastMessage } from "../toast"
const emit = defineEmits<{
(event: "close"): void
}>()
const { fetchOrganization, deleteUser } = useAuthSession()
const { fetchAuthSession, fetchOrganization, hasPassword, deleteUser } =
useAuthSession()
const { isEmailEnabled } = useAuthAPI()
const config = useRuntimeConfig()
const { t } = useI18n({ useScope: "global" })
// the password is asked for, and required, only where it is the whole
// confirmation: without email there is no link to send instead.
const passwordRequired = computed(
() => !isEmailEnabled.value && hasPassword.value,
)
const formSchema = toTypedSchema(
z
.object({
password: z.string().optional(),
})
.superRefine((values, ctx) => {
if (!passwordRequired.value || values.password) {
return
}
ctx.addIssue({
code: "custom",
path: ["password"],
message: t(
"settings.action-modals.account-deletion.errors.password-required",
),
})
}),
)
const form = useForm({
validationSchema: formSchema,
})
const loading = ref(false)
const lastOrgMemeber = computed(() => {
const org = fetchOrganization.state.value.data?.data
@ -19,16 +54,42 @@ const lastOrgMemeber = computed(() => {
return org.members.length <= 1
})
async function deleteAccount() {
const onSubmit = form.handleSubmit(async (values) => {
loading.value = true
await delay(300) // show loading spinner for at least a moment
// without email the password confirms the deletion instead of a link;
// auth-realtime checks it before better-auth handles the request.
const { error } = (await deleteUser({
callbackURL: addDeletionSuccessStatusToUrl(
`${config.public.appBaseURL}/signup`,
),
...(isEmailEnabled.value ? {} : { password: values.password }),
})) as AuthResponse
if (error) {
loading.value = false
if (error.code === "INVALID_PASSWORD") {
form.setFieldError(
"password",
t("settings.action-modals.account-deletion.errors.invalid-password"),
)
return
}
if (error.code === "CREDENTIAL_ACCOUNT_NOT_FOUND") {
showToastMessage(
"error",
t("settings.action-modals.account-deletion.errors.no-password.title"),
t(
"settings.action-modals.account-deletion.errors.no-password.description",
),
)
return
}
showToastMessage(
"error",
t("settings.action-modals.account-deletion.errors.deletion-failed.title"),
@ -36,7 +97,17 @@ async function deleteAccount() {
"settings.action-modals.account-deletion.errors.deletion-failed.description",
),
)
loading.value = false
return
}
// without email better-auth has already deleted the account and ended
// its session. The session query still caches the signed-in user, so it
// is refetched before the middleware reads it on the way to signup.
if (!isEmailEnabled.value) {
await fetchAuthSession.refetch()
emit("close")
void navigateTo({ path: "/signup", query: { deletion: "success" } })
return
}
@ -51,13 +122,19 @@ async function deleteAccount() {
),
)
emit("close")
}
})
</script>
<template>
<div class="flex flex-col gap-4">
<div class="flex flex-col">
<div class="flex flex-col gap-2 self-stretch">
<ShadcnUiDialogDescription class="text-2sm">
{{ $t("settings.action-modals.account-deletion.description") }}
{{
isEmailEnabled
? $t("settings.action-modals.account-deletion.description")
: $t(
"settings.action-modals.account-deletion.description-without-email",
)
}}
</ShadcnUiDialogDescription>
<div v-if="lastOrgMemeber" class="text-2sm font-medium text-foreground">
{{
@ -67,32 +144,63 @@ async function deleteAccount() {
}}
</div>
</div>
<div class="flex gap-2 self-stretch">
<ShadcnUiButton
type="button"
variant="destructive"
size="sm"
:disabled="loading"
class="text-2sm"
@click="deleteAccount"
<form class="mt-5 flex flex-col gap-5" @submit="onSubmit">
<ShadcnUiFormField
v-if="passwordRequired"
v-slot="{ componentField }"
name="password"
class="w-full"
>
<Icon
v-show="loading"
name="svg-spinners:blocks-shuffle-3"
class="size-3"
/>
{{ $t("settings.action-modals.account-deletion.confirm-button") }}
</ShadcnUiButton>
<ShadcnUiButton
type="button"
size="sm"
variant="secondary"
:disabled="loading"
class="text-2sm"
@click="emit('close')"
>
{{ $t("settings.action-modals.account-deletion.cancel-button") }}
</ShadcnUiButton>
</div>
<ShadcnUiFormItem class="w-full">
<ShadcnUiFormLabel disable-destructive-effect class="text-2sm">
{{ $t("settings.action-modals.account-deletion.password-label") }}
</ShadcnUiFormLabel>
<ShadcnUiFormControl>
<ShadcnUiInput
type="password"
autocomplete="current-password"
:placeholder="
$t(
'settings.action-modals.account-deletion.password-placeholder',
)
"
disable-focus-effect
disable-destructive-effect
:class="cn('h-8 text-2sm!', loading && 'pointer-events-none')"
v-bind="{
...componentField,
}"
/>
</ShadcnUiFormControl>
<ShadcnUiFormMessage class="text-2xs" />
</ShadcnUiFormItem>
</ShadcnUiFormField>
<div class="flex gap-2 self-stretch">
<ShadcnUiButton
type="submit"
variant="destructive"
size="sm"
:disabled="loading"
class="text-2sm"
>
<Icon
v-show="loading"
name="svg-spinners:blocks-shuffle-3"
class="size-3"
/>
{{ $t("settings.action-modals.account-deletion.confirm-button") }}
</ShadcnUiButton>
<ShadcnUiButton
type="button"
size="sm"
variant="secondary"
:disabled="loading"
class="text-2sm"
@click="emit('close')"
>
{{ $t("settings.action-modals.account-deletion.cancel-button") }}
</ShadcnUiButton>
</div>
</form>
</div>
</template>

View File

@ -7,9 +7,13 @@ import {
} from "~/composables/api/test-helpers"
import EmailChangeAction from "./EmailChangeAction.vue"
import {
at,
findButtonByText,
mockAuthEndpoint,
mountWithFrozenClock,
raisedToasts,
seedAuthAccounts,
seedAuthConfig,
seedAuthSession,
settleActionSubmit,
t,
@ -125,4 +129,109 @@ describe("<EmailChangeAction>", { concurrent: false }, () => {
expect(calls).toHaveLength(0)
expect(wrapper.emitted("close")).toHaveLength(1)
})
describe("when the server sends no email", { concurrent: false }, () => {
beforeEach(() => {
seedAuthConfig({ emailEnabled: false })
seedAuthAccounts(["credential"])
})
async function submitWithPassword(
wrapper: Awaited<ReturnType<typeof mountAction>>,
password: string,
) {
const inputs = wrapper.findAll("input")
await at(inputs, 0).setValue("new@oxynote.test")
await at(inputs, 1).setValue(password)
await wrapper.get("form").trigger("submit")
await settleActionSubmit()
}
it("asks for the current password instead of promising an email", async ({
expect,
}) => {
const wrapper = await mountAction()
expect(wrapper.find("input[type='password']").exists()).toBe(true)
expect(wrapper.text()).toContain(
t("settings.action-modals.email-change.description-without-email"),
)
})
it("sends the password along with the new address", async ({ expect }) => {
const calls = mockAuthEndpoint("change-email", () => ({ status: true }))
const wrapper = await mountAction()
await submitWithPassword(wrapper, "correct-horse-1!")
expect(calls).toHaveLength(1)
expect(calls[0]?.body).toMatchObject({
newEmail: "new@oxynote.test",
password: "correct-horse-1!",
})
})
it("reports the address as changed and closes", async ({ expect }) => {
mockAuthEndpoint("change-email", () => ({ status: true }))
const wrapper = await mountAction()
await submitWithPassword(wrapper, "correct-horse-1!")
expect(raisedToasts()).toMatchObject([
{
type: "success",
title: t(
"settings.action-modals.email-change.success-message-without-email.title",
),
},
])
expect(wrapper.emitted("close")).toHaveLength(1)
})
it("points at the password field when the server rejects it", async ({
expect,
}) => {
mockAuthEndpoint("change-email", (_call, event) => {
setResponseStatus(event, 400)
return { code: "INVALID_PASSWORD", message: "Invalid password" }
})
const wrapper = await mountAction()
await submitWithPassword(wrapper, "wrong-password")
expect(wrapper.text()).toContain(
t("settings.action-modals.email-change.errors.invalid-password"),
)
expect(wrapper.emitted("close")).toBeUndefined()
})
it("explains why an account without a password cannot change it", async ({
expect,
}) => {
seedAuthAccounts(["github"])
mockAuthEndpoint("change-email", (_call, event) => {
setResponseStatus(event, 403)
return {
code: "CREDENTIAL_ACCOUNT_NOT_FOUND",
message: "Credential account not found",
}
})
const wrapper = await mountAction()
await submitEmail(wrapper, "new@oxynote.test")
expect(wrapper.find("input[type='password']").exists()).toBe(false)
expect(raisedToasts()).toMatchObject([
{
type: "error",
description: t(
"settings.action-modals.email-change.errors.no-password.description",
),
},
])
expect(wrapper.emitted("close")).toBeUndefined()
})
})
})

View File

@ -14,12 +14,14 @@ const emit = defineEmits<{
const formSchema = toTypedSchema(
z.object({
email: z.string().trim().email(),
password: z.string().optional(),
}),
)
const form = useForm({
validationSchema: formSchema,
})
const { fetchAuthSession, changeEmail } = useAuthSession()
const { fetchAuthSession, hasPassword, changeEmail } = useAuthSession()
const { isEmailEnabled } = useAuthAPI()
const config = useRuntimeConfig()
const { t } = useI18n({ useScope: "global" })
const loading = ref(false)
@ -33,36 +35,77 @@ const onSubmit = form.handleSubmit(async (values) => {
loading.value = true
await delay(300) // show loading spinner for at least a moment
// without email the password confirms the change instead of a link;
// auth-realtime checks it before better-auth handles the request.
const { error } = (await changeEmail({
newEmail: values.email,
callbackURL: postEmailVerificationUrl(
config.public.appBaseURL,
values.email,
),
...(isEmailEnabled.value ? {} : { password: values.password }),
})) as AuthResponse
if (error) {
form.setErrors({ email: error.message })
loading.value = false
if (error.code === "INVALID_PASSWORD") {
form.setFieldError(
"password",
t("settings.action-modals.email-change.errors.invalid-password"),
)
return
}
if (error.code === "CREDENTIAL_ACCOUNT_NOT_FOUND") {
showToastMessage(
"error",
t("settings.action-modals.email-change.errors.no-password.title"),
t("settings.action-modals.email-change.errors.no-password.description"),
)
return
}
form.setErrors({ email: error.message })
return
}
await fetchAuthSession.refetch()
showToastMessage(
"success",
t("settings.action-modals.email-change.success-message.title"),
t("settings.action-modals.email-change.success-message.description", {
email: values.email,
}),
)
if (isEmailEnabled.value) {
showToastMessage(
"success",
t("settings.action-modals.email-change.success-message.title"),
t("settings.action-modals.email-change.success-message.description", {
email: values.email,
}),
)
} else {
showToastMessage(
"success",
t(
"settings.action-modals.email-change.success-message-without-email.title",
),
t(
"settings.action-modals.email-change.success-message-without-email.description",
{ email: values.email },
),
)
}
emit("close")
})
</script>
<template>
<div class="flex flex-col">
<ShadcnUiDialogDescription class="text-2sm">
{{ $t("settings.action-modals.email-change.description") }}
{{
isEmailEnabled
? $t("settings.action-modals.email-change.description")
: $t("settings.action-modals.email-change.description-without-email")
}}
</ShadcnUiDialogDescription>
<form
class="mt-5 flex w-full flex-col items-center gap-5"
@ -95,6 +138,34 @@ const onSubmit = form.handleSubmit(async (values) => {
<ShadcnUiFormMessage class="text-2xs" />
</ShadcnUiFormItem>
</ShadcnUiFormField>
<ShadcnUiFormField
v-if="!isEmailEnabled && hasPassword"
v-slot="{ componentField }"
name="password"
class="w-full"
>
<ShadcnUiFormItem class="w-full">
<ShadcnUiFormLabel disable-destructive-effect class="text-2sm">
{{ $t("settings.action-modals.email-change.password-label") }}
</ShadcnUiFormLabel>
<ShadcnUiFormControl>
<ShadcnUiInput
type="password"
autocomplete="current-password"
:placeholder="
$t('settings.action-modals.email-change.password-placeholder')
"
disable-focus-effect
disable-destructive-effect
:class="cn('h-8 text-2sm!', loading && 'pointer-events-none')"
v-bind="{
...componentField,
}"
/>
</ShadcnUiFormControl>
<ShadcnUiFormMessage class="text-2xs" />
</ShadcnUiFormItem>
</ShadcnUiFormField>
<div class="flex w-full gap-2">
<ShadcnUiButton
type="submit"

View File

@ -11,6 +11,7 @@ import {
findButtonByText,
mockAuthEndpoint,
mountWithFrozenClock,
raisedToasts,
seedAuthAccounts,
seedAuthSession,
settleActionSubmit,
@ -284,6 +285,36 @@ describe("<PasswordChangeAction>", { concurrent: false }, () => {
expect(wrapper.emitted("close")).toBeUndefined()
})
it("explains that a server without email cannot send the link", async ({
expect,
}) => {
mockAuthEndpoint("request-password-reset", (_call, event) => {
setResponseStatus(event, 400)
return {
code: "RESET_PASSWORD_DISABLED",
message: "Reset password isn't enabled",
}
})
const wrapper = await mountAction()
await findButtonByText(
wrapper,
t("settings.action-modals.password-change.send-link-button"),
).trigger("click")
await settleActionSubmit()
expect(raisedToasts()).toMatchObject([
{
type: "error",
description: t(
"settings.action-modals.password-change.errors.link-unavailable.description",
),
},
])
expect(wrapper.emitted("close")).toBeUndefined()
})
it("does nothing while the signed-in address is unknown", async ({
expect,
}) => {

View File

@ -114,6 +114,21 @@ async function onSetSubmit() {
})) as AuthResponse
if (error) {
loading.value = false
if (error.code === "RESET_PASSWORD_DISABLED") {
showToastMessage(
"error",
t(
"settings.action-modals.password-change.errors.link-unavailable.title",
),
t(
"settings.action-modals.password-change.errors.link-unavailable.description",
),
)
return
}
showToastMessage(
"error",
t("settings.action-modals.password-change.errors.link-failed.title"),

View File

@ -11,6 +11,8 @@ import {
mockAuthEndpoint,
mockAuthOrganization,
mountWithFrozenClock,
raisedToasts,
seedAuthConfig,
seedAuthSession,
settleActionSubmit,
t,
@ -101,6 +103,27 @@ describe("<WorkspaceInvitationAction>", { concurrent: false }, () => {
expect(wrapper.emitted("close")).toHaveLength(1)
})
it("tells the inviter to hand the link over when the server sends no email", async ({
expect,
}) => {
seedAuthConfig({ emailEnabled: false })
seedWorkspace(1)
mockAuthEndpoint("organization/invite-member", () => ({ id: "inv-1" }))
const wrapper = await mountAction()
await submitEmail(wrapper, "grace@oxynote.test")
expect(raisedToasts()).toMatchObject([
{
type: "success",
title: t(
"settings.action-modals.workspace-invitation.success-message-without-email.title",
),
},
])
expect(wrapper.emitted("close")).toHaveLength(1)
})
it("closes without inviting when the address is the user's own", async ({
expect,
}) => {

View File

@ -21,6 +21,7 @@ const form = useForm({
})
const { fetchAuthSession, fetchOrganization, inviteOrganizationMember } =
useAuthSession()
const { isEmailEnabled } = useAuthAPI()
const { t } = useI18n({ useScope: "global" })
const loading = ref(false)
const isMaxMembersReached = computed(() => {
@ -63,16 +64,30 @@ const onSubmit = form.handleSubmit(async (values) => {
await fetchOrganization.refetch()
showToastMessage(
"success",
t("settings.action-modals.workspace-invitation.success-message.title"),
t(
"settings.action-modals.workspace-invitation.success-message.description",
{
email: values.email,
},
),
)
if (isEmailEnabled.value) {
showToastMessage(
"success",
t("settings.action-modals.workspace-invitation.success-message.title"),
t(
"settings.action-modals.workspace-invitation.success-message.description",
{
email: values.email,
},
),
)
} else {
showToastMessage(
"success",
t(
"settings.action-modals.workspace-invitation.success-message-without-email.title",
),
t(
"settings.action-modals.workspace-invitation.success-message-without-email.description",
{ email: values.email },
),
)
}
emit("close")
})
</script>

View File

@ -8,11 +8,15 @@ import {
} from "~/composables/api/test-helpers"
import WorkspaceSection from "./WorkspaceSection.vue"
import {
at,
clearTeleportedOverlays,
findButtonByText,
menuItem,
mockAuthEndpoint,
mockAuthOrganization,
mountUnderTooltipProvider,
raisedToasts,
seedAuthConfig,
seedAuthSession,
settleMutations,
t,
@ -34,6 +38,17 @@ function member(id: string, name: string) {
}
}
function invitation(inviterId: string) {
return {
id: "inv-1",
organizationId: "org-1",
status: "pending",
email: "grace@oxynote.test",
role: "owner",
inviterId: inviterId,
}
}
function seedWorkspace(
overrides: { members?: unknown[]; invitations?: unknown[] } = {},
) {
@ -237,7 +252,56 @@ describe("<WorkspaceSection>", { concurrent: false }, () => {
expect(calls).toHaveLength(0)
})
describe("without email", { concurrent: false }, () => {
beforeEach(() => {
seedAuthConfig({ emailEnabled: false })
})
it("says that invitation links go out by hand", async ({ expect }) => {
seedWorkspace()
const { wrapper } = await mountSection()
expect(wrapper.text()).toContain(
t("settings.workspace.email-disabled-notice"),
)
})
})
// settings can be opened without the login page having filled the auth
// config cache first, so the section has to fetch it itself
it("shows the notice once the server's config arrives", async ({
expect,
}) => {
mockEndpoint(
"GET",
"http://test.local/auth-realtime/api/auth-config",
() => ({ methods: ["email-password"], emailEnabled: false }),
)
seedWorkspace()
const { wrapper } = await mountSection()
await settleMutations()
expect(wrapper.text()).toContain(
t("settings.workspace.email-disabled-notice"),
)
})
it("shows no email notice where email is delivered", async ({ expect }) => {
seedAuthConfig()
seedWorkspace()
const { wrapper } = await mountSection()
expect(wrapper.text()).not.toContain(
t("settings.workspace.email-disabled-notice"),
)
})
describe("member list", { concurrent: false }, () => {
beforeEach(clearTeleportedOverlays)
it("lists the joined members", async ({ expect }) => {
seedWorkspace({ members: [member("u1", "ada"), member("u2", "linus")] })
@ -323,6 +387,68 @@ describe("<WorkspaceSection>", { concurrent: false }, () => {
id: "member-u2",
})
})
it("copies a pending invitation's link to hand over", async ({
expect,
}) => {
const writeText = vi
.spyOn(navigator.clipboard, "writeText")
.mockResolvedValue(undefined)
seedWorkspace({
members: [member("u1", "ada"), member("u2", "linus")],
invitations: [invitation("u2")],
})
const { wrapper } = await mountSection()
await at(
wrapper.findAll("[data-slot='dropdown-menu-trigger']"),
1,
).trigger("click")
menuItem(t("settings.workspace.member-options.copy-link.title")).click()
await settleMutations()
expect(writeText).toHaveBeenCalledTimes(1)
expect(writeText).toHaveBeenCalledWith(
"http://test.local/accept-invite?id=inv-1&email=grace%40oxynote.test&inviter=linus&orgName=Acme&orgId=org-1",
)
expect(raisedToasts()).toMatchObject([
{
type: "success",
title: t("settings.workspace.invitation-link-copied"),
},
])
})
it("names the signed-in user as inviter once the inviter has left", async ({
expect,
}) => {
const writeText = vi
.spyOn(navigator.clipboard, "writeText")
.mockResolvedValue(undefined)
seedWorkspace({ invitations: [invitation("u9")] })
const { wrapper } = await mountSection()
await wrapper.get("[data-slot='dropdown-menu-trigger']").trigger("click")
menuItem(t("settings.workspace.member-options.copy-link.title")).click()
await settleMutations()
expect(writeText).toHaveBeenCalledWith(
expect.stringContaining("&inviter=ada&"),
)
})
it("offers no link to copy on a joined member's row", async ({
expect,
}) => {
seedWorkspace({ members: [member("u1", "ada"), member("u2", "linus")] })
const { wrapper } = await mountSection()
await wrapper.get("[data-slot='dropdown-menu-trigger']").trigger("click")
expect(() =>
menuItem(t("settings.workspace.member-options.copy-link.title")),
).toThrow()
})
})
})

View File

@ -15,8 +15,10 @@ const emit = defineEmits<{
const { fetchOrganization, fetchAuthSession, updateOrganization } =
useAuthSession()
const { isEmailEnabled } = useAuthAPI()
const { t } = useI18n({ useScope: "global" })
const { uploadOrganizationLogo } = useOrganizationAPI()
const config = useRuntimeConfig()
const formSchema = toTypedSchema(
z.object({
@ -39,8 +41,9 @@ const form = useForm({
validationSchema: formSchema,
})
const members = computed<OrganizationMember[]>(() => {
const organization = fetchOrganization.state.value.data?.data
const members: OrganizationMember[] =
fetchOrganization.state.value.data?.data?.members.map((m) => {
organization?.members.map((m) => {
return {
id: m.id,
organizationId: m.organizationId,
@ -51,13 +54,26 @@ const members = computed<OrganizationMember[]>(() => {
}
}) ?? []
const invs: OrganizationMember[] =
fetchOrganization.state.value.data?.data?.invitations
organization?.invitations
.filter((inv) => inv.status === "pending")
.map((inv) => {
return {
id: inv.id,
organizationId: inv.organizationId,
invitationPending: true,
// an inviter who has since left the workspace is no longer
// listed, so whoever hands the link over stands in for them.
invitationUrl: acceptInvitationUrl(config.public.appBaseURL, {
id: inv.id,
email: inv.email,
inviter:
organization.members.find((m) => m.userId === inv.inviterId)?.user
.name ??
fetchAuthSession.state.value.data?.data?.user.name ??
"",
organizationName: organization.name,
organizationId: organization.id,
}),
role: inv.role,
user: { name: extractNameFromEmail(inv.email), email: inv.email },
}
@ -169,9 +185,26 @@ function handleAvatarClick() {
function handleMemberDelete(member: OrganizationMember) {
emit("member-removal", member)
}
async function copyInvitationLink(member: OrganizationMember) {
if (!member.invitationUrl) {
// NOCOV: the menu item renders only for a row that has a link.
return
}
await navigator.clipboard.writeText(member.invitationUrl)
showToastMessage("success", t("settings.workspace.invitation-link-copied"))
}
</script>
<template>
<form class="flex flex-col" autocomplete="off" @submit.prevent="onSubmit">
<div
v-if="!isEmailEnabled"
class="mb-3.5 flex items-start gap-2 rounded-md border bg-background px-3 py-2 text-2sm text-muted-foreground"
>
<Icon name="lucide:mail-x" class="mt-0.25 size-3.5 shrink-0" />
<p>{{ $t("settings.workspace.email-disabled-notice") }}</p>
</div>
<ShadcnUiFormField v-slot="{ componentField }" name="logoUrl">
<ShadcnUiFormItem class="flex w-full items-center justify-between gap-2">
<div class="flex flex-col gap-0.5">
@ -394,6 +427,19 @@ function handleMemberDelete(member: OrganizationMember) {
loop
inside-modal
>
<ShadcnUiDropdownMenuItem
v-if="member.invitationUrl"
@click="copyInvitationLink(member)"
>
<Icon name="lucide:link" />
<span>
{{
$t(
"settings.workspace.member-options.copy-link.title",
)
}}
</span>
</ShadcnUiDropdownMenuItem>
<ShadcnUiDropdownMenuItem
@click="handleMemberDelete(member)"
>

View File

@ -4,6 +4,7 @@ export interface OrganizationMember {
createdAt?: Date
userId?: string
invitationPending?: boolean
invitationUrl?: string
role: string
user: {
name: string

View File

@ -5,7 +5,8 @@ import { mountSuspended } from "@nuxt/test-utils/runtime"
import { flushPromises, type VueWrapper } from "@vue/test-utils"
import { vi } from "vitest"
import type { H3Event } from "h3"
import type { ComponentPublicInstance } from "vue"
import type { ComponentPublicInstance, VNode } from "vue"
import { toast } from "vue-sonner"
import {
mockEndpoint,
runInApp,
@ -83,6 +84,26 @@ export function seedCapabilities(capabilities: Partial<Capabilities> = {}) {
})
}
// email defaults to delivered here, matching what useAuthAPI reports
// before its request resolves
export function seedAuthConfig(config: Partial<AuthConfig> = {}) {
seedQueryData(["auth", "config"], {
methods: ["email-password"],
emailEnabled: true,
...config,
})
}
// what each toast raised so far says, read off the render function every
// showToastMessage call hands the module-mocked vue-sonner
export function raisedToasts(): { type: string; title: string }[] {
return vi.mocked(toast.custom).mock.calls.map(([render]) => {
const vnode = (render as () => VNode)()
return vnode.props as { type: string; title: string }
})
}
// reka-ui tooltips inject a provider context that the app installs once,
// at page level (app/pages/[[organizationSlug]]/[[documentSlug]].vue), so
// a component rendering a tooltip needs one around it to mount at all

View File

@ -36,4 +36,35 @@ describe("useAuthAPI", { concurrent: false }, () => {
expect(configCalls).toHaveLength(1)
})
})
describe("isEmailEnabled", () => {
it("assumes email is delivered until the config arrives", async ({
expect,
}) => {
mockEndpoint(
"GET",
"http://test.local/auth-realtime/api/auth-config",
() => ({ methods: ["email-password"], emailEnabled: false }),
)
const api = makeAuthAPI()
expect(api.isEmailEnabled.value).toBe(true)
// 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",
() => ({ methods: ["email-password"], emailEnabled: false }),
)
const api = makeAuthAPI()
await api.fetchAuthConfig.refresh()
expect(api.isEmailEnabled.value).toBe(false)
})
})
})

View File

@ -18,5 +18,11 @@ export default function () {
staleTime: 5 * 60 * 1000, // 5 mins
})
return { fetchAuthConfig }
// until the config arrives email is assumed to be delivered, so a page
// renders the flows every configured server supports.
const isEmailEnabled = computed(
() => fetchAuthConfig.state.value.data?.emailEnabled ?? true,
)
return { fetchAuthConfig, isEmailEnabled }
}

View File

@ -25,7 +25,7 @@ const {
requestPasswordReset,
setupSignInRedirect,
} = useAuthSession()
const { fetchAuthConfig } = useAuthAPI()
const { fetchAuthConfig, isEmailEnabled } = useAuthAPI()
const route = useRoute()
const config = useRuntimeConfig()
@ -136,15 +136,22 @@ async function logInWithProvider(provider: "github" | "google" | "slack") {
const onEmailPasswordSubmit = emailPasswordForm.handleSubmit(async (values) => {
loading.value = "email-password"
// the callbackURL only matters for the verification email that an
// unverified sign-in attempt re-sends — its link drops the user back
// here with the confirmation flag. Absolute against the app origin
// because a relative path would resolve against the auth server's
// origin, which does not serve the frontend in host-dev mode.
// the callbackURL does two jobs: the verification link an unverified
// sign-in attempt re-sends drops the user there with the confirmation
// flag, and a successful sign-in sends the browser there, where the
// middleware forwards the signed-in visitor to next. Absolute against
// the app origin because a relative path would resolve against the auth
// server's origin, which does not serve the frontend in host-dev mode.
const nextUrl = route.query.next as string | undefined
const callbackQuery = new URLSearchParams({ verified: "true" })
if (nextUrl) {
callbackQuery.set("next", nextUrl)
}
const res = (await signInEmailPassword({
email: values.email,
password: values.password,
callbackURL: `${config.public.appBaseURL}/login?verified=true`,
callbackURL: `${config.public.appBaseURL}/login?${callbackQuery.toString()}`,
})) as AuthResponse
if (res.error) {
@ -176,20 +183,30 @@ const onEmailPasswordSubmit = emailPasswordForm.handleSubmit(async (values) => {
return
}
// the session query still caches the signed-out null within its
// staleTime — refetch before navigating or the middleware bounces
// straight back to /login
await fetchAuthSession.refetch()
// a full navigation, not a router push: the authorize endpoint
// answers with OAuth redirects the SPA router cannot follow.
// answers with OAuth redirects the SPA router cannot follow. It
// replaces the redirect below.
if (oauthContinueUrl.value) {
window.location.href = oauthContinueUrl.value
return
}
const nextUrl = route.query.next as string | undefined
// on the web a successful sign-in answers with redirect: true, and
// better-auth's fetch plugin sets window.location to the callbackURL. A
// router navigation here as well races that full-page load and cancels
// it, which leaves the page stuck on /login.
if (!__DESKTOP_BUILD__) {
return
}
// the desktop bridge signs in from Electron's main process, where no
// window follows the redirect, so the renderer navigates itself. The
// session query still caches the signed-out null within its staleTime —
// refetch before navigating or the middleware bounces straight back to
// /login
await fetchAuthSession.refetch()
void navigateTo(nextUrl ? decodeURIComponent(nextUrl) : "/", {
replace: true,
})
@ -512,7 +529,7 @@ function methodVariant(method: AuthMethod) {
</ShadcnUiButton>
</div>
<i18n-t
v-if="view === 'email-password'"
v-if="view === 'email-password' && isEmailEnabled"
scope="global"
keypath="onboarding.login.forgot-password.main"
tag="div"

View File

@ -17,9 +17,13 @@ useHead({
title: () => t("general.signup-page-title"),
})
const { signInSocial, signUpEmailPassword, setupSignInRedirect } =
useAuthSession()
const { fetchAuthConfig } = useAuthAPI()
const {
fetchAuthSession,
signInSocial,
signUpEmailPassword,
setupSignInRedirect,
} = useAuthSession()
const { fetchAuthConfig, isEmailEnabled } = useAuthAPI()
const { fetchOrganizationStats } = useOrganizationAPI()
const { currentUrl } = useCurrentUrl()
@ -159,10 +163,11 @@ const onEmailPasswordSubmit = emailPasswordForm.handleSubmit(async (values) => {
name: values.email.split("@")[0] || values.email,
callbackURL: `${config.public.appBaseURL}/login?verified=true`,
})) as AuthResponse
// no duplicate-email branch on purpose: better-auth answers duplicate
// signups with a synthetic success so the browser can't probe which
// emails have accounts. The existing owner is notified through their
// inbox instead (onExistingUserSignUp in auth-realtime).
// no duplicate-email branch on purpose: where email is sent,
// better-auth answers duplicate signups with a synthetic success so the
// browser can't probe which emails have accounts. The existing owner is
// notified through their inbox instead (onExistingUserSignUp in
// auth-realtime).
if (res.error) {
loading.value = null
showToastMessage("error", t("onboarding.signup.errors.signup-failed"))
@ -170,6 +175,20 @@ const onEmailPasswordSubmit = emailPasswordForm.handleSubmit(async (values) => {
return
}
// without email better-auth signs the new account in at once, so there
// is no inbox to wait on. The session query still caches the signed-out
// null, so it is refetched before the middleware reads it.
if (!isEmailEnabled.value) {
await fetchAuthSession.refetch()
const nextUrl = route.query.next as string | undefined
void navigateTo(nextUrl ? decodeURIComponent(nextUrl) : "/", {
replace: true,
})
return
}
void navigateTo({
path: "/verify-email",
query: { new: values.email, sent: "true" },

View File

@ -11,7 +11,20 @@ const { t } = useI18n({ useScope: "global" })
useHead({
title: () => t("general.verify-email-page-title"),
})
const { fetchAuthSession } = useAuthSession()
const pageRoute = useRoute()
// both steps of a change of address land here through the same callback
// url, and only the session separates them: better-auth writes the new
// address into it once the change is applied, creating a session first if
// the link was opened in a browser that had none. The page is exempt from
// the global middleware, so nothing has read the session yet.
await fetchAuthSession.refresh()
const changeApplied = computed(
() =>
fetchAuthSession.state.value.data?.data?.user.email === pageRoute.query.new,
)
</script>
<template>
<main
@ -24,18 +37,35 @@ const pageRoute = useRoute()
{{
pageRoute.query.sent
? $t("onboarding.verify-email.sent-heading")
: $t("onboarding.verify-email.heading")
: changeApplied
? $t("onboarding.verify-email.heading")
: $t("onboarding.verify-email.approved-heading")
}}
</div>
</div>
<div class="flex w-full flex-col gap-3">
<i18n-t
v-if="pageRoute.query.sent"
scope="global"
:keypath="
pageRoute.query.sent
? 'onboarding.verify-email.sent-title'
: 'onboarding.verify-email.title'
"
keypath="onboarding.verify-email.sent-title"
tag="div"
class="text-center text-xs text-accent-foreground"
>
<template #email>{{ pageRoute.query.new }}</template>
</i18n-t>
<i18n-t
v-else-if="changeApplied"
scope="global"
keypath="onboarding.verify-email.title"
tag="div"
class="text-center text-xs text-accent-foreground"
>
<template #email>{{ pageRoute.query.new }}</template>
</i18n-t>
<i18n-t
v-else
scope="global"
keypath="onboarding.verify-email.approved-title"
tag="div"
class="text-center text-xs text-accent-foreground"
>

View File

@ -2,4 +2,5 @@ export type AuthMethod = "email-password" | "github" | "google" | "slack"
export interface AuthConfig {
methods: AuthMethod[]
emailEnabled: boolean
}

View File

@ -1,5 +1,9 @@
import { describe, it } from "vitest"
import { postAuthDocumentUrl, postEmailVerificationUrl } from "./auth"
import {
acceptInvitationUrl,
postAuthDocumentUrl,
postEmailVerificationUrl,
} from "./auth"
describe("postAuthDocumentUrl", () => {
it.for([
@ -38,6 +42,22 @@ describe("postAuthDocumentUrl", () => {
})
})
describe("acceptInvitationUrl", () => {
it("carries everything the accept page renders, encoded", ({ expect }) => {
expect(
acceptInvitationUrl("https://app.test", {
id: "inv-1",
email: "a+b@test.io",
inviter: "Ada Lovelace",
organizationName: "Acme & Co",
organizationId: "org-1",
}),
).toBe(
"https://app.test/accept-invite?id=inv-1&email=a%2Bb%40test.io&inviter=Ada+Lovelace&orgName=Acme+%26+Co&orgId=org-1",
)
})
})
describe("postEmailVerificationUrl", () => {
it("builds the verification url with the email encoded", ({ expect }) => {
expect(postEmailVerificationUrl("https://app.test", "a+b@test.io")).toBe(

View File

@ -17,6 +17,30 @@ export function postAuthDocumentUrl(
return `${baseUrl || ""}${next ? decodeURIComponent(next) : defaultPath}`
}
// the accept page renders from these parameters before the invitee has a
// session. auth-realtime builds the same link for the invitation email
// (invitationLink in server/auth-realtime/src/auth.ts).
export function acceptInvitationUrl(
baseUrl: string | undefined,
invitation: {
id: string
email: string
inviter: string
organizationName: string
organizationId: string
},
): string {
const query = new URLSearchParams({
id: invitation.id,
email: invitation.email,
inviter: invitation.inviter,
orgName: invitation.organizationName,
orgId: invitation.organizationId,
})
return `${baseUrl || ""}/accept-invite?${query.toString()}`
}
export function postEmailVerificationUrl(
baseUrl: string | undefined,
email: string,

View File

@ -94,6 +94,8 @@
"verify-email": {
"heading": "Email verified",
"sent-heading": "Verify your email",
"approved-heading": "One more step",
"approved-title": "Thanks for approving the change. We've sent a verification link to {email}. Open it to finish moving your account.",
"title": "Thank you for verifying {email} as your email address!",
"sent-title": "We've sent a verification link to {email}. Please check your inbox.",
"button": "Continue to Oxynote",

View File

@ -7,13 +7,27 @@
"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-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",
"password-label": "Your current password",
"password-placeholder": "Current password",
"submit-button": "Change Email",
"cancel-button": "Cancel",
"success-message": {
"title": "Verification email sent",
"description": "We've sent a verification link to {email}. Once you click the link, your email address will be updated"
},
"success-message-without-email": {
"title": "Email changed",
"description": "Your email address is now {email}"
},
"errors": {
"invalid-password": "Incorrect password",
"no-password": {
"title": "Email couldn't be changed",
"description": "Email sending isn't configured, so the change has to be confirmed with a password, and your account doesn't have one"
}
}
},
"password-change": {
@ -53,13 +67,20 @@
"link-failed": {
"title": "Link couldn't be sent",
"description": "An error occurred while trying to send the password setup link. Please try again"
},
"link-unavailable": {
"title": "Link couldn't be sent",
"description": "Email sending isn't configured, so a password can't be set through a link"
}
}
},
"account-deletion": {
"title": "Delete Account",
"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.",
"password-label": "Your current password",
"password-placeholder": "Current password",
"confirm-button": "Delete Account",
"cancel-button": "Cancel",
"success-message": {
@ -76,6 +97,12 @@
"deletion-failed": {
"title": "Account couldn't be deleted",
"description": "An error occurred while trying to delete your account. Please try again"
},
"password-required": "Please enter your current password",
"invalid-password": "Incorrect password",
"no-password": {
"title": "Account couldn't be deleted",
"description": "Email sending isn't configured, so the deletion has to be confirmed with a password, and your account doesn't have one"
}
}
},
@ -107,6 +134,10 @@
"success-message": {
"title": "Invitation sent",
"description": "An invitation was sent to {email}"
},
"success-message-without-email": {
"title": "Invitation created",
"description": "Email sending isn't configured. Copy the invitation link from the member list and send it to {email} yourself"
}
},
"workspace-member-removal": {
@ -388,7 +419,12 @@
"members-option-button-screen-reader-hint": "Manage Member",
"joined-label": "Joined",
"invited-label": "Invited",
"email-disabled-notice": "Email sending isn't configured. Copy invitation links from the member list and send them yourself.",
"invitation-link-copied": "Invitation link copied",
"member-options": {
"copy-link": {
"title": "Copy invitation link"
},
"delete": {
"title": "Remove"
}