mirror of
https://github.com/moeru-ai/airi.git
synced 2026-04-28 06:29:33 +00:00
feat(auth): email login & profile (#1745)
Co-authored-by: Liet Blue <127093491+lietblue@users.noreply.github.com>
This commit is contained in:
parent
0346aa729e
commit
172e4ce59c
50 changed files with 3469 additions and 385 deletions
|
|
@ -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`
|
||||
|
|
|
|||
78
apps/server/docs/ai-context/email-auth-resend.md
Normal file
78
apps/server/docs/ai-context/email-auth-resend.md
Normal file
|
|
@ -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:`<UI_BASE>/verify-email?token=<token>`
|
||||
- Reset password:`<UI_BASE>/reset-password?token=<token>`
|
||||
- 由 `getAuthTrustedOrigins(request)` 第一个匹配的 origin 决定 `<UI_BASE>`,避免硬编码。
|
||||
- **`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-<path>.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
|
||||
79
apps/server/docs/ai-context/verifications/email-auth.md
Normal file
79
apps/server/docs/ai-context/verifications/email-auth.md
Normal file
|
|
@ -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: <session>, 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/<project>/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/<token>?callbackURL=http://localhost:5173/_ui/server-auth/reset-password` → 302 → UI form rendered with `?token=<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: <session>, 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/<project>/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=<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`).
|
||||
|
|
@ -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:"
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
},
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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<void>
|
|||
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<void>
|
|||
}
|
||||
}
|
||||
|
||||
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: {
|
||||
|
|
|
|||
|
|
@ -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 <email>` 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()),
|
||||
|
||||
|
|
|
|||
|
|
@ -19,12 +19,18 @@ type AuthInstance = ReturnType<typeof createAuth>
|
|||
*/
|
||||
export function sessionMiddleware(auth: AuthInstance, env: Env): MiddlewareHandler<HonoEnv> {
|
||||
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'
|
||||
) {
|
||||
|
|
|
|||
|
|
@ -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<Response> {
|
||||
|
|
@ -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)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
276
apps/server/src/services/email.ts
Normal file
276
apps/server/src/services/email.ts
Normal file
|
|
@ -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<void>
|
||||
sendVerification: (params: { to: string, url: string }) => Promise<void>
|
||||
sendPasswordReset: (params: { to: string, url: string }) => Promise<void>
|
||||
sendMagicLink: (params: { to: string, url: string }) => Promise<void>
|
||||
sendChangeEmailConfirmation: (params: { to: string, newEmail: string, url: string }) => Promise<void>
|
||||
}
|
||||
|
||||
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 <noreply@a.io>'`
|
||||
*/
|
||||
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<void> {
|
||||
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, '"')
|
||||
.replace(/'/g, ''')
|
||||
}
|
||||
|
||||
function renderActionEmailHtml(args: { heading: string, body: string, ctaLabel: string, url: string, footer: string }): string {
|
||||
const safeUrl = escapeHtml(args.url)
|
||||
return `<!doctype html>
|
||||
<html><body style="font-family: -apple-system, Segoe UI, sans-serif; color: #111; max-width: 480px; margin: 24px auto; padding: 0 16px;">
|
||||
<h2 style="margin: 0 0 16px;">${escapeHtml(args.heading)}</h2>
|
||||
<p style="margin: 0 0 16px;">${escapeHtml(args.body)}</p>
|
||||
<p style="margin: 0 0 16px;"><a href="${safeUrl}" style="display: inline-block; padding: 10px 16px; background: #111; color: #fff; border-radius: 6px; text-decoration: none;">${escapeHtml(args.ctaLabel)}</a></p>
|
||||
<p style="margin: 0 0 16px; font-size: 12px; color: #666;">If the button doesn't work, copy this URL into your browser:<br/><span style="word-break: break-all;">${safeUrl}</span></p>
|
||||
<p style="margin: 24px 0 0; font-size: 12px; color: #888;">${escapeHtml(args.footer)}</p>
|
||||
</body></html>`
|
||||
}
|
||||
|
||||
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.',
|
||||
})
|
||||
}
|
||||
|
|
@ -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'
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<Env, 'API_SERVER_URL'>, request?: Request): string[] {
|
||||
const origins = new Set<string>()
|
||||
const apiServerOrigin = getOriginFromUrl(env.API_SERVER_URL)
|
||||
|
|
@ -64,6 +81,10 @@ export function getAuthTrustedOrigins(env: Pick<Env, 'API_SERVER_URL'>, request?
|
|||
origins.add(apiServerOrigin)
|
||||
}
|
||||
|
||||
for (const origin of ALWAYS_TRUSTED_AUTH_ORIGINS) {
|
||||
origins.add(origin)
|
||||
}
|
||||
|
||||
if (request) {
|
||||
const requestOrigin = resolveTrustedRequestOrigin(request)
|
||||
if (requestOrigin) {
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
<script setup lang="ts">
|
||||
import { applyOIDCTokens, fetchSession } from '@proj-airi/stage-ui/libs/auth'
|
||||
import { errorMessageFrom } from '@moeru/std'
|
||||
import { applyOIDCTokens, fetchSession, triggerSignIn } from '@proj-airi/stage-ui/libs/auth'
|
||||
import { consumeFlowState, exchangeCodeForTokens } from '@proj-airi/stage-ui/libs/auth-oidc'
|
||||
import { Button } from '@proj-airi/ui'
|
||||
import { onMounted, ref } from 'vue'
|
||||
|
|
@ -39,12 +40,12 @@ onMounted(async () => {
|
|||
router.replace('/')
|
||||
}
|
||||
catch (err) {
|
||||
error.value = err instanceof Error ? err.message : t('server.auth.webCallback.message.tokenExchangeFailed')
|
||||
error.value = errorMessageFrom(err) ?? t('server.auth.webCallback.message.tokenExchangeFailed')
|
||||
}
|
||||
})
|
||||
|
||||
function handleTryAgain() {
|
||||
router.replace('/auth/sign-in')
|
||||
async function handleTryAgain() {
|
||||
await triggerSignIn()
|
||||
}
|
||||
</script>
|
||||
|
||||
|
|
|
|||
|
|
@ -1,108 +0,0 @@
|
|||
<script setup lang="ts">
|
||||
import type { OAuthProvider } from '@proj-airi/stage-ui/libs/auth'
|
||||
|
||||
import { LoginDrawer } from '@proj-airi/stage-ui/components/auth'
|
||||
import { useBreakpoints } from '@proj-airi/stage-ui/composables'
|
||||
import { fetchSession, signInOIDC } from '@proj-airi/stage-ui/libs/auth'
|
||||
import { OIDC_CLIENT_ID, OIDC_REDIRECT_URI } from '@proj-airi/stage-ui/libs/auth-config'
|
||||
import { Button } from '@proj-airi/ui'
|
||||
import { onMounted, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { toast } from 'vue-sonner'
|
||||
|
||||
const router = useRouter()
|
||||
const { t } = useI18n()
|
||||
|
||||
const { isDesktop } = useBreakpoints()
|
||||
|
||||
const loading = ref<Record<OAuthProvider, boolean>>({
|
||||
google: false,
|
||||
github: false,
|
||||
})
|
||||
|
||||
async function handleSignIn(provider: OAuthProvider) {
|
||||
loading.value[provider] = true
|
||||
try {
|
||||
await signInOIDC({
|
||||
clientId: OIDC_CLIENT_ID,
|
||||
redirectUri: OIDC_REDIRECT_URI,
|
||||
provider,
|
||||
})
|
||||
}
|
||||
catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : t('server.auth.signIn.error.unknown'))
|
||||
}
|
||||
finally {
|
||||
loading.value[provider] = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
// Check URL for error from failed OAuth callback
|
||||
const url = new URL(window.location.href)
|
||||
const error = url.searchParams.get('error')
|
||||
if (error) {
|
||||
toast.error(error === 'auth_failed' ? t('server.auth.signIn.error.authFailed') : error)
|
||||
url.searchParams.delete('error')
|
||||
window.history.replaceState(null, '', url.pathname)
|
||||
}
|
||||
|
||||
fetchSession()
|
||||
.then((authenticated) => {
|
||||
if (authenticated || !isDesktop.value) {
|
||||
router.replace('/')
|
||||
}
|
||||
})
|
||||
.catch(() => {})
|
||||
})
|
||||
|
||||
watch(isDesktop, (val) => {
|
||||
if (!val) {
|
||||
router.replace('/')
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="isDesktop" class="min-h-screen flex flex-col items-center justify-center font-cuteen">
|
||||
<div class="mb-8 text-3xl font-bold">
|
||||
{{ t('server.auth.signIn.title') }}
|
||||
</div>
|
||||
<div class="max-w-xs w-full flex flex-col gap-3">
|
||||
<Button
|
||||
:class="['w-full', 'py-2', 'flex', 'items-center', 'justify-center']"
|
||||
icon="i-simple-icons-google"
|
||||
:loading="loading.google"
|
||||
@click="handleSignIn('google')"
|
||||
>
|
||||
<span>Google</span>
|
||||
</Button>
|
||||
<Button
|
||||
:class="['w-full', 'py-2', 'flex', 'items-center', 'justify-center']"
|
||||
icon="i-simple-icons-github"
|
||||
:loading="loading.github"
|
||||
@click="handleSignIn('github')"
|
||||
>
|
||||
<span>GitHub</span>
|
||||
</Button>
|
||||
</div>
|
||||
<div class="mt-8 text-xs text-gray-400">
|
||||
{{ t('server.auth.signIn.footer.prefix') }}
|
||||
<a href="https://airi.moeru.ai/docs/en/about/terms" class="underline">{{ t('server.auth.signIn.footer.terms') }}</a>
|
||||
{{ t('server.auth.signIn.footer.and') }}
|
||||
<a href="https://airi.moeru.ai/docs/en/about/privacy" class="underline">{{ t('server.auth.signIn.footer.privacy') }}</a>.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="min-h-screen flex flex-col items-center justify-center bg-neutral-100 dark:bg-neutral-950">
|
||||
<div class="mb-12 flex flex-col items-center gap-4">
|
||||
<img src="../../assets/logo.svg" class="h-24 w-24 rounded-3xl shadow-lg">
|
||||
<div class="text-3xl font-bold">
|
||||
AIRI
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<LoginDrawer :open="true" />
|
||||
</div>
|
||||
</template>
|
||||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
156
apps/ui-server-auth/src/modules/auth-fetch.ts
Normal file
156
apps/ui-server-auth/src/modules/auth-fetch.ts
Normal file
|
|
@ -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<path>` 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<T>(
|
||||
base: AuthFetchBase,
|
||||
path: string,
|
||||
body: Record<string, unknown>,
|
||||
parse: (data: unknown, response: Response) => T,
|
||||
): Promise<T> {
|
||||
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<path>` 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<T>(
|
||||
base: AuthFetchBase,
|
||||
path: string,
|
||||
parse: (data: unknown, response: Response) => T,
|
||||
): Promise<T> {
|
||||
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
|
||||
}
|
||||
182
apps/ui-server-auth/src/modules/email-password.ts
Normal file
182
apps/ui-server-auth/src/modules/email-password.ts
Normal file
|
|
@ -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<CheckEmailResult> {
|
||||
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<SignInResult> {
|
||||
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<SignUpResult> {
|
||||
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<void> {
|
||||
await postAuthJSON(
|
||||
args,
|
||||
'/request-password-reset',
|
||||
{ email: args.email, redirectTo: args.redirectTo },
|
||||
() => undefined,
|
||||
)
|
||||
}
|
||||
|
||||
export async function resetPasswordWithToken(args: ResetPasswordArgs): Promise<void> {
|
||||
// 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'
|
||||
}
|
||||
125
apps/ui-server-auth/src/modules/profile.test.ts
Normal file
125
apps/ui-server-auth/src/modules/profile.test.ts
Normal file
|
|
@ -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<typeof fetch>(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<typeof fetch>(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<typeof fetch>(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<typeof fetch>(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<typeof fetch>(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<typeof fetch>(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<typeof fetch>(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' }),
|
||||
)
|
||||
})
|
||||
})
|
||||
174
apps/ui-server-auth/src/modules/profile.ts
Normal file
174
apps/ui-server-auth/src/modules/profile.ts
Normal file
|
|
@ -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<CurrentSessionResult> {
|
||||
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<string, unknown>
|
||||
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<void> {
|
||||
const body: Record<string, unknown> = {}
|
||||
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<void> {
|
||||
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<void> {
|
||||
await postAuthJSON(args, '/sign-out', {}, () => undefined)
|
||||
}
|
||||
|
||||
export function describeProfileError(error: unknown): string {
|
||||
return errorMessageFrom(error) ?? 'Unexpected error'
|
||||
}
|
||||
|
|
@ -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')
|
||||
}
|
||||
|
|
|
|||
113
apps/ui-server-auth/src/pages/forgot-password.vue
Normal file
113
apps/ui-server-auth/src/pages/forgot-password.vue
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
<script setup lang="ts">
|
||||
import { SERVER_URL } from '@proj-airi/stage-ui/libs/server'
|
||||
import { Button, FieldInput } from '@proj-airi/ui'
|
||||
import { reactive, shallowRef } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { describeAuthError, requestPasswordReset } from '../modules/email-password'
|
||||
import { getServerAuthBootstrapContext } from '../modules/server-auth-context'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const bootstrapContext = getServerAuthBootstrapContext()
|
||||
const apiServerUrl = bootstrapContext?.apiServerUrl ?? SERVER_URL
|
||||
|
||||
// Reset link must redirect back into ui-server-auth itself. Use the current
|
||||
// origin so dev (localhost) and prod (auth.airi…) both resolve correctly.
|
||||
const resetRedirect = `${window.location.origin}/auth/reset-password`
|
||||
|
||||
const form = reactive({ email: '' })
|
||||
const errorMessage = shallowRef<string | null>(null)
|
||||
const loading = shallowRef(false)
|
||||
const submitted = shallowRef(false)
|
||||
|
||||
async function handleSubmit(event: Event) {
|
||||
event.preventDefault()
|
||||
if (loading.value)
|
||||
return
|
||||
|
||||
errorMessage.value = null
|
||||
loading.value = true
|
||||
|
||||
try {
|
||||
await requestPasswordReset({
|
||||
apiServerUrl,
|
||||
email: form.email.trim(),
|
||||
redirectTo: resetRedirect,
|
||||
})
|
||||
submitted.value = true
|
||||
}
|
||||
catch (error) {
|
||||
errorMessage.value = describeAuthError(error) || t('server.auth.forgotPassword.error.fallback')
|
||||
}
|
||||
finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main
|
||||
:class="[
|
||||
'min-h-screen flex flex-col items-center justify-center px-6 py-10 font-cuteen',
|
||||
]"
|
||||
>
|
||||
<div :class="['mb-6 text-2xl font-bold']">
|
||||
{{ t('server.auth.forgotPassword.title') }}
|
||||
</div>
|
||||
|
||||
<p
|
||||
v-if="!submitted"
|
||||
:class="['mb-6 max-w-sm text-center text-sm text-neutral-600 dark:text-neutral-300']"
|
||||
>
|
||||
{{ t('server.auth.forgotPassword.description') }}
|
||||
</p>
|
||||
|
||||
<form
|
||||
v-if="!submitted"
|
||||
:class="['max-w-xs w-full flex flex-col gap-3']"
|
||||
@submit="handleSubmit"
|
||||
>
|
||||
<FieldInput
|
||||
v-model="form.email"
|
||||
type="email"
|
||||
:label="t('server.auth.forgotPassword.email.label')"
|
||||
:placeholder="t('server.auth.forgotPassword.email.placeholder')"
|
||||
required
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
:class="['w-full', 'py-2', 'flex', 'items-center', 'justify-center']"
|
||||
:loading="loading"
|
||||
>
|
||||
<span>{{ t('server.auth.forgotPassword.action.send') }}</span>
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<div
|
||||
v-else
|
||||
:class="['max-w-sm text-center text-sm text-neutral-600 dark:text-neutral-300']"
|
||||
>
|
||||
{{ t('server.auth.forgotPassword.message.sent', { email: form.email.trim() }) }}
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="errorMessage"
|
||||
:class="['mt-4 max-w-xs w-full text-center text-sm text-red-500']"
|
||||
>
|
||||
{{ errorMessage }}
|
||||
</div>
|
||||
|
||||
<RouterLink
|
||||
to="/sign-in"
|
||||
:class="['mt-8 text-xs text-neutral-500 underline']"
|
||||
>
|
||||
{{ t('server.auth.forgotPassword.action.backToSignIn') }}
|
||||
</RouterLink>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<route lang="yaml">
|
||||
meta:
|
||||
layout: plain
|
||||
</route>
|
||||
|
|
@ -1,7 +1,13 @@
|
|||
<script setup lang="ts">
|
||||
import { RouterView } from 'vue-router'
|
||||
// Empty placeholder. The route below redirects /auth/ to /auth/profile so a
|
||||
// directly-typed `/auth/` URL or a post-sign-in callback that fell back to
|
||||
// the auth-UI root lands on a usable page instead of an empty RouterView.
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<RouterView />
|
||||
<div />
|
||||
</template>
|
||||
|
||||
<route lang="yaml">
|
||||
redirect: /profile
|
||||
</route>
|
||||
|
|
|
|||
361
apps/ui-server-auth/src/pages/profile.vue
Normal file
361
apps/ui-server-auth/src/pages/profile.vue
Normal file
|
|
@ -0,0 +1,361 @@
|
|||
<script setup lang="ts">
|
||||
import type { ProfileUser } from '../modules/profile'
|
||||
|
||||
import { SERVER_URL } from '@proj-airi/stage-ui/libs/server'
|
||||
import { Button, FieldInput } from '@proj-airi/ui'
|
||||
import { computed, onMounted, reactive, shallowRef } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
import {
|
||||
changePassword,
|
||||
describeProfileError,
|
||||
getCurrentSession,
|
||||
signOut,
|
||||
updateUserProfile,
|
||||
} from '../modules/profile'
|
||||
import { getServerAuthBootstrapContext } from '../modules/server-auth-context'
|
||||
|
||||
const { t, locale } = useI18n()
|
||||
const router = useRouter()
|
||||
|
||||
const bootstrapContext = getServerAuthBootstrapContext()
|
||||
const apiServerUrl = bootstrapContext?.apiServerUrl ?? SERVER_URL
|
||||
|
||||
const initialLoading = shallowRef(true)
|
||||
const user = shallowRef<ProfileUser | null>(null)
|
||||
|
||||
const profileForm = reactive({ name: '' })
|
||||
const profileLoading = shallowRef(false)
|
||||
const profileError = shallowRef<string | null>(null)
|
||||
const profileSuccess = shallowRef<string | null>(null)
|
||||
|
||||
const passwordForm = reactive({
|
||||
current: '',
|
||||
next: '',
|
||||
confirm: '',
|
||||
})
|
||||
const passwordLoading = shallowRef(false)
|
||||
const passwordError = shallowRef<string | null>(null)
|
||||
const passwordSuccess = shallowRef<string | null>(null)
|
||||
|
||||
const signOutLoading = shallowRef(false)
|
||||
const signOutError = shallowRef<string | null>(null)
|
||||
|
||||
const nameDirty = computed(() => {
|
||||
if (!user.value)
|
||||
return false
|
||||
return profileForm.name.trim().length > 0 && profileForm.name.trim() !== user.value.name
|
||||
})
|
||||
|
||||
// Render createdAt with the active i18n locale so dates feel native (e.g. zh
|
||||
// users see `2025年4月1日` while en users see `April 1, 2025`). Falls back to
|
||||
// the raw ISO string if Intl rejects the locale.
|
||||
const formattedCreatedAt = computed(() => {
|
||||
if (!user.value?.createdAt)
|
||||
return ''
|
||||
try {
|
||||
return new Intl.DateTimeFormat(locale.value, { dateStyle: 'long' })
|
||||
.format(new Date(user.value.createdAt))
|
||||
}
|
||||
catch {
|
||||
return user.value.createdAt
|
||||
}
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const result = await getCurrentSession({ apiServerUrl })
|
||||
if (!result.user) {
|
||||
// Preserve the original target so the user lands back on /profile after
|
||||
// sign-in, rather than the sign-in default landing.
|
||||
await router.replace({
|
||||
path: '/sign-in',
|
||||
query: { redirect: '/profile' },
|
||||
})
|
||||
return
|
||||
}
|
||||
user.value = result.user
|
||||
profileForm.name = result.user.name
|
||||
}
|
||||
catch (error) {
|
||||
profileError.value = describeProfileError(error) || t('server.auth.profile.error.loadFailed')
|
||||
}
|
||||
finally {
|
||||
initialLoading.value = false
|
||||
}
|
||||
})
|
||||
|
||||
async function handleSaveName(event: Event) {
|
||||
event.preventDefault()
|
||||
if (profileLoading.value || !user.value || !nameDirty.value)
|
||||
return
|
||||
|
||||
profileError.value = null
|
||||
profileSuccess.value = null
|
||||
profileLoading.value = true
|
||||
|
||||
const trimmed = profileForm.name.trim()
|
||||
try {
|
||||
await updateUserProfile({ apiServerUrl, name: trimmed })
|
||||
user.value = { ...user.value, name: trimmed }
|
||||
profileForm.name = trimmed
|
||||
profileSuccess.value = t('server.auth.profile.message.profileSaved')
|
||||
}
|
||||
catch (error) {
|
||||
profileError.value = describeProfileError(error) || t('server.auth.profile.error.saveFailed')
|
||||
}
|
||||
finally {
|
||||
profileLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleChangePassword(event: Event) {
|
||||
event.preventDefault()
|
||||
if (passwordLoading.value)
|
||||
return
|
||||
|
||||
passwordError.value = null
|
||||
passwordSuccess.value = null
|
||||
|
||||
if (passwordForm.next !== passwordForm.confirm) {
|
||||
passwordError.value = t('server.auth.profile.error.passwordMismatch')
|
||||
return
|
||||
}
|
||||
|
||||
if (passwordForm.next === passwordForm.current) {
|
||||
passwordError.value = t('server.auth.profile.error.passwordSameAsCurrent')
|
||||
return
|
||||
}
|
||||
|
||||
passwordLoading.value = true
|
||||
try {
|
||||
await changePassword({
|
||||
apiServerUrl,
|
||||
currentPassword: passwordForm.current,
|
||||
newPassword: passwordForm.next,
|
||||
})
|
||||
passwordForm.current = ''
|
||||
passwordForm.next = ''
|
||||
passwordForm.confirm = ''
|
||||
passwordSuccess.value = t('server.auth.profile.message.passwordChanged')
|
||||
}
|
||||
catch (error) {
|
||||
passwordError.value = describeProfileError(error) || t('server.auth.profile.error.changePasswordFailed')
|
||||
}
|
||||
finally {
|
||||
passwordLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSignOut() {
|
||||
if (signOutLoading.value)
|
||||
return
|
||||
|
||||
signOutError.value = null
|
||||
signOutLoading.value = true
|
||||
|
||||
try {
|
||||
await signOut({ apiServerUrl })
|
||||
await router.replace('/sign-in')
|
||||
}
|
||||
catch (error) {
|
||||
signOutError.value = describeProfileError(error) || t('server.auth.profile.error.signOutFailed')
|
||||
signOutLoading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main
|
||||
:class="[
|
||||
'min-h-screen flex flex-col items-center justify-center px-6 py-10 font-cuteen',
|
||||
]"
|
||||
>
|
||||
<div :class="['mb-2 text-3xl font-bold']">
|
||||
{{ t('server.auth.profile.title') }}
|
||||
</div>
|
||||
<div :class="['mb-6 max-w-sm text-center text-sm text-neutral-500']">
|
||||
{{ t('server.auth.profile.description') }}
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="initialLoading"
|
||||
:class="['max-w-sm w-full text-center text-sm text-neutral-500']"
|
||||
>
|
||||
{{ t('server.auth.profile.message.loading') }}
|
||||
</div>
|
||||
|
||||
<template v-else-if="user">
|
||||
<!-- Identity summary: read-only fields (email, verification, created at) -->
|
||||
<section
|
||||
:class="['max-w-sm w-full flex flex-col gap-2 border border-neutral-200 dark:border-neutral-700 rounded-lg p-4 mb-6']"
|
||||
>
|
||||
<div :class="['flex items-center justify-between text-sm']">
|
||||
<span :class="['text-neutral-500']">{{ t('server.auth.profile.field.email') }}</span>
|
||||
<span :class="['font-medium']">{{ user.email }}</span>
|
||||
</div>
|
||||
<div :class="['flex items-center justify-between text-sm']">
|
||||
<span :class="['text-neutral-500']">{{ t('server.auth.profile.field.emailVerified') }}</span>
|
||||
<span
|
||||
:class="[
|
||||
'rounded px-2 py-0.5 text-xs',
|
||||
user.emailVerified
|
||||
? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300'
|
||||
: 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-300',
|
||||
]"
|
||||
>
|
||||
{{
|
||||
user.emailVerified
|
||||
? t('server.auth.profile.label.verified')
|
||||
: t('server.auth.profile.label.unverified')
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="formattedCreatedAt"
|
||||
:class="['flex items-center justify-between text-sm']"
|
||||
>
|
||||
<span :class="['text-neutral-500']">{{ t('server.auth.profile.field.createdAt') }}</span>
|
||||
<span :class="['font-medium']">{{ formattedCreatedAt }}</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Display name form -->
|
||||
<form
|
||||
:class="['max-w-sm w-full flex flex-col gap-3 mb-6']"
|
||||
@submit="handleSaveName"
|
||||
>
|
||||
<h2 :class="['text-base font-semibold']">
|
||||
{{ t('server.auth.profile.section.profile') }}
|
||||
</h2>
|
||||
|
||||
<FieldInput
|
||||
v-model="profileForm.name"
|
||||
type="text"
|
||||
:label="t('server.auth.profile.name.label')"
|
||||
:placeholder="t('server.auth.profile.name.placeholder')"
|
||||
/>
|
||||
|
||||
<div
|
||||
v-if="profileError"
|
||||
:class="['text-sm text-red-500']"
|
||||
role="alert"
|
||||
aria-live="polite"
|
||||
>
|
||||
{{ profileError }}
|
||||
</div>
|
||||
<div
|
||||
v-else-if="profileSuccess"
|
||||
:class="['text-sm text-green-600 dark:text-green-400']"
|
||||
aria-live="polite"
|
||||
>
|
||||
{{ profileSuccess }}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
:class="['w-full', 'py-2', 'flex', 'items-center', 'justify-center']"
|
||||
:loading="profileLoading"
|
||||
:disabled="!nameDirty"
|
||||
>
|
||||
<span>{{ t('server.auth.profile.action.saveProfile') }}</span>
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<!-- Change password form -->
|
||||
<form
|
||||
:class="['max-w-sm w-full flex flex-col gap-3 mb-6']"
|
||||
@submit="handleChangePassword"
|
||||
>
|
||||
<h2 :class="['text-base font-semibold']">
|
||||
{{ t('server.auth.profile.section.password') }}
|
||||
</h2>
|
||||
|
||||
<FieldInput
|
||||
v-model="passwordForm.current"
|
||||
type="password"
|
||||
:label="t('server.auth.profile.password.currentLabel')"
|
||||
:placeholder="t('server.auth.profile.password.currentPlaceholder')"
|
||||
required
|
||||
hide-required-mark
|
||||
/>
|
||||
<FieldInput
|
||||
v-model="passwordForm.next"
|
||||
type="password"
|
||||
:label="t('server.auth.profile.password.newLabel')"
|
||||
:placeholder="t('server.auth.profile.password.newPlaceholder')"
|
||||
required
|
||||
hide-required-mark
|
||||
/>
|
||||
<FieldInput
|
||||
v-model="passwordForm.confirm"
|
||||
type="password"
|
||||
:label="t('server.auth.profile.password.confirmLabel')"
|
||||
:placeholder="t('server.auth.profile.password.confirmPlaceholder')"
|
||||
required
|
||||
hide-required-mark
|
||||
/>
|
||||
|
||||
<div
|
||||
v-if="passwordError"
|
||||
:class="['text-sm text-red-500']"
|
||||
role="alert"
|
||||
aria-live="polite"
|
||||
>
|
||||
{{ passwordError }}
|
||||
</div>
|
||||
<div
|
||||
v-else-if="passwordSuccess"
|
||||
:class="['text-sm text-green-600 dark:text-green-400']"
|
||||
aria-live="polite"
|
||||
>
|
||||
{{ passwordSuccess }}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
:class="['w-full', 'py-2', 'flex', 'items-center', 'justify-center']"
|
||||
:loading="passwordLoading"
|
||||
>
|
||||
<span>{{ t('server.auth.profile.action.changePassword') }}</span>
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<!-- Sign out -->
|
||||
<div :class="['max-w-sm w-full flex flex-col gap-2']">
|
||||
<Button
|
||||
:class="['w-full', 'py-2', 'flex', 'items-center', 'justify-center']"
|
||||
variant="secondary"
|
||||
:loading="signOutLoading"
|
||||
@click="handleSignOut"
|
||||
>
|
||||
<span>{{ t('server.auth.profile.action.signOut') }}</span>
|
||||
</Button>
|
||||
<div
|
||||
v-if="signOutError"
|
||||
:class="['text-sm text-red-500 text-center']"
|
||||
role="alert"
|
||||
aria-live="polite"
|
||||
>
|
||||
{{ signOutError }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- No user, no longer initial loading: bootstrap error happened. The
|
||||
router.replace already fired for unauthenticated; this branch is for
|
||||
the network/error case so the user isn't stuck on a blank page. -->
|
||||
<div
|
||||
v-else
|
||||
:class="['max-w-sm w-full text-center text-sm text-red-500']"
|
||||
>
|
||||
{{ profileError || t('server.auth.profile.error.loadFailed') }}
|
||||
</div>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<route lang="yaml">
|
||||
meta:
|
||||
layout: plain
|
||||
</route>
|
||||
136
apps/ui-server-auth/src/pages/reset-password.vue
Normal file
136
apps/ui-server-auth/src/pages/reset-password.vue
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
<script setup lang="ts">
|
||||
import { SERVER_URL } from '@proj-airi/stage-ui/libs/server'
|
||||
import { Button, FieldInput } from '@proj-airi/ui'
|
||||
import { computed, reactive, shallowRef } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
import { describeAuthError, resetPasswordWithToken } from '../modules/email-password'
|
||||
import { getServerAuthBootstrapContext } from '../modules/server-auth-context'
|
||||
|
||||
const { t } = useI18n()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const bootstrapContext = getServerAuthBootstrapContext()
|
||||
const apiServerUrl = bootstrapContext?.apiServerUrl ?? SERVER_URL
|
||||
|
||||
// better-auth's reset-password landing GET endpoint redirects here with
|
||||
// `?token=...` after validating the link's existence. We submit token + new
|
||||
// password to /reset-password POST.
|
||||
const token = computed(() => {
|
||||
const value = route.query.token
|
||||
return typeof value === 'string' ? value : ''
|
||||
})
|
||||
|
||||
const form = reactive({ password: '', confirmPassword: '' })
|
||||
const errorMessage = shallowRef<string | null>(null)
|
||||
const loading = shallowRef(false)
|
||||
const completed = shallowRef(false)
|
||||
|
||||
async function handleSubmit(event: Event) {
|
||||
event.preventDefault()
|
||||
if (loading.value)
|
||||
return
|
||||
|
||||
errorMessage.value = null
|
||||
|
||||
if (!token.value) {
|
||||
errorMessage.value = t('server.auth.resetPassword.error.missingToken')
|
||||
return
|
||||
}
|
||||
|
||||
if (form.password !== form.confirmPassword) {
|
||||
errorMessage.value = t('server.auth.resetPassword.error.passwordMismatch')
|
||||
return
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
await resetPasswordWithToken({
|
||||
apiServerUrl,
|
||||
newPassword: form.password,
|
||||
token: token.value,
|
||||
})
|
||||
completed.value = true
|
||||
}
|
||||
catch (error) {
|
||||
errorMessage.value = describeAuthError(error) || t('server.auth.resetPassword.error.fallback')
|
||||
}
|
||||
finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function goSignIn() {
|
||||
await router.push('/sign-in')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main
|
||||
:class="[
|
||||
'min-h-screen flex flex-col items-center justify-center px-6 py-10 font-cuteen',
|
||||
]"
|
||||
>
|
||||
<div :class="['mb-6 text-2xl font-bold']">
|
||||
{{
|
||||
completed
|
||||
? t('server.auth.resetPassword.title.success')
|
||||
: t('server.auth.resetPassword.title.default')
|
||||
}}
|
||||
</div>
|
||||
|
||||
<form
|
||||
v-if="!completed"
|
||||
:class="['max-w-xs w-full flex flex-col gap-3']"
|
||||
@submit="handleSubmit"
|
||||
>
|
||||
<FieldInput
|
||||
v-model="form.password"
|
||||
type="password"
|
||||
:label="t('server.auth.resetPassword.password.label')"
|
||||
:placeholder="t('server.auth.resetPassword.password.placeholder')"
|
||||
required
|
||||
/>
|
||||
<FieldInput
|
||||
v-model="form.confirmPassword"
|
||||
type="password"
|
||||
:label="t('server.auth.resetPassword.confirmPassword.label')"
|
||||
:placeholder="t('server.auth.resetPassword.confirmPassword.placeholder')"
|
||||
required
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
:class="['w-full', 'py-2', 'flex', 'items-center', 'justify-center']"
|
||||
:loading="loading"
|
||||
>
|
||||
<span>{{ t('server.auth.resetPassword.action.reset') }}</span>
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<div
|
||||
v-else
|
||||
:class="['max-w-sm flex flex-col items-center gap-4 text-center text-sm']"
|
||||
>
|
||||
<p :class="['text-neutral-600 dark:text-neutral-300']">
|
||||
{{ t('server.auth.resetPassword.message.success') }}
|
||||
</p>
|
||||
<Button @click="goSignIn">
|
||||
<span>{{ t('server.auth.resetPassword.action.goSignIn') }}</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="errorMessage"
|
||||
:class="['mt-4 max-w-xs w-full text-center text-sm text-red-500']"
|
||||
>
|
||||
{{ errorMessage }}
|
||||
</div>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<route lang="yaml">
|
||||
meta:
|
||||
layout: plain
|
||||
</route>
|
||||
|
|
@ -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<Step>('identify')
|
||||
const errorMessage = shallowRef<string | null>(null)
|
||||
const pendingProvider = shallowRef<OAuthProvider | null>(null)
|
||||
const autoStartedProvider = shallowRef<OAuthProvider | null>(null)
|
||||
const identifierLoading = shallowRef(false)
|
||||
const credentialsLoading = shallowRef(false)
|
||||
|
||||
const credentials = reactive({
|
||||
email: '',
|
||||
password: '',
|
||||
confirmPassword: '',
|
||||
name: '',
|
||||
})
|
||||
|
||||
const providerLookup = new Set<OAuthProvider>(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<OAuthProvider | null>(() => {
|
||||
const provider = signInContext.value.requestedProvider
|
||||
|
||||
|
|
@ -34,6 +79,22 @@ const requestedProvider = computed<OAuthProvider | null>(() => {
|
|||
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
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
|
@ -72,61 +265,158 @@ async function handleProviderSelect(provider: OAuthProvider) {
|
|||
'min-h-screen flex flex-col items-center justify-center px-6 py-10 font-cuteen',
|
||||
]"
|
||||
>
|
||||
<div
|
||||
:class="[
|
||||
'mb-8 text-3xl font-bold',
|
||||
]"
|
||||
>
|
||||
{{ t('server.auth.signIn.title') }}
|
||||
<div :class="['mb-2 text-3xl font-bold']">
|
||||
{{ stepHeading }}
|
||||
</div>
|
||||
<div :class="['mb-4 max-w-xs text-center text-sm text-neutral-500']">
|
||||
{{ stepDescription }}
|
||||
</div>
|
||||
|
||||
<!-- Reserve space for the error region so a transition into the error
|
||||
state doesn't shove the form downward. Renders an empty paragraph
|
||||
when there's nothing to show; the role swaps to alert when populated. -->
|
||||
<div
|
||||
:class="[
|
||||
'max-w-xs w-full flex flex-col gap-3',
|
||||
'mb-2 max-w-xs w-full min-h-[1.25rem] text-center text-sm',
|
||||
errorMessage ? 'text-red-500' : 'text-transparent select-none',
|
||||
]"
|
||||
:role="errorMessage ? 'alert' : undefined"
|
||||
:aria-live="errorMessage ? 'polite' : undefined"
|
||||
>
|
||||
{{ errorMessage || '·' }}
|
||||
</div>
|
||||
|
||||
<!-- Step 1: identify -->
|
||||
<form
|
||||
v-if="step === 'identify'"
|
||||
:class="['max-w-xs w-full flex flex-col gap-3']"
|
||||
@submit="handleIdentify"
|
||||
>
|
||||
<FieldInput
|
||||
v-model="credentials.email"
|
||||
type="email"
|
||||
:label="t('server.auth.signIn.email.label')"
|
||||
:placeholder="t('server.auth.signIn.email.placeholder')"
|
||||
required
|
||||
hide-required-mark
|
||||
/>
|
||||
|
||||
<Button
|
||||
v-for="provider in defaultSignInProviders"
|
||||
:key="provider.id"
|
||||
type="submit"
|
||||
:class="['w-full', 'py-2', 'flex', 'items-center', 'justify-center']"
|
||||
:icon="provider.id === 'google' ? 'i-simple-icons-google' : provider.id === 'github' ? 'i-simple-icons-github' : undefined"
|
||||
:loading="pendingProvider === provider.id"
|
||||
@click="handleProviderSelect(provider.id)"
|
||||
:loading="identifierLoading"
|
||||
>
|
||||
<span>{{ provider.name }}</span>
|
||||
<span>{{ t('server.auth.signIn.action.continue') }}</span>
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div
|
||||
v-if="errorMessage"
|
||||
:class="[
|
||||
'mt-4 max-w-xs w-full text-center text-sm text-red-500',
|
||||
]"
|
||||
<!-- Step 2A: existing user, password -->
|
||||
<form
|
||||
v-else-if="step === 'password'"
|
||||
:class="['max-w-xs w-full flex flex-col gap-3']"
|
||||
@submit="handleEmailSignIn"
|
||||
>
|
||||
{{ errorMessage }}
|
||||
</div>
|
||||
<FieldInput
|
||||
v-model="credentials.password"
|
||||
type="password"
|
||||
:label="t('server.auth.signIn.password.label')"
|
||||
:placeholder="t('server.auth.signIn.password.placeholder')"
|
||||
required
|
||||
hide-required-mark
|
||||
/>
|
||||
|
||||
<div
|
||||
:class="[
|
||||
'mt-8 text-center text-xs text-gray-400',
|
||||
]"
|
||||
>
|
||||
{{ t('server.auth.signIn.footer.prefix') }}
|
||||
<a
|
||||
href="https://airi.moeru.ai/docs/en/about/terms"
|
||||
:class="[
|
||||
'underline',
|
||||
]"
|
||||
<Button
|
||||
type="submit"
|
||||
:class="['w-full', 'py-2', 'flex', 'items-center', 'justify-center']"
|
||||
:loading="credentialsLoading"
|
||||
>
|
||||
<span>{{ t('server.auth.signIn.action.signIn') }}</span>
|
||||
</Button>
|
||||
|
||||
<div :class="['flex items-center justify-between text-xs text-neutral-500']">
|
||||
<RouterLink to="/forgot-password" :class="['underline']">
|
||||
{{ t('server.auth.signIn.action.forgotPassword') }}
|
||||
</RouterLink>
|
||||
<button type="button" :class="['underline']" @click="backToIdentify">
|
||||
{{ t('server.auth.signIn.action.useDifferentEmail') }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<!-- Step 2B: new user, sign up -->
|
||||
<form
|
||||
v-else
|
||||
:class="['max-w-xs w-full flex flex-col gap-3']"
|
||||
@submit="handleEmailSignUp"
|
||||
>
|
||||
<FieldInput
|
||||
v-model="credentials.name"
|
||||
type="text"
|
||||
:label="t('server.auth.signIn.name.label')"
|
||||
:placeholder="t('server.auth.signIn.name.placeholder')"
|
||||
/>
|
||||
<FieldInput
|
||||
v-model="credentials.password"
|
||||
type="password"
|
||||
:label="t('server.auth.signIn.newPassword.label')"
|
||||
:placeholder="t('server.auth.signIn.newPassword.placeholder')"
|
||||
required
|
||||
hide-required-mark
|
||||
/>
|
||||
<FieldInput
|
||||
v-model="credentials.confirmPassword"
|
||||
type="password"
|
||||
:label="t('server.auth.signIn.confirmPassword.label')"
|
||||
:placeholder="t('server.auth.signIn.confirmPassword.placeholder')"
|
||||
required
|
||||
hide-required-mark
|
||||
/>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
:class="['w-full', 'py-2', 'flex', 'items-center', 'justify-center']"
|
||||
:loading="credentialsLoading"
|
||||
>
|
||||
<span>{{ t('server.auth.signIn.action.createAccount') }}</span>
|
||||
</Button>
|
||||
|
||||
<div :class="['flex items-center justify-end text-xs text-neutral-500']">
|
||||
<button type="button" :class="['underline']" @click="backToIdentify">
|
||||
{{ t('server.auth.signIn.action.useDifferentEmail') }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<!-- OAuth buttons: only on identifier step. After picking an email/password
|
||||
path, the OAuth options stay one click away via "use a different email". -->
|
||||
<template v-if="step === 'identify'">
|
||||
<div :class="['my-6 max-w-xs w-full flex items-center gap-3 text-xs text-neutral-400']">
|
||||
<div :class="['h-px flex-1 bg-neutral-200 dark:bg-neutral-700']" />
|
||||
<span>{{ t('server.auth.signIn.divider.or') }}</span>
|
||||
<div :class="['h-px flex-1 bg-neutral-200 dark:bg-neutral-700']" />
|
||||
</div>
|
||||
|
||||
<div :class="['max-w-xs w-full flex flex-col gap-3']">
|
||||
<Button
|
||||
v-for="provider in defaultSignInProviders"
|
||||
:key="provider.id"
|
||||
:class="['w-full', 'py-2', 'flex', 'items-center', 'justify-center']"
|
||||
:icon="provider.id === 'google' ? 'i-simple-icons-google' : provider.id === 'github' ? 'i-simple-icons-github' : undefined"
|
||||
:loading="pendingProvider === provider.id"
|
||||
@click="handleProviderSelect(provider.id)"
|
||||
>
|
||||
<span>{{ provider.name }}</span>
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div :class="['mt-8 text-center text-xs text-gray-400']">
|
||||
{{ t('server.auth.signIn.footer.prefix') }}
|
||||
<a href="https://airi.moeru.ai/docs/en/about/terms" :class="['underline']">
|
||||
{{ t('server.auth.signIn.footer.terms') }}
|
||||
</a>
|
||||
{{ t('server.auth.signIn.footer.and') }}
|
||||
<a
|
||||
href="https://airi.moeru.ai/docs/en/about/privacy"
|
||||
:class="[
|
||||
'underline',
|
||||
]"
|
||||
>
|
||||
<a href="https://airi.moeru.ai/docs/en/about/privacy" :class="['underline']">
|
||||
{{ t('server.auth.signIn.footer.privacy') }}
|
||||
</a>.
|
||||
</div>
|
||||
|
|
|
|||
164
apps/ui-server-auth/src/pages/verify-email.vue
Normal file
164
apps/ui-server-auth/src/pages/verify-email.vue
Normal file
|
|
@ -0,0 +1,164 @@
|
|||
<script setup lang="ts">
|
||||
import { SERVER_URL } from '@proj-airi/stage-ui/libs/server'
|
||||
import { useBroadcastChannel } from '@vueuse/core'
|
||||
import { computed, onMounted, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useRoute } from 'vue-router'
|
||||
|
||||
import { getServerAuthBootstrapContext } from '../modules/server-auth-context'
|
||||
|
||||
const { t } = useI18n()
|
||||
const route = useRoute()
|
||||
const bootstrapContext = getServerAuthBootstrapContext()
|
||||
const apiServerUrl = bootstrapContext?.apiServerUrl ?? SERVER_URL
|
||||
|
||||
// Two distinct entry shapes share this page:
|
||||
// 1) Post-sign-up notice screen: query is `?email=user@host`, no `error`.
|
||||
// 2) Verification landing after better-auth redirected from /api/auth/verify-email.
|
||||
// On success: `?verified=true`. On failure: `?error=...&status=failed`.
|
||||
const email = computed(() => {
|
||||
const value = route.query.email
|
||||
return typeof value === 'string' ? value : ''
|
||||
})
|
||||
|
||||
const error = computed(() => {
|
||||
const value = route.query.error
|
||||
return typeof value === 'string' ? value : null
|
||||
})
|
||||
|
||||
const verified = computed(() => route.query.verified === 'true')
|
||||
|
||||
// Captured at mount time on the original tab (the one that just submitted the
|
||||
// sign-up form) so that, when the verification tab signals success, we know
|
||||
// where to resume the upstream OIDC flow. Empty when the sign-up was not
|
||||
// initiated inside an OIDC handoff.
|
||||
const continueURL = computed(() => {
|
||||
const value = route.query.continueURL
|
||||
return typeof value === 'string' ? value : ''
|
||||
})
|
||||
|
||||
// NOTICE:
|
||||
// Cross-tab signal between the verification-success tab (the one opened from
|
||||
// the email link) and the original "check your inbox" tab. Both tabs live on
|
||||
// the same origin (/auth/...), so BroadcastChannel works without setup.
|
||||
//
|
||||
// Why not poll /get-session every 2s? An abandoned pending tab would burn
|
||||
// 1800 requests/hour for no reason, and the request volume scales with time
|
||||
// the user takes to check their inbox. With BroadcastChannel the only work
|
||||
// happens when verification actually finishes.
|
||||
//
|
||||
// Why still call /get-session at all? The verifying tab cannot complete the
|
||||
// OIDC handoff itself — the original tab is the only one carrying the PKCE
|
||||
// flowState in sessionStorage. So we wait for the signal, then fetch the
|
||||
// session once to make sure the cookie is live before navigating into the
|
||||
// OIDC continuation URL.
|
||||
type VerifyEmailEvent = 'verified'
|
||||
const { post, data, isSupported } = useBroadcastChannel<VerifyEmailEvent, VerifyEmailEvent>({
|
||||
name: 'airi-auth-verify-email',
|
||||
})
|
||||
|
||||
async function resumeIfSessionReady(): Promise<boolean> {
|
||||
try {
|
||||
const response = await fetch(new URL('/api/auth/get-session', apiServerUrl).toString(), {
|
||||
credentials: 'include',
|
||||
cache: 'no-store',
|
||||
})
|
||||
if (!response.ok)
|
||||
return false
|
||||
|
||||
const payload = await response.json().catch(() => null) as { session?: unknown } | null
|
||||
if (!payload?.session)
|
||||
return false
|
||||
|
||||
// Same-tab navigation preserves sessionStorage on the destination origin,
|
||||
// so the original PKCE flowState saved by the OIDC client is still
|
||||
// available when /auth/callback runs.
|
||||
window.location.href = continueURL.value || `${window.location.origin}/auth/`
|
||||
return true
|
||||
}
|
||||
catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
// Verification-success tab: announce to any sibling pending tab that the
|
||||
// session cookie has been written, then stay put so the user sees the
|
||||
// success message. The pending tab does the OIDC continuation.
|
||||
if (verified.value) {
|
||||
if (isSupported.value)
|
||||
post('verified')
|
||||
return
|
||||
}
|
||||
|
||||
if (error.value)
|
||||
return
|
||||
|
||||
// Pending tab: cover the case where verification already happened before
|
||||
// this tab subscribed (back-button navigation, page reload, etc.). One
|
||||
// session check, no recurring poll.
|
||||
await resumeIfSessionReady()
|
||||
})
|
||||
|
||||
// React to a verification event broadcast from the success tab. `data` flips
|
||||
// from null to 'verified' the moment the message arrives.
|
||||
watch(data, async (event) => {
|
||||
if (event !== 'verified' || verified.value || error.value)
|
||||
return
|
||||
|
||||
await resumeIfSessionReady()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main
|
||||
:class="[
|
||||
'min-h-screen flex flex-col items-center justify-center px-6 py-10 font-cuteen',
|
||||
]"
|
||||
>
|
||||
<div :class="['mb-6 text-2xl font-bold']">
|
||||
{{
|
||||
error
|
||||
? t('server.auth.verifyEmail.title.failed')
|
||||
: verified
|
||||
? t('server.auth.verifyEmail.title.success')
|
||||
: t('server.auth.verifyEmail.title.pending')
|
||||
}}
|
||||
</div>
|
||||
|
||||
<p
|
||||
v-if="error"
|
||||
:class="['max-w-sm text-center text-sm text-red-500']"
|
||||
>
|
||||
{{ t('server.auth.verifyEmail.message.failed', { error }) }}
|
||||
</p>
|
||||
<p
|
||||
v-else-if="verified"
|
||||
:class="['max-w-sm text-center text-sm text-neutral-600 dark:text-neutral-300']"
|
||||
>
|
||||
{{ t('server.auth.verifyEmail.message.success') }}
|
||||
</p>
|
||||
<p
|
||||
v-else
|
||||
:class="['max-w-sm text-center text-sm text-neutral-600 dark:text-neutral-300']"
|
||||
>
|
||||
{{
|
||||
email
|
||||
? t('server.auth.verifyEmail.message.pendingWithAddress', { email })
|
||||
: t('server.auth.verifyEmail.message.pending')
|
||||
}}
|
||||
</p>
|
||||
|
||||
<RouterLink
|
||||
to="/sign-in"
|
||||
:class="['mt-8 text-xs text-neutral-500 underline']"
|
||||
>
|
||||
{{ t('server.auth.verifyEmail.action.backToSignIn') }}
|
||||
</RouterLink>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<route lang="yaml">
|
||||
meta:
|
||||
layout: plain
|
||||
</route>
|
||||
|
|
@ -13,7 +13,7 @@ import VueRouter from 'vue-router/vite'
|
|||
import { defineConfig } from 'vite'
|
||||
|
||||
export default defineConfig({
|
||||
base: '/_ui/server-auth/',
|
||||
base: '/auth/',
|
||||
optimizeDeps: {
|
||||
exclude: [
|
||||
// Internal Packages
|
||||
|
|
|
|||
|
|
@ -39,6 +39,23 @@ export default defineConfig({
|
|||
'depend/ban-dependencies': 'warn',
|
||||
'import/order': 'off',
|
||||
'no-console': ['error', { allow: ['warn', 'error', 'info'] }],
|
||||
|
||||
// Catches the manual `error instanceof Error ? error.message : ...`
|
||||
// pattern AGENTS.md forbids. The selector matches a ConditionalExpression
|
||||
// whose test is `<x> instanceof Error` and whose consequent is `<x>.message`,
|
||||
// so it does NOT false-positive on `error instanceof Error ? error : new Error(...)`
|
||||
// (where the consequent is the error itself, not its `.message`). Antfu's
|
||||
// default no-restricted-syntax patterns are preserved alongside.
|
||||
'no-restricted-syntax': [
|
||||
'warn',
|
||||
{
|
||||
selector: 'ConditionalExpression[test.type=\'BinaryExpression\'][test.operator=\'instanceof\'][test.right.name=\'Error\'][consequent.type=\'MemberExpression\'][consequent.property.name=\'message\']',
|
||||
message: 'Avoid `error instanceof Error ? error.message : ...`. Use `errorMessageFrom(error)` from \'@moeru/std\' (or `errorMessageFromUnknown(error, fallback)` from \'@proj-airi/stage-shared\'). Pair with `?? \'fallback\'` when a default is needed.',
|
||||
},
|
||||
'TSEnumDeclaration[const=true]',
|
||||
'TSExportAssignment',
|
||||
],
|
||||
|
||||
// 'sonarjs/cognitive-complexity': 'off',
|
||||
// 'sonarjs/no-commented-code': 'off',
|
||||
// 'sonarjs/pseudo-random': 'off',
|
||||
|
|
|
|||
|
|
@ -24,15 +24,132 @@ electronCallback:
|
|||
closeTabManually: Some browsers do not allow this page to close itself automatically. You can close it manually.
|
||||
signIn:
|
||||
title: Sign in
|
||||
step:
|
||||
identify:
|
||||
heading: Sign in
|
||||
description: Enter your email to sign in or create an account.
|
||||
password:
|
||||
heading: Welcome back
|
||||
description: Continue as {email}.
|
||||
create:
|
||||
heading: Create your account
|
||||
description: We will create a new account for {email}.
|
||||
email:
|
||||
label: Email
|
||||
placeholder: you{'@'}example.com
|
||||
password:
|
||||
label: Password
|
||||
placeholder: Your password
|
||||
newPassword:
|
||||
label: Create password
|
||||
placeholder: At least 8 characters
|
||||
confirmPassword:
|
||||
label: Confirm password
|
||||
placeholder: Repeat your password
|
||||
name:
|
||||
label: Display name
|
||||
placeholder: Optional, defaults to your email handle
|
||||
action:
|
||||
continue: Continue
|
||||
signIn: Sign in
|
||||
createAccount: Create account
|
||||
forgotPassword: Forgot password?
|
||||
useDifferentEmail: Use a different email
|
||||
divider:
|
||||
or: or
|
||||
error:
|
||||
fallback: Sign in failed
|
||||
unknown: An unknown error occurred
|
||||
authFailed: Authentication failed. Please try again.
|
||||
passwordMismatch: Password and confirmation do not match.
|
||||
invalidEmail: Please enter a valid email address.
|
||||
footer:
|
||||
prefix: By continuing, you agree to our
|
||||
terms: Terms
|
||||
and: and
|
||||
privacy: Privacy Policy
|
||||
verifyEmail:
|
||||
title:
|
||||
pending: Check your inbox
|
||||
success: Email verified
|
||||
failed: Verification failed
|
||||
message:
|
||||
pending: We sent a verification link to your email. Open it to finish creating your account — this page will continue automatically once you click the link.
|
||||
pendingWithAddress: We sent a verification link to {email}. Open it to finish creating your account — this page will continue automatically once you click the link.
|
||||
success: Your email has been verified and you are signed in. You can close this tab.
|
||||
failed: We could not verify your email ({error}). Try requesting a new link.
|
||||
action:
|
||||
backToSignIn: Back to sign in
|
||||
forgotPassword:
|
||||
title: Reset your password
|
||||
description: Enter the email address associated with your account and we will send you a reset link.
|
||||
email:
|
||||
label: Email
|
||||
placeholder: you{'@'}example.com
|
||||
action:
|
||||
send: Send reset link
|
||||
backToSignIn: Back to sign in
|
||||
message:
|
||||
sent: If {email} matches an account, a reset link is on the way.
|
||||
error:
|
||||
fallback: We could not send the reset email.
|
||||
resetPassword:
|
||||
title:
|
||||
default: Set a new password
|
||||
success: Password updated
|
||||
password:
|
||||
label: New password
|
||||
placeholder: At least 8 characters
|
||||
confirmPassword:
|
||||
label: Confirm new password
|
||||
placeholder: Repeat your new password
|
||||
action:
|
||||
reset: Update password
|
||||
goSignIn: Go to sign in
|
||||
message:
|
||||
success: Your password has been updated. Sign in with your new password.
|
||||
error:
|
||||
fallback: We could not update your password.
|
||||
missingToken: Reset link is invalid or has expired.
|
||||
passwordMismatch: Password and confirmation do not match.
|
||||
profile:
|
||||
title: Account profile
|
||||
description: Manage your display name and password.
|
||||
section:
|
||||
profile: Profile
|
||||
password: Password
|
||||
field:
|
||||
email: Email
|
||||
emailVerified: Email status
|
||||
createdAt: Joined
|
||||
label:
|
||||
verified: Verified
|
||||
unverified: Unverified
|
||||
name:
|
||||
label: Display name
|
||||
placeholder: How others see you
|
||||
password:
|
||||
currentLabel: Current password
|
||||
currentPlaceholder: Enter your current password
|
||||
newLabel: New password
|
||||
newPlaceholder: At least 8 characters
|
||||
confirmLabel: Confirm new password
|
||||
confirmPlaceholder: Repeat your new password
|
||||
action:
|
||||
saveProfile: Save changes
|
||||
changePassword: Change password
|
||||
signOut: Sign out
|
||||
message:
|
||||
loading: Loading your profile...
|
||||
profileSaved: Profile updated.
|
||||
passwordChanged: Password changed. Other sessions have been signed out.
|
||||
error:
|
||||
loadFailed: We could not load your profile.
|
||||
saveFailed: We could not save your changes.
|
||||
changePasswordFailed: We could not change your password.
|
||||
signOutFailed: We could not sign you out.
|
||||
passwordMismatch: New password and confirmation do not match.
|
||||
passwordSameAsCurrent: New password must differ from the current one.
|
||||
webCallback:
|
||||
title:
|
||||
signIn: Sign in
|
||||
|
|
|
|||
|
|
@ -161,6 +161,49 @@ pages:
|
|||
fluxBalance: Flux Balance
|
||||
viewFluxDetails: View details
|
||||
signedInAs: Signed in as
|
||||
profile:
|
||||
tab: Profile
|
||||
title: Profile
|
||||
description: Manage your public display name.
|
||||
name:
|
||||
label: Display name
|
||||
placeholder: How others see you
|
||||
action:
|
||||
save: Save changes
|
||||
message:
|
||||
saved: Profile updated.
|
||||
error:
|
||||
fallback: We could not save your changes.
|
||||
security:
|
||||
tab: Security
|
||||
title: Security
|
||||
description: Change your password. Your other devices will be signed out.
|
||||
currentPassword:
|
||||
label: Current password
|
||||
placeholder: Enter your current password
|
||||
newPassword:
|
||||
label: New password
|
||||
placeholder: At least 8 characters
|
||||
confirmPassword:
|
||||
label: Confirm new password
|
||||
placeholder: Repeat your new password
|
||||
action:
|
||||
changePassword: Change password
|
||||
message:
|
||||
changed: Password changed. Other sessions have been signed out.
|
||||
error:
|
||||
fallback: We could not change your password.
|
||||
passwordMismatch: New password and confirmation do not match.
|
||||
passwordSameAsCurrent: New password must differ from the current one.
|
||||
danger:
|
||||
tab: Danger Zone
|
||||
title: Danger Zone
|
||||
description: Irreversible account actions live here.
|
||||
deleteAccount:
|
||||
title: Delete account
|
||||
description: Permanently remove your account, sessions, and personal data.
|
||||
action: Delete
|
||||
notAvailable: Coming soon — contact support to delete your account today.
|
||||
card:
|
||||
activate: Activate
|
||||
active: Active
|
||||
|
|
|
|||
|
|
@ -24,15 +24,88 @@ electronCallback:
|
|||
closeTabManually: 一些浏览器不允许自动关闭这个页面,你可以手动关掉它。
|
||||
signIn:
|
||||
title: 登录
|
||||
step:
|
||||
identify:
|
||||
heading: 登录
|
||||
description: 输入邮箱以登录或创建账号。
|
||||
password:
|
||||
heading: 欢迎回来
|
||||
description: 以 {email} 继续。
|
||||
create:
|
||||
heading: 创建账号
|
||||
description: 我们会为 {email} 创建一个新账号。
|
||||
email:
|
||||
label: 邮箱
|
||||
placeholder: you{'@'}example.com
|
||||
password:
|
||||
label: 密码
|
||||
placeholder: 你的密码
|
||||
newPassword:
|
||||
label: 设置密码
|
||||
placeholder: 至少 8 位
|
||||
confirmPassword:
|
||||
label: 确认密码
|
||||
placeholder: 再次输入密码
|
||||
name:
|
||||
label: 显示名
|
||||
placeholder: 可选,默认使用邮箱前缀
|
||||
action:
|
||||
continue: 继续
|
||||
signIn: 登录
|
||||
createAccount: 创建账号
|
||||
forgotPassword: 忘记密码?
|
||||
useDifferentEmail: 换个邮箱
|
||||
divider:
|
||||
or: 或
|
||||
error:
|
||||
fallback: 登录失败
|
||||
unknown: 发生了未知错误
|
||||
authFailed: Authentication failed. Please try again.
|
||||
passwordMismatch: 两次输入的密码不一致。
|
||||
invalidEmail: 请输入有效的邮箱地址。
|
||||
footer:
|
||||
prefix: 继续之前,你需要同意我们的
|
||||
terms: 服务条款
|
||||
and: 和
|
||||
privacy: 隐私政策
|
||||
profile:
|
||||
title: 账号资料
|
||||
description: 管理你的显示名和密码。
|
||||
section:
|
||||
profile: 个人资料
|
||||
password: 密码
|
||||
field:
|
||||
email: 邮箱
|
||||
emailVerified: 邮箱状态
|
||||
createdAt: 加入时间
|
||||
label:
|
||||
verified: 已验证
|
||||
unverified: 未验证
|
||||
name:
|
||||
label: 显示名
|
||||
placeholder: 别人看到的名字
|
||||
password:
|
||||
currentLabel: 当前密码
|
||||
currentPlaceholder: 输入当前密码
|
||||
newLabel: 新密码
|
||||
newPlaceholder: 至少 8 位
|
||||
confirmLabel: 确认新密码
|
||||
confirmPlaceholder: 再次输入新密码
|
||||
action:
|
||||
saveProfile: 保存修改
|
||||
changePassword: 修改密码
|
||||
signOut: 退出登录
|
||||
message:
|
||||
loading: 正在加载你的资料...
|
||||
profileSaved: 资料已更新。
|
||||
passwordChanged: 密码已修改。其他设备的登录会话已被注销。
|
||||
error:
|
||||
loadFailed: 无法加载你的资料。
|
||||
saveFailed: 无法保存修改。
|
||||
changePasswordFailed: 无法修改密码。
|
||||
signOutFailed: 退出登录失败。
|
||||
passwordMismatch: 两次输入的新密码不一致。
|
||||
passwordSameAsCurrent: 新密码不能和当前密码相同。
|
||||
webCallback:
|
||||
title:
|
||||
signIn: 登录
|
||||
|
|
|
|||
|
|
@ -147,11 +147,54 @@ pages:
|
|||
title: 账号
|
||||
description: 查看您的个人资料并管理您的帐户
|
||||
notLoggedIn: 登录以查看您的帐户并访问所有功能
|
||||
login: Sign in
|
||||
logout: Sign out
|
||||
login: 登录
|
||||
logout: 退出登录
|
||||
fluxBalance: 剩余通量
|
||||
viewFluxDetails: 查看详情
|
||||
signedInAs: 登录 为
|
||||
signedInAs: 已登录为
|
||||
profile:
|
||||
tab: 个人资料
|
||||
title: 个人资料
|
||||
description: 管理你公开显示的名字。
|
||||
name:
|
||||
label: 显示名
|
||||
placeholder: 别人看到的名字
|
||||
action:
|
||||
save: 保存修改
|
||||
message:
|
||||
saved: 资料已更新。
|
||||
error:
|
||||
fallback: 无法保存修改。
|
||||
security:
|
||||
tab: 安全
|
||||
title: 安全
|
||||
description: 修改密码。其他设备的登录会话会被注销。
|
||||
currentPassword:
|
||||
label: 当前密码
|
||||
placeholder: 输入当前密码
|
||||
newPassword:
|
||||
label: 新密码
|
||||
placeholder: 至少 8 位
|
||||
confirmPassword:
|
||||
label: 确认新密码
|
||||
placeholder: 再次输入新密码
|
||||
action:
|
||||
changePassword: 修改密码
|
||||
message:
|
||||
changed: 密码已修改。其他设备的登录会话已被注销。
|
||||
error:
|
||||
fallback: 无法修改密码。
|
||||
passwordMismatch: 两次输入的新密码不一致。
|
||||
passwordSameAsCurrent: 新密码不能和当前密码相同。
|
||||
danger:
|
||||
tab: 危险操作
|
||||
title: 危险操作
|
||||
description: 这里的操作不可撤销,请谨慎使用。
|
||||
deleteAccount:
|
||||
title: 删除账号
|
||||
description: 永久删除你的账号、会话与个人数据。
|
||||
action: 删除
|
||||
notAvailable: 即将上线——如需立即删除账号请联系支持。
|
||||
card:
|
||||
activate: 激活
|
||||
active: 已激活
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { signOut } from '@proj-airi/stage-ui/libs/auth'
|
|||
import { useAuthStore } from '@proj-airi/stage-ui/stores/auth'
|
||||
import { onClickOutside } from '@vueuse/core'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { computed, ref } from 'vue'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { RouterLink } from 'vue-router'
|
||||
|
||||
const authStore = useAuthStore()
|
||||
|
|
@ -14,6 +14,16 @@ const userAvatar = computed(() => user.value?.image)
|
|||
const showDropdown = ref(false)
|
||||
const dropdownRef = ref(null)
|
||||
|
||||
// Fall back to the user-icon placeholder when the avatar URL fails to load
|
||||
// (broken/expired host, network error, hot-linked image taken down). Without
|
||||
// this the header pill renders the browser's default broken-image glyph,
|
||||
// which looks worse than the explicit placeholder we already ship.
|
||||
// Reset on URL change so a fixed URL re-attempts loading.
|
||||
const avatarLoadError = ref(false)
|
||||
watch(userAvatar, () => { avatarLoadError.value = false })
|
||||
|
||||
const formattedCredits = computed(() => credits.value.toLocaleString())
|
||||
|
||||
onClickOutside(dropdownRef, () => {
|
||||
showDropdown.value = false
|
||||
})
|
||||
|
|
@ -57,9 +67,11 @@ onClickOutside(dropdownRef, () => {
|
|||
@click="showDropdown = !showDropdown"
|
||||
>
|
||||
<img
|
||||
v-if="userAvatar"
|
||||
v-if="userAvatar && !avatarLoadError"
|
||||
:src="userAvatar"
|
||||
:alt="userName"
|
||||
class="h-7 w-7 rounded-full object-cover"
|
||||
@error="avatarLoadError = true"
|
||||
>
|
||||
<div
|
||||
v-else
|
||||
|
|
@ -98,11 +110,20 @@ onClickOutside(dropdownRef, () => {
|
|||
</p>
|
||||
<div class="mt-1 flex items-center gap-1.5 text-xs text-primary-600 font-medium dark:text-primary-400">
|
||||
<div class="i-solar:battery-charge-bold-duotone text-sm" />
|
||||
<span>{{ credits }} Flux</span>
|
||||
<span>{{ formattedCredits }} Flux</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="py-1">
|
||||
<RouterLink
|
||||
to="/settings/account"
|
||||
class="group w-full flex items-center gap-3 rounded-lg px-3 py-2 text-sm text-neutral-700 transition hover:bg-neutral-100 dark:text-neutral-200 dark:hover:bg-neutral-800"
|
||||
@click="showDropdown = false"
|
||||
>
|
||||
<div class="i-solar:user-id-bold-duotone text-lg text-neutral-400 transition group-hover:text-primary-500" />
|
||||
Profile
|
||||
</RouterLink>
|
||||
|
||||
<RouterLink
|
||||
to="/settings/flux"
|
||||
class="group w-full flex items-center gap-3 rounded-lg px-3 py-2 text-sm text-neutral-700 transition hover:bg-neutral-100 dark:text-neutral-200 dark:hover:bg-neutral-800"
|
||||
|
|
|
|||
|
|
@ -1,11 +1,8 @@
|
|||
<script setup lang="ts">
|
||||
import { isStageTamagotchi } from '@proj-airi/stage-shared'
|
||||
import { LoginDrawer, PageHeader } from '@proj-airi/stage-ui/components'
|
||||
import { useBreakpoints } from '@proj-airi/stage-ui/composables'
|
||||
import { useAuthStore } from '@proj-airi/stage-ui/stores/auth'
|
||||
import { PageHeader } from '@proj-airi/stage-ui/components'
|
||||
import { useProvidersStore } from '@proj-airi/stage-ui/stores/providers'
|
||||
import { useTheme } from '@proj-airi/ui'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { computed, onMounted, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { RouterView, useRoute } from 'vue-router'
|
||||
|
|
@ -15,8 +12,6 @@ import HeaderLink from '../components/Layouts/HeaderLink.vue'
|
|||
import { themeColorFromValue, useThemeColor } from '../composables/theme-color'
|
||||
|
||||
const route = useRoute()
|
||||
const { isMobile } = useBreakpoints()
|
||||
const { needsLogin } = storeToRefs(useAuthStore())
|
||||
const { isDark: dark } = useTheme()
|
||||
const { t } = useI18n()
|
||||
const providersStore = useProvidersStore()
|
||||
|
|
@ -111,9 +106,4 @@ onMounted(() => updateThemeColor())
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<LoginDrawer
|
||||
v-if="isMobile"
|
||||
v-model:open="needsLogin"
|
||||
/>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -1,21 +1,9 @@
|
|||
<script setup lang="ts">
|
||||
import { LoginDrawer } from '@proj-airi/stage-ui/components'
|
||||
import { useBreakpoints } from '@proj-airi/stage-ui/composables'
|
||||
import { useAuthStore } from '@proj-airi/stage-ui/stores/auth'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { RouterView } from 'vue-router'
|
||||
|
||||
const { isMobile } = useBreakpoints()
|
||||
const { needsLogin } = storeToRefs(useAuthStore())
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main h-full font-cute>
|
||||
<RouterView />
|
||||
|
||||
<LoginDrawer
|
||||
v-if="isMobile"
|
||||
v-model:open="needsLogin"
|
||||
/>
|
||||
</main>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -1,11 +1,15 @@
|
|||
<script setup lang="ts">
|
||||
import { errorMessageFrom } from '@moeru/std'
|
||||
import { authClient } from '@proj-airi/stage-ui/libs/auth'
|
||||
import { useAuthStore } from '@proj-airi/stage-ui/stores/auth'
|
||||
import { Button } from '@proj-airi/ui'
|
||||
import { Button, FieldInput } from '@proj-airi/ui'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { computed } from 'vue'
|
||||
import { computed, reactive, ref, shallowRef, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { RouterLink } from 'vue-router'
|
||||
|
||||
type SectionId = 'profile' | 'security' | 'danger'
|
||||
|
||||
const emit = defineEmits<{
|
||||
login: []
|
||||
logout: []
|
||||
|
|
@ -18,74 +22,453 @@ const { isAuthenticated, user, credits } = storeToRefs(authStore)
|
|||
const userName = computed(() => user.value?.name ?? '')
|
||||
const userEmail = computed(() => user.value?.email ?? null)
|
||||
const userAvatar = computed(() => user.value?.image ?? null)
|
||||
|
||||
// Track avatar load failure so we can fall back to the placeholder icon
|
||||
// instead of rendering an alt-text overflow inside the circle. Resets when
|
||||
// the URL changes so a fixed URL re-attempts loading.
|
||||
const avatarLoadError = ref(false)
|
||||
watch(userAvatar, () => { avatarLoadError.value = false })
|
||||
|
||||
// Locale-aware thousand separator. Bare 5–6 digit numbers are noisy to scan
|
||||
// (e.g. "44965" reads as one block); Intl.NumberFormat respects user locale
|
||||
// (44,965 / 44 965 / 44.965 depending on region) without us having to ship a
|
||||
// formatter.
|
||||
const formattedCredits = computed(() => credits.value.toLocaleString())
|
||||
|
||||
// Profile form. Initialized from store and re-synced when user changes (e.g.
|
||||
// after a successful save we mutate the store).
|
||||
// NOTICE:
|
||||
// Avatar editing is intentionally absent here pending the avatar-upload
|
||||
// feature (R2/MinIO presigned PUT pipeline). The previous URL-pasting input
|
||||
// was a placeholder UX and has been removed; the existing user.image is
|
||||
// still rendered as the avatar circle above, just not editable for now.
|
||||
const profileForm = reactive({ name: '' })
|
||||
|
||||
watch(
|
||||
user,
|
||||
(next) => {
|
||||
profileForm.name = next?.name ?? ''
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
const profileLoading = shallowRef(false)
|
||||
const profileError = shallowRef<string | null>(null)
|
||||
const profileSuccess = shallowRef<string | null>(null)
|
||||
|
||||
const profileDirty = computed(() => {
|
||||
if (!user.value)
|
||||
return false
|
||||
const name = profileForm.name.trim()
|
||||
if (!name)
|
||||
return false
|
||||
return name !== (user.value.name ?? '')
|
||||
})
|
||||
|
||||
// Security form: change password.
|
||||
const passwordForm = reactive({ current: '', next: '', confirm: '' })
|
||||
const passwordLoading = shallowRef(false)
|
||||
const passwordError = shallowRef<string | null>(null)
|
||||
const passwordSuccess = shallowRef<string | null>(null)
|
||||
|
||||
// Sidebar active section. Click jumps + highlights; we don't observe scroll
|
||||
// position because the page is short enough that simple click → scroll is
|
||||
// sufficient and easier to reason about.
|
||||
const activeSection = ref<SectionId>('profile')
|
||||
const profileSectionRef = ref<HTMLElement | null>(null)
|
||||
const securitySectionRef = ref<HTMLElement | null>(null)
|
||||
const dangerSectionRef = ref<HTMLElement | null>(null)
|
||||
|
||||
function scrollToSection(id: SectionId) {
|
||||
activeSection.value = id
|
||||
const target
|
||||
= id === 'profile'
|
||||
? profileSectionRef.value
|
||||
: id === 'security'
|
||||
? securitySectionRef.value
|
||||
: dangerSectionRef.value
|
||||
// Settings layout owns a custom scroll container (#settings-scroll-container).
|
||||
// scrollIntoView walks up parents to find a scrollable ancestor, so it works
|
||||
// for both window-scroll pages and our inner-scroll layout without a special
|
||||
// case here.
|
||||
target?.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
||||
}
|
||||
|
||||
async function handleSaveProfile(event: Event) {
|
||||
event.preventDefault()
|
||||
if (profileLoading.value || !profileDirty.value)
|
||||
return
|
||||
|
||||
profileError.value = null
|
||||
profileSuccess.value = null
|
||||
profileLoading.value = true
|
||||
|
||||
const trimmedName = profileForm.name.trim()
|
||||
|
||||
try {
|
||||
const { error } = await authClient.updateUser({
|
||||
name: trimmedName,
|
||||
})
|
||||
if (error)
|
||||
throw new Error(error.message ?? 'updateUser failed')
|
||||
|
||||
if (user.value) {
|
||||
authStore.user = {
|
||||
...user.value,
|
||||
name: trimmedName,
|
||||
}
|
||||
}
|
||||
profileForm.name = trimmedName
|
||||
profileSuccess.value = t('settings.pages.account.profile.message.saved')
|
||||
}
|
||||
catch (error) {
|
||||
profileError.value = errorMessageFrom(error) ?? t('settings.pages.account.profile.error.fallback')
|
||||
}
|
||||
finally {
|
||||
profileLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleChangePassword(event: Event) {
|
||||
event.preventDefault()
|
||||
if (passwordLoading.value)
|
||||
return
|
||||
|
||||
passwordError.value = null
|
||||
passwordSuccess.value = null
|
||||
|
||||
if (passwordForm.next !== passwordForm.confirm) {
|
||||
passwordError.value = t('settings.pages.account.security.error.passwordMismatch')
|
||||
return
|
||||
}
|
||||
if (passwordForm.next === passwordForm.current) {
|
||||
passwordError.value = t('settings.pages.account.security.error.passwordSameAsCurrent')
|
||||
return
|
||||
}
|
||||
|
||||
passwordLoading.value = true
|
||||
try {
|
||||
const { error } = await authClient.changePassword({
|
||||
currentPassword: passwordForm.current,
|
||||
newPassword: passwordForm.next,
|
||||
revokeOtherSessions: true,
|
||||
})
|
||||
if (error)
|
||||
throw new Error(error.message ?? 'changePassword failed')
|
||||
|
||||
passwordForm.current = ''
|
||||
passwordForm.next = ''
|
||||
passwordForm.confirm = ''
|
||||
passwordSuccess.value = t('settings.pages.account.security.message.changed')
|
||||
}
|
||||
catch (error) {
|
||||
passwordError.value = errorMessageFrom(error) ?? t('settings.pages.account.security.error.fallback')
|
||||
}
|
||||
finally {
|
||||
passwordLoading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="['flex flex-col gap-6', 'p-4']">
|
||||
<template v-if="isAuthenticated">
|
||||
<div :class="['flex flex-col items-center gap-3', 'rounded-xl p-6', 'bg-neutral-50 dark:bg-neutral-900']">
|
||||
<div :class="['size-20 rounded-full overflow-hidden', 'bg-neutral-200 dark:bg-neutral-700', 'flex items-center justify-center']">
|
||||
<img
|
||||
v-if="userAvatar"
|
||||
:src="userAvatar"
|
||||
:alt="userName"
|
||||
:class="['size-full object-cover']"
|
||||
<!-- 2-col layout on md+; pure single-column on mobile. The sidebar is
|
||||
navigation chrome that adds noise on small viewports — sections are
|
||||
short enough to scroll through directly. Sign-out lives at the page
|
||||
foot as a standalone action so it doesn't share visual real estate
|
||||
with the destructive Danger Zone tab. -->
|
||||
<div :class="['flex flex-col md:grid md:grid-cols-[180px_minmax(0,1fr)] md:items-start gap-8']">
|
||||
<!-- Sidebar / section nav (desktop only). Logout sits at the foot,
|
||||
separated by a divider — it's an action, not a section anchor, so
|
||||
the visual break prevents users from reading it as just another
|
||||
section like Profile / Security / Danger. -->
|
||||
<aside :class="['hidden md:flex flex-col gap-1 md:sticky md:top-2']">
|
||||
<button
|
||||
v-for="section in ['profile', 'security', 'danger'] as SectionId[]"
|
||||
:key="section"
|
||||
type="button"
|
||||
:class="[
|
||||
'w-full text-left rounded-lg px-3 py-2 text-sm transition-colors cursor-pointer',
|
||||
activeSection === section
|
||||
? section === 'danger'
|
||||
? 'bg-red-100/70 text-red-600 dark:bg-red-900/30 dark:text-red-300'
|
||||
: 'bg-primary-100/70 text-primary-700 dark:bg-primary-900/30 dark:text-primary-200'
|
||||
: section === 'danger'
|
||||
? 'text-red-600 hover:bg-red-50 dark:text-red-400 dark:hover:bg-red-900/20'
|
||||
: 'text-neutral-700 hover:bg-neutral-100 dark:text-neutral-200 dark:hover:bg-neutral-800/60',
|
||||
]"
|
||||
@click="scrollToSection(section)"
|
||||
>
|
||||
<div
|
||||
v-else
|
||||
:class="['i-solar:user-circle-bold-duotone', 'size-12 text-neutral-400']"
|
||||
/>
|
||||
</div>
|
||||
{{ t(`settings.pages.account.${section}.tab`) }}
|
||||
</button>
|
||||
|
||||
<div :class="['flex flex-col items-center gap-1']">
|
||||
<span :class="['text-sm text-neutral-500 dark:text-neutral-400']">
|
||||
{{ t('settings.pages.account.signedInAs') }}
|
||||
</span>
|
||||
<h2 :class="['text-lg font-semibold']">
|
||||
{{ userName }}
|
||||
</h2>
|
||||
<p
|
||||
v-if="userEmail"
|
||||
:class="['text-sm text-neutral-500 dark:text-neutral-400']"
|
||||
<div :class="['my-2 border-t border-neutral-200/70 dark:border-neutral-800/60']" />
|
||||
|
||||
<button
|
||||
type="button"
|
||||
:class="[
|
||||
'w-full text-left rounded-lg px-3 py-2 text-sm transition-colors cursor-pointer',
|
||||
'flex items-center gap-2',
|
||||
'text-neutral-600 hover:bg-neutral-100 dark:text-neutral-300 dark:hover:bg-neutral-800/60',
|
||||
]"
|
||||
@click="emit('logout')"
|
||||
>
|
||||
{{ userEmail }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div :class="['i-solar:logout-3-bold-duotone', 'size-4 flex-shrink-0']" />
|
||||
{{ t('settings.pages.account.logout') }}
|
||||
</button>
|
||||
</aside>
|
||||
|
||||
<RouterLink
|
||||
to="/settings/flux"
|
||||
:class="[
|
||||
'flex items-center justify-between',
|
||||
'rounded-xl p-4',
|
||||
'border-2 border-neutral-200 dark:border-neutral-800',
|
||||
'hover:bg-neutral-50 dark:hover:bg-neutral-800/50',
|
||||
'transition-colors',
|
||||
'no-underline text-inherit',
|
||||
]"
|
||||
>
|
||||
<div :class="['flex items-center gap-3']">
|
||||
<div :class="['i-solar:battery-charge-bold-duotone', 'size-6 text-primary-500']" />
|
||||
<div :class="['flex flex-col']">
|
||||
<span :class="['text-sm font-medium']">
|
||||
{{ t('settings.pages.account.fluxBalance') }}
|
||||
</span>
|
||||
<span :class="['text-2xl font-bold']">
|
||||
{{ credits }}
|
||||
</span>
|
||||
<!-- Main column. max-w-3xl keeps long lines (descriptions, the Flux
|
||||
card) within a comfortable reading column instead of stretching
|
||||
across the full settings viewport. -->
|
||||
<div :class="['flex flex-col min-w-0 max-w-3xl']">
|
||||
<!-- Identity + Flux. Combined into a single divider-separated
|
||||
section so the page reads as: account-info | profile | security
|
||||
| danger. Earlier version split identity (no border) and flux
|
||||
(border-b) into two visual blocks, which read as "Flux is its
|
||||
own section like Profile/Security" — but Flux is metadata
|
||||
about the same account, not a separate concern. -->
|
||||
<section :class="['flex flex-col gap-3 pb-6 border-b border-neutral-200/70 dark:border-neutral-800/60']">
|
||||
<div :class="['flex items-center gap-4 py-2']">
|
||||
<div :class="['size-16 sm:size-20 rounded-full overflow-hidden flex-shrink-0', 'bg-neutral-100 dark:bg-neutral-800', 'flex items-center justify-center']">
|
||||
<img
|
||||
v-if="userAvatar && !avatarLoadError"
|
||||
:src="userAvatar"
|
||||
:alt="userName"
|
||||
:class="['size-full object-cover']"
|
||||
@error="avatarLoadError = true"
|
||||
>
|
||||
<div v-else :class="['i-solar:user-circle-bold-duotone', 'size-10 text-neutral-400']" />
|
||||
</div>
|
||||
<div :class="['flex flex-col gap-0.5 min-w-0']">
|
||||
<span :class="['text-xs text-neutral-500 dark:text-neutral-400']">
|
||||
{{ t('settings.pages.account.signedInAs') }}
|
||||
</span>
|
||||
<h2 :class="['text-lg sm:text-xl font-semibold truncate']">
|
||||
{{ userName || t('settings.pages.account.profile.name.placeholder') }}
|
||||
</h2>
|
||||
<p
|
||||
v-if="userEmail"
|
||||
:class="['text-sm text-neutral-500 dark:text-neutral-400 truncate']"
|
||||
>
|
||||
{{ userEmail }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Flux row — quiet inline metadata. Hover bg only on hover so at
|
||||
rest it reads as plain text (not a button); chevron + colored
|
||||
link text are the only navigability hints. -->
|
||||
<RouterLink
|
||||
to="/settings/flux"
|
||||
:class="[
|
||||
'-mx-2 flex items-center gap-2 px-2 py-1.5 rounded-md',
|
||||
'text-sm no-underline text-inherit',
|
||||
'hover:bg-neutral-50 dark:hover:bg-neutral-800/40 transition-colors',
|
||||
]"
|
||||
>
|
||||
<span :class="['text-neutral-500 dark:text-neutral-400']">
|
||||
{{ t('settings.pages.account.fluxBalance') }}
|
||||
</span>
|
||||
<span :class="['font-semibold tabular-nums']">
|
||||
{{ formattedCredits }}
|
||||
</span>
|
||||
<span :class="['ml-auto flex items-center gap-1 text-primary-600 dark:text-primary-400']">
|
||||
<span>{{ t('settings.pages.account.viewFluxDetails') }}</span>
|
||||
<div :class="['i-solar:alt-arrow-right-linear', 'size-4']" />
|
||||
</span>
|
||||
</RouterLink>
|
||||
</section>
|
||||
|
||||
<!-- Profile section. No card outline — sections are separated by a
|
||||
bottom divider + generous padding so the page reads as a single
|
||||
surface rather than stacked boxes. Form chrome is constrained to
|
||||
max-w-md so display-name / URL fields don't sprawl across the
|
||||
viewport. -->
|
||||
<section
|
||||
ref="profileSectionRef"
|
||||
:class="['flex flex-col gap-4 py-8 border-b border-neutral-200/70 dark:border-neutral-800/60']"
|
||||
>
|
||||
<header :class="['flex flex-col gap-1']">
|
||||
<h3 :class="['text-lg font-semibold']">
|
||||
{{ t('settings.pages.account.profile.title') }}
|
||||
</h3>
|
||||
<p :class="['text-sm text-neutral-500 dark:text-neutral-400']">
|
||||
{{ t('settings.pages.account.profile.description') }}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<form :class="['flex flex-col gap-3 max-w-md']" @submit="handleSaveProfile">
|
||||
<FieldInput
|
||||
v-model="profileForm.name"
|
||||
type="text"
|
||||
:label="t('settings.pages.account.profile.name.label')"
|
||||
:placeholder="t('settings.pages.account.profile.name.placeholder')"
|
||||
/>
|
||||
|
||||
<div
|
||||
v-if="profileError"
|
||||
:class="['text-sm text-red-500']"
|
||||
role="alert"
|
||||
aria-live="polite"
|
||||
>
|
||||
{{ profileError }}
|
||||
</div>
|
||||
<div
|
||||
v-else-if="profileSuccess"
|
||||
:class="['text-sm text-green-600 dark:text-green-400']"
|
||||
aria-live="polite"
|
||||
>
|
||||
{{ profileSuccess }}
|
||||
</div>
|
||||
|
||||
<div :class="['flex justify-start']">
|
||||
<Button
|
||||
type="submit"
|
||||
:loading="profileLoading"
|
||||
:disabled="!profileDirty"
|
||||
:label="t('settings.pages.account.profile.action.save')"
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<!-- Security section. Same treatment as Profile — borderless,
|
||||
divider-separated, form constrained to readable column width. -->
|
||||
<section
|
||||
ref="securitySectionRef"
|
||||
:class="['flex flex-col gap-4 py-8 border-b border-neutral-200/70 dark:border-neutral-800/60']"
|
||||
>
|
||||
<header :class="['flex flex-col gap-1']">
|
||||
<h3 :class="['text-lg font-semibold']">
|
||||
{{ t('settings.pages.account.security.title') }}
|
||||
</h3>
|
||||
<p :class="['text-sm text-neutral-500 dark:text-neutral-400']">
|
||||
{{ t('settings.pages.account.security.description') }}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<form :class="['flex flex-col gap-3 max-w-md']" @submit="handleChangePassword">
|
||||
<FieldInput
|
||||
v-model="passwordForm.current"
|
||||
type="password"
|
||||
:label="t('settings.pages.account.security.currentPassword.label')"
|
||||
:placeholder="t('settings.pages.account.security.currentPassword.placeholder')"
|
||||
required
|
||||
hide-required-mark
|
||||
autocomplete="current-password"
|
||||
/>
|
||||
<FieldInput
|
||||
v-model="passwordForm.next"
|
||||
type="password"
|
||||
:label="t('settings.pages.account.security.newPassword.label')"
|
||||
:placeholder="t('settings.pages.account.security.newPassword.placeholder')"
|
||||
required
|
||||
hide-required-mark
|
||||
autocomplete="new-password"
|
||||
/>
|
||||
<FieldInput
|
||||
v-model="passwordForm.confirm"
|
||||
type="password"
|
||||
:label="t('settings.pages.account.security.confirmPassword.label')"
|
||||
:placeholder="t('settings.pages.account.security.confirmPassword.placeholder')"
|
||||
required
|
||||
hide-required-mark
|
||||
autocomplete="new-password"
|
||||
/>
|
||||
|
||||
<div
|
||||
v-if="passwordError"
|
||||
:class="['text-sm text-red-500']"
|
||||
role="alert"
|
||||
aria-live="polite"
|
||||
>
|
||||
{{ passwordError }}
|
||||
</div>
|
||||
<div
|
||||
v-else-if="passwordSuccess"
|
||||
:class="['text-sm text-green-600 dark:text-green-400']"
|
||||
aria-live="polite"
|
||||
>
|
||||
{{ passwordSuccess }}
|
||||
</div>
|
||||
|
||||
<div :class="['flex justify-start']">
|
||||
<Button
|
||||
type="submit"
|
||||
:loading="passwordLoading"
|
||||
:label="t('settings.pages.account.security.action.changePassword')"
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<!-- Danger zone. Same divider-based section style as Profile /
|
||||
Security; semantic weight comes from the red header text and the
|
||||
variant="danger" button, not nested boxes. Trailing border-b
|
||||
separates the destructive group from the quiet sign-out utility
|
||||
below — without it the logout row visually attaches to delete
|
||||
account, blurring the boundary between "reversible" and
|
||||
"destructive". -->
|
||||
<section
|
||||
ref="dangerSectionRef"
|
||||
:class="['flex flex-col gap-4 py-8 border-b border-neutral-200/70 dark:border-neutral-800/60']"
|
||||
>
|
||||
<header :class="['flex flex-col gap-1']">
|
||||
<h3 :class="['text-lg font-semibold text-red-600 dark:text-red-400']">
|
||||
{{ t('settings.pages.account.danger.title') }}
|
||||
</h3>
|
||||
<p :class="['text-sm text-neutral-500 dark:text-neutral-400']">
|
||||
{{ t('settings.pages.account.danger.description') }}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<!-- TODO: Wire up delete-account once server enables
|
||||
user.deleteUser in better-auth config. The endpoint sends a
|
||||
confirmation email and revokes all sessions, so the UX needs
|
||||
a confirmation modal + post-delete redirect. -->
|
||||
<div :class="['flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3']">
|
||||
<div :class="['flex flex-col gap-0.5 min-w-0']">
|
||||
<span :class="['text-sm font-medium']">
|
||||
{{ t('settings.pages.account.danger.deleteAccount.title') }}
|
||||
</span>
|
||||
<span :class="['text-xs text-neutral-500 dark:text-neutral-400']">
|
||||
{{ t('settings.pages.account.danger.deleteAccount.description') }}
|
||||
</span>
|
||||
</div>
|
||||
<div :class="['flex-shrink-0']">
|
||||
<Button
|
||||
variant="danger"
|
||||
disabled
|
||||
:title="t('settings.pages.account.danger.deleteAccount.notAvailable')"
|
||||
:label="t('settings.pages.account.danger.deleteAccount.action')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Sign out at the page foot — mobile-only fallback because the
|
||||
sidebar (which owns logout on desktop) is hidden on small
|
||||
viewports. Kept outside the Danger Zone because logging out is
|
||||
reversible (just sign back in) — putting it in the destructive
|
||||
group would over-signal severity. -->
|
||||
<div :class="['md:hidden pt-2']">
|
||||
<button
|
||||
type="button"
|
||||
:class="[
|
||||
'flex items-center gap-2 rounded-lg px-3 py-2 text-sm cursor-pointer',
|
||||
'text-neutral-600 hover:bg-neutral-100 dark:text-neutral-300 dark:hover:bg-neutral-800/60',
|
||||
'transition-colors',
|
||||
]"
|
||||
@click="emit('logout')"
|
||||
>
|
||||
<div :class="['i-solar:logout-3-bold-duotone', 'size-4']" />
|
||||
{{ t('settings.pages.account.logout') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div :class="['flex items-center gap-1', 'text-sm text-neutral-500 dark:text-neutral-400']">
|
||||
<span>{{ t('settings.pages.account.viewFluxDetails') }}</span>
|
||||
<div :class="['i-solar:alt-arrow-right-linear', 'size-4']" />
|
||||
</div>
|
||||
</RouterLink>
|
||||
|
||||
<Button
|
||||
variant="danger"
|
||||
:label="t('settings.pages.account.logout')"
|
||||
@click="emit('logout')"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@
|
|||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@moeru/std": "catalog:",
|
||||
"@vueuse/core": "catalog:",
|
||||
"gpuu": "^1.0.7",
|
||||
"pinia": "catalog:",
|
||||
|
|
|
|||
19
packages/stage-shared/src/error-message.ts
Normal file
19
packages/stage-shared/src/error-message.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import { errorMessageFrom } from '@moeru/std'
|
||||
|
||||
/**
|
||||
* Returns a stable human-readable message for an unknown error.
|
||||
*
|
||||
* Use when:
|
||||
* - Surfacing arbitrary thrown values (network failures, third-party errors,
|
||||
* non-Error throws) to the UI as a string.
|
||||
*
|
||||
* Expects:
|
||||
* - `error` may be anything — Error, string, plain object, undefined.
|
||||
*
|
||||
* Returns:
|
||||
* - The first non-empty message extracted by {@link errorMessageFrom},
|
||||
* else `unknownMessage`, else the literal `'Unknown error'`.
|
||||
*/
|
||||
export function errorMessageFromUnknown(error: unknown, unknownMessage?: string): string {
|
||||
return errorMessageFrom(error) ?? unknownMessage ?? 'Unknown error'
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
export * from './artistry'
|
||||
export * from './env-vars'
|
||||
export * from './environment'
|
||||
export * from './error-message'
|
||||
export * from './export-csv'
|
||||
export * from './perf/io-trace'
|
||||
export * from './perf/tracer'
|
||||
|
|
|
|||
|
|
@ -1,74 +0,0 @@
|
|||
<script setup lang="ts">
|
||||
import type { OAuthProvider } from '../../libs/auth'
|
||||
|
||||
import { Button } from '@proj-airi/ui'
|
||||
import { useResizeObserver, useScreenSafeArea } from '@vueuse/core'
|
||||
import { DrawerContent, DrawerHandle, DrawerOverlay, DrawerPortal, DrawerRoot } from 'vaul-vue'
|
||||
import { ref } from 'vue'
|
||||
import { toast } from 'vue-sonner'
|
||||
|
||||
import { signInOIDC } from '../../libs/auth'
|
||||
import { OIDC_CLIENT_ID, OIDC_REDIRECT_URI } from '../../libs/auth-config'
|
||||
import { defaultSignInProviders } from './providers'
|
||||
|
||||
const open = defineModel<boolean>('open', { required: true })
|
||||
|
||||
const screenSafeArea = useScreenSafeArea()
|
||||
useResizeObserver(document.documentElement, () => screenSafeArea.update())
|
||||
|
||||
const loading = ref<Record<OAuthProvider, boolean>>({
|
||||
google: false,
|
||||
github: false,
|
||||
})
|
||||
|
||||
async function handleSignIn(provider: OAuthProvider) {
|
||||
loading.value[provider] = true
|
||||
try {
|
||||
await signInOIDC({
|
||||
clientId: OIDC_CLIENT_ID,
|
||||
redirectUri: OIDC_REDIRECT_URI,
|
||||
provider,
|
||||
})
|
||||
}
|
||||
catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : 'An unknown error occurred')
|
||||
}
|
||||
finally {
|
||||
loading.value[provider] = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DrawerRoot v-model:open="open" should-scale-background>
|
||||
<DrawerPortal>
|
||||
<DrawerOverlay class="fixed inset-0 z-1000 bg-black/40" />
|
||||
<DrawerContent
|
||||
class="fixed bottom-0 left-0 right-0 z-1001 flex flex-col rounded-t-3xl bg-white outline-none dark:bg-neutral-900"
|
||||
:style="{ paddingBottom: `${Math.max(Number.parseFloat(screenSafeArea.bottom.value.replace('px', '')), 24)}px` }"
|
||||
>
|
||||
<div class="px-6 pt-2">
|
||||
<DrawerHandle class="mb-6" />
|
||||
<div class="mb-6 text-2xl font-bold">
|
||||
Sign in
|
||||
</div>
|
||||
<div class="flex flex-col gap-4">
|
||||
<Button
|
||||
v-for="provider in defaultSignInProviders"
|
||||
:key="provider.id"
|
||||
:class="['w-full', 'py-4', 'flex', 'items-center', 'justify-center', 'gap-3', 'text-lg', 'rounded-2xl']"
|
||||
:icon="provider.icon"
|
||||
:loading="loading[provider.id]"
|
||||
@click="handleSignIn(provider.id)"
|
||||
>
|
||||
<span>Sign in with {{ provider.name }}</span>
|
||||
</Button>
|
||||
</div>
|
||||
<div class="mt-10 pb-2 text-center text-xs text-gray-400">
|
||||
By continuing, you agree to our <a href="https://airi.moeru.ai/docs/en/about/terms" class="underline">Terms</a> and <a href="https://airi.moeru.ai/docs/en/about/privacy" class="underline">Privacy Policy</a>.
|
||||
</div>
|
||||
</div>
|
||||
</DrawerContent>
|
||||
</DrawerPortal>
|
||||
</DrawerRoot>
|
||||
</template>
|
||||
|
|
@ -1,3 +1,2 @@
|
|||
export { default as LoginDrawer } from './LoginDrawer.vue'
|
||||
export * from './providers'
|
||||
export { default as SignInPanel } from './SignInPanel.vue'
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import type { OIDCFlowParams, TokenResponse } from './auth-oidc'
|
|||
import { createAuthClient } from 'better-auth/vue'
|
||||
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import { OIDC_CLIENT_ID, OIDC_REDIRECT_URI } from './auth-config'
|
||||
import { buildAuthorizationURL, persistFlowState } from './auth-oidc'
|
||||
import { SERVER_URL } from './server'
|
||||
|
||||
|
|
@ -67,6 +68,12 @@ export async function applyOIDCTokens(tokens: TokenResponse, clientId: string):
|
|||
authStore.token = tokens.access_token
|
||||
if (tokens.refresh_token)
|
||||
authStore.refreshToken = tokens.refresh_token
|
||||
// Persist the ID token so signOut() can drive RP-Initiated Logout via
|
||||
// `id_token_hint`. Token rotation does not refresh the ID token, so the
|
||||
// value captured here at sign-in time is the one we use for the lifetime
|
||||
// of the local session.
|
||||
if (tokens.id_token)
|
||||
authStore.idToken = tokens.id_token
|
||||
|
||||
// Persist client info for refresh after page reload
|
||||
authStore.oidcClientId = clientId
|
||||
|
|
@ -101,22 +108,69 @@ export async function listSessions() {
|
|||
|
||||
export async function signOut() {
|
||||
const authStore = useAuthStore()
|
||||
|
||||
// Capture the bits we need before clearOIDCState() wipes them.
|
||||
const idTokenHint = authStore.idToken
|
||||
const clientId = authStore.oidcClientId
|
||||
const bearerToken = authStore.token
|
||||
|
||||
// Optimistic logout — clear all client state synchronously so the UI
|
||||
// (router guards, isAuthenticated watchers, logout hooks) can react in the
|
||||
// same tick. The end-session round-trip below was previously awaited which
|
||||
// gave a ~2s perceived stall on click; since the call is already
|
||||
// best-effort (we swallow errors), it doesn't need to block the user.
|
||||
authStore.clearOIDCState()
|
||||
|
||||
// NOTICE: Server signOut is wrapped in try/catch so that local state cleanup
|
||||
// always runs regardless of server errors (e.g. network unreachable). User
|
||||
// intent to sign out is respected even if token revocation fails server-side.
|
||||
try {
|
||||
await authClient.signOut()
|
||||
}
|
||||
catch {
|
||||
// Swallow — local cleanup below ensures the user is signed out client-side.
|
||||
}
|
||||
|
||||
authStore.user = null
|
||||
authStore.session = null
|
||||
authStore.token = null
|
||||
authStore.refreshToken = null
|
||||
|
||||
// NOTICE:
|
||||
// OIDC RP-Initiated Logout (`/api/auth/oauth2/end-session`) is the
|
||||
// Bearer-friendly logout path. It accepts an `id_token_hint`, decodes the
|
||||
// `sid` claim, and deletes the corresponding `session` row directly via
|
||||
// `internalAdapter.deleteSession(session.token)` — no cookie required.
|
||||
// Once the row is gone, even if the browser still carries a stale session
|
||||
// cookie (cross-site SameSite=Lax can attach it on a top-level redirect to
|
||||
// /oauth2/authorize), the server cannot resolve it to an active session,
|
||||
// so the next sign-in attempt prompts proper authentication instead of
|
||||
// silently re-issuing tokens.
|
||||
//
|
||||
// Requires the trusted OIDC client to be seeded with `enableEndSession: true`,
|
||||
// which also gates whether the issued ID token carries the `sid` claim.
|
||||
// Source: node_modules/@better-auth/oauth-provider/dist/index.mjs L996+
|
||||
//
|
||||
// Fire-and-forget: the user has already been logged out locally; this is
|
||||
// best-effort server-side session cleanup. If it fails (offline, server
|
||||
// down) the worst case is the server-side session row is orphaned until
|
||||
// its TTL expires — better-auth will reject it on next use anyway.
|
||||
if (idTokenHint && clientId) {
|
||||
const url = new URL('/api/auth/oauth2/end-session', SERVER_URL)
|
||||
url.searchParams.set('id_token_hint', idTokenHint)
|
||||
url.searchParams.set('client_id', clientId)
|
||||
fetch(url.toString(), { method: 'GET', keepalive: true }).catch(() => {})
|
||||
return
|
||||
}
|
||||
|
||||
// NOTICE:
|
||||
// Fallback for sessions created before id_token persistence existed (legacy
|
||||
// installs prior to applyOIDCTokens persisting `id_token`), or any code
|
||||
// path that signed in without going through the OIDC client (e.g. a future
|
||||
// direct credential flow). Without this branch those sessions would skip
|
||||
// server-side cleanup entirely, leaving the row alive and allowing the
|
||||
// next /oauth2/authorize hop to silently re-issue tokens (cookie attached
|
||||
// via SameSite=Lax on top-level redirect).
|
||||
//
|
||||
// /api/auth/sign-out is the standard better-auth Bearer sign-out endpoint;
|
||||
// it deletes the session row keyed off the Authorization header.
|
||||
if (bearerToken) {
|
||||
const url = new URL('/api/auth/sign-out', SERVER_URL)
|
||||
fetch(url.toString(), {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${bearerToken}` },
|
||||
keepalive: true,
|
||||
}).catch(() => {})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -138,3 +192,31 @@ export async function signInOIDC(params: OIDCFlowParams) {
|
|||
callbackURL: url.toString(),
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger the project-default OIDC sign-in flow.
|
||||
*
|
||||
* Use when:
|
||||
* - Any UI surface needs to start a login (top-nav button, 401 handler,
|
||||
* onboarding gate, "Try again" on a failed callback). Sign-in is an
|
||||
* action, not a page — callers do NOT navigate to a sign-in route first.
|
||||
*
|
||||
* Expects:
|
||||
* - `auth-config.ts` provides `OIDC_CLIENT_ID` and `OIDC_REDIRECT_URI` for
|
||||
* the current app (web vs. tamagotchi vs. pocket).
|
||||
*
|
||||
* Returns:
|
||||
* - Resolves after the browser has been navigated. In practice the page
|
||||
* unloads, so callers usually do not see the resolution.
|
||||
*
|
||||
* `opts.provider` (optional): skip the picker page and jump straight to a
|
||||
* social provider. Omit to land on the project's hosted login page
|
||||
* (ui-server-auth) where the user can choose email/password or social.
|
||||
*/
|
||||
export async function triggerSignIn(opts?: { provider?: OAuthProvider }): Promise<void> {
|
||||
await signInOIDC({
|
||||
clientId: OIDC_CLIENT_ID,
|
||||
redirectUri: OIDC_REDIRECT_URI,
|
||||
...opts,
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import { computed, ref, watch } from 'vue'
|
|||
|
||||
import { client } from '../composables/api'
|
||||
import { useBreakpoints } from '../composables/use-breakpoints'
|
||||
import { triggerSignIn } from '../libs/auth'
|
||||
import { refreshAccessToken } from '../libs/auth-oidc'
|
||||
|
||||
/**
|
||||
|
|
@ -23,6 +24,12 @@ export const useAuthStore = defineStore('auth', () => {
|
|||
const session = useLocalStorage<Session | null>('auth/v1/session', null, { serializer: StorageSerializers.object })
|
||||
const token = useLocalStorage<string | null>('auth/v1/token', null)
|
||||
const refreshToken = useLocalStorage<string | null>('auth/v1/refresh-token', null)
|
||||
// NOTICE:
|
||||
// Persisted to drive `id_token_hint` on RP-Initiated Logout
|
||||
// (`/api/auth/oauth2/end-session`). The `sid` claim inside the ID token is
|
||||
// what lets the OIDC provider locate the server-side session row to delete
|
||||
// — without this we'd be back to relying on cross-site session cookies.
|
||||
const idToken = useLocalStorage<string | null>('auth/v1/oidc-id-token', null)
|
||||
const isAuthenticated = computed(() => !!user.value && !!session.value)
|
||||
const userId = computed(() => user.value?.id ?? 'local')
|
||||
|
||||
|
|
@ -33,26 +40,21 @@ export const useAuthStore = defineStore('auth', () => {
|
|||
|
||||
const credits = useLocalStorage<number>('user/v1/flux', 0)
|
||||
|
||||
// For controlling the login drawer on mobile
|
||||
// Cross-app "user must log in" flag. Setting this to true triggers an
|
||||
// immediate OIDC redirect on web (mobile + desktop). Electron skips this
|
||||
// path because controls-island-auth-button listens for IPC and handles
|
||||
// sign-in in the main process.
|
||||
const needsLogin = ref(false)
|
||||
const { isMobile } = useBreakpoints()
|
||||
|
||||
whenever(needsLogin, () => {
|
||||
// On mobile, LoginDrawer handles it via v-model
|
||||
if (isMobile.value)
|
||||
return
|
||||
|
||||
// On Electron, auth is triggered via IPC from controls-island-auth-button.
|
||||
// Setting needsLogin is a no-op in Electron — the button listens directly.
|
||||
whenever(needsLogin, async () => {
|
||||
if (isStageTamagotchi())
|
||||
return
|
||||
|
||||
// On web desktop, redirect to login page
|
||||
// TODO: type safe, import `useRouter` from router.ts
|
||||
window.location.href = '/auth/sign-in'
|
||||
await triggerSignIn()
|
||||
})
|
||||
|
||||
// Reset status when changing the window viewport
|
||||
// Reset the flag if the viewport class flips, so a stale needsLogin from a
|
||||
// previous breakpoint does not surface again on resize.
|
||||
watch(isMobile, () => needsLogin.value = false)
|
||||
|
||||
// --- Lifecycle hooks ---
|
||||
|
|
@ -153,6 +155,7 @@ export const useAuthStore = defineStore('auth', () => {
|
|||
session.value = null
|
||||
token.value = null
|
||||
refreshToken.value = null
|
||||
idToken.value = null
|
||||
oidcClientId.value = null
|
||||
tokenExpiry.value = null
|
||||
return null
|
||||
|
|
@ -213,6 +216,7 @@ export const useAuthStore = defineStore('auth', () => {
|
|||
stopRefreshTimer()
|
||||
oidcClientId.value = null
|
||||
tokenExpiry.value = null
|
||||
idToken.value = null
|
||||
}
|
||||
|
||||
const updateCredits = async () => {
|
||||
|
|
@ -242,6 +246,7 @@ export const useAuthStore = defineStore('auth', () => {
|
|||
session,
|
||||
token,
|
||||
refreshToken,
|
||||
idToken,
|
||||
isAuthenticated,
|
||||
credits,
|
||||
updateCredits,
|
||||
|
|
|
|||
|
|
@ -43,7 +43,7 @@ export const useOnboardingStore = defineStore('onboarding', () => {
|
|||
})
|
||||
|
||||
// Check if first-time setup should be shown
|
||||
const skipOnboardingPath = ['/auth/sign-in', '/auth/callback']
|
||||
const skipOnboardingPath = ['/auth/callback']
|
||||
const needsOnboarding = computed(() =>
|
||||
!authStore.isAuthenticated
|
||||
&& !authStore.token
|
||||
|
|
|
|||
|
|
@ -11,7 +11,21 @@ const props = withDefaults(defineProps<{
|
|||
label?: string
|
||||
description?: string
|
||||
placeholder?: string
|
||||
/**
|
||||
* Marks the field as required: enables native HTML5 `required` validation
|
||||
* on the underlying input and (by default) renders a `*` next to the label.
|
||||
* Use `hideRequiredMark` when the form already conveys required-ness
|
||||
* through other means (e.g. all fields are required).
|
||||
*/
|
||||
required?: boolean
|
||||
/**
|
||||
* Suppress the `*` indicator next to the label without disabling the
|
||||
* underlying HTML5 `required` validation. Useful for forms where every
|
||||
* field is required so the marker would just add noise.
|
||||
*
|
||||
* @default false
|
||||
*/
|
||||
hideRequiredMark?: boolean
|
||||
type?: InputType
|
||||
inputClass?: string
|
||||
singleLine?: boolean
|
||||
|
|
@ -30,7 +44,7 @@ const modelValue = defineModel<T>({ required: false })
|
|||
<slot name="label">
|
||||
{{ props.label }}
|
||||
</slot>
|
||||
<span v-if="props.required" class="text-red-500">*</span>
|
||||
<span v-if="props.required && !props.hideRequiredMark" class="text-red-500">*</span>
|
||||
</div>
|
||||
<div class="text-xs text-neutral-500 dark:text-neutral-400" text-wrap>
|
||||
<slot name="description">
|
||||
|
|
@ -43,6 +57,7 @@ const modelValue = defineModel<T>({ required: false })
|
|||
v-model.number="modelValue"
|
||||
:type="props.type"
|
||||
:placeholder="props.placeholder"
|
||||
:required="props.required"
|
||||
:class="props.inputClass"
|
||||
/>
|
||||
<Input
|
||||
|
|
@ -50,6 +65,7 @@ const modelValue = defineModel<T>({ required: false })
|
|||
v-model="modelValue"
|
||||
:type="props.type"
|
||||
:placeholder="props.placeholder"
|
||||
:required="props.required"
|
||||
:class="props.inputClass"
|
||||
/>
|
||||
<textarea
|
||||
|
|
@ -57,6 +73,7 @@ const modelValue = defineModel<T>({ required: false })
|
|||
v-model="modelValue as string | undefined"
|
||||
:type="props.type"
|
||||
:placeholder="props.placeholder"
|
||||
:required="props.required"
|
||||
:class="[
|
||||
props.inputClass,
|
||||
'focus:primary-300 dark:focus:primary-400/50 border-2 border-solid border-neutral-100 dark:border-neutral-900',
|
||||
|
|
|
|||
|
|
@ -18,6 +18,12 @@ const props = withDefaults(defineProps<{
|
|||
variant?: InputVariant // Button style variant
|
||||
size?: InputSize // Button size variant
|
||||
theme?: InputTheme // Button theme
|
||||
/**
|
||||
* Forwarded to the underlying `<input>` element so the browser participates
|
||||
* in form validation (HTML5 `:invalid` styling and submit blocking) without
|
||||
* the consumer having to drop down to raw HTML.
|
||||
*/
|
||||
required?: boolean
|
||||
}>(), {
|
||||
variant: 'primary',
|
||||
size: 'md',
|
||||
|
|
@ -69,6 +75,7 @@ const variantClasses: Record<InputVariant, Record<InputTheme, {
|
|||
<input
|
||||
v-model.number="modelValue"
|
||||
:type="props.type || 'text'"
|
||||
:required="props.required"
|
||||
:class="[
|
||||
'transition-all duration-200 ease-in-out',
|
||||
'cursor-disabled:not-allowed',
|
||||
|
|
@ -80,6 +87,7 @@ const variantClasses: Record<InputVariant, Record<InputTheme, {
|
|||
<input
|
||||
v-model="modelValue"
|
||||
:type="props.type || 'text'"
|
||||
:required="props.required"
|
||||
:class="[
|
||||
'transition-all duration-200 ease-in-out',
|
||||
'cursor-disabled:not-allowed',
|
||||
|
|
|
|||
57
pnpm-lock.yaml
generated
57
pnpm-lock.yaml
generated
|
|
@ -707,6 +707,9 @@ importers:
|
|||
pg:
|
||||
specifier: ^8.20.0
|
||||
version: 8.20.0
|
||||
resend:
|
||||
specifier: ^6.12.2
|
||||
version: 6.12.2
|
||||
stripe:
|
||||
specifier: ^22.0.2
|
||||
version: 22.0.2(@types/node@25.6.0)
|
||||
|
|
@ -3037,6 +3040,9 @@ importers:
|
|||
|
||||
packages/stage-shared:
|
||||
dependencies:
|
||||
'@moeru/std':
|
||||
specifier: 'catalog:'
|
||||
version: 0.1.0-beta.17
|
||||
'@vueuse/core':
|
||||
specifier: 'catalog:'
|
||||
version: 14.1.0(vue@3.5.32(typescript@5.9.3))
|
||||
|
|
@ -9242,6 +9248,9 @@ packages:
|
|||
'@socket.io/component-emitter@3.1.2':
|
||||
resolution: {integrity: sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==}
|
||||
|
||||
'@stablelib/base64@1.0.1':
|
||||
resolution: {integrity: sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==}
|
||||
|
||||
'@standard-schema/spec@1.1.0':
|
||||
resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
|
||||
|
||||
|
|
@ -12900,6 +12909,9 @@ packages:
|
|||
resolution: {integrity: sha512-dwsoQlS7h9hMeYUq1W++23NDcBLV4KqONnITDV9DjfS3q1SgDGVrBdvvTLUotWtPSD7asWDV9/CmsZPy8Hf70A==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
fast-sha256@1.3.0:
|
||||
resolution: {integrity: sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==}
|
||||
|
||||
fast-string-truncated-width@1.2.1:
|
||||
resolution: {integrity: sha512-Q9acT/+Uu3GwGj+5w/zsGuQjh9O1TyywhIwAxHudtWrgF09nHOPrvTLhQevPbttcxjr/SNN7mJmfOw/B1bXgow==}
|
||||
|
||||
|
|
@ -15516,6 +15528,9 @@ packages:
|
|||
popmotion@11.0.5:
|
||||
resolution: {integrity: sha512-la8gPM1WYeFznb/JqF4GiTkRRPZsfaj2+kCxqQgr2MJylMmIKUwBfWW8Wa5fml/8gmtlD5yI01MP1QCZPWmppA==}
|
||||
|
||||
postal-mime@2.7.4:
|
||||
resolution: {integrity: sha512-0WdnFQYUrPGGTFu1uOqD2s7omwua8xaeYGdO6rb88oD5yJ/4pPHDA4sdWqfD8wQVfCny563n/HQS7zTFft+f/g==}
|
||||
|
||||
postcss-selector-parser@7.1.1:
|
||||
resolution: {integrity: sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==}
|
||||
engines: {node: '>=4'}
|
||||
|
|
@ -15998,6 +16013,15 @@ packages:
|
|||
resolution: {integrity: sha512-vHjcY2MlAITJhC0eRD/Vv8Vlgmu9Sd3LX9zZvtGzU5ZImdTN3+d6e/4mnTyV8vEbyf1sgNIrWxhWlrys52OkEA==}
|
||||
engines: {node: '>=12', npm: '>=6'}
|
||||
|
||||
resend@6.12.2:
|
||||
resolution: {integrity: sha512-xwgmU4b0OqoabJsIoK/x0Whk0Fcs3bpbK4i/DEWPiE5hYJHyHl0TbB6QbI3gIr+bLdLUJ1GYm/fe41aVFuHXgw==}
|
||||
engines: {node: '>=20'}
|
||||
peerDependencies:
|
||||
'@react-email/render': '*'
|
||||
peerDependenciesMeta:
|
||||
'@react-email/render':
|
||||
optional: true
|
||||
|
||||
reserved-identifiers@1.2.0:
|
||||
resolution: {integrity: sha512-yE7KUfFvaBFzGPs5H3Ops1RevfUEsDc5Iz65rOwWg4lE8HJSYtle77uul3+573457oHvBKuHYDl/xqUkKpEEdw==}
|
||||
engines: {node: '>=18'}
|
||||
|
|
@ -16465,6 +16489,9 @@ packages:
|
|||
standard-as-callback@2.1.0:
|
||||
resolution: {integrity: sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==}
|
||||
|
||||
standardwebhooks@1.0.0:
|
||||
resolution: {integrity: sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==}
|
||||
|
||||
stat-mode@1.0.0:
|
||||
resolution: {integrity: sha512-jH9EhtKIjuXZ2cWxmXS8ZP80XyC3iasQxMDV8jzhNJpfDb7VbQLVW4Wvsxz9QZvzV+G4YoSfBUVKDOyxLzi/sg==}
|
||||
engines: {node: '>= 6'}
|
||||
|
|
@ -16632,6 +16659,9 @@ packages:
|
|||
resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
svix@1.90.0:
|
||||
resolution: {integrity: sha512-ljkZuyy2+IBEoESkIpn8sLM+sxJHQcPxlZFxU+nVDhltNfUMisMBzWX/UR8SjEnzoI28ZjCzMbmYAPwSTucoMw==}
|
||||
|
||||
symbol-tree@3.2.4:
|
||||
resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==}
|
||||
|
||||
|
|
@ -17415,6 +17445,10 @@ packages:
|
|||
uuid-1345@1.0.2:
|
||||
resolution: {integrity: sha512-bA5zYZui+3nwAc0s3VdGQGBfbVsJLVX7Np7ch2aqcEWFi5lsAEcmO3+lx3djM1npgpZI8KY2FITZ2uYTnYUYyw==}
|
||||
|
||||
uuid@10.0.0:
|
||||
resolution: {integrity: sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==}
|
||||
hasBin: true
|
||||
|
||||
uuid@13.0.0:
|
||||
resolution: {integrity: sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==}
|
||||
hasBin: true
|
||||
|
|
@ -23121,6 +23155,8 @@ snapshots:
|
|||
|
||||
'@socket.io/component-emitter@3.1.2': {}
|
||||
|
||||
'@stablelib/base64@1.0.1': {}
|
||||
|
||||
'@standard-schema/spec@1.1.0': {}
|
||||
|
||||
'@stdlib/string-base-kebabcase@0.2.3':
|
||||
|
|
@ -27464,6 +27500,8 @@ snapshots:
|
|||
|
||||
fast-redact@3.5.0: {}
|
||||
|
||||
fast-sha256@1.3.0: {}
|
||||
|
||||
fast-string-truncated-width@1.2.1: {}
|
||||
|
||||
fast-string-truncated-width@3.0.3: {}
|
||||
|
|
@ -30671,6 +30709,8 @@ snapshots:
|
|||
style-value-types: 5.1.2
|
||||
tslib: 2.4.0
|
||||
|
||||
postal-mime@2.7.4: {}
|
||||
|
||||
postcss-selector-parser@7.1.1:
|
||||
dependencies:
|
||||
cssesc: 3.0.0
|
||||
|
|
@ -31317,6 +31357,11 @@ snapshots:
|
|||
dependencies:
|
||||
pe-library: 0.4.1
|
||||
|
||||
resend@6.12.2:
|
||||
dependencies:
|
||||
postal-mime: 2.7.4
|
||||
svix: 1.90.0
|
||||
|
||||
reserved-identifiers@1.2.0: {}
|
||||
|
||||
resolve-alpn@1.2.1: {}
|
||||
|
|
@ -31961,6 +32006,11 @@ snapshots:
|
|||
|
||||
standard-as-callback@2.1.0: {}
|
||||
|
||||
standardwebhooks@1.0.0:
|
||||
dependencies:
|
||||
'@stablelib/base64': 1.0.1
|
||||
fast-sha256: 1.3.0
|
||||
|
||||
stat-mode@1.0.0: {}
|
||||
|
||||
stats-gl@2.4.2(@types/three@0.184.0)(three@0.184.0):
|
||||
|
|
@ -32112,6 +32162,11 @@ snapshots:
|
|||
|
||||
supports-preserve-symlinks-flag@1.0.0: {}
|
||||
|
||||
svix@1.90.0:
|
||||
dependencies:
|
||||
standardwebhooks: 1.0.0
|
||||
uuid: 10.0.0
|
||||
|
||||
symbol-tree@3.2.4: {}
|
||||
|
||||
synckit@0.11.12:
|
||||
|
|
@ -33023,6 +33078,8 @@ snapshots:
|
|||
dependencies:
|
||||
macaddress: 0.5.4
|
||||
|
||||
uuid@10.0.0: {}
|
||||
|
||||
uuid@13.0.0: {}
|
||||
|
||||
uuid@3.4.0: {}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue