feat(auth): email login & profile (#1745)

Co-authored-by: Liet Blue <127093491+lietblue@users.noreply.github.com>
This commit is contained in:
RainbowBird 2026-04-28 00:07:38 +08:00 committed by GitHub
parent 0346aa729e
commit 172e4ce59c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
50 changed files with 3469 additions and 385 deletions

View file

@ -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`

View 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 webhookbounce / complaint 回调)接入
- 邮件审计日志写入 `request_log`
- Magic link 前端 UI 与 change-email 前端 UI

View 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`).

View file

@ -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:"

View file

@ -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)
},
})

View file

@ -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: {

View file

@ -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()),

View file

@ -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'
) {

View file

@ -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)
})

View file

@ -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)

View 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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;')
}
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.',
})
}

View file

@ -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'
}

View file

@ -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) {

View file

@ -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))

View file

@ -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>

View file

@ -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>

View file

@ -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({

View file

@ -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)

View 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
}

View 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'
}

View 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' }),
)
})
})

View 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'
}

View file

@ -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')
}

View 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>

View file

@ -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>

View 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 `202541` 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>

View 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>

View file

@ -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>

View 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>

View file

@ -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

View file

@ -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',

View file

@ -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

View file

@ -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

View file

@ -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: 登录

View file

@ -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: 已激活

View file

@ -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"

View file

@ -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>

View file

@ -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>

View file

@ -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 56 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>

View file

@ -28,6 +28,7 @@
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@moeru/std": "catalog:",
"@vueuse/core": "catalog:",
"gpuu": "^1.0.7",
"pinia": "catalog:",

View 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'
}

View file

@ -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'

View file

@ -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>

View file

@ -1,3 +1,2 @@
export { default as LoginDrawer } from './LoginDrawer.vue'
export * from './providers'
export { default as SignInPanel } from './SignInPanel.vue'

View file

@ -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,
})
}

View file

@ -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,

View file

@ -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

View file

@ -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',

View file

@ -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
View file

@ -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: {}