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:
@@ -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 } }
|
||||
}),
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}),
|
||||
},
|
||||
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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' }), {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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']">
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
Generated
+3
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user