@@ -1,14 +1,14 @@
|
||||
export type ElectronCallbackParseResult
|
||||
= | {
|
||||
status: 'ready'
|
||||
code: string
|
||||
port: string
|
||||
relayUrl: string
|
||||
state: string
|
||||
status: 'ready'
|
||||
relayUrl: string
|
||||
}
|
||||
| {
|
||||
message: string
|
||||
status: 'error'
|
||||
message: string
|
||||
}
|
||||
|
||||
export function buildElectronLoopbackUrl(params: {
|
||||
|
||||
@@ -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({ history: createWebHashHistory(AUTH_UI_ROUTER_BASE_PATH), routes: routeRecords })
|
||||
router = createRouter({ routes: routeRecords, history: createWebHashHistory(AUTH_UI_ROUTER_BASE_PATH) })
|
||||
else
|
||||
router = createRouter({ history: createWebHistory(AUTH_UI_ROUTER_BASE_PATH), routes: routeRecords })
|
||||
router = createRouter({ routes: routeRecords, history: createWebHistory(AUTH_UI_ROUTER_BASE_PATH) })
|
||||
|
||||
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({ requires_verification: true, source: 'email' })
|
||||
trackSignupFormCompleted({ source: 'email', requires_verification: true })
|
||||
|
||||
expect(adapterMocks.capture).toHaveBeenCalledWith(
|
||||
'signup_form_completed',
|
||||
{ requires_verification: true, source: 'email' },
|
||||
{ source: 'email', requires_verification: true },
|
||||
{ beforeNavigation: false },
|
||||
)
|
||||
})
|
||||
|
||||
@@ -16,12 +16,6 @@
|
||||
|
||||
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'
|
||||
|
||||
@@ -33,12 +27,18 @@ interface CaptureOptions {
|
||||
beforeNavigation?: boolean
|
||||
}
|
||||
|
||||
type LoadState = 'idle' | 'loading' | 'ready' | 'unavailable'
|
||||
/** 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 PendingOperation
|
||||
= | { event: string, kind: 'capture', options?: CaptureOptions, properties: Record<string, unknown> }
|
||||
= | { kind: 'capture', event: string, properties: Record<string, unknown>, options?: CaptureOptions }
|
||||
| { 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,26 +49,6 @@ 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
|
||||
@@ -93,6 +73,26 @@ 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,15 +117,6 @@ export class AnalyticsClient {
|
||||
|
||||
const analytics = new AnalyticsClient()
|
||||
|
||||
/**
|
||||
* 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
|
||||
* uses as `distinctId` (see `server/apps/api` product events forwarding).
|
||||
*/
|
||||
export function identifyAuthUser(userId: string): void {
|
||||
analytics.identify(userId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts loading the optional provider adapter without exposing its SDK to
|
||||
* pages or to the application's static module graph.
|
||||
@@ -135,30 +126,21 @@ export function loadAnalyticsAdapter(loader: () => Promise<AnalyticsAdapter>): P
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletion-confirmed landing page reached (`delete-account.vue`). The
|
||||
* deletion request itself is raised from the stage apps' account settings.
|
||||
* 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
|
||||
* uses as `distinctId` (see `server/apps/api` product events forwarding).
|
||||
*/
|
||||
export function trackAccountDeletionCompleted(): void {
|
||||
capture('account_deletion_completed', {})
|
||||
export function identifyAuthUser(userId: string): void {
|
||||
analytics.identify(userId)
|
||||
}
|
||||
|
||||
/** Verification link landing with `?verified=true`. */
|
||||
export function trackEmailVerificationCompleted(): void {
|
||||
capture('email_verification_completed', {})
|
||||
function capture(event: string, properties: Record<string, unknown>, options?: CaptureOptions): void {
|
||||
analytics.capture(event, properties, options)
|
||||
}
|
||||
|
||||
/** 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)
|
||||
/** 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 })
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -176,13 +158,34 @@ export function trackLoginSucceeded(properties: { method: AuthMethod }): void {
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* 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 trackOauthCallbackFailed(properties: { stage: Extract<OauthCallbackFailureStage, 'parse' | 'relay_unreachable'> }): void {
|
||||
capture('oauth_callback_failed', properties)
|
||||
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', {})
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -198,27 +201,24 @@ export function trackOauthProviderUnlinked(properties: { provider: string }): vo
|
||||
capture('oauth_provider_unlinked', properties)
|
||||
}
|
||||
|
||||
export function trackPasswordChanged(): void {
|
||||
capture('password_changed', {})
|
||||
}
|
||||
|
||||
export function trackPasswordResetCompleted(): void {
|
||||
capture('password_reset_completed', {})
|
||||
}
|
||||
|
||||
export function trackPasswordResetRequested(): void {
|
||||
capture('password_reset_requested', {})
|
||||
/**
|
||||
* 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 trackSignedOut(): void {
|
||||
capture('signed_out', {})
|
||||
}
|
||||
|
||||
/** 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)
|
||||
/**
|
||||
* 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)
|
||||
}
|
||||
|
||||
@@ -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()],
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -28,41 +28,49 @@ export interface AuthFetchBase {
|
||||
}
|
||||
|
||||
/**
|
||||
* 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): null | string {
|
||||
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(), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
credentials: 'include',
|
||||
})
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -90,8 +98,8 @@ export async function getAuthJSON<T>(
|
||||
const endpoint = new URL(`/api/auth${path}`, base.apiServerUrl)
|
||||
|
||||
const response = await fetchImpl(endpoint.toString(), {
|
||||
credentials: 'include',
|
||||
method: 'GET',
|
||||
credentials: 'include',
|
||||
})
|
||||
|
||||
let data: unknown
|
||||
@@ -110,47 +118,39 @@ export async function getAuthJSON<T>(
|
||||
}
|
||||
|
||||
/**
|
||||
* 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): string | null {
|
||||
if (!data || typeof data !== 'object')
|
||||
return null
|
||||
|
||||
const response = await fetchImpl(endpoint.toString(), {
|
||||
body: JSON.stringify(body),
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
method: 'POST',
|
||||
})
|
||||
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
|
||||
}
|
||||
|
||||
@@ -15,6 +15,10 @@ import { errorMessageFrom } from '@moeru/std'
|
||||
|
||||
import { postAuthJSON } from './auth-fetch'
|
||||
|
||||
interface CheckEmailArgs extends AuthFetchBase {
|
||||
email: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of the email-first identifier probe.
|
||||
*
|
||||
@@ -29,23 +33,19 @@ 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 {
|
||||
callbackURL?: string
|
||||
email: string
|
||||
name: string
|
||||
password: string
|
||||
name: string
|
||||
callbackURL?: 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: null | string
|
||||
redirectURL: string | null
|
||||
/**
|
||||
* True if email verification is still pending; UI should route to
|
||||
* the `verify-email` notice page.
|
||||
@@ -106,8 +106,51 @@ export async function checkEmail(args: CheckEmailArgs): Promise<CheckEmailResult
|
||||
)
|
||||
}
|
||||
|
||||
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',
|
||||
{
|
||||
email: args.email,
|
||||
password: args.password,
|
||||
callbackURL: args.callbackURL,
|
||||
rememberMe: args.rememberMe ?? true,
|
||||
},
|
||||
(data) => {
|
||||
const url = typeof (data as { url?: unknown })?.url === 'string'
|
||||
? (data as { url: string }).url
|
||||
: null
|
||||
// NOTICE:
|
||||
// better-auth surfaces `requiresEmailVerification` (rather than throwing)
|
||||
// when emailAndPassword.requireEmailVerification is true and the user is
|
||||
// not yet verified. Frontend uses this to route into the `verify-email`
|
||||
// notice page instead of bouncing to the OIDC callback.
|
||||
// Source: node_modules/better-auth/dist/api/routes/sign-in.mjs L235+
|
||||
const requiresVerification = Boolean(
|
||||
(data as { requiresEmailVerification?: unknown })?.requiresEmailVerification,
|
||||
)
|
||||
return { redirectURL: url, requiresVerification }
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
export async function signUpWithEmail(args: EmailSignUpArgs): Promise<SignUpResult> {
|
||||
return postAuthJSON(
|
||||
args,
|
||||
'/sign-up/email',
|
||||
{
|
||||
email: args.email,
|
||||
password: args.password,
|
||||
name: args.name,
|
||||
callbackURL: args.callbackURL,
|
||||
},
|
||||
(data) => {
|
||||
// When verification is required, better-auth returns `{ token: null, user: ... }`
|
||||
// and queues the verification email; otherwise it returns a session token.
|
||||
const token = (data as { token?: unknown })?.token
|
||||
return { requiresVerification: token === null || token === undefined }
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
export async function requestPasswordReset(args: RequestPasswordResetArgs): Promise<void> {
|
||||
@@ -134,49 +177,6 @@ export async function resetPasswordWithToken(args: ResetPasswordArgs): Promise<v
|
||||
)
|
||||
}
|
||||
|
||||
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 }
|
||||
},
|
||||
)
|
||||
export function describeAuthError(error: unknown): string {
|
||||
return errorMessageFrom(error) ?? 'Unexpected error'
|
||||
}
|
||||
|
||||
@@ -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), {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
status,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
}
|
||||
|
||||
@@ -14,12 +14,12 @@ describe('ui-server-auth profile flow helpers', () => {
|
||||
const fetchImpl = vi.fn<typeof fetch>(async () => jsonResponse({
|
||||
session: { id: 'sess-1' },
|
||||
user: {
|
||||
createdAt: '2025-04-01T00:00:00.000Z',
|
||||
id: 'user-1',
|
||||
name: 'Alice',
|
||||
email: 'alice@example.test',
|
||||
emailVerified: true,
|
||||
id: 'user-1',
|
||||
image: 'https://cdn.example.test/avatar.png',
|
||||
name: 'Alice',
|
||||
createdAt: '2025-04-01T00:00:00.000Z',
|
||||
// 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: {
|
||||
createdAt: '2025-04-01T00:00:00.000Z',
|
||||
id: 'user-1',
|
||||
name: 'Alice',
|
||||
email: 'alice@example.test',
|
||||
emailVerified: true,
|
||||
id: 'user-1',
|
||||
image: 'https://cdn.example.test/avatar.png',
|
||||
name: 'Alice',
|
||||
createdAt: '2025-04-01T00:00:00.000Z',
|
||||
},
|
||||
})
|
||||
|
||||
@@ -92,8 +92,8 @@ describe('ui-server-auth profile flow helpers', () => {
|
||||
|
||||
await changePassword({
|
||||
apiServerUrl: 'https://api.airi.test',
|
||||
currentPassword: 'old-pw',
|
||||
fetchImpl,
|
||||
currentPassword: 'old-pw',
|
||||
newPassword: 'new-pw',
|
||||
})
|
||||
|
||||
@@ -112,8 +112,8 @@ describe('ui-server-auth profile flow helpers', () => {
|
||||
|
||||
await expect(changePassword({
|
||||
apiServerUrl: 'https://api.airi.test',
|
||||
currentPassword: 'wrong',
|
||||
fetchImpl,
|
||||
currentPassword: 'wrong',
|
||||
newPassword: 'new-pw',
|
||||
})).rejects.toThrow('Invalid current password')
|
||||
})
|
||||
|
||||
@@ -19,16 +19,6 @@ 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`.
|
||||
*
|
||||
@@ -39,12 +29,12 @@ export interface CurrentSessionResult {
|
||||
* worry about Date-vs-string drift across the ui-server-auth boundary.
|
||||
*/
|
||||
export interface ProfileUser {
|
||||
/** ISO timestamp from `created_at`. */
|
||||
createdAt: null | string
|
||||
id: string
|
||||
/** Display name set on sign-up or via {@link updateUserProfile}. */
|
||||
name: string
|
||||
email: string
|
||||
/** True once the user clicked the verification link sent on sign-up. */
|
||||
emailVerified: boolean
|
||||
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
|
||||
@@ -52,9 +42,26 @@ export interface ProfileUser {
|
||||
* detects the fallback by URL prefix
|
||||
* (`https://www.gravatar.com/avatar/`).
|
||||
*/
|
||||
image: null | string
|
||||
/** Display name set on sign-up or via {@link updateUserProfile}. */
|
||||
name: string
|
||||
image: string | null
|
||||
/** ISO timestamp from `created_at`. */
|
||||
createdAt: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of a `/get-session` probe.
|
||||
*
|
||||
* `user` is `null` when no session cookie is present (or it expired). Caller
|
||||
* uses that to redirect to the sign-in page instead of rendering the form.
|
||||
*/
|
||||
export interface CurrentSessionResult {
|
||||
user: ProfileUser | null
|
||||
}
|
||||
|
||||
interface UpdateUserProfileArgs extends AuthFetchBase {
|
||||
/** Trim before passing — server stores the value as-is. */
|
||||
name?: string
|
||||
/** Optional avatar URL. Pass `null` to clear it. */
|
||||
image?: string | null
|
||||
}
|
||||
|
||||
interface ChangePasswordArgs extends AuthFetchBase {
|
||||
@@ -68,39 +75,6 @@ interface ChangePasswordArgs extends AuthFetchBase {
|
||||
revokeOtherSessions?: boolean
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
/**
|
||||
* Change the signed-in user's password using their current credential.
|
||||
*
|
||||
* Use when:
|
||||
* - User is signed in and wants to rotate their password from the profile
|
||||
* page (not the forgot-password email flow).
|
||||
*
|
||||
* Expects:
|
||||
* - The user has a `credential` account; social-only users get a server-side
|
||||
* error which surfaces as a thrown `Error` here.
|
||||
*/
|
||||
export async function changePassword(args: ChangePasswordArgs): Promise<void> {
|
||||
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')
|
||||
}
|
||||
|
||||
export function describeProfileError(error: unknown): string {
|
||||
return errorMessageFrom(error) ?? 'Unexpected error'
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the current session via the typed better-auth client.
|
||||
*
|
||||
@@ -123,16 +97,60 @@ export async function getCurrentSession(args: AuthFetchBase): Promise<CurrentSes
|
||||
|
||||
return {
|
||||
user: {
|
||||
createdAt: toIsoString(data.user.createdAt),
|
||||
id: data.user.id,
|
||||
name: data.user.name,
|
||||
email: data.user.email,
|
||||
emailVerified: data.user.emailVerified,
|
||||
id: data.user.id,
|
||||
image: data.user.image ?? null,
|
||||
name: data.user.name,
|
||||
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')
|
||||
}
|
||||
|
||||
/**
|
||||
* Change the signed-in user's password using their current credential.
|
||||
*
|
||||
* Use when:
|
||||
* - User is signed in and wants to rotate their password from the profile
|
||||
* page (not the forgot-password email flow).
|
||||
*
|
||||
* Expects:
|
||||
* - The user has a `credential` account; social-only users get a server-side
|
||||
* error which surfaces as a thrown `Error` here.
|
||||
*/
|
||||
export async function changePassword(args: ChangePasswordArgs): Promise<void> {
|
||||
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 better-auth's `/sign-out` endpoint.
|
||||
*
|
||||
@@ -151,26 +169,8 @@ export async function signOut(args: AuthFetchBase): Promise<void> {
|
||||
throw new Error(error.message ?? 'signOut failed')
|
||||
}
|
||||
|
||||
/**
|
||||
* 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')
|
||||
export function describeProfileError(error: unknown): string {
|
||||
return errorMessageFrom(error) ?? 'Unexpected error'
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -183,7 +183,7 @@ export async function updateUserProfile(args: UpdateUserProfileArgs): Promise<vo
|
||||
* After:
|
||||
* - `'2025-04-01T00:00:00.000Z'` / `'2025-04-01T00:00:00.000Z'` / `null`
|
||||
*/
|
||||
function toIsoString(value: unknown): null | string {
|
||||
function toIsoString(value: unknown): string | null {
|
||||
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: null | ServerAuthBootstrapContext | undefined
|
||||
let cachedContext: ServerAuthBootstrapContext | null | undefined
|
||||
|
||||
export function getServerAuthBootstrapContext(): null | ServerAuthBootstrapContext {
|
||||
export function getServerAuthBootstrapContext(): ServerAuthBootstrapContext | null {
|
||||
if (cachedContext !== undefined)
|
||||
return cachedContext
|
||||
|
||||
@@ -77,7 +77,7 @@ export function getServerAuthBootstrapContext(): null | ServerAuthBootstrapConte
|
||||
* - A bootstrap context using the trusted API origin, or null when no trusted
|
||||
* override is present.
|
||||
*/
|
||||
export function resolveStandaloneServerAuthContext(currentUrl: string, fallbackApiServerUrl: string): null | ServerAuthBootstrapContext {
|
||||
export function resolveStandaloneServerAuthContext(currentUrl: string, fallbackApiServerUrl: string): ServerAuthBootstrapContext | null {
|
||||
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: null | string): null | string {
|
||||
function normalizeTrustedApiServerUrl(value: string | null): string | null {
|
||||
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({ redirect: true, url: 'https://steamcommunity.com/openid/login?...' }), {
|
||||
return new Response(JSON.stringify({ url: 'https://steamcommunity.com/openid/login?...', redirect: true }), {
|
||||
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,
|
||||
})
|
||||
|
||||
|
||||
@@ -20,14 +20,14 @@ const TRUSTED_LOCAL_ADMIN_REDIRECT_ORIGIN_PATTERNS = [
|
||||
|
||||
export interface ServerSignInContext {
|
||||
callbackURL: string
|
||||
requestedProvider: null | string
|
||||
requestedProvider: string | null
|
||||
}
|
||||
|
||||
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,34 +83,7 @@ export function createServerSignInContext(currentUrl: string, apiServerUrl: stri
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
function normalizeStandaloneRedirect(currentUrl: URL, redirect: string | null): string | null {
|
||||
if (!redirect)
|
||||
return null
|
||||
|
||||
@@ -127,7 +100,7 @@ function normalizeStandaloneRedirect(currentUrl: URL, redirect: null | string):
|
||||
return `${currentUrl.origin}${buildAuthUiPath(redirect)}`
|
||||
}
|
||||
|
||||
function normalizeTrustedAdminRedirect(redirect: string): null | string {
|
||||
function normalizeTrustedAdminRedirect(redirect: string): string | null {
|
||||
try {
|
||||
const url = new URL(redirect)
|
||||
if (TRUSTED_ADMIN_REDIRECT_ORIGINS.includes(url.origin))
|
||||
@@ -143,6 +116,33 @@ function normalizeTrustedAdminRedirect(redirect: string): null | string {
|
||||
}
|
||||
}
|
||||
|
||||
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.
|
||||
|
||||
Reference in New Issue
Block a user