feat(auth): oidc jwt bearer plugin & linked social accounts (#1753)

Co-authored-by: Liet Blue <127093491+lietblue@users.noreply.github.com>
This commit is contained in:
RainbowBird
2026-04-28 16:56:51 +08:00
committed by GitHub
co-authored by Liet Blue
parent 9dc78be7c1
commit 0af7d294a8
20 changed files with 1678 additions and 133 deletions
@@ -0,0 +1,275 @@
import type { BetterAuthPlugin } from 'better-auth'
import type { JSONWebKeySet } from 'jose'
import type { Env } from '../env'
import { createHmac } from 'node:crypto'
import { createAuthMiddleware } from 'better-auth/api'
import { createLocalJWKSet, jwtVerify } from 'jose'
/**
* Bridge plugin that lets better-auth's `sessionMiddleware` accept the
* RS256 JWT access tokens minted by our own oauthProvider plugin, instead
* of only the HMAC-signed session tokens that the stock {@link bearer}
* plugin understands.
*
* Use when:
* - The same Hono app hosts both the OIDC IdP (oauthProvider) and the
* resource server (`/api/v1/*`, `/api/auth/*`). Stage-web / Electron /
* Pocket clients carry an OIDC JWT for everything; without this plugin
* their `Authorization: Bearer <jwt>` is silently rejected by every
* `/api/auth/*` endpoint that needs `c.context.session`.
*
* Why a plugin (vs. per-route shims):
* - The `before` hook fires before `sessionMiddleware`, so a single
* translation here lets every better-auth endpoint (current + future)
* accept JWTs. Per-route shims would have to be rewritten for each new
* endpoint we expose to OIDC clients.
*
* Architecture mismatch this paves over:
* - better-auth's official OIDC story assumes the IdP and the resource
* server are different processes / different trust domains. The IdP
* issues JWTs for *external* RSes; the IdP itself only authenticates
* its own admin / profile API via cookies + HMAC bearer. Hosting both
* in one process is uncommon upstream, hence the gap.
*
* Mechanism:
* 1. Detect a JWT-shaped Bearer token (3 base64url segments).
* 2. Verify it via the local JWKS endpoint (the same RS256 keys our
* oauthProvider plugin signs with). If verification fails, bail out
* so the stock {@link bearer} plugin can still try its HMAC path.
* 3. Mint a short-lived bridge `session` row (5 min TTL via the
* `override.expiresAt` parameter on `internalAdapter.createSession`).
* Reusing an existing OIDC-flow session would seem cheaper, but it
* would let a refreshed-after-sign-out JWT silently keep working
* until its own TTL — minting anew avoids that surprise.
* 4. Sign the session token the same way better-auth's bearer plugin
* does (`serializeSignedCookie('', token, secret)` then strip the `=`),
* inject it as the `better-auth.session_token` cookie on the request
* headers, and let `sessionMiddleware` resolve from there as if a
* real cookie had been sent.
*
* NOTICE:
* - We intentionally only run on JWT-shaped tokens. HMAC tokens (no `.`s
* in the obvious places, or fail JWKS verify) are passed through to
* the stock {@link bearer} plugin so the existing better-auth-only
* clients keep working.
* - The bridge session table grows by one row per JWT-authed `/api/auth/*`
* request. With a 5-minute TTL the steady-state size is bounded; if
* that becomes load-bearing we can swap in a per-jti cache.
* - Mirror of `bearer()`'s cookie injection trick:
* node_modules/better-auth/dist/plugins/bearer/index.mjs L26-58.
* Removal condition: better-auth ships a first-party way to verify
* externally-signed JWTs against a JWKS for its own session resolution.
*/
export function oidcJwtBearer(env: Env): BetterAuthPlugin {
// JWT shape: three base64url segments separated by dots. Catches the
// happy path without us decoding; downstream JWKS verify is the real
// gate. Anything that fails this regex falls through to bearer().
const JWT_SHAPE_RE = /^[\w-]+\.[\w-]+\.[\w-]+$/
// Bridge session lifetime. Long enough to span an OAuth round-trip
// (link-social → provider → callback) on slow networks; short enough
// that an unused row TTL-prunes quickly.
const BRIDGE_SESSION_TTL_MS = 5 * 60 * 1000
// Process-local JWKS cache.
//
// NOTICE:
// Why local (not `createRemoteJWKSet`): we are the JWKS endpoint. Using
// jose's remote variant would loopback-fetch `/api/auth/jwks` on the same
// process, which both costs 5+ seconds in slow-DB environments AND
// contends for the same Postgres connection pool we're already inside —
// observed as 5s `Connection terminated due to connection timeout` from
// the `jwks` SELECT during a JWT-authed `/api/auth/list-accounts`. We
// read the jwks table directly via the better-auth adapter and assemble
// the JWKS in-process. Cached for 60s so steady-state traffic is
// effectively no-op; rotations propagate within a minute.
// Source: better-auth jwt plugin endpoint that builds the same shape:
// node_modules/better-auth/dist/plugins/jwt/index.mjs L102-129.
// Removal condition: never — local JWKS is the right primitive when the
// server *is* the IdP. Only revisit if jwks ever moves out of process.
const JWKS_TTL_MS = 60 * 1000
let cachedKeySet: ReturnType<typeof createLocalJWKSet> | null = null
let cachedAt = 0
interface JwkRow {
id: string
publicKey: string
alg?: string
crv?: string
expiresAt?: Date | null
}
/**
* Build (or reuse) the local JWKS resolver from rows in the `jwks` table.
*
* Use when:
* - About to verify a JWT inside this plugin and we need an up-to-date
* `JWKSLike` callable for `jwtVerify`.
*
* Expects:
* - `c.context.adapter.findMany({ model: 'jwks' })` returns a list of
* rows shaped like {@link JwkRow}.
*
* Returns:
* - The same `createLocalJWKSet` callable on cache hit; freshly assembled
* one on miss / expiry. Returns `null` if no keys are present (better
* to bail than to lock everyone out — bearer() may still succeed).
*/
async function getOrLoadJWKS(
adapter: { findMany: (args: { model: string }) => Promise<unknown[]> },
): Promise<ReturnType<typeof createLocalJWKSet> | null> {
if (cachedKeySet && Date.now() - cachedAt < JWKS_TTL_MS)
return cachedKeySet
const rows = await adapter.findMany({ model: 'jwks' }) as JwkRow[]
const now = Date.now()
const keys = rows
.filter(row => !row.expiresAt || row.expiresAt.getTime() > now)
// NOTICE:
// `JSON.parse` is intentionally not wrapped in try/catch. The
// `publicKey` column is written exclusively by better-auth's jwt
// plugin via `JSON.stringify(publicWebKey)` (see
// node_modules/@better-auth/core/dist/plugins/jwt/utils.mjs L30, L50)
// — i.e. data we ourselves serialised. A parse failure means the
// table is corrupt or upstream's serialisation contract has
// shifted, and both are exactly the cases that should fail loud
// (5xx + alert) rather than be silently skipped, which would
// 401 every JWT signed with the dropped key and bury the root
// cause. PR #1753 review suggested adding the try/catch; declined
// for the reason above.
.map((row) => {
const publicKey = JSON.parse(row.publicKey) as Record<string, unknown>
return {
...(row.alg ? { alg: row.alg } : {}),
...(row.crv ? { crv: row.crv } : {}),
...publicKey,
kid: row.id,
}
})
if (keys.length === 0)
return null
const jwks: JSONWebKeySet = { keys: keys as JSONWebKeySet['keys'] }
cachedKeySet = createLocalJWKSet(jwks)
cachedAt = Date.now()
return cachedKeySet
}
/**
* Inline copy of `better-call`'s `signCookieValue`.
*
* Use when:
* - Producing a session-token cookie value that the stock {@link bearer}
* plugin would also accept on the verify path.
*
* Format:
* - HMAC-SHA-256 the raw value with `secret`, base64-encode the digest,
* join as `value.signature`, then URI-encode. Mirrors the upstream
* recipe at node_modules/better-call/dist/crypto.mjs L27-32.
*
* Why inline (not import from better-call): better-call is a transitive
* via better-auth, not a direct dep of apps/server. Inlining a 3-line
* helper avoids polluting package.json with what is, semantically, an
* internal of better-auth's bearer flow.
*/
function signCookieValue(value: string, secret: string): string {
const signature = createHmac('sha256', secret).update(value).digest('base64')
return encodeURIComponent(`${value}.${signature}`)
}
return {
id: 'oidc-jwt-bearer',
hooks: {
before: [
{
// Same matcher shape as bearer(). Run only when an Authorization
// header is present so we don't pay the cost on cookie-only flows.
matcher(context) {
return Boolean(
context.request?.headers.get('authorization')
?? context.headers?.get('authorization'),
)
},
handler: createAuthMiddleware(async (c) => {
const incomingHeaders = c.request?.headers ?? c.headers
if (!incomingHeaders)
return
const authHeader = incomingHeaders.get('authorization')
if (!authHeader)
return
const lower = authHeader.slice(0, 7).toLowerCase()
if (lower !== 'bearer ')
return
const token = authHeader.slice(7).trim()
if (!token || !JWT_SHAPE_RE.test(token))
return
// Verify against our own JWKS, read directly from DB (no
// self-fetch). If it isn't ours (signature mismatch, wrong
// issuer, expired) we silently skip and let bearer() try —
// that path is the only one that knows how to accept
// HMAC-signed better-auth session tokens.
const adapter = c.context.adapter as {
findMany: (args: { model: string }) => Promise<unknown[]>
}
const keySet = await getOrLoadJWKS(adapter)
if (!keySet)
return
let userId: string
try {
const { payload } = await jwtVerify(token, keySet, {
issuer: `${env.API_SERVER_URL}/api/auth`,
audience: env.API_SERVER_URL,
})
if (typeof payload.sub !== 'string')
return
userId = payload.sub
}
catch {
return
}
// Mint a bridge session bound to this user. The override sets
// a short TTL so abandoned bridge rows self-prune; the second
// arg `undefined` keeps `dontRememberMe` at its default.
const expiresAt = new Date(Date.now() + BRIDGE_SESSION_TTL_MS)
const bridgeSession = await c.context.internalAdapter.createSession(
userId,
undefined,
{ expiresAt },
)
if (!bridgeSession?.token)
return
// Format the session token exactly like bearer() expects it
// when the cookie comes back in (see plugin source above).
const signedValue = signCookieValue(bridgeSession.token, c.context.secret)
const cookieName = c.context.authCookies.sessionToken.name
const newCookieEntry = `${cookieName}=${signedValue}`
// Clone headers so we don't mutate the caller's. Append our
// cookie to whatever was already there (mostly nothing for
// Bearer-only stage-web; possibly other cookies in mixed flows).
const newHeaders = new Headers(incomingHeaders)
const existingCookie = newHeaders.get('cookie')
newHeaders.set(
'cookie',
existingCookie ? `${existingCookie}; ${newCookieEntry}` : newCookieEntry,
)
return { context: { headers: newHeaders } }
}),
},
],
},
}
}
+93 -6
View File
@@ -9,11 +9,13 @@ 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 { deleteSessionCookie } from 'better-auth/cookies'
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 { oidcJwtBearer } from './auth-plugins/oidc-jwt-bearer'
import * as authSchema from '../schemas/accounts'
@@ -349,6 +351,14 @@ export function createAuth(db: Database, env: Env, email?: EmailService, metrics
plugins: [
bearer(),
jwt(),
// NOTICE:
// Bridges OIDC JWT access tokens (RS256, signed by our oauthProvider)
// into a real better-auth session so `sessionMiddleware` and every
// downstream `/api/auth/*` endpoint accept them. Must run after
// bearer() so we don't intercept HMAC session tokens that bearer()
// already handles. See libs/auth-plugins/oidc-jwt-bearer.ts for the
// architectural mismatch this paves over.
oidcJwtBearer(env),
magicLink({
// NOTICE: better-auth's magic-link callback receives a server-side
// verification URL ({baseURL}/magic-link/verify?token=...&callbackURL=...).
@@ -388,6 +398,23 @@ export function createAuth(db: Database, env: Env, email?: EmailService, metrics
async sendResetPassword({ user, url }) {
await requireEmailService(email).sendPasswordReset({ to: user.email, url })
},
// NOTICE:
// Why: clicking the password-reset link is itself proof that the user
// controls the address, so emailVerified must be true after a successful
// reset. Without this, social-only users who later set a password via
// "forgot password" stay stuck with emailVerified=false (better-auth's
// /reset-password handler only writes the password, see
// node_modules/better-auth/dist/api/routes/password.mjs L120-166) and
// get rejected on the next email/password sign-in by
// `requireEmailVerification: true`.
// Removal condition: better-auth flips emailVerified itself on reset.
async onPasswordReset({ user }) {
if (user.emailVerified)
return
await db.update(authSchema.user)
.set({ emailVerified: true, updatedAt: new Date() })
.where(eq(authSchema.user.id, user.id))
},
},
emailVerification: {
@@ -432,12 +459,29 @@ export function createAuth(db: Database, env: Env, email?: EmailService, metrics
// table. Without DB-backed sessions the FK INSERT fails when issuing tokens.
storeSessionInDatabase: true,
// NOTICE: keep a short-lived signed session cache cookie so follow-up
// session reads avoid hitting the database on every request.
cookieCache: {
enabled: true,
maxAge: 60 * 5,
},
// NOTICE:
// cookieCache is intentionally OFF.
//
// Why: with cookieCache enabled, the signed sessionData cookie keeps a
// "valid session" view for up to maxAge seconds even after the DB row is
// gone. /oauth2/end-session deletes the DB session row but does not
// expire that cookie (oauth-provider/dist/index.mjs L1069-1090 only calls
// internalAdapter.deleteSession). The next /oauth2/authorize then reads
// the cached session via getSessionFromCtx (better-auth/dist/api/routes/session.mjs L93+),
// binds an authorization code to the deleted session.id, and /oauth2/token
// fails with `invalid_request: session no longer exists`
// (oauth-provider/dist/index.mjs L557-567) — locking users out for the
// entire cookieCache TTL window after each logout.
//
// Trade-off: every getSession / authorize now hits the DB once. With the
// current AIRI flow the cost is negligible: cookie-based /get-session is
// only used by ui-server-auth pages, and /oauth2/authorize is rare.
// Bearer-token sessions (the hot path for stage-web/electron/pocket) bypass
// this entirely via libs/request-auth.ts.
//
// Removal condition: oauth-provider's end-session itself clears session
// cookies upstream, OR cookieCache TTL is reduced to a window short
// enough that "session no longer exists" is not user-visible.
},
baseURL: env.API_SERVER_URL,
@@ -459,10 +503,36 @@ export function createAuth(db: Database, env: Env, email?: EmailService, metrics
google: {
clientId: env.AUTH_GOOGLE_CLIENT_ID,
clientSecret: env.AUTH_GOOGLE_CLIENT_SECRET,
// NOTICE:
// Why: better-auth's google provider already maps email_verified
// through, but a stale Google profile that omits the claim falls
// through to undefined → false. Default to true defensively so the
// requireEmailVerification gate in emailAndPassword above doesn't
// reject legitimate Google-OAuth users on a follow-up password sign-in.
// Source: node_modules/@better-auth/core/dist/social-providers/google.mjs L95.
// Removal condition: never — Google emails are always verified before
// OAuth issuance, so the override is correct in all cases.
mapProfileToUser: profile => ({
emailVerified: profile.email_verified ?? true,
}),
},
github: {
clientId: env.AUTH_GITHUB_CLIENT_ID,
clientSecret: env.AUTH_GITHUB_CLIENT_SECRET,
// NOTICE:
// Why: better-auth derives emailVerified from the GitHub /user/emails
// response, but `emails.find(e => e.email === profile.email)?.verified`
// returns undefined when GitHub returns the noreply proxy email
// (`<id>+<login>@users.noreply.github.com`) which is not present in
// the /user/emails list. The result is `?? false`, leaving brand-new
// GitHub users at emailVerified=false → blocked from email/password
// sign-in after a password reset.
// Source: node_modules/@better-auth/core/dist/social-providers/github.mjs L77.
// Removal condition: GitHub OAuth itself enforces a verified email
// before authorization, so forcing true is safe and matches the
// upstream invariant. Drop only if better-auth fixes the noreply
// lookup.
mapProfileToUser: () => ({ emailVerified: true }),
},
},
@@ -489,6 +559,23 @@ export function createAuth(db: Database, env: Env, email?: EmailService, metrics
throw ctx.redirect(url.toString())
}
}
// NOTICE:
// OIDC RP-Initiated Logout (/oauth2/end-session) only deletes the DB session
// row via internalAdapter.deleteSession; it does NOT expire the
// sessionToken / sessionData cookies. With cookieCache.enabled=true the
// signed sessionData cookie keeps a stale "valid session" view alive
// for up to maxAge seconds. The next /oauth2/authorize then picks up
// the cached old session, binds the auth code to a now-deleted
// session.id, and /oauth2/token fails with "session no longer exists".
// We mirror /sign-out's deleteSessionCookie call here so RP-Initiated
// Logout fully invalidates client-visible session state.
// Source: node_modules/@better-auth/oauth-provider/dist/index.mjs L1069-1090
// (deleteSession only) vs node_modules/better-auth/dist/api/routes/sign-out.mjs L19-27.
// Removal condition: oauth-provider's end-session itself starts clearing
// session cookies upstream.
if (ctx.path === '/oauth2/end-session')
deleteSessionCookie(ctx)
}),
},
+82
View File
@@ -0,0 +1,82 @@
/**
* Gravatar fallback URL builder for the server side.
*
* Use when:
* - Decorating session/profile responses so every client (web, Electron,
* mobile) receives a usable avatar URL even if no provider supplied an
* `image` and the user never uploaded one. Computing on the server keeps
* the hashing implementation in one place and lets future swaps (e.g.
* hosting our own avatars or proxying through a CDN) happen without
* touching every client.
*
* Background:
* - Gravatar's modern API hashes the trimmed/lowercased email with SHA-256.
* Docs: https://docs.gravatar.com/api/avatars/hash/.
*/
import { createHash } from 'node:crypto'
const GRAVATAR_BASE_URL = 'https://www.gravatar.com/avatar/'
/**
* Default-image keyword for Gravatar's `d` query parameter.
*
* `identicon` deterministically generates a geometric pattern from the email
* hash so users with no Gravatar still get a unique-looking avatar. Switch
* to `mp` (mystery person) when a neutral silhouette is preferred.
*/
const DEFAULT_FALLBACK = 'identicon'
/**
* Default rendered size in pixels. Profile avatar slot is rendered at
* 96px logical, so 200px gives sharp output on retina displays.
*/
const DEFAULT_SIZE = 200
interface GravatarOptions {
/**
* Default image keyword to serve when the email has no Gravatar profile.
*
* @default 'identicon'
*/
fallback?: 'identicon' | 'monsterid' | 'wavatar' | 'retro' | 'robohash' | 'mp' | '404'
/**
* Output square size in pixels.
*
* @default 200
*/
size?: number
}
/**
* Build a Gravatar avatar URL from an email address.
*
* Use when:
* - Decorating an API response that exposes a user; falls back to a
* personalised placeholder when no real avatar is on file.
*
* Expects:
* - `email` is non-empty. Empty/whitespace emails return `null` so the
* caller can decide whether to omit the field entirely.
*
* Returns:
* - Full HTTPS Gravatar URL or `null` when input is unusable.
*
* Before:
* - "Hello@Example.COM "
*
* After:
* - "https://www.gravatar.com/avatar/973dfe463ec85785f5f95af5ba3906eedb2d931c24e69824a89ea65dba4e813b?d=identicon&s=200"
*/
export function buildGravatarUrl(email: string, options: GravatarOptions = {}): string | null {
const trimmed = email.trim().toLowerCase()
if (!trimmed)
return null
const hash = createHash('sha256').update(trimmed).digest('hex')
const url = new URL(hash, GRAVATAR_BASE_URL)
url.searchParams.set('d', options.fallback ?? DEFAULT_FALLBACK)
url.searchParams.set('s', String(options.size ?? DEFAULT_SIZE))
return url.toString()
}
-5
View File
@@ -4,13 +4,9 @@ import type { createAuth } from '../libs/auth'
import type { Env } from '../libs/env'
import type { HonoEnv } from '../types/hono'
import { useLogger } from '@guiiai/logg'
import { resolveRequestAuth } from '../libs/request-auth'
import { createUnauthorizedError } from '../utils/error'
const logger = useLogger('auth')
type AuthInstance = ReturnType<typeof createAuth>
/**
@@ -60,7 +56,6 @@ export function sessionMiddleware(auth: AuthInstance, env: Env): MiddlewareHandl
export const authGuard: MiddlewareHandler<HonoEnv> = async (c, next) => {
const user = c.get('user')
if (!user) {
logger.withFields({ path: c.req.path, method: c.req.method }).debug('Unauthorized request blocked')
throw createUnauthorizedError()
}
await next()
+24 -1
View File
@@ -4,6 +4,7 @@ import type { HonoEnv } from '../../types/hono'
import { Hono } from 'hono'
import { buildGravatarUrl } from '../../libs/gravatar'
import { resolveRequestAuth } from '../../libs/request-auth'
export interface OIDCTokenAuthRouteDeps {
@@ -15,7 +16,29 @@ export function createOIDCTokenAuthRoute(deps: OIDCTokenAuthRouteDeps) {
return new Hono<HonoEnv>()
.on(['GET', 'POST'], '/get-session', async (c) => {
const session = await resolveRequestAuth(deps.auth, deps.env, c.req.raw.headers)
return c.json(session)
if (!session)
return c.json(null)
// NOTICE:
// Avatar fallback to Gravatar happens here so every client (web,
// Electron, mobile, future SSR) renders the same picture without
// re-implementing SHA-256 hashing or Gravatar URL conventions. The
// DB only stores user-set / provider-set images; the fallback is
// computed at response time so a future swap (DiceBear, self-hosted
// proxy) is a one-line change here.
//
// We intentionally do NOT carry an `imageSource` flag — the URL
// itself is the signal: anything starting with
// `https://www.gravatar.com/avatar/` is the fallback, anything else
// is manual / provider-set. Skipping the flag keeps the API surface
// small and the server free of redundant state. If we ever change
// the fallback provider, both this file and the client-side prefix
// check must be updated together.
// Removal condition: avatar storage moves off-band (e.g. CDN) and
// `user.image` becomes the canonical URL for every user.
const image = session.user.image || buildGravatarUrl(session.user.email)
return c.json({ ...session, user: { ...session.user, image } })
})
.post('/sign-out', async (c) => {
// NOTICE: JWT access tokens are self-contained and expire naturally.
+1
View File
@@ -34,6 +34,7 @@
"@vueuse/core": "^14.2.1",
"@vueuse/shared": "^14.2.1",
"animejs": "^4.3.6",
"better-auth": "catalog:",
"colorjs.io": "^0.6.1",
"culori": "^4.0.2",
"date-fns": "^4.1.0",
@@ -0,0 +1,78 @@
/**
* Better-auth client factory for the auth-only SPA (`apps/ui-server-auth`).
*
* Use when:
* - Calling any `/api/auth/*` endpoint from the auth UI (profile read/write,
* sign-in / sign-up, password reset, linked accounts management). Lets us
* reuse better-auth's typed client surface instead of re-deriving response
* shapes from `unknown` JSON in N hand-written wrappers.
*
* Why a separate factory (vs. importing the singleton in
* `packages/stage-ui/src/libs/auth.ts`):
* - Stage-UI's client is configured for **Bearer-only** access (`credentials:
* 'omit'` so cookies don't tag along with OIDC JWTs). It also injects a
* Bearer token from the auth store on every request — nonsense in this
* app, since the auth UI is the page the cookie was *just* set on.
* - This client uses the better-auth defaults (cookies via
* `credentials: 'include'`) and skips the Bearer header. That matches
* what the auth UI actually has at hand.
*
* Test seam:
* - Pass `fetchImpl` to substitute `globalThis.fetch`. Better-auth wires it
* as `customFetchImpl` (see node_modules/better-auth/dist/client/config.mjs
* L+: the spread of `restOfFetchOptions` happens after the default, so a
* user-supplied value wins). Production callers omit `fetchImpl` and we
* memoise per `apiServerUrl` so we don't rebuild on every render.
*
* Removal condition: better-auth ships a hosted typed client for OIDC IdP
* setups where one process is both IdP and resource server. Until then,
* one factory per credential mode is the cleanest contract.
*/
import { createAuthClient } from 'better-auth/vue'
export interface AuthClientArgs {
apiServerUrl: string
/**
* Optional fetch override for tests. When provided we *do not* memoise so
* every test case can install its own mock without bleed-through.
*/
fetchImpl?: typeof fetch
}
const cache = new Map<string, ReturnType<typeof createAuthClient>>()
/**
* Build (or reuse) a better-auth client pointed at the given server.
*
* Use when:
* - Any module needs to call `/api/auth/*` from the auth UI.
*
* Expects:
* - `apiServerUrl` is a fully-qualified origin (e.g. `https://api.airi.test`
* or `http://localhost:3000`). Trailing slash optional; better-auth
* normalises.
*
* Returns:
* - A typed client whose methods (`getSession`, `updateUser`, `listAccounts`,
* etc.) match the better-auth endpoint surface. Tokens / cookies handled
* via `credentials: 'include'` defaults.
*/
export function getAuthClient(args: AuthClientArgs): ReturnType<typeof createAuthClient> {
if (args.fetchImpl) {
// Tests: never cache, never share. The injected fetchImpl is the whole
// point of the call.
return createAuthClient({
baseURL: args.apiServerUrl,
fetchOptions: { customFetchImpl: args.fetchImpl },
})
}
const cached = cache.get(args.apiServerUrl)
if (cached)
return cached
const client = createAuthClient({ baseURL: args.apiServerUrl })
cache.set(args.apiServerUrl, client)
return client
}
@@ -39,11 +39,17 @@ describe('ui-server-auth profile flow helpers', () => {
},
})
// NOTICE:
// We assert on the URL only, not on the fetch options shape. better-auth
// client builds the request via better-fetch, which decorates the
// options with framework metadata (plugins, jsonParser, signal, etc.).
// Pinning option fields ties the test to better-auth internals; the URL
// and the parsed result are the behavioural contract we actually care
// about.
// better-fetch passes a URL object, not a string, so coerce before
// matching. URL.toString() yields the canonical href.
expect(fetchImpl).toHaveBeenCalledTimes(1)
expect(fetchImpl).toHaveBeenCalledWith(
'https://api.airi.test/api/auth/get-session',
expect.objectContaining({ method: 'GET', credentials: 'include' }),
)
expect(String(fetchImpl.mock.calls[0]?.[0])).toBe('https://api.airi.test/api/auth/get-session')
})
it('returns user=null when better-auth reports no session', async () => {
@@ -112,14 +118,13 @@ describe('ui-server-auth profile flow helpers', () => {
})).rejects.toThrow('Invalid current password')
})
it('posts to /sign-out with credentials included', async () => {
it('hits /sign-out via the auth client', 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' }),
)
// See URL-coercion + URL-only rationale above the get-session assertion.
expect(fetchImpl).toHaveBeenCalledTimes(1)
expect(String(fetchImpl.mock.calls[0]?.[0])).toBe('https://api.airi.test/api/auth/sign-out')
})
})
+77 -59
View File
@@ -1,26 +1,32 @@
/**
* Account profile flows backed by better-auth's built-in user routes.
* Account profile flows backed by better-auth's typed Vue client.
*
* Use when:
* - Driving the profile page in `apps/ui-server-auth` (load current user,
* update display name, change password, sign out).
* update display name / avatar, 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).
* Why this delegates to {@link getAuthClient} instead of hand-rolling
* `fetch` wrappers: better-auth already returns typed payloads. Mapping
* `unknown` JSON to a hand-written interface duplicated work the upstream
* client already does and produced a layer of defensive `typeof x ===
* 'string' ? x : ''` casts that were brittle and noisy. See
* `auth-client.ts` for the rationale on the cookie-based credentials mode.
*/
import type { AuthFetchBase } from './auth-fetch'
import { errorMessageFrom } from '@moeru/std'
import { getAuthJSON, postAuthJSON } from './auth-fetch'
import { getAuthClient } from './auth-client'
/**
* Subset of the better-auth `user` row needed to render the profile page.
* Trimmed view of the better-auth `user` row exposed via `/get-session`.
*
* 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.
* Mirrors the better-auth `User` shape but flattens `createdAt` to a
* string (or null) for ergonomic rendering — better-auth's client returns
* `Date`, but the profile page formats it via `Intl.DateTimeFormat` which
* accepts both. We keep the string projection so consumers don't have to
* worry about Date-vs-string drift across the ui-server-auth boundary.
*/
export interface ProfileUser {
id: string
@@ -29,7 +35,13 @@ export interface ProfileUser {
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. */
/**
* Avatar URL. Server decorates this so it's always populated for
* signed-in users: provider-set / user-uploaded URL when present, or a
* Gravatar fallback derived server-side from the email hash. The UI
* detects the fallback by URL prefix
* (`https://www.gravatar.com/avatar/`).
*/
image: string | null
/** ISO timestamp from `created_at`. */
createdAt: string | null
@@ -64,42 +76,35 @@ interface ChangePasswordArgs extends AuthFetchBase {
}
/**
* Read the current session from `/api/auth/get-session`.
* Read the current session via the typed better-auth client.
*
* 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).
* - `user: null` for unauthenticated requests (better-auth client returns
* `null` data, not an error, in that case).
* - {@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 client = getAuthClient(args)
const { data, error } = await client.getSession()
if (error)
throw new Error(error.message ?? `Auth request failed (${error.status ?? 'unknown'})`)
if (!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 }
})
return {
user: {
id: data.user.id,
name: data.user.name,
email: data.user.email,
emailVerified: data.user.emailVerified,
image: data.user.image ?? null,
createdAt: toIsoString(data.user.createdAt),
},
}
}
/**
@@ -111,18 +116,17 @@ export async function getCurrentSession(args: AuthFetchBase): Promise<CurrentSes
* 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> = {}
const client = getAuthClient(args)
const body: { name?: string, image?: string | null } = {}
if (args.name !== undefined)
body.name = args.name
if (args.image !== undefined)
body.image = args.image
await postAuthJSON(args, '/update-user', body, () => undefined)
const { error } = await client.updateUser(body)
if (error)
throw new Error(error.message ?? 'updateUser failed')
}
/**
@@ -135,27 +139,20 @@ export async function updateUserProfile(args: UpdateUserProfileArgs): Promise<vo
* 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,
)
const client = getAuthClient(args)
const { error } = await client.changePassword({
currentPassword: args.currentPassword,
newPassword: args.newPassword,
revokeOtherSessions: args.revokeOtherSessions ?? true,
})
if (error)
throw new Error(error.message ?? 'changePassword failed')
}
/**
* Sign the current user out via `/api/auth/sign-out`.
* Sign the current user out via better-auth's `/sign-out` endpoint.
*
* Use when:
* - User clicks "Sign out" on the profile page.
@@ -166,9 +163,30 @@ export async function changePassword(args: ChangePasswordArgs): Promise<void> {
* page after this resolves.
*/
export async function signOut(args: AuthFetchBase): Promise<void> {
await postAuthJSON(args, '/sign-out', {}, () => undefined)
const client = getAuthClient(args)
const { error } = await client.signOut()
if (error)
throw new Error(error.message ?? 'signOut failed')
}
export function describeProfileError(error: unknown): string {
return errorMessageFrom(error) ?? 'Unexpected error'
}
/**
* Normalise better-auth's `Date | string | null | undefined` createdAt into
* the ISO string the rest of the UI expects.
*
* Before:
* - `new Date('2025-04-01T00:00:00.000Z')` / `'2025-04-01T00:00:00.000Z'` / `null`
*
* After:
* - `'2025-04-01T00:00:00.000Z'` / `'2025-04-01T00:00:00.000Z'` / `null`
*/
function toIsoString(value: unknown): string | null {
if (value instanceof Date)
return value.toISOString()
if (typeof value === 'string')
return value
return null
}
@@ -23,6 +23,41 @@ describe('ui-server-auth sign-in flow helpers', () => {
})
})
// ROOT CAUSE:
//
// Production hit `/api/auth/oauth2/authorize?token=4HEMlnagmOESfes99kW5nNmZ`
// and got a VALIDATION_ERROR for missing client_id / response_type.
//
// The 24-char `token=...` is the format better-auth's password-reset
// callback appends to redirectTo (password.mjs L65 generateId(24), L118
// redirectCallback adds `?token=<token>`). When such a token-only URL
// landed on /auth/sign-in (e.g. via a stale or misconfigured reset email),
// the previous filter only stripped `provider`/`prompt` and treated any
// remaining query as an OIDC handoff — synthesizing
// /api/auth/oauth2/authorize?token=... and trapping the user on a 422.
//
// Fix: require both `client_id` and `response_type` before treating the
// query as an OIDC continuation; otherwise fall back to '/'.
it('ignores stray non-OIDC query params (Issue: production reset-password token leaking into authorize URL)', () => {
expect(createServerSignInContext(
'https://auth.airi.test/sign-in?token=4HEMlnagmOESfes99kW5nNmZ',
'https://api.airi.test',
)).toEqual({
callbackURL: '/',
requestedProvider: null,
})
})
it('still falls back when the OIDC handoff is partial (client_id without response_type)', () => {
expect(createServerSignInContext(
'https://auth.airi.test/sign-in?client_id=airi-stage-web&scope=openid',
'https://api.airi.test',
)).toEqual({
callbackURL: '/',
requestedProvider: null,
})
})
it('posts the selected provider and callback URL to the social sign-in endpoint', async () => {
const fetchImpl = vi.fn<typeof fetch>(async () => {
return new Response(JSON.stringify({ url: 'https://accounts.example.test/oauth/google' }), {
+13 -1
View File
@@ -22,7 +22,19 @@ export function createServerSignInContext(currentUrl: string, apiServerUrl: stri
oidcParams.delete('provider')
oidcParams.delete('prompt')
if (!oidcParams.size) {
// NOTICE:
// Only synthesize an OIDC authorize callback when the page query genuinely
// looks like an OIDC handoff. Without this guard, a stray `?token=...` —
// e.g. the 24-char password-reset token better-auth appends when it
// redirects through redirectTo (better-auth/dist/api/routes/password.mjs L65, L118)
// back into /auth/sign-in — would synthesize
// `/api/auth/oauth2/authorize?token=...` as the callback. The OIDC zod
// schema then rejects it for missing client_id / response_type
// (oauth-provider/dist/index.mjs L2808-2826) and the user sees a
// VALIDATION_ERROR instead of the sign-in form.
// Removal condition: redirectTo origins are exhaustively scoped so reset /
// verification redirects can never land on /auth/sign-in carrying a `token`.
if (!oidcParams.has('client_id') || !oidcParams.has('response_type')) {
return {
callbackURL: '/',
requestedProvider,
+330 -40
View File
@@ -1,12 +1,16 @@
<script setup lang="ts">
import type { ProfileUser } from '../modules/profile'
import { defaultSignInProviders } from '@proj-airi/stage-ui/components/auth'
import { useLinkedAccounts } from '@proj-airi/stage-ui/composables'
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 { getAuthClient } from '../modules/auth-client'
import { requestPasswordReset } from '../modules/email-password'
import {
changePassword,
describeProfileError,
@@ -39,9 +43,63 @@ const passwordLoading = shallowRef(false)
const passwordError = shallowRef<string | null>(null)
const passwordSuccess = shallowRef<string | null>(null)
// "Set password" path for users without an existing credential account
// (signed up via social provider). We send them through the standard
// forgot-password email flow rather than exposing a direct setPassword
// endpoint — re-using the link-based flow keeps server surface small and
// gives us a fresh email-ownership proof at the moment of password set.
const setPasswordLoading = shallowRef(false)
const setPasswordError = shallowRef<string | null>(null)
const setPasswordSuccess = shallowRef<string | null>(null)
const signOutLoading = shallowRef(false)
const signOutError = shallowRef<string | null>(null)
// Avatar comes pre-decorated by the server: `image` is either the manually
// set / provider URL or a Gravatar fallback URL. We detect the fallback by
// URL prefix so the server doesn't need to ship a redundant `imageSource`
// flag — gravatar URLs are stable enough that prefix-matching is fine.
// See apps/server/src/routes/oidc/token-auth.ts for the server-side build.
const GRAVATAR_AVATAR_PREFIX = 'https://www.gravatar.com/avatar/'
const avatarUrl = computed(() => user.value?.image ?? null)
const usingGravatarFallback = computed(
() => avatarUrl.value?.startsWith(GRAVATAR_AVATAR_PREFIX) ?? false,
)
const gravatarProfileUrl = computed(() => {
if (!usingGravatarFallback.value || !user.value?.email)
return null
return `https://gravatar.com/${encodeURIComponent(user.value.email.trim().toLowerCase())}`
})
// Connected accounts: state + handlers come from the shared composable
// in stage-ui (mirrored on stage-web). Destructuring at top-level so the
// refs auto-unwrap inside the template — Vue's auto-unwrap only fires on
// top-level setup bindings, not on `obj.someRef` access.
const isAuthenticated = computed(() => user.value !== null)
const {
loading: linkedAccountsLoading,
loaded: linkedAccountsLoaded,
error: linkedAccountsError,
message: linkedAccountsMessage,
inFlight: linkActionInFlight,
accountsByProvider: linkedAccountsByProvider,
hasCredentialAccount,
unlink: unlinkLinkedProvider,
link: linkLinkedProvider,
} = useLinkedAccounts({
client: getAuthClient({ apiServerUrl }),
isAuthenticated,
describeError: describeProfileError,
messages: {
listFailed: t('server.auth.profile.linkedAccounts.error.listFailed'),
unlinkFailed: t('server.auth.profile.linkedAccounts.error.unlinkFailed'),
linkFailed: t('server.auth.profile.linkedAccounts.error.linkFailed'),
lastAccount: t('server.auth.profile.linkedAccounts.error.lastAccount'),
unlinked: provider => t('server.auth.profile.linkedAccounts.message.unlinked', { provider }),
linkStarted: provider => t('server.auth.profile.linkedAccounts.message.linkStarted', { provider }),
},
})
const nameDirty = computed(() => {
if (!user.value)
return false
@@ -75,6 +133,9 @@ onMounted(async () => {
})
return
}
// Setting `user` flips `isAuthenticated` true and the composable's
// watch picks it up to load linked accounts — no explicit refresh
// call needed here.
user.value = result.user
profileForm.name = result.user.name
}
@@ -148,6 +209,44 @@ async function handleChangePassword(event: Event) {
}
}
async function handleSendSetPasswordLink() {
if (setPasswordLoading.value || !user.value)
return
setPasswordLoading.value = true
setPasswordError.value = null
setPasswordSuccess.value = null
try {
// NOTICE:
// `redirectTo` is built off `apiServerUrl` (the publicly reachable
// API origin) rather than `window.location.origin`. ui-server-auth
// happens to be served from the same origin in practice, but the
// sibling stage-pages settings page is shared with the Tamagotchi
// Electron renderer which loads from `file://` — keeping all
// password-reset flows pinned to the API origin avoids that footgun
// and makes it copy/paste-safe across surfaces.
// Source: PR #1753 review (chatgpt-codex-connector P1).
//
// /reset-password handles both initial-set and rotate cases because
// its handler creates a credential row when none exists, see
// node_modules/better-auth/dist/api/routes/password.mjs L152-158.
await requestPasswordReset({
apiServerUrl,
email: user.value.email,
redirectTo: new URL('/auth/reset-password', apiServerUrl).toString(),
})
setPasswordSuccess.value = t('server.auth.profile.password.setLinkSent', { email: user.value.email })
}
catch (error) {
setPasswordError.value = describeProfileError(error)
|| t('server.auth.profile.password.setLinkFailed')
}
finally {
setPasswordLoading.value = false
}
}
async function handleSignOut() {
if (signOutLoading.value)
return
@@ -164,6 +263,27 @@ async function handleSignOut() {
signOutLoading.value = false
}
}
function handleUnlinkProvider(providerId: string) {
const providerName = defaultSignInProviders.find(p => p.id === providerId)?.name ?? providerId
return unlinkLinkedProvider(providerId, providerName)
}
function handleLinkProvider(providerId: 'github' | 'google') {
const providerName = defaultSignInProviders.find(p => p.id === providerId)?.name ?? providerId
return linkLinkedProvider(providerId, providerName)
}
function formatLinkedSince(iso: string): string {
if (!iso)
return ''
try {
return new Intl.DateTimeFormat(locale.value, { dateStyle: 'medium' }).format(new Date(iso))
}
catch {
return iso
}
}
</script>
<template>
@@ -187,6 +307,41 @@ async function handleSignOut() {
</div>
<template v-else-if="user">
<!-- Avatar block: prefer user.image, fall back to Gravatar so the user
always has a personalised picture even before they upload one. -->
<section
:class="['max-w-sm w-full flex flex-col items-center gap-2 mb-6']"
>
<div
:class="[
'h-24 w-24 overflow-hidden rounded-full border border-neutral-200 dark:border-neutral-700 bg-neutral-100 dark:bg-neutral-800',
]"
>
<img
v-if="avatarUrl"
:src="avatarUrl"
:alt="t('server.auth.profile.avatar.altText')"
:class="['h-full w-full object-cover']"
referrerpolicy="no-referrer"
>
</div>
<div
v-if="usingGravatarFallback"
:class="['flex flex-col items-center gap-1 text-center text-xs text-neutral-500']"
>
<span>{{ t('server.auth.profile.avatar.gravatarNotice') }}</span>
<a
v-if="gravatarProfileUrl"
:href="gravatarProfileUrl"
target="_blank"
rel="noreferrer"
:class="['underline underline-offset-2 hover:text-neutral-700 dark:hover:text-neutral-300']"
>
{{ t('server.auth.profile.avatar.gravatarLink') }}
</a>
</div>
</section>
<!-- 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']"
@@ -263,64 +418,199 @@ async function handleSignOut() {
</Button>
</form>
<!-- Change password form -->
<form
<!-- Password section: branches on whether the user already has a
credential account. Social-only users (signed up via GitHub /
Google) have no current password to type, so we drive them
through the email-based set-password flow instead of showing a
form they can't fill. We gate on `linkedAccountsLoaded` (true
only after a *successful* listAccounts) rather than just
`!linkedAccountsLoading` — a transient fetch error must not
flip a credentialed user into the "set password" branch. -->
<section
v-if="linkedAccountsLoaded"
: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
/>
<form
v-if="hasCredentialAccount"
:class="['flex flex-col gap-3']"
@submit="handleChangePassword"
>
<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>
<div
v-if="passwordError"
v-else
:class="['flex flex-col gap-3']"
>
<p :class="['text-sm text-neutral-500 dark:text-neutral-400']">
{{ t('server.auth.profile.password.setDescription') }}
</p>
<div
v-if="setPasswordError"
:class="['text-sm text-red-500']"
role="alert"
aria-live="polite"
>
{{ setPasswordError }}
</div>
<div
v-else-if="setPasswordSuccess"
:class="['text-sm text-green-600 dark:text-green-400']"
aria-live="polite"
>
{{ setPasswordSuccess }}
</div>
<Button
:class="['w-full', 'py-2', 'flex', 'items-center', 'justify-center']"
:loading="setPasswordLoading"
:disabled="!!setPasswordSuccess"
@click="handleSendSetPasswordLink"
>
<span>{{ t('server.auth.profile.action.sendSetPasswordLink') }}</span>
</Button>
</div>
</section>
<!-- Connected accounts: list each known social provider with its
bind/unbind affordance. Re-binding is just unlink + link in
sequence; we surface that flow via the i18n description rather
than a dedicated button to keep the UI predictable. -->
<section :class="['max-w-sm w-full flex flex-col gap-3 mb-6']">
<h2 :class="['text-base font-semibold']">
{{ t('server.auth.profile.section.linkedAccounts') }}
</h2>
<p :class="['text-xs text-neutral-500']">
{{ t('server.auth.profile.linkedAccounts.description') }}
</p>
<div
v-if="linkedAccountsLoading"
:class="['text-sm text-neutral-500']"
>
{{ t('server.auth.profile.linkedAccounts.message.loading') }}
</div>
<ul
v-else
:class="['flex flex-col gap-2']"
>
<li
v-for="provider in defaultSignInProviders"
:key="provider.id"
:class="[
'flex items-center justify-between gap-3 rounded-lg border border-neutral-200 dark:border-neutral-700 px-3 py-2',
]"
>
<div :class="['flex items-center gap-2 min-w-0']">
<span :class="[provider.icon, 'h-5 w-5 shrink-0']" aria-hidden="true" />
<div :class="['flex flex-col min-w-0']">
<span :class="['truncate text-sm font-medium']">{{ provider.name }}</span>
<span :class="['truncate text-xs text-neutral-500']">
<template v-if="linkedAccountsByProvider.get(provider.id)">
{{
t('server.auth.profile.linkedAccounts.status.linkedSince', {
date: formatLinkedSince(linkedAccountsByProvider.get(provider.id)!.createdAt),
})
}}
</template>
<template v-else>
{{ t('server.auth.profile.linkedAccounts.status.notLinked') }}
</template>
</span>
</div>
</div>
<Button
v-if="linkedAccountsByProvider.get(provider.id)"
variant="secondary"
:class="['shrink-0 px-3 py-1 text-xs']"
:loading="linkActionInFlight === provider.id"
:disabled="!!linkActionInFlight && linkActionInFlight !== provider.id"
@click="handleUnlinkProvider(provider.id)"
>
<span>{{ t('server.auth.profile.linkedAccounts.action.unlink') }}</span>
</Button>
<Button
v-else
:class="['shrink-0 px-3 py-1 text-xs']"
:loading="linkActionInFlight === provider.id"
:disabled="!!linkActionInFlight && linkActionInFlight !== provider.id"
@click="handleLinkProvider(provider.id)"
>
<span>{{ t('server.auth.profile.linkedAccounts.action.link') }}</span>
</Button>
</li>
</ul>
<div
v-if="linkedAccountsError"
:class="['text-sm text-red-500']"
role="alert"
aria-live="polite"
>
{{ passwordError }}
{{ linkedAccountsError }}
</div>
<div
v-else-if="passwordSuccess"
v-else-if="linkedAccountsMessage"
:class="['text-sm text-green-600 dark:text-green-400']"
aria-live="polite"
>
{{ passwordSuccess }}
{{ linkedAccountsMessage }}
</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>
</section>
<!-- Sign out -->
<div :class="['max-w-sm w-full flex flex-col gap-2']">
+29 -1
View File
@@ -114,10 +114,12 @@ resetPassword:
passwordMismatch: Password and confirmation do not match.
profile:
title: Account profile
description: Manage your display name and password.
description: Manage your avatar, display name, and connected accounts.
section:
profile: Profile
avatar: Avatar
password: Password
linkedAccounts: Connected accounts
field:
email: Email
emailVerified: Email status
@@ -128,6 +130,10 @@ profile:
name:
label: Display name
placeholder: How others see you
avatar:
altText: Profile avatar
gravatarNotice: Showing your Gravatar avatar — based on a hash of your email.
gravatarLink: Manage on gravatar.com
password:
currentLabel: Current password
currentPlaceholder: Enter your current password
@@ -135,9 +141,31 @@ profile:
newPlaceholder: At least 8 characters
confirmLabel: Confirm new password
confirmPlaceholder: Repeat your new password
setDescription: You signed in via a social provider, so there is no current password yet. We'll email a secure link to set one.
setLinkSent: Check {email} for the link to set your password.
setLinkFailed: We could not send the password setup link.
linkedAccounts:
description: Sign in to AIRI through these providers. Unlink to revoke access, then link again to switch to a different account.
status:
linked: Linked
notLinked: Not linked
linkedSince: Linked on {date}
action:
link: Link
unlink: Unlink
message:
loading: Loading connected accounts...
unlinked: '{provider} disconnected.'
linkStarted: Redirecting to {provider}…
error:
listFailed: Could not load connected accounts.
unlinkFailed: Could not unlink this account.
linkFailed: Could not start the link flow.
lastAccount: This is your only sign-in method. Set a password first before unlinking it.
action:
saveProfile: Save changes
changePassword: Change password
sendSetPasswordLink: Email me a link to set my password
signOut: Sign out
message:
loading: Loading your profile...
@@ -168,16 +168,41 @@ pages:
name:
label: Display name
placeholder: How others see you
avatar:
gravatarNotice: Showing your Gravatar avatar — based on a hash of your email.
gravatarLink: Manage on gravatar.com
action:
save: Save changes
message:
saved: Profile updated.
error:
fallback: We could not save your changes.
connections:
tab: Connected accounts
title: Connected accounts
description: Sign in to AIRI through these providers. Unlink to revoke access, then link again to switch to a different account.
status:
linked: Linked
notLinked: Not linked
linkedSince: Linked on {date}
action:
link: Link
unlink: Unlink
message:
loading: Loading connected accounts...
unlinked: '{provider} disconnected.'
linked: '{provider} connected.'
linkStarted: Redirecting to {provider}…
error:
listFailed: Could not load connected accounts.
unlinkFailed: Could not unlink this account.
linkFailed: Could not start the link flow.
lastAccount: This is your only sign-in method. Set a password first before unlinking it.
security:
tab: Security
title: Security
description: Change your password. Your other devices will be signed out.
setDescription: You signed in via a social provider, so there is no current password yet. We'll email a secure link to set one.
currentPassword:
label: Current password
placeholder: Enter your current password
@@ -189,10 +214,13 @@ pages:
placeholder: Repeat your new password
action:
changePassword: Change password
sendSetLink: Email me a link to set my password
message:
changed: Password changed. Other sessions have been signed out.
setLinkSent: Check {email} for the link to set your password.
error:
fallback: We could not change your password.
setLinkFailed: We could not send the password setup link.
passwordMismatch: New password and confirmation do not match.
passwordSameAsCurrent: New password must differ from the current one.
danger:
@@ -70,10 +70,12 @@ signIn:
privacy: 隐私政策
profile:
title: 账号资料
description: 管理你的显示名密码。
description: 管理你的头像、显示名密码和已绑定的社交账号
section:
profile: 个人资料
avatar: 头像
password: 密码
linkedAccounts: 已绑定的社交账号
field:
email: 邮箱
emailVerified: 邮箱状态
@@ -84,6 +86,10 @@ profile:
name:
label: 显示名
placeholder: 别人看到的名字
avatar:
altText: 用户头像
gravatarNotice: 正在使用 Gravatar 提供的头像(基于你邮箱地址的哈希值)。
gravatarLink: 前往 gravatar.com 管理
password:
currentLabel: 当前密码
currentPlaceholder: 输入当前密码
@@ -91,9 +97,31 @@ profile:
newPlaceholder: 至少 8 位
confirmLabel: 确认新密码
confirmPlaceholder: 再次输入新密码
setDescription: 你通过社交账号登录,还没有设置过密码。我们会向你的邮箱发送一个安全链接,用来设置密码。
setLinkSent: 已向 {email} 发送了设置密码的链接,请前往查收。
setLinkFailed: 无法发送设置密码链接。
linkedAccounts:
description: 通过这些渠道登录 AIRI。解绑可以撤销访问,再次绑定即可切换到其他账号。
status:
linked: 已绑定
notLinked: 未绑定
linkedSince: 绑定于 {date}
action:
link: 绑定
unlink: 解绑
message:
loading: 正在加载已绑定的社交账号...
unlinked: 已解除与 {provider} 的绑定。
linkStarted: 正在跳转到 {provider}…
error:
listFailed: 无法加载已绑定的社交账号。
unlinkFailed: 无法解除绑定。
linkFailed: 无法开始绑定流程。
lastAccount: 这是你唯一的登录方式。请先设置密码再尝试解绑。
action:
saveProfile: 保存修改
changePassword: 修改密码
sendSetPasswordLink: 给我发送设置密码的邮件
signOut: 退出登录
message:
loading: 正在加载你的资料...
@@ -159,16 +159,41 @@ pages:
name:
label: 显示名
placeholder: 别人看到的名字
avatar:
gravatarNotice: 正在使用 Gravatar 提供的头像(基于你邮箱地址的哈希值)。
gravatarLink: 前往 gravatar.com 管理
action:
save: 保存修改
message:
saved: 资料已更新。
error:
fallback: 无法保存修改。
connections:
tab: 已连接的账号
title: 已连接的账号
description: 通过这些渠道登录 AIRI。解绑可以撤销访问,再次绑定即可切换到其他账号。
status:
linked: 已绑定
notLinked: 未绑定
linkedSince: 绑定于 {date}
action:
link: 绑定
unlink: 解绑
message:
loading: 正在加载已绑定的社交账号...
unlinked: 已解除与 {provider} 的绑定。
linked: 已与 {provider} 完成绑定。
linkStarted: 正在跳转到 {provider}…
error:
listFailed: 无法加载已绑定的社交账号。
unlinkFailed: 无法解除绑定。
linkFailed: 无法开始绑定流程。
lastAccount: 这是你唯一的登录方式。请先设置密码再尝试解绑。
security:
tab: 安全
title: 安全
description: 修改密码。其他设备的登录会话会被注销。
setDescription: 你通过社交账号登录,还没有设置过密码。我们会向你的邮箱发送一个安全链接,用来设置密码。
currentPassword:
label: 当前密码
placeholder: 输入当前密码
@@ -180,10 +205,13 @@ pages:
placeholder: 再次输入新密码
action:
changePassword: 修改密码
sendSetLink: 给我发送设置密码的邮件
message:
changed: 密码已修改。其他设备的登录会话已被注销。
setLinkSent: 已向 {email} 发送了设置密码的链接,请前往查收。
error:
fallback: 无法修改密码。
setLinkFailed: 无法发送设置密码链接。
passwordMismatch: 两次输入的新密码不一致。
passwordSameAsCurrent: 新密码不能和当前密码相同。
danger:
@@ -1,6 +1,9 @@
<script setup lang="ts">
import { errorMessageFrom } from '@moeru/std'
import { defaultSignInProviders } from '@proj-airi/stage-ui/components/auth'
import { useLinkedAccounts } from '@proj-airi/stage-ui/composables'
import { authClient } from '@proj-airi/stage-ui/libs/auth'
import { SERVER_URL } from '@proj-airi/stage-ui/libs/server'
import { useAuthStore } from '@proj-airi/stage-ui/stores/auth'
import { Button, FieldInput } from '@proj-airi/ui'
import { storeToRefs } from 'pinia'
@@ -8,7 +11,7 @@ import { computed, reactive, ref, shallowRef, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { RouterLink } from 'vue-router'
type SectionId = 'profile' | 'security' | 'danger'
type SectionId = 'profile' | 'security' | 'connections' | 'danger'
const emit = defineEmits<{
login: []
@@ -22,6 +25,20 @@ 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)
// Gravatar fallback is decorated server-side onto `user.image`. We detect
// the fallback by URL prefix instead of carrying a redundant `imageSource`
// flag Gravatar URL format is stable and prefix-matching keeps the API
// surface small. If the avatar source ever changes, both this constant
// and apps/server/src/libs/gravatar.ts must move together.
const GRAVATAR_AVATAR_PREFIX = 'https://www.gravatar.com/avatar/'
const usingGravatarFallback = computed(
() => userAvatar.value?.startsWith(GRAVATAR_AVATAR_PREFIX) ?? false,
)
const gravatarProfileUrl = computed(() => {
if (!usingGravatarFallback.value || !userEmail.value)
return null
return `https://gravatar.com/${encodeURIComponent(userEmail.value.trim().toLowerCase())}`
})
// 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
@@ -71,27 +88,102 @@ const passwordLoading = shallowRef(false)
const passwordError = shallowRef<string | null>(null)
const passwordSuccess = shallowRef<string | null>(null)
// Set-password path for social-only users (no `credential` row, hence no
// "current password" to type). Drives them through the existing
// /request-password-reset email flow rather than exposing a direct
// setPassword endpoint fewer custom routes, fresh email-ownership
// proof at the moment of password set.
const setPasswordLoading = shallowRef(false)
const setPasswordError = shallowRef<string | null>(null)
const setPasswordSuccess = 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 connectionsSectionRef = 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' })
const targets: Record<SectionId, HTMLElement | null> = {
profile: profileSectionRef.value,
security: securitySectionRef.value,
connections: connectionsSectionRef.value,
danger: dangerSectionRef.value,
}
targets[id]?.scrollIntoView({ behavior: 'smooth', block: 'start' })
}
// Connected accounts: state + handlers come from the shared composable
// in stage-ui so this page and apps/ui-server-auth's profile page stay
// in lockstep. The composable handles list/unlink/link, the last-sign-in-
// method guard, and the auto-refresh on auth state change.
//
// We destructure at top level so the refs auto-unwrap inside the template
// Vue's auto-unwrap only fires on top-level setup bindings, not on
// `obj.someRef` field access.
const {
loading: linkedAccountsLoading,
loaded: linkedAccountsLoaded,
error: linkedAccountsError,
message: linkedAccountsMessage,
inFlight: linkActionInFlight,
accountsByProvider: linkedAccountsByProvider,
hasCredentialAccount,
unlink: unlinkLinkedProvider,
link: linkLinkedProvider,
} = useLinkedAccounts({
client: authClient,
isAuthenticated,
describeError: error => errorMessageFrom(error) ?? '',
messages: {
listFailed: t('settings.pages.account.connections.error.listFailed'),
unlinkFailed: t('settings.pages.account.connections.error.unlinkFailed'),
linkFailed: t('settings.pages.account.connections.error.linkFailed'),
lastAccount: t('settings.pages.account.connections.error.lastAccount'),
unlinked: provider => t('settings.pages.account.connections.message.unlinked', { provider }),
linkStarted: provider => t('settings.pages.account.connections.message.linkStarted', { provider }),
},
})
const connectionsDateFormatter = computed(() => {
try {
return new Intl.DateTimeFormat(undefined, { dateStyle: 'medium' })
}
catch {
return null
}
})
function formatLinkedSince(iso: string): string {
if (!iso)
return ''
const formatter = connectionsDateFormatter.value
if (!formatter)
return iso
try {
return formatter.format(new Date(iso))
}
catch {
return iso
}
}
function handleUnlinkProvider(providerId: string) {
const providerName = defaultSignInProviders.find(p => p.id === providerId)?.name ?? providerId
return unlinkLinkedProvider(providerId, providerName)
}
function handleLinkProvider(providerId: 'github' | 'google') {
const providerName = defaultSignInProviders.find(p => p.id === providerId)?.name ?? providerId
return linkLinkedProvider(providerId, providerName)
}
async function handleSaveProfile(event: Event) {
@@ -168,6 +260,43 @@ async function handleChangePassword(event: Event) {
passwordLoading.value = false
}
}
async function handleSendSetPasswordLink() {
const email = userEmail.value
if (setPasswordLoading.value || !email)
return
setPasswordLoading.value = true
setPasswordError.value = null
setPasswordSuccess.value = null
try {
// NOTICE:
// `redirectTo` MUST live on the API server origin, not on the current
// browser origin. This page is shared with apps/stage-tamagotchi,
// whose Electron renderer loads from `file://` `window.location.origin`
// would put a `file://` URL into the reset email and break the flow
// for any user who clicks from their inbox. The auth UI is hosted at
// `${SERVER_URL}/auth/reset-password` and reachable from the public
// internet.
// Source: PR #1753 review (chatgpt-codex-connector P1).
//
// The reset-password endpoint also covers the initial-set case it
// creates a credential row when none exists, see
// node_modules/better-auth/dist/api/routes/password.mjs L152-158.
const redirectTo = new URL('/auth/reset-password', SERVER_URL).toString()
const { error } = await authClient.requestPasswordReset({ email, redirectTo })
if (error)
throw new Error(error.message ?? 'requestPasswordReset failed')
setPasswordSuccess.value = t('settings.pages.account.security.message.setLinkSent', { email })
}
catch (error) {
setPasswordError.value = errorMessageFrom(error) ?? t('settings.pages.account.security.error.setLinkFailed')
}
finally {
setPasswordLoading.value = false
}
}
</script>
<template>
@@ -185,7 +314,7 @@ async function handleChangePassword(event: Event) {
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[]"
v-for="section in ['profile', 'security', 'connections', 'danger'] as SectionId[]"
:key="section"
type="button"
:class="[
@@ -254,6 +383,21 @@ async function handleChangePassword(event: Event) {
>
{{ userEmail }}
</p>
<p
v-if="usingGravatarFallback"
:class="['text-xs text-neutral-500 dark:text-neutral-400 mt-1']"
>
<span>{{ t('settings.pages.account.profile.avatar.gravatarNotice') }}</span>
<a
v-if="gravatarProfileUrl"
:href="gravatarProfileUrl"
target="_blank"
rel="noreferrer"
:class="['ml-1 underline underline-offset-2 hover:text-neutral-700 dark:hover:text-neutral-300']"
>
{{ t('settings.pages.account.profile.avatar.gravatarLink') }}
</a>
</p>
</div>
</div>
@@ -349,7 +493,19 @@ async function handleChangePassword(event: Event) {
</p>
</header>
<form :class="['flex flex-col gap-3 max-w-md']" @submit="handleChangePassword">
<!-- Branches on whether the user has an existing credential
account: social-only users have no current password to
type, so we send them through the email-based set-password
flow instead of showing an unfillable form. We gate on
`linkedAccountsLoaded` (true only after a *successful*
listAccounts) rather than `!linkedAccountsLoading` so a
transient fetch error doesn't flip a credentialed user
into the "set password" branch. -->
<form
v-if="linkedAccountsLoaded && hasCredentialAccount"
:class="['flex flex-col gap-3 max-w-md']"
@submit="handleChangePassword"
>
<FieldInput
v-model="passwordForm.current"
type="password"
@@ -402,6 +558,128 @@ async function handleChangePassword(event: Event) {
/>
</div>
</form>
<div
v-else-if="linkedAccountsLoaded"
:class="['flex flex-col gap-3 max-w-md']"
>
<p :class="['text-sm text-neutral-500 dark:text-neutral-400']">
{{ t('settings.pages.account.security.setDescription') }}
</p>
<div
v-if="setPasswordError"
:class="['text-sm text-red-500']"
role="alert"
aria-live="polite"
>
{{ setPasswordError }}
</div>
<div
v-else-if="setPasswordSuccess"
:class="['text-sm text-green-600 dark:text-green-400']"
aria-live="polite"
>
{{ setPasswordSuccess }}
</div>
<div :class="['flex justify-start']">
<Button
:loading="setPasswordLoading"
:disabled="!!setPasswordSuccess"
:label="t('settings.pages.account.security.action.sendSetLink')"
@click="handleSendSetPasswordLink"
/>
</div>
</div>
</section>
<!-- Connected accounts section. Lives between Security and Danger
because it's identity-adjacent (which providers can authenticate
you) and reversible unlinking and re-linking is a routine
account hygiene task, not a destructive one. -->
<section
ref="connectionsSectionRef"
: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.connections.title') }}
</h3>
<p :class="['text-sm text-neutral-500 dark:text-neutral-400']">
{{ t('settings.pages.account.connections.description') }}
</p>
</header>
<div
v-if="linkedAccountsLoading"
:class="['text-sm text-neutral-500 dark:text-neutral-400']"
>
{{ t('settings.pages.account.connections.message.loading') }}
</div>
<ul v-else :class="['flex flex-col gap-2 max-w-md']">
<li
v-for="provider in defaultSignInProviders"
:key="provider.id"
:class="[
'flex items-center justify-between gap-3 rounded-lg border border-neutral-200 dark:border-neutral-700 px-3 py-2',
]"
>
<div :class="['flex items-center gap-2 min-w-0']">
<span :class="[provider.icon, 'h-5 w-5 shrink-0']" aria-hidden="true" />
<div :class="['flex flex-col min-w-0']">
<span :class="['truncate text-sm font-medium']">{{ provider.name }}</span>
<span :class="['truncate text-xs text-neutral-500 dark:text-neutral-400']">
<template v-if="linkedAccountsByProvider.get(provider.id)">
{{
t('settings.pages.account.connections.status.linkedSince', {
date: formatLinkedSince(linkedAccountsByProvider.get(provider.id)!.createdAt),
})
}}
</template>
<template v-else>
{{ t('settings.pages.account.connections.status.notLinked') }}
</template>
</span>
</div>
</div>
<Button
v-if="linkedAccountsByProvider.get(provider.id)"
variant="secondary"
:class="['shrink-0 px-3 py-1 text-xs']"
:loading="linkActionInFlight === provider.id"
:disabled="!!linkActionInFlight && linkActionInFlight !== provider.id"
:label="t('settings.pages.account.connections.action.unlink')"
@click="handleUnlinkProvider(provider.id)"
/>
<Button
v-else
:class="['shrink-0 px-3 py-1 text-xs']"
:loading="linkActionInFlight === provider.id"
:disabled="!!linkActionInFlight && linkActionInFlight !== provider.id"
:label="t('settings.pages.account.connections.action.link')"
@click="handleLinkProvider(provider.id)"
/>
</li>
</ul>
<div
v-if="linkedAccountsError"
:class="['text-sm text-red-500']"
role="alert"
aria-live="polite"
>
{{ linkedAccountsError }}
</div>
<div
v-else-if="linkedAccountsMessage"
:class="['text-sm text-green-600 dark:text-green-400']"
aria-live="polite"
>
{{ linkedAccountsMessage }}
</div>
</section>
<!-- Danger zone. Same divider-based section style as Profile /
@@ -11,6 +11,7 @@ export * from './use-chat-session/summary'
export * from './use-inference-preload'
export * from './use-inference-status'
export * from './use-lamp-flicker-animation'
export * from './use-linked-accounts'
export * from './use-model-preload'
export * from './use-optimistic'
export * from './use-scroll-to-hash'
@@ -0,0 +1,250 @@
import type { Ref } from 'vue'
import { computed, onMounted, shallowRef, watch } from 'vue'
/**
* Provider key for the social-link / unlink endpoints. Matches the values
* better-auth recognises on `/api/auth/link-social` and `/api/auth/unlink-account`.
*/
export type LinkedProviderId = 'google' | 'github' | (string & {})
/**
* Trimmed view of the row better-auth returns from `/list-accounts`.
*
* `createdAt` is always an ISO string here even though the upstream client
* may hand back `Date` the composable normalises so consumers don't have
* to handle both shapes.
*/
export interface LinkedAccountRow {
id: string
accountId: string
providerId: string
createdAt: string
scopes: string[]
}
/**
* Minimum surface of better-auth's typed client the composable needs.
* Structural so cookie-credentialed (ui-server-auth) and Bearer-only
* (stage-web) clients both fit.
*/
export interface LinkedAccountsClient {
listAccounts: () => Promise<{
data: Array<{
id: string
accountId: string
providerId: string
createdAt: Date | string
scopes?: string[]
}> | null
error: { message?: string, status?: number } | null
}>
unlinkAccount: (args: { providerId: string, accountId?: string }) => Promise<{
data: unknown
error: { message?: string, status?: number } | null
}>
linkSocial: (args: { provider: string, callbackURL: string, errorCallbackURL?: string }) => Promise<{
data: { url?: string, redirect?: boolean, status?: boolean } | null
error: { message?: string, status?: number } | null
}>
}
/**
* Already-translated strings the composable surfaces back to the UI;
* keeps i18n implementation out of stage-ui.
*/
export interface LinkedAccountsMessages {
listFailed: string
unlinkFailed: string
linkFailed: string
/** Shown when the user tries to unlink the only sign-in method they have. */
lastAccount: string
unlinked: (provider: string) => string
linkStarted: (provider: string) => string
}
export interface UseLinkedAccountsArgs {
client: LinkedAccountsClient
/** Drives auto-refresh on sign-in and clear on sign-out. */
isAuthenticated: Ref<boolean>
messages: LinkedAccountsMessages
/** Caller-supplied error stringifier (e.g. `errorMessageFrom`). */
describeError: (error: unknown) => string
/**
* OAuth post-consent return URL.
* @default `() => window.location.href` survives both web-history
* and hash-history routers without further configuration.
*/
buildCallbackURL?: () => string
}
/**
* Shared state + handlers for the "Connected accounts" section.
* Two consumers (ui-server-auth profile, stage-web settings/account)
* share all the logic but render the section differently, so this stops
* at a composable rather than a shared component.
*/
export function useLinkedAccounts(args: UseLinkedAccountsArgs) {
const linkedAccounts = shallowRef<LinkedAccountRow[]>([])
const loading = shallowRef(true)
/**
* `true` after the first successful `listAccounts`. Survives transient
* fetch errors so a momentary 5xx doesn't flip `hasCredentialAccount`
* to false and mis-route credentialed users into the email-set-password
* branch. Resets only on sign-out.
* Source: PR #1753 review (chatgpt-codex-connector P2).
*/
const loaded = shallowRef(false)
const error = shallowRef<string | null>(null)
const message = shallowRef<string | null>(null)
/** Provider id currently being linked / unlinked. `null` when idle. */
const inFlight = shallowRef<string | null>(null)
const accountsByProvider = computed(() => {
const map = new Map<string, LinkedAccountRow>()
for (const account of linkedAccounts.value)
map.set(account.providerId, account)
return map
})
const hasCredentialAccount = computed(() => accountsByProvider.value.has('credential'))
const socialLinkedCount = computed(
() => linkedAccounts.value.filter(a => a.providerId !== 'credential').length,
)
/**
* Client-side mirror of better-auth's `FAILED_TO_UNLINK_LAST_ACCOUNT`
* guard so we can surface a user-friendly message before round-tripping.
*/
function isLastSignInMethod(providerId: string): boolean {
if (providerId === 'credential')
return socialLinkedCount.value === 0
return !hasCredentialAccount.value && socialLinkedCount.value <= 1
}
async function refresh() {
loading.value = true
error.value = null
try {
const { data, error: apiError } = await args.client.listAccounts()
if (apiError)
throw new Error(apiError.message ?? 'listAccounts failed')
// better-auth 1.6.6 widens listAccounts elements to `any`; consume
// the row directly rather than dressing `any` up with a fake shape.
// Field layout: node_modules/better-auth/dist/api/routes/account.mjs L20-50.
linkedAccounts.value = (data ?? []).map(account => ({
id: account.id,
accountId: account.accountId,
providerId: account.providerId,
createdAt: account.createdAt instanceof Date
? account.createdAt.toISOString()
: account.createdAt,
scopes: account.scopes ?? [],
}))
loaded.value = true
}
catch (err) {
// Keep prior `linkedAccounts` on error so a transient 5xx doesn't
// flip `hasCredentialAccount` and mis-route the password UI.
// Source: PR #1753 review (chatgpt-codex-connector P2).
error.value = args.describeError(err) || args.messages.listFailed
}
finally {
loading.value = false
}
}
async function unlink(providerId: string, providerName: string) {
if (inFlight.value)
return
if (isLastSignInMethod(providerId)) {
error.value = args.messages.lastAccount
message.value = null
return
}
inFlight.value = providerId
error.value = null
message.value = null
try {
const { error: apiError } = await args.client.unlinkAccount({ providerId })
if (apiError)
throw new Error(apiError.message ?? 'unlinkAccount failed')
message.value = args.messages.unlinked(providerName)
await refresh()
}
catch (err) {
error.value = args.describeError(err) || args.messages.unlinkFailed
}
finally {
inFlight.value = null
}
}
async function link(providerId: LinkedProviderId, providerName: string) {
if (inFlight.value)
return
inFlight.value = providerId
error.value = null
message.value = args.messages.linkStarted(providerName)
try {
const callbackURL = args.buildCallbackURL ? args.buildCallbackURL() : window.location.href
const { data, error: apiError } = await args.client.linkSocial({
provider: providerId,
callbackURL,
})
if (apiError)
throw new Error(apiError.message ?? 'linkSocial failed')
if (data?.url) {
window.location.assign(data.url)
return
}
// No URL came back (e.g. provider returned success synchronously) —
// refresh so the new row shows up without a navigation.
await refresh()
}
catch (err) {
error.value = args.describeError(err) || args.messages.linkFailed
message.value = null
inFlight.value = null
}
}
// Auto-refresh: load on mount when already authed; react to sign-in /
// sign-out so the list never shows stale rows.
onMounted(() => {
if (args.isAuthenticated.value)
refresh()
})
watch(args.isAuthenticated, (next) => {
if (next) {
refresh()
}
else {
// Don't leak previous user's accounts into the next session.
linkedAccounts.value = []
loaded.value = false
}
})
return {
linkedAccounts,
loading,
loaded,
error,
message,
inFlight,
accountsByProvider,
hasCredentialAccount,
socialLinkedCount,
isLastSignInMethod,
refresh,
unlink,
link,
}
}
+3
View File
@@ -2049,6 +2049,9 @@ importers:
animejs:
specifier: ^4.3.6
version: 4.3.6
better-auth:
specifier: 'catalog:'
version: 1.6.5(@opentelemetry/api@1.9.1)(@prisma/client@5.22.0)(better-sqlite3@12.5.0)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@electric-sql/pglite@0.4.4)(@opentelemetry/api@1.9.1)(@prisma/client@5.22.0)(@types/pg@8.20.0)(better-sqlite3@12.5.0)(kysely@0.28.14)(pg@8.20.0)(postgres@3.4.9))(pg@8.20.0)(react@19.2.3)(vitest@4.1.4)(vue@3.5.32(typescript@5.9.3))
colorjs.io:
specifier: ^0.6.1
version: 0.6.1