mirror of
https://github.com/oxynote/oxynote.git
synced 2026-10-17 10:59:20 +02:00
make email sending optional
This commit is contained in:
parent
8dc5392065
commit
cc162bad2b
|
|
@ -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`).
|
||||
|
|
|
|||
14
Makefile
14
Makefile
|
|
@ -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
|
||||
|
|
|
|||
47
README.md
47
README.md
|
|
@ -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
|
||||
|
||||
|
|
|
|||
6
docker/env/auth-realtime.example.env
vendored
6
docker/env/auth-realtime.example.env
vendored
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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`,
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 ?? "",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
30
e2e/helpers/without-email.ts
Normal file
30
e2e/helpers/without-email.ts
Normal 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 }
|
||||
}
|
||||
|
|
@ -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())
|
||||
|
||||
|
|
|
|||
245
e2e/tests/prod-without-email.test.ts
Normal file
245
e2e/tests/prod-without-email.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -109,6 +109,7 @@ export function testEnv(overrides: Partial<Env> = {}): Env {
|
|||
maxOrganizations: 100,
|
||||
maxOrganizationMembers: 5,
|
||||
rateLimitEnabled: true,
|
||||
emailEnabled: true,
|
||||
logLevel: "INFO",
|
||||
...overrides,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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{
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}) => {
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}) => {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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)"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ export interface OrganizationMember {
|
|||
createdAt?: Date
|
||||
userId?: string
|
||||
invitationPending?: boolean
|
||||
invitationUrl?: string
|
||||
role: string
|
||||
user: {
|
||||
name: string
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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" },
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -2,4 +2,5 @@ export type AuthMethod = "email-password" | "github" | "google" | "slack"
|
|||
|
||||
export interface AuthConfig {
|
||||
methods: AuthMethod[]
|
||||
emailEnabled: boolean
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user