style: lint

This commit is contained in:
Neko Ayaka
2026-08-26 19:49:58 +08:00
parent e60a04a4ec
commit 98f40d7d0b
1625 changed files with 75216 additions and 75203 deletions
@@ -1,14 +1,14 @@
export type ElectronCallbackParseResult
= | {
status: 'ready'
code: string
port: string
state: string
relayUrl: string
state: string
status: 'ready'
}
| {
status: 'error'
message: string
status: 'error'
}
export function buildElectronLoopbackUrl(params: {
+2 -2
View File
@@ -38,9 +38,9 @@ const routeRecords = setupLayouts(routes as RouteRecordRaw[])
let router: Router
if (isEnvTruthy(import.meta.env.VITE_APP_TARGET_HUGGINGFACE_SPACE))
router = createRouter({ routes: routeRecords, history: createWebHashHistory(AUTH_UI_ROUTER_BASE_PATH) })
router = createRouter({ history: createWebHashHistory(AUTH_UI_ROUTER_BASE_PATH), routes: routeRecords })
else
router = createRouter({ routes: routeRecords, history: createWebHistory(AUTH_UI_ROUTER_BASE_PATH) })
router = createRouter({ history: createWebHistory(AUTH_UI_ROUTER_BASE_PATH), routes: routeRecords })
router.beforeEach((to, from) => {
if (to.path !== from.path)
@@ -30,11 +30,11 @@ describe('auth analytics', () => {
it('keeps anonymous signup UI completion separate from the canonical server signup fact', async () => {
await expect(loadAnalyticsAdapter(async () => adapterMocks)).resolves.toBe(true)
trackSignupFormCompleted({ source: 'email', requires_verification: true })
trackSignupFormCompleted({ requires_verification: true, source: 'email' })
expect(adapterMocks.capture).toHaveBeenCalledWith(
'signup_form_completed',
{ source: 'email', requires_verification: true },
{ requires_verification: true, source: 'email' },
{ beforeNavigation: false },
)
})
+82 -82
View File
@@ -16,6 +16,12 @@
import type { OauthCallbackFailureStage } from '@proj-airi/stage-ui/composables'
/** Adapter contract installed by an optional analytics provider chunk. */
export interface AnalyticsAdapter {
capture: (event: string, properties: Record<string, unknown>, options?: CaptureOptions) => void
identify: (userId: string) => void
}
/** Login/signup credential kinds shown on the sign-in page. */
export type AuthMethod = 'email' | 'github' | 'google' | 'steam'
@@ -27,18 +33,12 @@ interface CaptureOptions {
beforeNavigation?: boolean
}
/** Adapter contract installed by an optional analytics provider chunk. */
export interface AnalyticsAdapter {
capture: (event: string, properties: Record<string, unknown>, options?: CaptureOptions) => void
identify: (userId: string) => void
}
type LoadState = 'idle' | 'loading' | 'ready' | 'unavailable'
type PendingOperation
= | { kind: 'capture', event: string, properties: Record<string, unknown>, options?: CaptureOptions }
= | { event: string, kind: 'capture', options?: CaptureOptions, properties: Record<string, unknown> }
| { kind: 'identify', userId: string }
type LoadState = 'idle' | 'loading' | 'ready' | 'unavailable'
/**
* Owns optional-adapter loading and guarantees that product-event calls never
* make core auth UI wait for, or depend on, a provider SDK.
@@ -49,6 +49,26 @@ export class AnalyticsClient {
private loadState: LoadState = 'idle'
private readonly pendingOperations: PendingOperation[] = []
capture(event: string, properties: Record<string, unknown>, options?: CaptureOptions): void {
if (this.adapter) {
this.adapter.capture(event, properties, options)
return
}
if (this.loadState === 'loading')
this.enqueue({ event, kind: 'capture', options, properties })
}
identify(userId: string): void {
if (this.adapter) {
this.adapter.identify(userId)
return
}
if (this.loadState === 'loading')
this.enqueue({ kind: 'identify', userId })
}
load(loader: () => Promise<AnalyticsAdapter>): Promise<boolean> {
if (this.loadPromise)
return this.loadPromise
@@ -73,26 +93,6 @@ export class AnalyticsClient {
return this.loadPromise
}
capture(event: string, properties: Record<string, unknown>, options?: CaptureOptions): void {
if (this.adapter) {
this.adapter.capture(event, properties, options)
return
}
if (this.loadState === 'loading')
this.enqueue({ kind: 'capture', event, properties, options })
}
identify(userId: string): void {
if (this.adapter) {
this.adapter.identify(userId)
return
}
if (this.loadState === 'loading')
this.enqueue({ kind: 'identify', userId })
}
private enqueue(operation: PendingOperation): void {
// A provider may remain slow indefinitely. Bound memory while preserving
// the newest auth funnel steps, which are the most useful after recovery.
@@ -117,14 +117,6 @@ export class AnalyticsClient {
const analytics = new AnalyticsClient()
/**
* Starts loading the optional provider adapter without exposing its SDK to
* pages or to the application's static module graph.
*/
export function loadAnalyticsAdapter(loader: () => Promise<AnalyticsAdapter>): Promise<boolean> {
return analytics.load(loader)
}
/**
* Merge this browser's anonymous events with the Better Auth user person.
* `userId` must be the Better Auth `user.id` — the same value the server
@@ -134,13 +126,39 @@ export function identifyAuthUser(userId: string): void {
analytics.identify(userId)
}
function capture(event: string, properties: Record<string, unknown>, options?: CaptureOptions): void {
analytics.capture(event, properties, options)
/**
* Starts loading the optional provider adapter without exposing its SDK to
* pages or to the application's static module graph.
*/
export function loadAnalyticsAdapter(loader: () => Promise<AnalyticsAdapter>): Promise<boolean> {
return analytics.load(loader)
}
/** Anonymous email-signup UI milestone; the server owns the registration fact. */
export function trackSignupFormCompleted(properties: { source: AuthMethod, requires_verification: boolean }): void {
capture('signup_form_completed', properties, { beforeNavigation: !properties.requires_verification })
/**
* Deletion-confirmed landing page reached (`delete-account.vue`). The
* deletion request itself is raised from the stage apps' account settings.
*/
export function trackAccountDeletionCompleted(): void {
capture('account_deletion_completed', {})
}
/** Verification link landing with `?verified=true`. */
export function trackEmailVerificationCompleted(): void {
capture('email_verification_completed', {})
}
/** Verification link landing with `?error=...`. */
export function trackEmailVerificationFailed(): void {
capture('email_verification_failed', {})
}
/**
* Sign-in attempt failed. No error detail on purpose — auth error messages
* can embed the email address, and the count per method is what the funnel
* needs.
*/
export function trackLoginFailed(properties: { method: AuthMethod }): void {
capture('login_failed', properties)
}
/**
@@ -158,34 +176,13 @@ export function trackLoginSucceeded(properties: { method: AuthMethod }): void {
}
/**
* Sign-in attempt failed. No error detail on purpose — auth error messages
* can embed the email address, and the count per method is what the funnel
* needs.
* Electron OIDC relay handoff failed. `stage` distinguishes a malformed
* callback (`parse`) from an unreachable local app (`relay_unreachable`);
* the full cross-surface vocabulary lives in stage-ui's
* `OauthCallbackFailureStage` so the two emitters share one schema.
*/
export function trackLoginFailed(properties: { method: AuthMethod }): void {
capture('login_failed', properties)
}
/** Verification link landing with `?verified=true`. */
export function trackEmailVerificationCompleted(): void {
capture('email_verification_completed', {})
}
/** Verification link landing with `?error=...`. */
export function trackEmailVerificationFailed(): void {
capture('email_verification_failed', {})
}
export function trackPasswordResetRequested(): void {
capture('password_reset_requested', {})
}
export function trackPasswordResetCompleted(): void {
capture('password_reset_completed', {})
}
export function trackPasswordChanged(): void {
capture('password_changed', {})
export function trackOauthCallbackFailed(properties: { stage: Extract<OauthCallbackFailureStage, 'parse' | 'relay_unreachable'> }): void {
capture('oauth_callback_failed', properties)
}
/**
@@ -201,24 +198,27 @@ export function trackOauthProviderUnlinked(properties: { provider: string }): vo
capture('oauth_provider_unlinked', properties)
}
/**
* Deletion-confirmed landing page reached (`delete-account.vue`). The
* deletion request itself is raised from the stage apps' account settings.
*/
export function trackAccountDeletionCompleted(): void {
capture('account_deletion_completed', {})
export function trackPasswordChanged(): void {
capture('password_changed', {})
}
export function trackPasswordResetCompleted(): void {
capture('password_reset_completed', {})
}
export function trackPasswordResetRequested(): void {
capture('password_reset_requested', {})
}
export function trackSignedOut(): void {
capture('signed_out', {})
}
/**
* Electron OIDC relay handoff failed. `stage` distinguishes a malformed
* callback (`parse`) from an unreachable local app (`relay_unreachable`);
* the full cross-surface vocabulary lives in stage-ui's
* `OauthCallbackFailureStage` so the two emitters share one schema.
*/
export function trackOauthCallbackFailed(properties: { stage: Extract<OauthCallbackFailureStage, 'parse' | 'relay_unreachable'> }): void {
capture('oauth_callback_failed', properties)
/** Anonymous email-signup UI milestone; the server owns the registration fact. */
export function trackSignupFormCompleted(properties: { requires_verification: boolean, source: AuthMethod }): void {
capture('signup_form_completed', properties, { beforeNavigation: !properties.requires_verification })
}
function capture(event: string, properties: Record<string, unknown>, options?: CaptureOptions): void {
analytics.capture(event, properties, options)
}
@@ -51,11 +51,11 @@ export function getAuthClient(args: AuthClientArgs): AuthClient {
if (args.fetchImpl || args.requestSignal) {
return createAuthClient({
baseURL: args.apiServerUrl,
plugins: [steamClient()],
fetchOptions: {
...(args.fetchImpl ? { customFetchImpl: args.fetchImpl } : {}),
...(args.requestSignal ? { signal: args.requestSignal } : {}),
},
plugins: [steamClient()],
})
}
+63 -63
View File
@@ -28,49 +28,41 @@ export interface AuthFetchBase {
}
/**
* POST a JSON body to `/api/auth<path>` and parse the response with `parse`.
* Pull a human-readable error string out of a Better Auth JSON error response.
*
* Use when:
* - You need a typed wrapper around a Better Auth POST endpoint that
* responds with JSON on both success and failure (the common case).
* Before:
* - `{ "message": "Invalid credentials", "code": "INVALID_CREDENTIALS" }`
* - `{ "error": { "message": "Token expired" } }`
* - `{ "error": "Rate limit" }`
*
* Expects:
* - `path` includes the leading slash (e.g. `/sign-in/email`).
* - `parse` runs only on 2xx responses; on non-2xx the wrapper throws.
* After:
* - `"Invalid credentials"` / `"Token expired"` / `"Rate limit"`
*
* Returns:
* - Whatever `parse` returns. Never returns on non-2xx — throws an `Error`
* carrying the server's `message` / `error.message` field.
* Returns `null` when the payload has no message-like field, leaving the
* caller to fall back to a status-code-only message.
*/
export async function postAuthJSON<T>(
base: AuthFetchBase,
path: string,
body: Record<string, unknown>,
parse: (data: unknown, response: Response) => T,
): Promise<T> {
const fetchImpl = base.fetchImpl ?? fetch
const endpoint = new URL(`/api/auth${path}`, base.apiServerUrl)
export function extractAuthError(data: unknown): null | string {
if (!data || typeof data !== 'object')
return null
const response = await fetchImpl(endpoint.toString(), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
credentials: 'include',
})
const maybe = data as { error?: unknown, message?: unknown }
if (typeof maybe.message === 'string')
return maybe.message
let data: unknown
try {
data = await response.json()
}
catch {
data = null
const error = maybe.error
if (typeof error === 'string')
return error
if (
error
&& typeof error === 'object'
&& 'message' in error
&& typeof (error as { message: unknown }).message === 'string'
) {
return (error as { message: string }).message
}
if (!response.ok) {
throw new Error(extractAuthError(data) ?? `Auth request failed (${response.status})`)
}
return parse(data, response)
return null
}
/**
@@ -98,8 +90,8 @@ export async function getAuthJSON<T>(
const endpoint = new URL(`/api/auth${path}`, base.apiServerUrl)
const response = await fetchImpl(endpoint.toString(), {
method: 'GET',
credentials: 'include',
method: 'GET',
})
let data: unknown
@@ -118,39 +110,47 @@ export async function getAuthJSON<T>(
}
/**
* Pull a human-readable error string out of a Better Auth JSON error response.
* POST a JSON body to `/api/auth<path>` and parse the response with `parse`.
*
* Before:
* - `{ "message": "Invalid credentials", "code": "INVALID_CREDENTIALS" }`
* - `{ "error": { "message": "Token expired" } }`
* - `{ "error": "Rate limit" }`
* Use when:
* - You need a typed wrapper around a Better Auth POST endpoint that
* responds with JSON on both success and failure (the common case).
*
* After:
* - `"Invalid credentials"` / `"Token expired"` / `"Rate limit"`
* Expects:
* - `path` includes the leading slash (e.g. `/sign-in/email`).
* - `parse` runs only on 2xx responses; on non-2xx the wrapper throws.
*
* Returns `null` when the payload has no message-like field, leaving the
* caller to fall back to a status-code-only message.
* Returns:
* - Whatever `parse` returns. Never returns on non-2xx — throws an `Error`
* carrying the server's `message` / `error.message` field.
*/
export function extractAuthError(data: unknown): string | null {
if (!data || typeof data !== 'object')
return null
export async function postAuthJSON<T>(
base: AuthFetchBase,
path: string,
body: Record<string, unknown>,
parse: (data: unknown, response: Response) => T,
): Promise<T> {
const fetchImpl = base.fetchImpl ?? fetch
const endpoint = new URL(`/api/auth${path}`, base.apiServerUrl)
const maybe = data as { error?: unknown, message?: unknown }
if (typeof maybe.message === 'string')
return maybe.message
const response = await fetchImpl(endpoint.toString(), {
body: JSON.stringify(body),
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
method: 'POST',
})
const error = maybe.error
if (typeof error === 'string')
return error
if (
error
&& typeof error === 'object'
&& 'message' in error
&& typeof (error as { message: unknown }).message === 'string'
) {
return (error as { message: string }).message
let data: unknown
try {
data = await response.json()
}
catch {
data = null
}
return null
if (!response.ok) {
throw new Error(extractAuthError(data) ?? `Auth request failed (${response.status})`)
}
return parse(data, response)
}
@@ -15,10 +15,6 @@ import { errorMessageFrom } from '@moeru/std'
import { postAuthJSON } from './auth-fetch'
interface CheckEmailArgs extends AuthFetchBase {
email: string
}
/**
* Result of the email-first identifier probe.
*
@@ -33,19 +29,23 @@ export interface CheckEmailResult {
hasPassword: boolean
}
interface CheckEmailArgs extends AuthFetchBase {
email: string
}
interface EmailSignInArgs extends AuthFetchBase {
callbackURL?: string
email: string
password: string
callbackURL?: string
/** @default true */
rememberMe?: boolean
}
interface EmailSignUpArgs extends AuthFetchBase {
email: string
password: string
name: string
callbackURL?: string
email: string
name: string
password: string
}
interface RequestPasswordResetArgs extends AuthFetchBase {
@@ -64,7 +64,7 @@ interface ResetPasswordArgs extends AuthFetchBase {
interface SignInResult {
/** Set when better-auth allows browser to follow the OIDC redirect itself. */
redirectURL: string | null
redirectURL: null | string
/**
* True if email verification is still pending; UI should route to
* the `verify-email` notice page.
@@ -106,51 +106,8 @@ export async function checkEmail(args: CheckEmailArgs): Promise<CheckEmailResult
)
}
export async function signInWithEmail(args: EmailSignInArgs): Promise<SignInResult> {
return postAuthJSON(
args,
'/sign-in/email',
{
email: args.email,
password: args.password,
callbackURL: args.callbackURL,
rememberMe: args.rememberMe ?? true,
},
(data) => {
const url = typeof (data as { url?: unknown })?.url === 'string'
? (data as { url: string }).url
: null
// NOTICE:
// better-auth surfaces `requiresEmailVerification` (rather than throwing)
// when emailAndPassword.requireEmailVerification is true and the user is
// not yet verified. Frontend uses this to route into the `verify-email`
// notice page instead of bouncing to the OIDC callback.
// Source: node_modules/better-auth/dist/api/routes/sign-in.mjs L235+
const requiresVerification = Boolean(
(data as { requiresEmailVerification?: unknown })?.requiresEmailVerification,
)
return { redirectURL: url, requiresVerification }
},
)
}
export async function signUpWithEmail(args: EmailSignUpArgs): Promise<SignUpResult> {
return postAuthJSON(
args,
'/sign-up/email',
{
email: args.email,
password: args.password,
name: args.name,
callbackURL: args.callbackURL,
},
(data) => {
// When verification is required, better-auth returns `{ token: null, user: ... }`
// and queues the verification email; otherwise it returns a session token.
const token = (data as { token?: unknown })?.token
return { requiresVerification: token === null || token === undefined }
},
)
export function describeAuthError(error: unknown): string {
return errorMessageFrom(error) ?? 'Unexpected error'
}
export async function requestPasswordReset(args: RequestPasswordResetArgs): Promise<void> {
@@ -177,6 +134,49 @@ export async function resetPasswordWithToken(args: ResetPasswordArgs): Promise<v
)
}
export function describeAuthError(error: unknown): string {
return errorMessageFrom(error) ?? 'Unexpected error'
export async function signInWithEmail(args: EmailSignInArgs): Promise<SignInResult> {
return postAuthJSON(
args,
'/sign-in/email',
{
callbackURL: args.callbackURL,
email: args.email,
password: args.password,
rememberMe: args.rememberMe ?? true,
},
(data) => {
const url = typeof (data as { url?: unknown })?.url === 'string'
? (data as { url: string }).url
: null
// NOTICE:
// better-auth surfaces `requiresEmailVerification` (rather than throwing)
// when emailAndPassword.requireEmailVerification is true and the user is
// not yet verified. Frontend uses this to route into the `verify-email`
// notice page instead of bouncing to the OIDC callback.
// Source: node_modules/better-auth/dist/api/routes/sign-in.mjs L235+
const requiresVerification = Boolean(
(data as { requiresEmailVerification?: unknown })?.requiresEmailVerification,
)
return { redirectURL: url, requiresVerification }
},
)
}
export async function signUpWithEmail(args: EmailSignUpArgs): Promise<SignUpResult> {
return postAuthJSON(
args,
'/sign-up/email',
{
callbackURL: args.callbackURL,
email: args.email,
name: args.name,
password: args.password,
},
(data) => {
// When verification is required, better-auth returns `{ token: null, user: ... }`
// and queues the verification email; otherwise it returns a session token.
const token = (data as { token?: unknown })?.token
return { requiresVerification: token === null || token === undefined }
},
)
}
+1 -1
View File
@@ -15,8 +15,8 @@ function getLocale() {
}
export const i18n = createI18n({
fallbackLocale: 'en',
legacy: false,
locale: getLocale(),
fallbackLocale: 'en',
messages,
})
@@ -4,8 +4,8 @@ import { changePassword, getCurrentSession, signOut, updateUserProfile } from '.
function jsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { 'Content-Type': 'application/json' },
status,
})
}
@@ -14,12 +14,12 @@ describe('ui-server-auth profile flow helpers', () => {
const fetchImpl = vi.fn<typeof fetch>(async () => jsonResponse({
session: { id: 'sess-1' },
user: {
id: 'user-1',
name: 'Alice',
createdAt: '2025-04-01T00:00:00.000Z',
email: 'alice@example.test',
emailVerified: true,
id: 'user-1',
image: 'https://cdn.example.test/avatar.png',
createdAt: '2025-04-01T00:00:00.000Z',
name: 'Alice',
// Field intentionally not in ProfileUser — must be ignored.
twoFactorEnabled: true,
},
@@ -30,12 +30,12 @@ describe('ui-server-auth profile flow helpers', () => {
fetchImpl,
})).resolves.toEqual({
user: {
id: 'user-1',
name: 'Alice',
createdAt: '2025-04-01T00:00:00.000Z',
email: 'alice@example.test',
emailVerified: true,
id: 'user-1',
image: 'https://cdn.example.test/avatar.png',
createdAt: '2025-04-01T00:00:00.000Z',
name: 'Alice',
},
})
@@ -92,8 +92,8 @@ describe('ui-server-auth profile flow helpers', () => {
await changePassword({
apiServerUrl: 'https://api.airi.test',
fetchImpl,
currentPassword: 'old-pw',
fetchImpl,
newPassword: 'new-pw',
})
@@ -112,8 +112,8 @@ describe('ui-server-auth profile flow helpers', () => {
await expect(changePassword({
apiServerUrl: 'https://api.airi.test',
fetchImpl,
currentPassword: 'wrong',
fetchImpl,
newPassword: 'new-pw',
})).rejects.toThrow('Invalid current password')
})
+78 -78
View File
@@ -19,6 +19,16 @@ import { errorMessageFrom } from '@moeru/std'
import { getAuthClient } from './auth-client'
/**
* Result of a `/get-session` probe.
*
* `user` is `null` when no session cookie is present (or it expired). Caller
* uses that to redirect to the sign-in page instead of rendering the form.
*/
export interface CurrentSessionResult {
user: null | ProfileUser
}
/**
* Trimmed view of the better-auth `user` row exposed via `/get-session`.
*
@@ -29,12 +39,12 @@ import { getAuthClient } from './auth-client'
* worry about Date-vs-string drift across the ui-server-auth boundary.
*/
export interface ProfileUser {
id: string
/** Display name set on sign-up or via {@link updateUserProfile}. */
name: string
/** ISO timestamp from `created_at`. */
createdAt: null | string
email: string
/** True once the user clicked the verification link sent on sign-up. */
emailVerified: boolean
id: string
/**
* Avatar URL. Server decorates this so it's always populated for
* signed-in users: provider-set / user-uploaded URL when present, or a
@@ -42,26 +52,9 @@ export interface ProfileUser {
* detects the fallback by URL prefix
* (`https://www.gravatar.com/avatar/`).
*/
image: string | null
/** ISO timestamp from `created_at`. */
createdAt: string | null
}
/**
* Result of a `/get-session` probe.
*
* `user` is `null` when no session cookie is present (or it expired). Caller
* uses that to redirect to the sign-in page instead of rendering the form.
*/
export interface CurrentSessionResult {
user: ProfileUser | null
}
interface UpdateUserProfileArgs extends AuthFetchBase {
/** Trim before passing — server stores the value as-is. */
name?: string
/** Optional avatar URL. Pass `null` to clear it. */
image?: string | null
image: null | string
/** Display name set on sign-up or via {@link updateUserProfile}. */
name: string
}
interface ChangePasswordArgs extends AuthFetchBase {
@@ -75,58 +68,11 @@ interface ChangePasswordArgs extends AuthFetchBase {
revokeOtherSessions?: boolean
}
/**
* 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.
*
* Returns:
* - `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> {
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 }
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),
},
}
}
/**
* Update the signed-in user's display name and/or avatar.
*
* Use when:
* - Saving the "display name" form on the profile page.
*
* Expects:
* - Caller has already trimmed `name` and confirmed it's non-empty.
* - `image` is either an absolute URL or `null` (clear).
*/
export async function updateUserProfile(args: UpdateUserProfileArgs): Promise<void> {
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
const { error } = await client.updateUser(body)
if (error)
throw new Error(error.message ?? 'updateUser failed')
interface UpdateUserProfileArgs extends AuthFetchBase {
/** Optional avatar URL. Pass `null` to clear it. */
image?: null | string
/** Trim before passing — server stores the value as-is. */
name?: string
}
/**
@@ -151,6 +97,42 @@ export async function changePassword(args: ChangePasswordArgs): Promise<void> {
throw new Error(error.message ?? 'changePassword failed')
}
export function describeProfileError(error: unknown): string {
return errorMessageFrom(error) ?? 'Unexpected error'
}
/**
* 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.
*
* Returns:
* - `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> {
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 }
return {
user: {
createdAt: toIsoString(data.user.createdAt),
email: data.user.email,
emailVerified: data.user.emailVerified,
id: data.user.id,
image: data.user.image ?? null,
name: data.user.name,
},
}
}
/**
* Sign the current user out via better-auth's `/sign-out` endpoint.
*
@@ -169,8 +151,26 @@ export async function signOut(args: AuthFetchBase): Promise<void> {
throw new Error(error.message ?? 'signOut failed')
}
export function describeProfileError(error: unknown): string {
return errorMessageFrom(error) ?? 'Unexpected error'
/**
* Update the signed-in user's display name and/or avatar.
*
* Use when:
* - Saving the "display name" form on the profile page.
*
* Expects:
* - Caller has already trimmed `name` and confirmed it's non-empty.
* - `image` is either an absolute URL or `null` (clear).
*/
export async function updateUserProfile(args: UpdateUserProfileArgs): Promise<void> {
const client = getAuthClient(args)
const body: { image?: null | string, name?: string } = {}
if (args.name !== undefined)
body.name = args.name
if (args.image !== undefined)
body.image = args.image
const { error } = await client.updateUser(body)
if (error)
throw new Error(error.message ?? 'updateUser failed')
}
/**
@@ -183,7 +183,7 @@ export function describeProfileError(error: unknown): string {
* After:
* - `'2025-04-01T00:00:00.000Z'` / `'2025-04-01T00:00:00.000Z'` / `null`
*/
function toIsoString(value: unknown): string | null {
function toIsoString(value: unknown): null | string {
if (value instanceof Date)
return value.toISOString()
if (typeof value === 'string')
@@ -34,9 +34,9 @@ const TRUSTED_LOCAL_API_SERVER_ORIGIN_PATTERNS = [
/^https:\/\/127\.0\.0\.1(:\d+)?$/,
]
let cachedContext: ServerAuthBootstrapContext | null | undefined
let cachedContext: null | ServerAuthBootstrapContext | undefined
export function getServerAuthBootstrapContext(): ServerAuthBootstrapContext | null {
export function getServerAuthBootstrapContext(): null | ServerAuthBootstrapContext {
if (cachedContext !== undefined)
return cachedContext
@@ -77,7 +77,7 @@ export function getServerAuthBootstrapContext(): ServerAuthBootstrapContext | nu
* - A bootstrap context using the trusted API origin, or null when no trusted
* override is present.
*/
export function resolveStandaloneServerAuthContext(currentUrl: string, fallbackApiServerUrl: string): ServerAuthBootstrapContext | null {
export function resolveStandaloneServerAuthContext(currentUrl: string, fallbackApiServerUrl: string): null | ServerAuthBootstrapContext {
const url = new URL(currentUrl)
const apiServerUrl = normalizeTrustedApiServerUrl(
url.searchParams.get(API_SERVER_URL_QUERY_PARAM),
@@ -92,7 +92,7 @@ export function resolveStandaloneServerAuthContext(currentUrl: string, fallbackA
}
}
function normalizeTrustedApiServerUrl(value: string | null): string | null {
function normalizeTrustedApiServerUrl(value: null | string): null | string {
if (!value)
return null
@@ -117,9 +117,9 @@ describe('ui-server-auth sign-in flow helpers', () => {
await expect(requestSocialSignInRedirect({
apiServerUrl: 'https://api.airi.test',
provider: 'google',
callbackURL: 'https://api.airi.test/api/auth/oauth2/authorize?client_id=airi-stage-web',
fetchImpl,
provider: 'google',
})).resolves.toBe('https://accounts.example.test/oauth/google')
expect(fetchImpl).toHaveBeenCalledTimes(1)
@@ -127,24 +127,24 @@ describe('ui-server-auth sign-in flow helpers', () => {
expect(String(url)).toBe('https://api.airi.test/api/auth/sign-in/social')
expect((init as RequestInit).method).toBe('POST')
expect(JSON.parse(String((init as RequestInit).body))).toEqual({
provider: 'google',
callbackURL: 'https://api.airi.test/api/auth/oauth2/authorize?client_id=airi-stage-web',
disableRedirect: true,
provider: 'google',
})
})
it('posts only the callback URL (no provider field) to the Steam sign-in endpoint', async () => {
const fetchImpl = vi.fn<typeof fetch>(async () => {
return new Response(JSON.stringify({ url: 'https://steamcommunity.com/openid/login?...', redirect: true }), {
return new Response(JSON.stringify({ redirect: true, url: 'https://steamcommunity.com/openid/login?...' }), {
headers: { 'Content-Type': 'application/json' },
})
})
await expect(requestSocialSignInRedirect({
apiServerUrl: 'https://api.airi.test',
provider: 'steam',
callbackURL: 'https://api.airi.test/api/auth/oauth2/authorize?client_id=airi-stage-web',
fetchImpl,
provider: 'steam',
})).resolves.toBe('https://steamcommunity.com/openid/login?...')
const [url, init] = fetchImpl.mock.calls[0] ?? []
@@ -168,9 +168,9 @@ describe('ui-server-auth sign-in flow helpers', () => {
await expect(requestSocialSignInRedirect({
apiServerUrl: 'https://api.airi.test',
provider: 'github',
callbackURL: '/',
fetchImpl,
provider: 'github',
})).rejects.toThrow('Provider is temporarily unavailable')
})
@@ -193,9 +193,9 @@ describe('ui-server-auth sign-in flow helpers', () => {
try {
const request = requestSocialSignInRedirect({
apiServerUrl: 'https://api.airi.test',
provider,
callbackURL: '/',
fetchImpl,
provider,
timeoutMs: 50,
})
+31 -31
View File
@@ -20,14 +20,14 @@ const TRUSTED_LOCAL_ADMIN_REDIRECT_ORIGIN_PATTERNS = [
export interface ServerSignInContext {
callbackURL: string
requestedProvider: string | null
requestedProvider: null | string
}
export interface SocialSignInRedirectParams {
apiServerUrl: string
provider: OAuthProvider
callbackURL: string
fetchImpl?: typeof fetch
provider: OAuthProvider
/**
* Maximum wait for provider discovery before the UI restores sign-in controls.
* @default 15_000
@@ -83,7 +83,34 @@ export function createServerSignInContext(currentUrl: string, apiServerUrl: stri
}
}
function normalizeStandaloneRedirect(currentUrl: URL, redirect: string | null): string | null {
export async function requestSocialSignInRedirect(params: SocialSignInRedirectParams): Promise<string> {
const requestController = new AbortController()
const client = getAuthClient({
apiServerUrl: params.apiServerUrl,
fetchImpl: params.fetchImpl,
requestSignal: requestController.signal,
})
// Steam is OpenID 2.0, not OAuth2 — the server steam plugin exposes
// `/sign-in/steam`, surfaced here as the typed `signIn.steam` action.
// Other providers use the standard `/sign-in/social`.
const request = params.provider === 'steam'
? client.signIn.steam({ callbackURL: params.callbackURL, disableRedirect: true })
: client.signIn.social({ callbackURL: params.callbackURL, disableRedirect: true, provider: params.provider })
const result = await settleSocialSignInRequest(
request,
params.timeoutMs ?? SOCIAL_SIGN_IN_REQUEST_TIMEOUT_MS,
requestController,
)
const url = result.data?.url
if (typeof url === 'string')
return url
throw new Error(extractAuthError(result.data ?? result.error) ?? 'Unexpected response')
}
function normalizeStandaloneRedirect(currentUrl: URL, redirect: null | string): null | string {
if (!redirect)
return null
@@ -100,7 +127,7 @@ function normalizeStandaloneRedirect(currentUrl: URL, redirect: string | null):
return `${currentUrl.origin}${buildAuthUiPath(redirect)}`
}
function normalizeTrustedAdminRedirect(redirect: string): string | null {
function normalizeTrustedAdminRedirect(redirect: string): null | string {
try {
const url = new URL(redirect)
if (TRUSTED_ADMIN_REDIRECT_ORIGINS.includes(url.origin))
@@ -116,33 +143,6 @@ function normalizeTrustedAdminRedirect(redirect: string): string | null {
}
}
export async function requestSocialSignInRedirect(params: SocialSignInRedirectParams): Promise<string> {
const requestController = new AbortController()
const client = getAuthClient({
apiServerUrl: params.apiServerUrl,
fetchImpl: params.fetchImpl,
requestSignal: requestController.signal,
})
// Steam is OpenID 2.0, not OAuth2 — the server steam plugin exposes
// `/sign-in/steam`, surfaced here as the typed `signIn.steam` action.
// Other providers use the standard `/sign-in/social`.
const request = params.provider === 'steam'
? client.signIn.steam({ callbackURL: params.callbackURL, disableRedirect: true })
: client.signIn.social({ provider: params.provider, callbackURL: params.callbackURL, disableRedirect: true })
const result = await settleSocialSignInRequest(
request,
params.timeoutMs ?? SOCIAL_SIGN_IN_REQUEST_TIMEOUT_MS,
requestController,
)
const url = result.data?.url
if (typeof url === 'string')
return url
throw new Error(extractAuthError(result.data ?? result.error) ?? 'Unexpected response')
}
/**
* Bounds and cancels provider discovery so a timed-out request cannot apply a
* stale OAuth state cookie after the user starts another sign-in attempt.
+2 -2
View File
@@ -11,15 +11,15 @@ export default mergeConfigs([
...presetWebFontsFonts('fontsource'),
},
timeouts: {
warning: 5000,
failure: 10000,
warning: 5000,
},
}),
],
rules: [
['transition-colors-none', {
'transition-property': 'color, background-color, border-color, text-color',
'transition-duration': '0s',
'transition-property': 'color, background-color, border-color, text-color',
}],
],
},
+41 -41
View File
@@ -24,35 +24,6 @@ const assetsDirectory = 'assets-v2'
export default defineConfig({
base: '/',
optimizeDeps: {
exclude: [
// Internal Packages
'@proj-airi/stage-ui/*',
],
},
resolve: {
alias: {
'@proj-airi/i18n': resolve(join(import.meta.dirname, '..', '..', 'packages', 'i18n', 'src')),
'@proj-airi/stage-ui': resolve(join(import.meta.dirname, '..', '..', 'packages', 'stage-ui', 'src')),
'@proj-airi/stage-shared': resolve(join(import.meta.dirname, '..', '..', 'packages', 'stage-shared', 'src')),
'@proj-airi/stage-layouts': resolve(join(import.meta.dirname, '..', '..', 'packages', 'stage-layouts', 'src')),
},
},
server: {
fs: {
// To mute errors like:
// The request id ".../node_modules/@fontsource/sniglet/files/sniglet-latin-400-normal.woff" is outside of Vite serving allow list.
//
// See: https://vite.dev/config/server-options#server-fs-strict
strict: false,
},
warmup: {
clientFiles: [
`${resolve(join(import.meta.dirname, '..', '..', 'packages', 'stage-ui', 'src'))}/*.vue`,
],
},
},
build: {
assetsDir: assetsDirectory,
emptyOutDir: true,
@@ -76,38 +47,36 @@ export default defineConfig({
},
sourcemap: true,
},
worker: {
format: 'es',
rollupOptions: {
output: {
inlineDynamicImports: false,
},
},
},
optimizeDeps: {
exclude: [
// Internal Packages
'@proj-airi/stage-ui/*',
],
},
plugins: [
Info(),
Yaml(),
VueMacros({
betterDefine: false,
plugins: {
vue: Vue({
include: [/\.vue$/, /\.md$/],
}),
vueJsx: false,
},
betterDefine: false,
}),
VueRouter({
extensions: ['.vue', '.md'],
dts: resolve(import.meta.dirname, 'src/typed-router.d.ts'),
exclude: ['**/components/**'],
extensions: ['.vue', '.md'],
importMode: 'async',
routesFolder: [
resolve(import.meta.dirname, 'src', 'pages'),
],
exclude: ['**/components/**'],
}),
// https://github.com/JohnCampionJr/vite-plugin-vue-layouts
@@ -124,12 +93,43 @@ export default defineConfig({
// https://github.com/intlify/bundle-tools/tree/main/packages/unplugin-vue-i18n
VueI18n({
runtimeOnly: true,
compositionOnly: true,
fullInstall: true,
runtimeOnly: true,
}),
// https://github.com/webfansplz/vite-plugin-vue-devtools
VueDevTools(),
],
resolve: {
alias: {
'@proj-airi/i18n': resolve(join(import.meta.dirname, '..', '..', 'packages', 'i18n', 'src')),
'@proj-airi/stage-layouts': resolve(join(import.meta.dirname, '..', '..', 'packages', 'stage-layouts', 'src')),
'@proj-airi/stage-shared': resolve(join(import.meta.dirname, '..', '..', 'packages', 'stage-shared', 'src')),
'@proj-airi/stage-ui': resolve(join(import.meta.dirname, '..', '..', 'packages', 'stage-ui', 'src')),
},
},
server: {
fs: {
// To mute errors like:
// The request id ".../node_modules/@fontsource/sniglet/files/sniglet-latin-400-normal.woff" is outside of Vite serving allow list.
//
// See: https://vite.dev/config/server-options#server-fs-strict
strict: false,
},
warmup: {
clientFiles: [
`${resolve(join(import.meta.dirname, '..', '..', 'packages', 'stage-ui', 'src'))}/*.vue`,
],
},
},
worker: {
format: 'es',
rollupOptions: {
output: {
inlineDynamicImports: false,
},
},
},
})
+1 -1
View File
@@ -7,8 +7,8 @@ import { defineConfig } from 'vitest/config'
export default defineConfig(({ mode }) => {
return {
test: {
include: ['src/**/*.test.ts'],
env: loadEnv(mode, join(cwd(), 'apps', 'ui-server-auth'), ''),
include: ['src/**/*.test.ts'],
},
}
})