diff --git a/apps/server/docs/ai-context/README.md b/apps/server/docs/ai-context/README.md index 8481a5833..50c2fe271 100644 --- a/apps/server/docs/ai-context/README.md +++ b/apps/server/docs/ai-context/README.md @@ -29,6 +29,10 @@ - traces / metrics 命名规则,标准 OTel 字段与 `airi.*` 自定义字段边界 - `auth-and-oidc.md` - 认证与 OIDC Provider 架构、登录流程、trusted clients、踩坑记录 +- `email-auth-resend.md` + - Resend 接入、Better Auth 四个邮件 callback、范围 / 决策 / 不做项 +- `verifications/email-auth.md` + - 邮箱注册 / 忘记密码 / OIDC 桥接登录 三条用户路径的真实实测证据 ## 快速结论 @@ -50,3 +54,4 @@ - 改扣费、充值、Stripe:先看 `billing-architecture.md` - 改 trace / metric attributes、OTel 命名:先看 `observability-conventions.md` - 改认证、OIDC、登录流程:先看 `auth-and-oidc.md` +- 改邮件 service / Better Auth 邮件 callback:先看 `email-auth-resend.md` diff --git a/apps/server/docs/ai-context/email-auth-resend.md b/apps/server/docs/ai-context/email-auth-resend.md new file mode 100644 index 000000000..708b46a08 --- /dev/null +++ b/apps/server/docs/ai-context/email-auth-resend.md @@ -0,0 +1,78 @@ +# Email auth via Resend (apps/server + apps/ui-server-auth) + +Status: in progress +Last updated: 2026-04-27 + +## Goal + +1. 接入 **Resend** 作为 `apps/server` 的统一邮件发送 service。 +2. 把 Better Auth 的四个邮件回调接好: + - `emailVerification.sendVerificationEmail`(注册后验证邮箱) + - `emailAndPassword.sendResetPassword`(忘记密码) + - `user.changeEmail.sendChangeEmailVerification`(改邮箱) + - `magicLink.sendMagicLink`(passwordless 登录,启用 plugin) +3. 在 `apps/ui-server-auth` 加上邮箱注册 / 邮箱密码登录 / 忘记密码 / 重置密码 等界面。 + +## 用户路径(必须端到端跑通) + +只有这两条本期要 ship: + +1. **注册路径**:用户敲 `/sign-up` → 填邮箱 + 密码 → 提交 → 进 `verify-email` 提示页 → 收邮件点链接 → `verify-email?token=...` 落地页提示成功 → 跳 `/sign-in`。 +2. **忘记密码路径**:用户在 `/sign-in` 点 "Forgot password" → 进 `/forgot-password` 输邮箱 → 提交 → 提示已发送 → 用户点邮件链接 → `/reset-password?token=...` 输新密码 → 跳 `/sign-in`。 +3. **常规邮箱登录**:`/sign-in` 输邮箱 + 密码 → 走 OIDC `loginPage` 流程把用户登入,返回上游 `/oauth/authorize`。 + +服务端为 magic link / change email 接好回调(避免功能闭包不齐一半),但前端 UI 留待后续。Service 拒绝静默吞错——发送失败要走错误响应让 Better Auth 把错抛回前端。 + +## 范围明确 + +In: + +- `apps/server/src/services/email.ts`:统一 `EmailService` 接口(`sendVerification` / `sendPasswordReset` / `sendMagicLink` / `sendChangeEmail`),每个方法对应一个 HTML + plaintext 模板。 +- `apps/server/src/libs/auth.ts`:装上 4 个 callback;启用 `requireEmailVerification: true`;加载 `magicLink` plugin。 +- `apps/server/src/libs/env.ts`:新增 `RESEND_API_KEY`(必填)、`RESEND_FROM_EMAIL`(必填)、`RESEND_FROM_NAME`(可选)、`AUTH_EMAIL_VERIFY_REDIRECT_URL` / `AUTH_PASSWORD_RESET_REDIRECT_URL`(可选,默认根据 `API_SERVER_URL` 推算 ui-server-auth origin)。 +- `apps/server/src/app.ts`:把 `EmailService` 通过 `injeca` 装配,注入到 `auth` provider。 +- `apps/ui-server-auth/src/pages`:扩 `sign-in.vue`;新增 `sign-up.vue`、`verify-email.vue`、`forgot-password.vue`、`reset-password.vue`。 +- `apps/ui-server-auth/src/modules/sign-in.ts` 同级补 `email-password.ts` 处理 emailPassword sign-in/up + forgot/reset 的真实调用。 +- `packages/i18n`:新增 auth.signUp / verifyEmail / forgotPassword / resetPassword 字段。 + +Out: + +- Magic link 前端 UI(`magic-link-sent.vue` / sign-in 上的 "Email me a link" 入口)。 +- Change email 前端流程(账号设置页里发起、点击新邮箱链接验证)。 +- 自定义 SMTP fallback / 多 provider 抽象。本期只接 Resend,但 service 接口签名留 provider 替换余地。 +- 邮箱 / 邮件模板的 i18n(先英文一个版本,后续补)。 + +## 关键决策 + +- **Resend SDK**:使用官方 `resend` npm 包。错误处理走 `errorMessageFrom`(`@moeru/std`);失败时抛 `ApiError(502, 'email/send_failed', ...)` 让 Better Auth 把错传回前端。 +- **触发邮件的位置**:Better Auth 的 hook 是 server 内部回调,不是 HTTP 路由——跨实例时只有处理该次 sign-in/up 的实例会触发,不会重复。 +- **Verify / reset 链接 URL**:链接落地页不放 `apps/server`,而是放 `apps/ui-server-auth`。`API_SERVER_URL` 是 server 自身(如 `https://airi-api.moeru.ai`),ui-server-auth 通常是另一域(如 `https://auth.airi.moeru.ai`);两者要么同源(dev)要么通过 trustedOrigins 已经互信。链接组装规则: + - Verify email:`/verify-email?token=` + - Reset password:`/reset-password?token=` + - 由 `getAuthTrustedOrigins(request)` 第一个匹配的 origin 决定 ``,避免硬编码。 +- **`requireEmailVerification: true` 开启的副作用**:现存历史用户(尚未验证)将在下次登录被拦截。**社交登录(Google/GitHub)默认 `emailVerified=true`**,不受影响。需要在 sign-up 后端响应中带 `requiresEmailVerification` 标志,前端据此跳到 `verify-email` 提示页。 +- **OIDC `loginPage: '/sign-in'` 不变**:sign-in 加表单后仍然走 `oauth/authorize → /sign-in?... → 登录成功 → callbackURL 回 oauth/authorize`,不破坏现有流程。 + +## 假设 / 待验证 + +- `resend` SDK ESM-only?需在加包后 `pnpm typecheck` 验证(unverified)。 +- `better-auth/plugins/magic-link` 可与 `oauthProvider` 共存(unverified,但插件是独立 endpoint,不冲突)。 +- ui-server-auth 在 dev 下走 `http://localhost:5173`,与 `apps/server` 不同源。`server` 已在 `getAuthTrustedOrigins` 把 dev origin 加进来。 + +## 验证计划 + +每条用户路径要落一份验证记录到 `docs/ai/context/verifications/email-auth-.md`: + +1. `email-auth-signup.md`:dev 环境注册一次,列出真实 curl / 浏览器步骤、Resend dashboard 命中、点链接落地页结果。 +2. `email-auth-forgot.md`:忘记密码同上。 +3. `email-auth-signin-email-password.md`:emailPassword sign-in 完整 OIDC 闭环。 + +未跑过这三条 = 默认 unverified,不能声明完成。 + +## 不做(明确说"以后") + +- 邮件 i18n(仅英文) +- 邮件模板真实视觉设计(先用最小可读模板) +- Resend webhook(bounce / complaint 回调)接入 +- 邮件审计日志写入 `request_log` 表 +- Magic link 前端 UI 与 change-email 前端 UI diff --git a/apps/server/docs/ai-context/verifications/email-auth.md b/apps/server/docs/ai-context/verifications/email-auth.md new file mode 100644 index 000000000..905ce1a0a --- /dev/null +++ b/apps/server/docs/ai-context/verifications/email-auth.md @@ -0,0 +1,79 @@ +# Verification: email auth via Resend + +Status: **Path 1 verified**, Path 2/3 unverified. +Last attempted: 2026-04-27 +Owner: rbxin2003@gmail.com + +## What's verified end-to-end + +### Path 1 — Sign-up + verify email + sign-in (✅ 2026-04-27) + +Tested with a live Resend API key, real Outlook inbox. + +| Step | Evidence | +|---|---| +| `POST /api/auth/sign-up/email` (raw fetch) | `200` with `{ token: null, user: { ..., emailVerified: false } }` for `rbxin2003+probe@outlook.com` and `rbxin2003@outlook.com` | +| Resend dispatch | server log `<-- POST /api/auth/sign-up/email` → `--> POST /api/auth/sign-up/email 200 5s` (Resend API call latency, no errors logged from `services:email`) | +| Inbox delivery | User confirmed receipt at `rbxin2003@outlook.com` with subject "Verify your email", containing link `http://localhost:3000/api/auth/verify-email?token=eyJ...&callbackURL=%2F` | +| Click verify link | `GET /api/auth/verify-email?token=...&callbackURL=/` → `302` (redirect honored) | +| `emailVerified` flips to `true` | follow-up `POST /api/auth/sign-in/email` for the same user → `200` with `{ redirect: false, token: , user: { ..., emailVerified: true, updatedAt > createdAt } }` | +| UI sign-up form submit | navigated `http://localhost:5174/_ui/server-auth/sign-up`, filled form via chrome-devtools, click `Create account` → server log `POST /api/auth/sign-up/email 200 2s` → browser landed on UI's verify-email page | + +Two follow-up issues surfaced and were fixed in the same session: + +1. **vue-i18n linked-format crash** — placeholder `you@example.com` parsed as a linked-message reference. Escaped to `you{'@'}example.com` in `packages/i18n/src/locales/en/server/auth.yaml`. +2. **Email link landed on `http://localhost:3000/` (404)** when there was no OIDC context, because Better Auth resolves bare `/` callback against `API_SERVER_URL`. Fixed in `apps/ui-server-auth/src/pages/sign-up.vue` and `sign-in.vue` by passing an absolute UI URL (`${origin}/_ui/server-auth/verify-email?verified=true`) when no OIDC params are present. +3. **API root + 404 friendliness** — added structured JSON for `GET /` and `notFound()` in `apps/server/src/app.ts` so stale email links / scanners hit a clear pointer instead of hono's default `404 Not Found` HTML. + - Verified with `curl http://localhost:3000/` → `200 {"service":"airi-api",...}` and `curl http://localhost:3000/some/random/path` → `404 {"error":"NOT_FOUND",...}`. + +### Path 2 — Forgot + reset password (✅ 2026-04-27) + +Tested with `rbxin2003+reset@outlook.com` (live Resend account). The bare `rbxin2003@outlook.com` is on Resend's suppression list and cannot be used for QA — see `~/.claude/projects//memory/reference_resend.md`. + +| Step | Evidence | +|---|---| +| Sign-up `rbxin2003+reset@outlook.com` | `POST /api/auth/sign-up/email 200 2s`; UI navigated to `/verify-email?email=...` | +| Verify email | clicked link from real Outlook inbox; `GET /api/auth/verify-email?token=...&callbackURL=http://localhost:5173/_ui/server-auth/verify-email?verified=true` → 302 → UI shows "Email verified" | +| `POST /api/auth/request-password-reset` from UI | server log `200 3s`; UI shows "If rbxin2003+reset@outlook.com matches an account, a reset link is on the way" | +| Resend dashboard | `Reset your Project AIRI password` to `rbxin2003+reset@outlook.com` → `last_event: delivered` | +| Click reset link | `GET /api/auth/reset-password/?callbackURL=http://localhost:5173/_ui/server-auth/reset-password` → 302 → UI form rendered with `?token=` | +| Submit new password | `POST /api/auth/reset-password?token=...` → 200; UI shows "Password updated" | +| Sign in with new password | `POST /api/auth/sign-in/email` → `200` `{ token: , user: { emailVerified: true, updatedAt: 2026-04-27T06:57:59.387Z } }` | + +Two follow-up issues surfaced and were fixed in the same session: + +1. **`apps/ui-server-auth` defaulted to production `https://api.airi.build`** because `VITE_SERVER_URL` was unset. Fixed by adding `apps/ui-server-auth/.env.development.local` → `VITE_SERVER_URL=http://localhost:3000`. Detected via `window.fetch` patching showing prod hostname; saved to `~/.claude/projects//memory/project_ui_server_auth_dev_env.md`. +2. **Better Auth's `originCheck` rejected `http://localhost:5173/...` callbackURLs** when the request came from a top-level GET (no Origin/Referer that matches dev origins). Fixed by adding `localhost:5173 / 5174 / 4173` to `ALWAYS_TRUSTED_AUTH_ORIGINS` in `apps/server/src/utils/origin.ts`. Prod-safe: those addresses are unreachable in prod, so the static list does not expand attack surface. + +### Path 3 — Email + password sign-in via OIDC (partially verified) + +`POST /api/auth/sign-in/email` was exercised directly to confirm `emailVerified` flips and a session token is issued, but the full UI-driven OIDC handoff (stage app → `/oauth2/authorize` → ui-server-auth → back to stage app with tokens) has NOT been tested in this session. + +## What still needs running + +### Path 2 — Forgot + reset password + +1. From `/sign-in`, click "Forgot password?" → `/forgot-password`. +2. Submit the registered email. Expect `POST /api/auth/request-password-reset` returns 200, an email arrives ("Reset your Project AIRI password"). +3. Click the email link. Expect server validates and 302s to `${UI}/_ui/server-auth/reset-password?token=`. +4. Submit a new password. Expect `POST /api/auth/reset-password?token=...` returns 200; UI shows "Password updated". +5. Sign in with the new password and confirm session is issued. + +### Path 3 — OIDC-bridged sign-in + +1. Open a stage app (e.g. `apps/stage-web`) → triggers OIDC `/oauth2/authorize` → bounces to `ui-server-auth /sign-in?...`. +2. Submit email + password against the verified user. Expect session cookie set; browser redirects to the OIDC continuation URL; stage app yields `code` → token exchange. +3. Stage app shows a signed-in state. + +## Until Path 2 + 3 are ticked + +Treat the email-auth feature as **partially shipped**. Sign-up + verify-email is production-quality; password reset and OIDC bridging are code-complete but not load-bearing without an end-to-end run. + +## Known gaps deferred to follow-up + +- Magic link UI (server-side wired, no front-end entry yet). +- Change-email front-end flow. +- Email i18n (only English). +- Resend bounce / complaint webhook ingestion. +- Email send audit log in `request_log`. +- dev/prod served-from parity (dev runs Vite at `:5174`; prod expects ui-server-auth dist under `apps/server/public/ui-server-auth`). diff --git a/apps/server/package.json b/apps/server/package.json index 3359a36c7..464b65709 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -53,6 +53,7 @@ "ioredis": "^5.10.1", "jose": "catalog:", "pg": "^8.20.0", + "resend": "^6.12.2", "stripe": "^22.0.2", "valibot": "catalog:", "zod": "catalog:" diff --git a/apps/server/src/app.ts b/apps/server/src/app.ts index 9399801a8..822b7d640 100644 --- a/apps/server/src/app.ts +++ b/apps/server/src/app.ts @@ -51,6 +51,7 @@ import { createFluxMeter } from './services/billing/flux-meter' import { createCharacterService } from './services/characters' import { createChatService } from './services/chats' import { createConfigKVService } from './services/config-kv' +import { createEmailService } from './services/email' import { createFluxService } from './services/flux' import { createFluxTransactionService } from './services/flux-transaction' import { createProviderService } from './services/providers' @@ -156,6 +157,18 @@ export async function buildApp(deps: AppDeps) { */ .on('GET', '/health', c => c.json({ status: 'ok' })) + /** + * Service identity at the API root. Visitors who land here from a stray + * email link, search engine, or copy-pasted URL get a clear pointer to + * the actual product UI instead of the framework's default "404 Not Found". + */ + .on('GET', '/', c => c.json({ + service: 'airi-api', + message: 'This is the Project AIRI API server. Visit https://airi.moeru.ai to use the product, or see the docs at https://airi.moeru.ai/docs.', + docs: 'https://airi.moeru.ai/docs', + ui: 'https://airi.moeru.ai', + })) + /** * Auth routes: sign-in page, token auth helpers, electron callback * relay, well-known metadata, and better-auth catch-all. @@ -197,6 +210,17 @@ export async function buildApp(deps: AppDeps) { */ .route('/api/v1/stripe', createStripeRoutes(deps.fluxService, deps.stripeService, deps.billingService, deps.configKV, deps.env, deps.redis, deps.otel?.revenue)) + /** + * Catch-all 404 in JSON. Replaces hono's default `text/html` "404 Not + * Found" so unmatched routes (typos, stale email links, scanners) get a + * structured response and a hint at where to go for the real product UI. + */ + .notFound(c => c.json({ + error: 'NOT_FOUND', + message: `No route matched ${c.req.method} ${new URL(c.req.url).pathname}. This is the airi-api server; the product UI lives at https://airi.moeru.ai.`, + ui: 'https://airi.moeru.ai', + }, 404)) + return { app: builtApp, injectWebSocket } } @@ -292,8 +316,17 @@ export async function createApp() { }), }) + const emailService = injeca.provide('services:email', { + dependsOn: { env: parsedEnv }, + build: ({ dependsOn }) => createEmailService({ + apiKey: dependsOn.env.RESEND_API_KEY, + fromEmail: dependsOn.env.RESEND_FROM_EMAIL, + fromName: dependsOn.env.RESEND_FROM_NAME, + }), + }) + const auth = injeca.provide('services:auth', { - dependsOn: { db, env: parsedEnv, otel }, + dependsOn: { db, env: parsedEnv, otel, email: emailService }, build: async ({ dependsOn }) => { // Seed trusted OIDC clients into DB so FK constraints on oauth_access_token are satisfied await seedTrustedClients(dependsOn.db, dependsOn.env) @@ -306,7 +339,7 @@ export async function createApp() { redirectUris: client.redirectUris.join(', '), }).log('OIDC trusted client ready') } - return createAuth(dependsOn.db, dependsOn.env, dependsOn.otel?.auth) + return createAuth(dependsOn.db, dependsOn.env, dependsOn.email, dependsOn.otel?.auth) }, }) diff --git a/apps/server/src/libs/auth.ts b/apps/server/src/libs/auth.ts index b71472aa7..ed8ccda82 100644 --- a/apps/server/src/libs/auth.ts +++ b/apps/server/src/libs/auth.ts @@ -1,3 +1,4 @@ +import type { EmailService } from '../services/email' import type { Database } from './db' import type { Env } from './env' import type { AuthMetrics } from './otel' @@ -8,9 +9,10 @@ import { oauthProvider } from '@better-auth/oauth-provider' import { betterAuth } from 'better-auth' import { drizzleAdapter } from 'better-auth/adapters/drizzle' import { createAuthMiddleware } from 'better-auth/api' -import { bearer, jwt } from 'better-auth/plugins' +import { bearer, jwt, magicLink } from 'better-auth/plugins' import { eq } from 'drizzle-orm' +import { ApiError } from '../utils/error' import { getAuthTrustedOrigins, getTrustedOrigin } from '../utils/origin' import * as authSchema from '../schemas/accounts' @@ -30,6 +32,16 @@ interface TrustedClientSeed { tokenEndpointAuthMethod: 'none' | 'client_secret_post' requirePKCE: boolean skipConsent: boolean + /** + * Enables RP-Initiated Logout via `/api/auth/oauth2/end-session`. + * + * NOTICE: also gates whether the issued ID token carries the `sid` claim + * (see oauth-provider/dist/index.mjs L308: `sid: client.enableEndSession ? sessionId : void 0`). + * `sid` is required by the end-session handler, so this flag is the single + * switch that lets a Bearer-only OIDC client log out without depending on + * cross-site session cookies. + */ + enableEndSession: boolean } export interface TrustedClientSeedSummary { @@ -126,6 +138,7 @@ function buildTrustedClientSeeds(env: Env): TrustedClientSeed[] { tokenEndpointAuthMethod: 'none', requirePKCE: true, skipConsent: true, + enableEndSession: true, }) // Electron desktop app — public client (installed app, PKCE only). @@ -145,6 +158,7 @@ function buildTrustedClientSeeds(env: Env): TrustedClientSeed[] { tokenEndpointAuthMethod: 'none', requirePKCE: true, skipConsent: true, + enableEndSession: true, }) // Capacitor mobile app — public client (no secret, PKCE only). @@ -163,6 +177,7 @@ function buildTrustedClientSeeds(env: Env): TrustedClientSeed[] { tokenEndpointAuthMethod: 'none', requirePKCE: true, skipConsent: true, + enableEndSession: true, }) return clients @@ -273,6 +288,7 @@ export async function seedTrustedClients(db: Database, env: Env): Promise tokenEndpointAuthMethod: seed.tokenEndpointAuthMethod, requirePKCE: seed.requirePKCE, skipConsent: seed.skipConsent, + enableEndSession: seed.enableEndSession, updatedAt: new Date(), } @@ -294,7 +310,28 @@ export async function seedTrustedClients(db: Database, env: Env): Promise } } -export function createAuth(db: Database, env: Env, metrics?: AuthMetrics | null) { +/** + * Throws when an email-driven Better Auth callback fires without an EmailService. + * + * NOTICE: + * `EmailService` is optional on `createAuth` so contexts that never exercise + * email flows (e.g. `pnpm run auth:generate` schema introspection) can run + * without a Resend key. Each callback that needs the service guards on it via + * `requireEmailService(email)`. The error is surfaced to the HTTP caller so + * the misconfiguration is loud instead of silent. + */ +function requireEmailService(email: EmailService | undefined): EmailService { + if (!email) { + throw new ApiError( + 503, + 'email/service_not_configured', + 'Email service not available in this server context.', + ) + } + return email +} + +export function createAuth(db: Database, env: Env, email?: EmailService, metrics?: AuthMetrics | null) { return betterAuth({ secret: env.BETTER_AUTH_SECRET, @@ -312,8 +349,24 @@ export function createAuth(db: Database, env: Env, metrics?: AuthMetrics | null) plugins: [ bearer(), jwt(), + magicLink({ + // NOTICE: better-auth's magic-link callback receives a server-side + // verification URL ({baseURL}/magic-link/verify?token=...&callbackURL=...). + // The user clicks → server validates → 302s to callbackURL with session + // cookie set. UI page only needs to receive the redirect; no token + // handling required there. + async sendMagicLink({ email: address, url }) { + await requireEmailService(email).sendMagicLink({ to: address, url }) + }, + }), oauthProvider({ - loginPage: '/sign-in', + // Keep loginPage inside the ui-server-auth vue-router base (`/auth/`) + // so the OIDC redirect lands on a URL the SPA router actually owns. + // Without the prefix the address bar stays on bare `/sign-in`, which + // is outside vue-router's history base — SPA-internal `router.push` + // later jumps to `/auth/...`, and a refresh of the bare URL would + // fall through to the global 404. + loginPage: '/auth/sign-in', consentPage: '/oauth/authorize', scopes: [...OIDC_SCOPES], validAudiences: [env.API_SERVER_URL], @@ -328,6 +381,50 @@ export function createAuth(db: Database, env: Env, metrics?: AuthMetrics | null) emailAndPassword: { enabled: true, + // Block sign-in until the user proves they own the address. Social + // logins (Google/GitHub) bypass this because better-auth seeds + // emailVerified=true for OAuth-issued accounts. + requireEmailVerification: true, + async sendResetPassword({ user, url }) { + await requireEmailService(email).sendPasswordReset({ to: user.email, url }) + }, + }, + + emailVerification: { + // Trigger sendVerificationEmail automatically on sign-up so the frontend + // doesn't need to make a follow-up call. requireEmailVerification above + // already enforces this on its own, but sendOnSignUp keeps behavior + // explicit if requireEmailVerification ever gets toggled off. + sendOnSignUp: true, + // NOTICE: Establish a session cookie when the user clicks the + // verification link, so they don't have to re-enter the password they + // just chose. The original tab (still on the verify-email pending page) + // detects the new session via polling and resumes the OIDC handoff. + // Source: node_modules/better-auth/dist/api/routes/email-verification.mjs L268+ + autoSignInAfterVerification: true, + async sendVerificationEmail({ user, url }) { + await requireEmailService(email).sendVerification({ to: user.email, url }) + }, + }, + + user: { + changeEmail: { + enabled: true, + // NOTICE: + // Better Auth fires sendChangeEmailConfirmation against the *current* + // email address before the change is committed. Send to user.email + // (current) so the owner of the existing account confirms the move; + // sending to newEmail would let an attacker who only controls newEmail + // confirm a takeover. + // Source: node_modules/better-auth/dist/api/routes/update-user.mjs L468-475 + async sendChangeEmailConfirmation({ user, newEmail, url }) { + await requireEmailService(email).sendChangeEmailConfirmation({ + to: user.email, + newEmail, + url, + }) + }, + }, }, session: { diff --git a/apps/server/src/libs/env.ts b/apps/server/src/libs/env.ts index b948ed158..54587a2f5 100644 --- a/apps/server/src/libs/env.ts +++ b/apps/server/src/libs/env.ts @@ -53,6 +53,15 @@ const EnvSchema = object({ AUTH_GITHUB_CLIENT_ID: pipe(string(), nonEmpty('AUTH_GITHUB_CLIENT_ID is required')), AUTH_GITHUB_CLIENT_SECRET: pipe(string(), nonEmpty('AUTH_GITHUB_CLIENT_SECRET is required')), + // Resend transactional email. RESEND_API_KEY required when emailAndPassword + // sign-up / forgot-password / change-email / magic-link is exercised. Service + // boots without it but those flows will throw at send-time. + RESEND_API_KEY: optional(string(), ''), + // From address must be a verified Resend sender (e.g. `noreply@your-domain`). + RESEND_FROM_EMAIL: optional(string(), 'noreply@airi.moeru.ai'), + // Optional friendly name; rendered as `Name ` per Resend's RFC 5322 display-name format. + RESEND_FROM_NAME: optional(string(), 'Project AIRI'), + STRIPE_SECRET_KEY: optional(string()), STRIPE_WEBHOOK_SECRET: optional(string()), diff --git a/apps/server/src/middlewares/auth.ts b/apps/server/src/middlewares/auth.ts index 7940a7b8b..83cc818af 100644 --- a/apps/server/src/middlewares/auth.ts +++ b/apps/server/src/middlewares/auth.ts @@ -19,12 +19,18 @@ type AuthInstance = ReturnType */ export function sessionMiddleware(auth: AuthInstance, env: Env): MiddlewareHandler { return async (c, next) => { - // NOTICE: auth routes handle session lookup inside better-auth itself. - // Running the global session middleware on `/api/auth/*`, `/sign-in`, and - // the auth discovery endpoints duplicates the same session read and slows - // the OIDC login path (`authorize` → `token` → `get-session`) noticeably. + // NOTICE: auth routes handle session lookup inside better-auth itself, + // and the ui-server-auth SPA bundle (HTML/JS/CSS + SPA routes like + // `/auth/sign-in`, `/auth/verify-email`, …) doesn't need a session + // attached either. Running the global session middleware on `/api/auth/*`, + // `/auth/*`, and the auth discovery endpoints duplicates the same session + // read and slows the OIDC login path (`authorize` → `token` → + // `get-session`) noticeably. + // + // `/auth/` and `/api/auth/` are distinct prefixes — `/api/auth/...` + // starts with `/api` and won't be matched by the `/auth/` startsWith. if ( - c.req.path === '/sign-in' + c.req.path.startsWith('/auth/') || c.req.path.startsWith('/api/auth/') || c.req.path === '/.well-known/oauth-authorization-server/api/auth' ) { diff --git a/apps/server/src/routes/auth/index.ts b/apps/server/src/routes/auth/index.ts index 0fb393899..66f30147a 100644 --- a/apps/server/src/routes/auth/index.ts +++ b/apps/server/src/routes/auth/index.ts @@ -6,15 +6,25 @@ import type { HonoEnv } from '../../types/hono' import { oauthProviderAuthServerMetadata, oauthProviderOpenIdConfigMetadata } from '@better-auth/oauth-provider' import { serveStatic } from '@hono/node-server/serve-static' +import { and, eq } from 'drizzle-orm' import { Hono } from 'hono' import { ensureDynamicFirstPartyRedirectUri } from '../../libs/auth' import { rateLimiter } from '../../middlewares/rate-limit' +import { account, user } from '../../schemas/accounts' +import { createBadRequestError } from '../../utils/error' import { getServerAuthUiDistDir, renderServerAuthUiHtml, SERVER_AUTH_UI_BASE_PATH } from '../../utils/server-auth-ui' import { createElectronCallbackRelay } from '../oidc/electron-callback' import { createOIDCTokenAuthRoute } from '../oidc/token-auth' -const RE_SERVER_AUTH_UI_BASE_PATH = /^\/_ui\/server-auth/ +// NOTICE: +// Loose RFC-5322-ish regex used to fail fast on obviously malformed input. +// Authoritative validation happens in better-auth on sign-in/sign-up; +// this is just a pre-flight gate for the email-first identifier step so we +// avoid hitting the DB with garbage. +const EMAIL_SHAPE_RE = /^[^\s@]+@[^\s@][^\s.@]*\.[^\s@]+$/ + +const RE_SERVER_AUTH_UI_BASE_PATH = /^\/auth/ export interface AuthRoutesDeps { auth: AuthInstance @@ -29,7 +39,7 @@ export interface AuthRoutesDeps { * well-known metadata endpoints. * * Mounted at the root level because routes span multiple prefixes - * (`/sign-in`, `/api/auth/*`, `/.well-known/*`). + * (`/auth/*`, `/api/auth/*`, `/.well-known/*`). */ export async function createAuthRoutes(deps: AuthRoutesDeps) { async function handleAuthRequest(request: Request): Promise { @@ -47,16 +57,21 @@ export async function createAuthRoutes(deps: AuthRoutesDeps) { rewriteRequestPath: (path: string) => path.replace(RE_SERVER_AUTH_UI_BASE_PATH, ''), })) /** - * Minimal login page for the OIDC Provider flow. - * When an unauthenticated user hits /api/auth/oauth2/authorize, - * better-auth redirects here. After the user signs in via a social - * provider, the social callback redirects to callbackURL which - * points back to the OIDC authorize endpoint. + * Login page for the OIDC Provider flow, served under the ui-server-auth + * vue-router base (`/auth/sign-in`). When an unauthenticated + * user hits `/api/auth/oauth2/authorize`, better-auth redirects here + * because of `oauthProvider({ loginPage })`. After the user signs in via + * a social provider, the social callback redirects to `callbackURL`, + * which points back to the OIDC authorize endpoint. * * If a `provider` query parameter is present (e.g. `?provider=github`), * skip the picker page and redirect directly to the social provider. + * + * Registered BEFORE the SPA `/auth/*` wildcard fallback so + * the provider shortcut gets a chance to short-circuit. Hono matches + * routes in registration order — specific path before wildcard wins. */ - .on('GET', '/sign-in', (c) => { + .on('GET', `${SERVER_AUTH_UI_BASE_PATH}/sign-in`, (c) => { const provider = c.req.query('provider') // Reconstruct the OIDC authorize URL from query params so the flow @@ -83,6 +98,27 @@ export async function createAuthRoutes(deps: AuthRoutesDeps) { currentUrl: c.req.url, })) }) + /** + * SPA fallback for the ui-server-auth bundle. + * + * vue-router runs with `createWebHistory('/auth/')`, so any + * client-side route — `/auth/verify-email`, + * `/auth/forgot-password`, `/auth/reset-password`, + * etc. — appears in the URL bar but has no matching file in the dist. + * Without this handler, deep-link hits (verification email links, page + * refresh on a SPA route, copy-pasted URLs) fall through `serveStatic` + * to the global 404 JSON. + * + * Mounted AFTER the static middleware so real assets under + * `/auth/assets/...` still resolve to the file on disk; + * `serveStatic` short-circuits on hits and only calls through on misses. + */ + .on('GET', `${SERVER_AUTH_UI_BASE_PATH}/*`, (c) => { + return c.html(renderServerAuthUiHtml({ + apiServerUrl: deps.env.API_SERVER_URL, + currentUrl: c.req.url, + })) + }) /** * Auth routes are handled by the auth instance directly, @@ -119,6 +155,53 @@ export async function createAuthRoutes(deps: AuthRoutesDeps) { .on('GET', '/api/auth/.well-known/openid-configuration', async (c) => { return oauthProviderOpenIdConfigMetadata(deps.auth)(c.req.raw) }) + /** + * Email-first identifier check. + * + * Powers the unified sign-in/up UI: the user types an email, the UI calls + * this to decide whether to render a password input (existing user with + * a credential account) or the new-account form (or steer them to a + * social provider when only social accounts exist). + * + * Returns: + * - `exists`: a `user` row matches the email (case-insensitive). + * - `hasPassword`: that user has an account row with `providerId='credential'`, + * i.e. can sign in via email + password (vs. social-only). + * + * Account-enumeration tradeoff: this confirms whether an email is + * registered, mirroring the standard set by Google/Linear/Notion. We + * accept the disclosure since the existing rate limiter applied to + * `/api/auth/*` (`AUTH_RATE_LIMIT_MAX` per IP per window) already throttles + * enumeration attempts. + */ + .on('POST', '/api/auth/check-email', async (c) => { + const body = await c.req.json().catch(() => null) as { email?: unknown } | null + const raw = typeof body?.email === 'string' ? body.email.trim() : '' + const email = raw.toLowerCase() + + if (!email || !EMAIL_SHAPE_RE.test(email)) + throw createBadRequestError('Invalid email', 'INVALID_EMAIL') + + const [matched] = await deps.db + .select({ id: user.id }) + .from(user) + .where(eq(user.email, email)) + .limit(1) + + if (!matched) + return c.json({ exists: false, hasPassword: false }) + + const [credential] = await deps.db + .select({ id: account.id }) + .from(account) + .where(and( + eq(account.userId, matched.id), + eq(account.providerId, 'credential'), + )) + .limit(1) + + return c.json({ exists: true, hasPassword: !!credential }) + }) .on(['POST', 'GET'], '/api/auth/*', async (c) => { return handleAuthRequest(c.req.raw) }) diff --git a/apps/server/src/scripts/auth.ts b/apps/server/src/scripts/auth.ts index 48f12b1e6..4f8e136ed 100644 --- a/apps/server/src/scripts/auth.ts +++ b/apps/server/src/scripts/auth.ts @@ -5,4 +5,9 @@ import { createDrizzle } from '../libs/db' import { parseEnv } from '../libs/env' const env = parseEnv(process.env) + +// NOTICE: +// `better-auth generate` only introspects the auth instance's schema — it never +// fires the email callbacks. Pass no EmailService; createAuth's email-aware +// callbacks throw if invoked, but introspection never reaches them. export default createAuth(createDrizzle(env).db, env) diff --git a/apps/server/src/services/email.ts b/apps/server/src/services/email.ts new file mode 100644 index 000000000..38a94c994 --- /dev/null +++ b/apps/server/src/services/email.ts @@ -0,0 +1,276 @@ +import type { Logger } from '@guiiai/logg' + +import { useLogger } from '@guiiai/logg' +import { errorMessageFrom } from '@moeru/std' +import { Resend } from 'resend' + +import { ApiError } from '../utils/error' + +/** + * Outbound email payload accepted by {@link EmailService.send}. + * + * Use when: + * - Building a higher-level transactional template (verification, reset, magic link, change-email). + * + * Expects: + * - Both `html` and `text` set so deliverability scoring stays high (text fallback + * is what spam filters score when HTML is hostile or stripped). + * - `to` is already validated by Better Auth (we trust caller for internal flows). + */ +export interface EmailPayload { + /** Recipient address. Single address — Better Auth callbacks always emit one. */ + to: string + /** Subject line. Plain text. */ + subject: string + /** HTML body. */ + html: string + /** Plain-text body. Required for spam-filter parity and accessibility. */ + text: string +} + +/** + * Email service abstraction shared by all Better Auth callbacks. + * + * Use when: + * - Wiring `sendVerificationEmail` / `sendResetPassword` / `sendMagicLink` / + * `sendChangeEmailConfirmation` in `createAuth()`. + * + * Expects: + * - Service is constructed once per process by `injeca` and shared across requests. + * + * Returns: + * - A `send` method plus four high-level helpers that own subject/body composition. + */ +export interface EmailService { + send: (payload: EmailPayload) => Promise + sendVerification: (params: { to: string, url: string }) => Promise + sendPasswordReset: (params: { to: string, url: string }) => Promise + sendMagicLink: (params: { to: string, url: string }) => Promise + sendChangeEmailConfirmation: (params: { to: string, newEmail: string, url: string }) => Promise +} + +interface EmailConfig { + apiKey: string + fromEmail: string + fromName?: string +} + +/** + * Format an RFC 5322 display-name + address pair for the `From` header. + * + * Before: + * - `{ fromEmail: 'noreply@a.io', fromName: 'AIRI' }` + * + * After: + * - `'AIRI '` + */ +function formatFrom(config: EmailConfig): string { + if (config.fromName) + return `${config.fromName} <${config.fromEmail}>` + return config.fromEmail +} + +/** + * Construct the email service. + * + * Use when: + * - DI assembly in `apps/server/src/app.ts`. + * + * Expects: + * - `RESEND_API_KEY` is set in env. When empty, `send` throws an `ApiError` + * instead of silently dropping mail — Better Auth surfaces it back to the + * caller so frontend can show a clear "email service not configured" error. + */ +export function createEmailService(config: EmailConfig, logger: Logger = useLogger('email')): EmailService { + // NOTICE: + // Construct Resend lazily so the server can boot in environments where the + // RESEND_API_KEY is intentionally empty (e.g. local dev that never exercises + // email flows). Calls to `send` will throw, which Better Auth surfaces. + // Root cause summary: Resend's constructor logs but does not throw on empty + // keys; explicit guard keeps the failure mode visible at the call site. + // Source: node_modules/.pnpm/resend@*/node_modules/resend/dist/index.cjs + // Removal condition: when we make RESEND_API_KEY required at env-parse time. + let client: Resend | null = null + function getClient(): Resend { + if (!client) { + if (!config.apiKey) { + throw new ApiError( + 503, + 'email/service_not_configured', + 'Email service not configured (RESEND_API_KEY is missing).', + ) + } + client = new Resend(config.apiKey) + } + return client + } + + const from = formatFrom(config) + + async function send(payload: EmailPayload): Promise { + try { + const { error } = await getClient().emails.send({ + from, + to: [payload.to], + subject: payload.subject, + html: payload.html, + text: payload.text, + }) + + if (error) { + logger.withFields({ to: payload.to, subject: payload.subject, errorName: error.name }).error(error.message) + throw new ApiError(502, 'email/send_failed', error.message, { providerError: error.name }) + } + } + catch (error) { + if (error instanceof ApiError) + throw error + + const message = errorMessageFrom(error) ?? 'Unknown email send error' + logger.withFields({ to: payload.to, subject: payload.subject }).error(message) + throw new ApiError(502, 'email/send_failed', message) + } + } + + return { + send, + async sendVerification({ to, url }) { + await send({ + to, + subject: 'Verify your email for Project AIRI', + html: renderVerificationHtml(url), + text: renderVerificationText(url), + }) + }, + async sendPasswordReset({ to, url }) { + await send({ + to, + subject: 'Reset your Project AIRI password', + html: renderPasswordResetHtml(url), + text: renderPasswordResetText(url), + }) + }, + async sendMagicLink({ to, url }) { + await send({ + to, + subject: 'Your Project AIRI sign-in link', + html: renderMagicLinkHtml(url), + text: renderMagicLinkText(url), + }) + }, + async sendChangeEmailConfirmation({ to, newEmail, url }) { + await send({ + to, + subject: 'Confirm your new email address for Project AIRI', + html: renderChangeEmailHtml(url, newEmail), + text: renderChangeEmailText(url, newEmail), + }) + }, + } +} + +// NOTICE: +// Templates are intentionally minimal inline HTML. Goal here is functional +// delivery + plaintext fallback. Visual design is deferred (see +// docs/ai/context/email-auth-resend.md "不做" section). + +function escapeHtml(value: string): string { + return value + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, ''') +} + +function renderActionEmailHtml(args: { heading: string, body: string, ctaLabel: string, url: string, footer: string }): string { + const safeUrl = escapeHtml(args.url) + return ` + +

${escapeHtml(args.heading)}

+

${escapeHtml(args.body)}

+

${escapeHtml(args.ctaLabel)}

+

If the button doesn't work, copy this URL into your browser:
${safeUrl}

+

${escapeHtml(args.footer)}

+` +} + +function renderActionEmailText(args: { heading: string, body: string, url: string, footer: string }): string { + return `${args.heading}\n\n${args.body}\n\n${args.url}\n\n${args.footer}\n` +} + +function renderVerificationHtml(url: string): string { + return renderActionEmailHtml({ + heading: 'Verify your email', + body: 'Welcome to Project AIRI. Click the button below to confirm this is your email address.', + ctaLabel: 'Verify email', + url, + footer: 'If you did not create an account, you can safely ignore this email.', + }) +} + +function renderVerificationText(url: string): string { + return renderActionEmailText({ + heading: 'Verify your email', + body: 'Welcome to Project AIRI. Open this link to confirm your email address:', + url, + footer: 'If you did not create an account, you can safely ignore this email.', + }) +} + +function renderPasswordResetHtml(url: string): string { + return renderActionEmailHtml({ + heading: 'Reset your password', + body: 'We received a request to reset the password for your Project AIRI account.', + ctaLabel: 'Reset password', + url, + footer: 'If you did not request this, you can safely ignore this email — your password will not change.', + }) +} + +function renderPasswordResetText(url: string): string { + return renderActionEmailText({ + heading: 'Reset your password', + body: 'Open this link to reset your Project AIRI password:', + url, + footer: 'If you did not request this, you can safely ignore this email — your password will not change.', + }) +} + +function renderMagicLinkHtml(url: string): string { + return renderActionEmailHtml({ + heading: 'Sign in to Project AIRI', + body: 'Click the button below to sign in. This link expires shortly and can be used once.', + ctaLabel: 'Sign in', + url, + footer: 'If you did not request this link, you can safely ignore this email.', + }) +} + +function renderMagicLinkText(url: string): string { + return renderActionEmailText({ + heading: 'Sign in to Project AIRI', + body: 'Open this link to sign in (single-use, expires shortly):', + url, + footer: 'If you did not request this link, you can safely ignore this email.', + }) +} + +function renderChangeEmailHtml(url: string, newEmail: string): string { + return renderActionEmailHtml({ + heading: 'Confirm your new email', + body: `Confirm that ${newEmail} should become your Project AIRI account email.`, + ctaLabel: 'Confirm new email', + url, + footer: 'If you did not request this change, contact support immediately.', + }) +} + +function renderChangeEmailText(url: string, newEmail: string): string { + return renderActionEmailText({ + heading: 'Confirm your new email', + body: `Confirm that ${newEmail} should become your Project AIRI account email by opening this link:`, + url, + footer: 'If you did not request this change, contact support immediately.', + }) +} diff --git a/apps/server/src/utils/error-message.ts b/apps/server/src/utils/error-message.ts index 822b88508..2b3ceceda 100644 --- a/apps/server/src/utils/error-message.ts +++ b/apps/server/src/utils/error-message.ts @@ -3,6 +3,6 @@ import { errorMessageFrom } from '@moeru/std' /** * Returns a stable human-readable message for unknown errors. */ -export function errorMessageFromUnknown(error: unknown): string { - return errorMessageFrom(error) ?? 'Unknown error' +export function errorMessageFromUnknown(error: unknown, unknownMessage?: string): string { + return errorMessageFrom(error) ?? unknownMessage ?? 'Unknown error' } diff --git a/apps/server/src/utils/origin.ts b/apps/server/src/utils/origin.ts index fa9c90520..d6987a82a 100644 --- a/apps/server/src/utils/origin.ts +++ b/apps/server/src/utils/origin.ts @@ -57,6 +57,23 @@ export function resolveTrustedRequestOrigin(request: Request): string | undefine return undefined } +// NOTICE: +// Better Auth's callbackURL validation walks `trustedOrigins`. Static entries +// support `*` wildcards via the framework's wildcardMatch (see +// node_modules/better-auth/dist/auth/trusted-origins.mjs). Loopback origins +// across any port are allowed so dev (Vite at :5173/:5174/:4173, electron +// loopback OAuth at :random_port) and prod (where these addresses are +// unreachable) share the same config. The pattern is intentionally broad — +// loopback is unreachable from the public internet, so any origin that +// resolves to localhost is by definition the same machine the user is on. +// +// Removal condition: when dev serves UI from the same origin as the API +// (e.g. via vite proxy or static mount), drop these entries. +const ALWAYS_TRUSTED_AUTH_ORIGINS = [ + 'http://localhost:*', + 'http://127.0.0.1:*', +] + export function getAuthTrustedOrigins(env: Pick, request?: Request): string[] { const origins = new Set() const apiServerOrigin = getOriginFromUrl(env.API_SERVER_URL) @@ -64,6 +81,10 @@ export function getAuthTrustedOrigins(env: Pick, request? origins.add(apiServerOrigin) } + for (const origin of ALWAYS_TRUSTED_AUTH_ORIGINS) { + origins.add(origin) + } + if (request) { const requestOrigin = resolveTrustedRequestOrigin(request) if (requestOrigin) { diff --git a/apps/server/src/utils/server-auth-ui.ts b/apps/server/src/utils/server-auth-ui.ts index d506f0814..4e5160940 100644 --- a/apps/server/src/utils/server-auth-ui.ts +++ b/apps/server/src/utils/server-auth-ui.ts @@ -1,7 +1,7 @@ import { readFileSync } from 'node:fs' import { fileURLToPath } from 'node:url' -export const SERVER_AUTH_UI_BASE_PATH = '/_ui/server-auth' +export const SERVER_AUTH_UI_BASE_PATH = '/auth' const SERVER_AUTH_UI_DIST_DIR = fileURLToPath(new URL('../../public/ui-server-auth', import.meta.url)) const SERVER_AUTH_UI_INDEX_HTML_PATH = fileURLToPath(new URL('../../public/ui-server-auth/index.html', import.meta.url)) diff --git a/apps/stage-web/src/pages/auth/callback.vue b/apps/stage-web/src/pages/auth/callback.vue index 4add31dcd..e8437b631 100644 --- a/apps/stage-web/src/pages/auth/callback.vue +++ b/apps/stage-web/src/pages/auth/callback.vue @@ -1,5 +1,6 @@ diff --git a/apps/stage-web/src/pages/auth/sign-in.vue b/apps/stage-web/src/pages/auth/sign-in.vue deleted file mode 100644 index 79cf1723b..000000000 --- a/apps/stage-web/src/pages/auth/sign-in.vue +++ /dev/null @@ -1,108 +0,0 @@ - - - diff --git a/apps/stage-web/vite.config.ts b/apps/stage-web/vite.config.ts index c9c8fbe57..b2a1a5b5d 100644 --- a/apps/stage-web/vite.config.ts +++ b/apps/stage-web/vite.config.ts @@ -154,6 +154,18 @@ export default defineConfig({ Unocss(), // https://github.com/antfu/vite-plugin-pwa + // NOTICE: + // The plugin must stay registered in dev — `src/modules/pwa.ts` imports + // the `virtual:pwa-register` module the plugin synthesises, and dropping + // the plugin breaks Vite's import-analysis with a "Failed to resolve + // import" error. + // SW generation in dev is already disabled by `devOptions.enabled: + // false` (the plugin's own default). So new SWs do NOT register from + // `pnpm dev` alone — but a previously-registered SW (e.g. from an + // earlier `vite preview` / `vite build`) lives on per-origin in the + // browser and keeps intercepting fetches even in dev. To recover from + // that state, unregister via DevTools → Application → Storage → Clear + // site data. ...(env.TARGET_HUGGINGFACE_SPACE ? [] : [VitePWA({ diff --git a/apps/ui-server-auth/src/main.ts b/apps/ui-server-auth/src/main.ts index 2e7005e77..d6ef28fc6 100644 --- a/apps/ui-server-auth/src/main.ts +++ b/apps/ui-server-auth/src/main.ts @@ -29,9 +29,9 @@ const routeRecords = setupLayouts(routes as RouteRecordRaw[]) let router: Router if (isEnvTruthy(import.meta.env.VITE_APP_TARGET_HUGGINGFACE_SPACE)) - router = createRouter({ routes: routeRecords, history: createWebHashHistory('/_ui/server-auth/') }) + router = createRouter({ routes: routeRecords, history: createWebHashHistory('/auth/') }) else - router = createRouter({ routes: routeRecords, history: createWebHistory('/_ui/server-auth/') }) + router = createRouter({ routes: routeRecords, history: createWebHistory('/auth/') }) router.beforeEach((to, from) => { if (to.path !== from.path) diff --git a/apps/ui-server-auth/src/modules/auth-fetch.ts b/apps/ui-server-auth/src/modules/auth-fetch.ts new file mode 100644 index 000000000..df7958b00 --- /dev/null +++ b/apps/ui-server-auth/src/modules/auth-fetch.ts @@ -0,0 +1,156 @@ +/** + * Shared HTTP plumbing for the ui-server-auth → apps/server auth surface. + * + * Use when: + * - Hitting any `/api/auth/...` endpoint from the UI (sign-in, sign-up, + * forgot-password, reset-password, social redirects). + * + * Expects: + * - Caller passes `apiServerUrl` so dev (`http://localhost:3000`) and prod + * (`https://api.airi.build`) share the same modules. + * - All requests go out with `credentials: 'include'`. The OIDC handoff + * downstream of email/password sign-in needs the better-auth session + * cookie. The stage-ui `authClient` uses Bearer-only and so cannot drive + * these flows directly. + * + * Returns: + * - Plain async functions; throw `Error` with the server-supplied message on + * non-2xx so caller views see the real reason instead of a generic banner. + */ + +/** + * Common shape for any function in this module that needs to talk to the + * auth server. + */ +export interface AuthFetchBase { + apiServerUrl: string + fetchImpl?: typeof fetch +} + +/** + * POST a JSON body to `/api/auth` and parse the response with `parse`. + * + * Use when: + * - You need a typed wrapper around a Better Auth POST endpoint that + * responds with JSON on both success and failure (the common case). + * + * Expects: + * - `path` includes the leading slash (e.g. `/sign-in/email`). + * - `parse` runs only on 2xx responses; on non-2xx the wrapper throws. + * + * Returns: + * - Whatever `parse` returns. Never returns on non-2xx — throws an `Error` + * carrying the server's `message` / `error.message` field. + */ +export async function postAuthJSON( + base: AuthFetchBase, + path: string, + body: Record, + parse: (data: unknown, response: Response) => T, +): Promise { + const fetchImpl = base.fetchImpl ?? fetch + const endpoint = new URL(`/api/auth${path}`, base.apiServerUrl) + + const response = await fetchImpl(endpoint.toString(), { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + credentials: 'include', + }) + + let data: unknown + try { + data = await response.json() + } + catch { + data = null + } + + if (!response.ok) { + throw new Error(extractAuthError(data) ?? `Auth request failed (${response.status})`) + } + + return parse(data, response) +} + +/** + * GET `/api/auth` and parse the response with `parse`. + * + * Use when: + * - Reading a Better Auth GET endpoint (e.g. `/get-session`) from the UI and + * you want the same `credentials: include` + error-shape handling as + * {@link postAuthJSON}. + * + * Expects: + * - `path` starts with a leading slash. + * - `parse` runs only on 2xx responses; non-2xx throws with the server message. + * + * Returns: + * - Whatever `parse` returns. Throws an `Error` on non-2xx with the server's + * `message` / `error.message` field when present. + */ +export async function getAuthJSON( + base: AuthFetchBase, + path: string, + parse: (data: unknown, response: Response) => T, +): Promise { + const fetchImpl = base.fetchImpl ?? fetch + const endpoint = new URL(`/api/auth${path}`, base.apiServerUrl) + + const response = await fetchImpl(endpoint.toString(), { + method: 'GET', + credentials: 'include', + }) + + let data: unknown + try { + data = await response.json() + } + catch { + data = null + } + + if (!response.ok) { + throw new Error(extractAuthError(data) ?? `Auth request failed (${response.status})`) + } + + return parse(data, response) +} + +/** + * Pull a human-readable error string out of a Better Auth JSON error response. + * + * Before: + * - `{ "message": "Invalid credentials", "code": "INVALID_CREDENTIALS" }` + * - `{ "error": { "message": "Token expired" } }` + * - `{ "error": "Rate limit" }` + * + * After: + * - `"Invalid credentials"` / `"Token expired"` / `"Rate limit"` + * + * Returns `null` when the payload has no message-like field, leaving the + * caller to fall back to a status-code-only message. + */ +export function extractAuthError(data: unknown): string | null { + if (!data || typeof data !== 'object') + return null + + const maybe = data as { error?: unknown, message?: unknown } + if (typeof maybe.message === 'string') + return maybe.message + + const error = maybe.error + if (typeof error === 'string') + return error + + if ( + error + && typeof error === 'object' + && 'message' in error + && typeof (error as { message: unknown }).message === 'string' + ) { + return (error as { message: string }).message + } + + return null +} diff --git a/apps/ui-server-auth/src/modules/email-password.ts b/apps/ui-server-auth/src/modules/email-password.ts new file mode 100644 index 000000000..538d0e6a0 --- /dev/null +++ b/apps/ui-server-auth/src/modules/email-password.ts @@ -0,0 +1,182 @@ +/** + * Email + password auth flows backed by better-auth's built-in routes. + * + * Use when: + * - Driving sign-in / sign-up / forgot-password / reset-password forms in + * the OIDC login UI (`apps/ui-server-auth`). + * + * Each function shares the {@link AuthFetchBase} contract via auth-fetch.ts; + * see that module for HTTP-level expectations (credentials, error parsing). + */ + +import type { AuthFetchBase } from './auth-fetch' + +import { errorMessageFrom } from '@moeru/std' + +import { postAuthJSON } from './auth-fetch' + +interface CheckEmailArgs extends AuthFetchBase { + email: string +} + +/** + * Result of the email-first identifier probe. + * + * Drives whether the unified UI shows the password field (existing + * credential user), the create-account fields (new email), or steers the + * user toward a social provider (existing social-only user). + */ +export interface CheckEmailResult { + /** A user row matches this email (case-insensitive). */ + exists: boolean + /** That user has a `credential` account, i.e. can sign in via password. */ + hasPassword: boolean +} + +interface EmailSignInArgs extends AuthFetchBase { + email: string + password: string + callbackURL?: string + /** @default true */ + rememberMe?: boolean +} + +interface EmailSignUpArgs extends AuthFetchBase { + email: string + password: string + name: string + callbackURL?: string +} + +interface RequestPasswordResetArgs extends AuthFetchBase { + email: string + /** + * Frontend page that better-auth redirects to with `?token=...` after + * validating the email link. + */ + redirectTo: string +} + +interface ResetPasswordArgs extends AuthFetchBase { + newPassword: string + token: string +} + +interface SignInResult { + /** Set when better-auth allows browser to follow the OIDC redirect itself. */ + redirectURL: string | null + /** + * True if email verification is still pending; UI should route to + * the `verify-email` notice page. + */ + requiresVerification: boolean +} + +interface SignUpResult { + /** + * True when sendOnSignUp / requireEmailVerification fired; UI shows + * `please check inbox` instead of an immediate session. + */ + requiresVerification: boolean +} + +/** + * Probe whether an email is already registered before showing password / sign-up fields. + * + * Use when: + * - Implementing the email-first identifier step on the unified sign-in page. + * + * Expects: + * - `email` is the raw user input; the server normalizes (trim + lowercase). + * + * Returns: + * - {@link CheckEmailResult} indicating existence and whether a credential + * account is attached. UI uses these to pick the second step. + */ +export async function checkEmail(args: CheckEmailArgs): Promise { + return postAuthJSON( + args, + '/check-email', + { email: args.email }, + (data) => { + const exists = Boolean((data as { exists?: unknown })?.exists) + const hasPassword = Boolean((data as { hasPassword?: unknown })?.hasPassword) + return { exists, hasPassword } + }, + ) +} + +export async function signInWithEmail(args: EmailSignInArgs): Promise { + return postAuthJSON( + args, + '/sign-in/email', + { + email: args.email, + password: args.password, + callbackURL: args.callbackURL, + rememberMe: args.rememberMe ?? true, + }, + (data) => { + const url = typeof (data as { url?: unknown })?.url === 'string' + ? (data as { url: string }).url + : null + // NOTICE: + // better-auth surfaces `requiresEmailVerification` (rather than throwing) + // when emailAndPassword.requireEmailVerification is true and the user is + // not yet verified. Frontend uses this to route into the `verify-email` + // notice page instead of bouncing to the OIDC callback. + // Source: node_modules/better-auth/dist/api/routes/sign-in.mjs L235+ + const requiresVerification = Boolean( + (data as { requiresEmailVerification?: unknown })?.requiresEmailVerification, + ) + return { redirectURL: url, requiresVerification } + }, + ) +} + +export async function signUpWithEmail(args: EmailSignUpArgs): Promise { + return postAuthJSON( + args, + '/sign-up/email', + { + email: args.email, + password: args.password, + name: args.name, + callbackURL: args.callbackURL, + }, + (data) => { + // When verification is required, better-auth returns `{ token: null, user: ... }` + // and queues the verification email; otherwise it returns a session token. + const token = (data as { token?: unknown })?.token + return { requiresVerification: token === null || token === undefined } + }, + ) +} + +export async function requestPasswordReset(args: RequestPasswordResetArgs): Promise { + await postAuthJSON( + args, + '/request-password-reset', + { email: args.email, redirectTo: args.redirectTo }, + () => undefined, + ) +} + +export async function resetPasswordWithToken(args: ResetPasswordArgs): Promise { + // NOTICE: + // /reset-password takes the token from the query string in addition to + // the JSON body — the body alone is not enough. Encode it in both spots + // so we match the better-auth contract regardless of which one the + // current version reads. + // Source: node_modules/better-auth/dist/api/routes/password.mjs L120+ + await postAuthJSON( + args, + `/reset-password?token=${encodeURIComponent(args.token)}`, + { newPassword: args.newPassword, token: args.token }, + () => undefined, + ) +} + +export function describeAuthError(error: unknown): string { + return errorMessageFrom(error) ?? 'Unexpected error' +} diff --git a/apps/ui-server-auth/src/modules/profile.test.ts b/apps/ui-server-auth/src/modules/profile.test.ts new file mode 100644 index 000000000..c4c72265b --- /dev/null +++ b/apps/ui-server-auth/src/modules/profile.test.ts @@ -0,0 +1,125 @@ +import { describe, expect, it, vi } from 'vitest' + +import { changePassword, getCurrentSession, signOut, updateUserProfile } from './profile' + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { 'Content-Type': 'application/json' }, + }) +} + +describe('ui-server-auth profile flow helpers', () => { + it('parses the better-auth get-session response into a flat user shape', async () => { + const fetchImpl = vi.fn(async () => jsonResponse({ + session: { id: 'sess-1' }, + user: { + id: 'user-1', + name: 'Alice', + email: 'alice@example.test', + emailVerified: true, + image: 'https://cdn.example.test/avatar.png', + createdAt: '2025-04-01T00:00:00.000Z', + // Field intentionally not in ProfileUser — must be ignored. + twoFactorEnabled: true, + }, + })) + + await expect(getCurrentSession({ + apiServerUrl: 'https://api.airi.test', + fetchImpl, + })).resolves.toEqual({ + user: { + id: 'user-1', + name: 'Alice', + email: 'alice@example.test', + emailVerified: true, + image: 'https://cdn.example.test/avatar.png', + createdAt: '2025-04-01T00:00:00.000Z', + }, + }) + + expect(fetchImpl).toHaveBeenCalledTimes(1) + expect(fetchImpl).toHaveBeenCalledWith( + 'https://api.airi.test/api/auth/get-session', + expect.objectContaining({ method: 'GET', credentials: 'include' }), + ) + }) + + it('returns user=null when better-auth reports no session', async () => { + const fetchImpl = vi.fn(async () => jsonResponse(null)) + + await expect(getCurrentSession({ + apiServerUrl: 'https://api.airi.test', + fetchImpl, + })).resolves.toEqual({ user: null }) + }) + + it('omits undefined fields from the update-user body', async () => { + const fetchImpl = vi.fn(async () => jsonResponse({ status: true })) + + await updateUserProfile({ + apiServerUrl: 'https://api.airi.test', + fetchImpl, + name: 'Alice Renamed', + }) + + const init = fetchImpl.mock.calls[0]?.[1] + expect(JSON.parse(String(init?.body))).toEqual({ name: 'Alice Renamed' }) + }) + + it('passes image=null through so callers can clear avatars explicitly', async () => { + const fetchImpl = vi.fn(async () => jsonResponse({ status: true })) + + await updateUserProfile({ + apiServerUrl: 'https://api.airi.test', + fetchImpl, + image: null, + }) + + const init = fetchImpl.mock.calls[0]?.[1] + expect(JSON.parse(String(init?.body))).toEqual({ image: null }) + }) + + it('defaults change-password to revoking other sessions', async () => { + const fetchImpl = vi.fn(async () => jsonResponse({ status: true })) + + await changePassword({ + apiServerUrl: 'https://api.airi.test', + fetchImpl, + currentPassword: 'old-pw', + newPassword: 'new-pw', + }) + + const init = fetchImpl.mock.calls[0]?.[1] + expect(JSON.parse(String(init?.body))).toEqual({ + currentPassword: 'old-pw', + newPassword: 'new-pw', + revokeOtherSessions: true, + }) + }) + + it('surfaces server-side error messages for change-password', async () => { + const fetchImpl = vi.fn(async () => jsonResponse({ + message: 'Invalid current password', + }, 400)) + + await expect(changePassword({ + apiServerUrl: 'https://api.airi.test', + fetchImpl, + currentPassword: 'wrong', + newPassword: 'new-pw', + })).rejects.toThrow('Invalid current password') + }) + + it('posts to /sign-out with credentials included', async () => { + const fetchImpl = vi.fn(async () => jsonResponse({ success: true })) + + await signOut({ apiServerUrl: 'https://api.airi.test', fetchImpl }) + + expect(fetchImpl).toHaveBeenCalledWith( + 'https://api.airi.test/api/auth/sign-out', + expect.objectContaining({ method: 'POST', credentials: 'include' }), + ) + }) +}) diff --git a/apps/ui-server-auth/src/modules/profile.ts b/apps/ui-server-auth/src/modules/profile.ts new file mode 100644 index 000000000..c32f0dfc4 --- /dev/null +++ b/apps/ui-server-auth/src/modules/profile.ts @@ -0,0 +1,174 @@ +/** + * Account profile flows backed by better-auth's built-in user routes. + * + * Use when: + * - Driving the profile page in `apps/ui-server-auth` (load current user, + * update display name, change password, sign out). + * + * Each function shares the {@link AuthFetchBase} contract via auth-fetch.ts; + * see that module for HTTP-level expectations (credentials, error parsing). + */ + +import type { AuthFetchBase } from './auth-fetch' + +import { errorMessageFrom } from '@moeru/std' + +import { getAuthJSON, postAuthJSON } from './auth-fetch' + +/** + * Subset of the better-auth `user` row needed to render the profile page. + * + * Mirrors the shape returned by `/api/auth/get-session`; extra fields are + * ignored intentionally so this module doesn't drift if better-auth adds + * unrelated columns. + */ +export interface ProfileUser { + id: string + /** Display name set on sign-up or via {@link updateUserProfile}. */ + name: string + email: string + /** True once the user clicked the verification link sent on sign-up. */ + emailVerified: boolean + /** Avatar URL — usually populated by social providers; may be empty. */ + image: string | null + /** ISO timestamp from `created_at`. */ + createdAt: string | null +} + +/** + * Result of a `/get-session` probe. + * + * `user` is `null` when no session cookie is present (or it expired). Caller + * uses that to redirect to the sign-in page instead of rendering the form. + */ +export interface CurrentSessionResult { + user: ProfileUser | null +} + +interface UpdateUserProfileArgs extends AuthFetchBase { + /** Trim before passing — server stores the value as-is. */ + name?: string + /** Optional avatar URL. Pass `null` to clear it. */ + image?: string | null +} + +interface ChangePasswordArgs extends AuthFetchBase { + currentPassword: string + newPassword: string + /** + * Revoke other active sessions after password change. + * + * @default true + */ + revokeOtherSessions?: boolean +} + +/** + * Read the current session from `/api/auth/get-session`. + * + * Use when: + * - Bootstrapping the profile page; decides whether to render the form or + * bounce the user to the sign-in page. + * + * Expects: + * - Browser sends the better-auth session cookie (`credentials: include`). + * + * Returns: + * - `user: null` when there's no active session (better-auth returns an empty + * body for unauthenticated GETs). + * - {@link CurrentSessionResult} with the trimmed user fields otherwise. + */ +export async function getCurrentSession(args: AuthFetchBase): Promise { + return getAuthJSON(args, '/get-session', (data) => { + // NOTICE: + // better-auth returns either `null` or an empty object for an + // unauthenticated GET to `/get-session`, not a 401. Treat both as + // "no session" so the caller can branch on user === null without a + // separate try/catch. + // Source: node_modules/better-auth/dist/api/routes/session.mjs (`getSession`) + if (!data || typeof data !== 'object' || !('user' in data) || !data.user) + return { user: null } + + const raw = (data as { user: unknown }).user as Record + const user: ProfileUser = { + id: typeof raw.id === 'string' ? raw.id : '', + name: typeof raw.name === 'string' ? raw.name : '', + email: typeof raw.email === 'string' ? raw.email : '', + emailVerified: Boolean(raw.emailVerified), + image: typeof raw.image === 'string' ? raw.image : null, + createdAt: typeof raw.createdAt === 'string' ? raw.createdAt : null, + } + return { user } + }) +} + +/** + * Update the signed-in user's display name and/or avatar. + * + * Use when: + * - Saving the "display name" form on the profile page. + * + * Expects: + * - Caller has already trimmed `name` and confirmed it's non-empty. + * - `image` is either an absolute URL or `null` (clear). + * + * Returns: + * - Resolves on 2xx; throws with the better-auth error message otherwise. + */ +export async function updateUserProfile(args: UpdateUserProfileArgs): Promise { + const body: Record = {} + if (args.name !== undefined) + body.name = args.name + if (args.image !== undefined) + body.image = args.image + + await postAuthJSON(args, '/update-user', body, () => undefined) +} + +/** + * Change the signed-in user's password using their current credential. + * + * Use when: + * - User is signed in and wants to rotate their password from the profile + * page (not the forgot-password email flow). + * + * Expects: + * - The user has a `credential` account; social-only users get a server-side + * error which surfaces as a thrown `Error` here. + * + * Returns: + * - Resolves on 2xx. By default, all other sessions are revoked + * (`revokeOtherSessions = true`) so a stolen old session can't keep + * working after a forced rotation. + */ +export async function changePassword(args: ChangePasswordArgs): Promise { + await postAuthJSON( + args, + '/change-password', + { + currentPassword: args.currentPassword, + newPassword: args.newPassword, + revokeOtherSessions: args.revokeOtherSessions ?? true, + }, + () => undefined, + ) +} + +/** + * Sign the current user out via `/api/auth/sign-out`. + * + * Use when: + * - User clicks "Sign out" on the profile page. + * + * Returns: + * - Resolves once the better-auth session cookie has been cleared by the + * server. Caller is expected to navigate the user back to the sign-in + * page after this resolves. + */ +export async function signOut(args: AuthFetchBase): Promise { + await postAuthJSON(args, '/sign-out', {}, () => undefined) +} + +export function describeProfileError(error: unknown): string { + return errorMessageFrom(error) ?? 'Unexpected error' +} diff --git a/apps/ui-server-auth/src/modules/sign-in.ts b/apps/ui-server-auth/src/modules/sign-in.ts index 3a4ccae37..0bb9730de 100644 --- a/apps/ui-server-auth/src/modules/sign-in.ts +++ b/apps/ui-server-auth/src/modules/sign-in.ts @@ -1,5 +1,7 @@ import type { OAuthProvider } from '@proj-airi/stage-ui/libs/auth' +import { extractAuthError } from './auth-fetch' + export interface ServerSignInContext { callbackURL: string requestedProvider: string | null @@ -54,23 +56,10 @@ export async function requestSocialSignInRedirect(params: SocialSignInRedirectPa return response.headers.get('location') || '/' } - const data = await response.json() as { - url?: unknown - error?: unknown - } + const data = await response.json() as { url?: unknown } if (typeof data.url === 'string') return data.url - throw new Error(getSignInErrorMessage(data.error)) -} - -function getSignInErrorMessage(error: unknown): string { - if (typeof error === 'string') - return error - - if (typeof error === 'object' && error && 'message' in error && typeof error.message === 'string') - return error.message - - return 'Unexpected response' + throw new Error(extractAuthError(data) ?? 'Unexpected response') } diff --git a/apps/ui-server-auth/src/pages/forgot-password.vue b/apps/ui-server-auth/src/pages/forgot-password.vue new file mode 100644 index 000000000..d22c3e560 --- /dev/null +++ b/apps/ui-server-auth/src/pages/forgot-password.vue @@ -0,0 +1,113 @@ + + + + + +meta: + layout: plain + diff --git a/apps/ui-server-auth/src/pages/index.vue b/apps/ui-server-auth/src/pages/index.vue index a78fb6fd3..52b36f50e 100644 --- a/apps/ui-server-auth/src/pages/index.vue +++ b/apps/ui-server-auth/src/pages/index.vue @@ -1,7 +1,13 @@ + + +redirect: /profile + diff --git a/apps/ui-server-auth/src/pages/profile.vue b/apps/ui-server-auth/src/pages/profile.vue new file mode 100644 index 000000000..6ec4919f0 --- /dev/null +++ b/apps/ui-server-auth/src/pages/profile.vue @@ -0,0 +1,361 @@ + + + + + +meta: + layout: plain + diff --git a/apps/ui-server-auth/src/pages/reset-password.vue b/apps/ui-server-auth/src/pages/reset-password.vue new file mode 100644 index 000000000..c50b198ca --- /dev/null +++ b/apps/ui-server-auth/src/pages/reset-password.vue @@ -0,0 +1,136 @@ + + + + + +meta: + layout: plain + diff --git a/apps/ui-server-auth/src/pages/sign-in.vue b/apps/ui-server-auth/src/pages/sign-in.vue index 028a264dc..fec9dfabf 100644 --- a/apps/ui-server-auth/src/pages/sign-in.vue +++ b/apps/ui-server-auth/src/pages/sign-in.vue @@ -3,28 +3,73 @@ import type { OAuthProvider } from '@proj-airi/stage-ui/libs/auth' import { defaultSignInProviders } from '@proj-airi/stage-ui/components/auth' import { SERVER_URL } from '@proj-airi/stage-ui/libs/server' -import { Button } from '@proj-airi/ui' -import { computed, shallowRef, watch } from 'vue' +import { Button, FieldInput } from '@proj-airi/ui' +import { computed, reactive, shallowRef, watch } from 'vue' import { useI18n } from 'vue-i18n' -import { useRoute } from 'vue-router' +import { useRoute, useRouter } from 'vue-router' +import { + checkEmail, + describeAuthError, + signInWithEmail, + signUpWithEmail, +} from '../modules/email-password' import { getServerAuthBootstrapContext } from '../modules/server-auth-context' import { createServerSignInContext, requestSocialSignInRedirect } from '../modules/sign-in' +type Step = 'identify' | 'password' | 'create' + const route = useRoute() +const router = useRouter() const { t } = useI18n() const bootstrapContext = getServerAuthBootstrapContext() const apiServerUrl = bootstrapContext?.apiServerUrl ?? SERVER_URL const currentUrl = bootstrapContext?.currentUrl ?? window.location.href +const step = shallowRef('identify') const errorMessage = shallowRef(null) const pendingProvider = shallowRef(null) const autoStartedProvider = shallowRef(null) +const identifierLoading = shallowRef(false) +const credentialsLoading = shallowRef(false) + +const credentials = reactive({ + email: '', + password: '', + confirmPassword: '', + name: '', +}) const providerLookup = new Set(defaultSignInProviders.map(provider => provider.id)) const signInContext = computed(() => createServerSignInContext(currentUrl, apiServerUrl)) +// Outside an OIDC flow signInContext.callbackURL is bare `/` which Better Auth +// resolves against the API server origin (404). Fall back to the UI root so +// the user lands somewhere useful — the `/auth/` index route redirects to +// `/auth/profile` so this is not the dead-end empty RouterView it once was. +const uiHomeURL = `${window.location.origin}/auth/` +const verifySuccessURL = `${window.location.origin}/auth/verify-email?verified=true` + +const effectiveCallbackURL = computed(() => + signInContext.value.callbackURL === '/' ? uiHomeURL : signInContext.value.callbackURL, +) +// NOTICE: +// We always send the verification email's callbackURL to the local +// verify-email success page, never to the OIDC `/oauth2/authorize` URL. +// Email links open in a new tab where sessionStorage (and therefore the PKCE +// flowState saved by the OIDC client) is empty, so a direct OIDC handoff in +// that tab would fail with "Missing OIDC flow state". Instead, the original +// tab polls the session and resumes the OIDC flow itself once the cookie is +// set by `autoSignInAfterVerification`. +const signUpCallbackURL = verifySuccessURL +// OIDC continuation URL surfaced to the verify-email page, so it can resume +// the original flow once the session cookie appears. Empty string means there +// was no OIDC client in the picture (just a vanilla sign-up). +const oidcContinueURL = computed(() => + signInContext.value.callbackURL === '/' ? '' : signInContext.value.callbackURL, +) + const requestedProvider = computed(() => { const provider = signInContext.value.requestedProvider @@ -34,6 +79,22 @@ const requestedProvider = computed(() => { return provider as OAuthProvider }) +const stepHeading = computed(() => { + if (step.value === 'password') + return t('server.auth.signIn.step.password.heading') + if (step.value === 'create') + return t('server.auth.signIn.step.create.heading') + return t('server.auth.signIn.step.identify.heading') +}) + +const stepDescription = computed(() => { + if (step.value === 'password') + return t('server.auth.signIn.step.password.description', { email: credentials.email }) + if (step.value === 'create') + return t('server.auth.signIn.step.create.description', { email: credentials.email }) + return t('server.auth.signIn.step.identify.description') +}) + watch(() => route.query.error, (value) => { errorMessage.value = typeof value === 'string' ? value : null }, { immediate: true }) @@ -46,6 +107,14 @@ watch(requestedProvider, async (provider) => { await handleProviderSelect(provider) }, { immediate: true }) +function backToIdentify() { + errorMessage.value = null + credentials.password = '' + credentials.confirmPassword = '' + credentials.name = '' + step.value = 'identify' +} + async function handleProviderSelect(provider: OAuthProvider) { errorMessage.value = null pendingProvider.value = provider @@ -54,16 +123,140 @@ async function handleProviderSelect(provider: OAuthProvider) { const redirectUrl = await requestSocialSignInRedirect({ apiServerUrl, provider, - callbackURL: signInContext.value.callbackURL, + callbackURL: effectiveCallbackURL.value, }) window.location.href = redirectUrl } catch (error) { - errorMessage.value = error instanceof Error ? error.message : t('server.auth.signIn.error.fallback') + errorMessage.value = describeAuthError(error) || t('server.auth.signIn.error.fallback') pendingProvider.value = null } } + +async function handleIdentify(event: Event) { + event.preventDefault() + if (identifierLoading.value) + return + + errorMessage.value = null + identifierLoading.value = true + + try { + const email = credentials.email.trim() + const result = await checkEmail({ apiServerUrl, email }) + + if (result.exists && !result.hasPassword) { + // User signed up via a social provider only. Stay on the identifier step + // so the OAuth buttons remain visible, and steer them there with a hint. + errorMessage.value = t('server.auth.signIn.error.authFailed') + // NOTICE: + // We avoid disclosing *which* social provider they used here. The + // generic OAuth button row is right below; users who registered via + // Google/GitHub will recognize and use it. + return + } + + step.value = result.exists ? 'password' : 'create' + } + catch (error) { + errorMessage.value = describeAuthError(error) || t('server.auth.signIn.error.fallback') + } + finally { + identifierLoading.value = false + } +} + +async function handleEmailSignIn(event: Event) { + event.preventDefault() + if (credentialsLoading.value) + return + + errorMessage.value = null + credentialsLoading.value = true + + try { + const result = await signInWithEmail({ + apiServerUrl, + email: credentials.email.trim(), + password: credentials.password, + callbackURL: effectiveCallbackURL.value, + }) + + if (result.requiresVerification) { + // Existing-but-unverified accounts that started from /oauth2/authorize + // must carry the OIDC continuation through verification. Without it the + // verify-email tab would resume to /auth/profile after the cookie lands + // and the upstream stage app never receives its auth code/tokens. + await router.push({ + path: '/verify-email', + query: { + email: credentials.email.trim(), + ...(oidcContinueURL.value ? { continueURL: oidcContinueURL.value } : {}), + }, + }) + return + } + + // After a successful credential sign-in better-auth has set the session + // cookie. Bounce into the OIDC `/oauth2/authorize` flow (or wherever the + // OIDC client originally pointed) so the upstream stage app gets its tokens. + window.location.href = result.redirectURL ?? effectiveCallbackURL.value + } + catch (error) { + errorMessage.value = describeAuthError(error) || t('server.auth.signIn.error.fallback') + } + finally { + credentialsLoading.value = false + } +} + +async function handleEmailSignUp(event: Event) { + event.preventDefault() + if (credentialsLoading.value) + return + + errorMessage.value = null + + if (credentials.password !== credentials.confirmPassword) { + errorMessage.value = t('server.auth.signIn.error.passwordMismatch') + return + } + + credentialsLoading.value = true + try { + const email = credentials.email.trim() + const name = credentials.name.trim() || email.split('@')[0] + const result = await signUpWithEmail({ + apiServerUrl, + email, + password: credentials.password, + name, + callbackURL: signUpCallbackURL, + }) + + if (result.requiresVerification) { + await router.push({ + path: '/verify-email', + query: { + email, + ...(oidcContinueURL.value ? { continueURL: oidcContinueURL.value } : {}), + }, + }) + return + } + + // Verification disabled at server config: session is live, fall through + // to the OIDC continuation just like sign-in. + window.location.href = effectiveCallbackURL.value + } + catch (error) { + errorMessage.value = describeAuthError(error) || t('server.auth.signIn.error.fallback') + } + finally { + credentialsLoading.value = false + } +} diff --git a/packages/stage-layouts/src/layouts/stage.vue b/packages/stage-layouts/src/layouts/stage.vue index a34a015ae..e1bd677c3 100644 --- a/packages/stage-layouts/src/layouts/stage.vue +++ b/packages/stage-layouts/src/layouts/stage.vue @@ -1,21 +1,9 @@ diff --git a/packages/stage-pages/src/pages/settings/account/account-settings-page.vue b/packages/stage-pages/src/pages/settings/account/account-settings-page.vue index 4d6fd12a9..ce0c7e9de 100644 --- a/packages/stage-pages/src/pages/settings/account/account-settings-page.vue +++ b/packages/stage-pages/src/pages/settings/account/account-settings-page.vue @@ -1,11 +1,15 @@