feat(auth): email login & profile (#1745)
Co-authored-by: Liet Blue <127093491+lietblue@users.noreply.github.com>
This commit is contained in:
@@ -29,9 +29,9 @@ const routeRecords = setupLayouts(routes as RouteRecordRaw[])
|
||||
|
||||
let router: Router
|
||||
if (isEnvTruthy(import.meta.env.VITE_APP_TARGET_HUGGINGFACE_SPACE))
|
||||
router = createRouter({ routes: routeRecords, history: createWebHashHistory('/_ui/server-auth/') })
|
||||
router = createRouter({ routes: routeRecords, history: createWebHashHistory('/auth/') })
|
||||
else
|
||||
router = createRouter({ routes: routeRecords, history: createWebHistory('/_ui/server-auth/') })
|
||||
router = createRouter({ routes: routeRecords, history: createWebHistory('/auth/') })
|
||||
|
||||
router.beforeEach((to, from) => {
|
||||
if (to.path !== from.path)
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
/**
|
||||
* Shared HTTP plumbing for the ui-server-auth → apps/server auth surface.
|
||||
*
|
||||
* Use when:
|
||||
* - Hitting any `/api/auth/...` endpoint from the UI (sign-in, sign-up,
|
||||
* forgot-password, reset-password, social redirects).
|
||||
*
|
||||
* Expects:
|
||||
* - Caller passes `apiServerUrl` so dev (`http://localhost:3000`) and prod
|
||||
* (`https://api.airi.build`) share the same modules.
|
||||
* - All requests go out with `credentials: 'include'`. The OIDC handoff
|
||||
* downstream of email/password sign-in needs the better-auth session
|
||||
* cookie. The stage-ui `authClient` uses Bearer-only and so cannot drive
|
||||
* these flows directly.
|
||||
*
|
||||
* Returns:
|
||||
* - Plain async functions; throw `Error` with the server-supplied message on
|
||||
* non-2xx so caller views see the real reason instead of a generic banner.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Common shape for any function in this module that needs to talk to the
|
||||
* auth server.
|
||||
*/
|
||||
export interface AuthFetchBase {
|
||||
apiServerUrl: string
|
||||
fetchImpl?: typeof fetch
|
||||
}
|
||||
|
||||
/**
|
||||
* POST a JSON body to `/api/auth<path>` and parse the response with `parse`.
|
||||
*
|
||||
* Use when:
|
||||
* - You need a typed wrapper around a Better Auth POST endpoint that
|
||||
* responds with JSON on both success and failure (the common case).
|
||||
*
|
||||
* Expects:
|
||||
* - `path` includes the leading slash (e.g. `/sign-in/email`).
|
||||
* - `parse` runs only on 2xx responses; on non-2xx the wrapper throws.
|
||||
*
|
||||
* Returns:
|
||||
* - Whatever `parse` returns. Never returns on non-2xx — throws an `Error`
|
||||
* carrying the server's `message` / `error.message` field.
|
||||
*/
|
||||
export async function postAuthJSON<T>(
|
||||
base: AuthFetchBase,
|
||||
path: string,
|
||||
body: Record<string, unknown>,
|
||||
parse: (data: unknown, response: Response) => T,
|
||||
): Promise<T> {
|
||||
const fetchImpl = base.fetchImpl ?? fetch
|
||||
const endpoint = new URL(`/api/auth${path}`, base.apiServerUrl)
|
||||
|
||||
const response = await fetchImpl(endpoint.toString(), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
credentials: 'include',
|
||||
})
|
||||
|
||||
let data: unknown
|
||||
try {
|
||||
data = await response.json()
|
||||
}
|
||||
catch {
|
||||
data = null
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(extractAuthError(data) ?? `Auth request failed (${response.status})`)
|
||||
}
|
||||
|
||||
return parse(data, response)
|
||||
}
|
||||
|
||||
/**
|
||||
* GET `/api/auth<path>` and parse the response with `parse`.
|
||||
*
|
||||
* Use when:
|
||||
* - Reading a Better Auth GET endpoint (e.g. `/get-session`) from the UI and
|
||||
* you want the same `credentials: include` + error-shape handling as
|
||||
* {@link postAuthJSON}.
|
||||
*
|
||||
* Expects:
|
||||
* - `path` starts with a leading slash.
|
||||
* - `parse` runs only on 2xx responses; non-2xx throws with the server message.
|
||||
*
|
||||
* Returns:
|
||||
* - Whatever `parse` returns. Throws an `Error` on non-2xx with the server's
|
||||
* `message` / `error.message` field when present.
|
||||
*/
|
||||
export async function getAuthJSON<T>(
|
||||
base: AuthFetchBase,
|
||||
path: string,
|
||||
parse: (data: unknown, response: Response) => T,
|
||||
): Promise<T> {
|
||||
const fetchImpl = base.fetchImpl ?? fetch
|
||||
const endpoint = new URL(`/api/auth${path}`, base.apiServerUrl)
|
||||
|
||||
const response = await fetchImpl(endpoint.toString(), {
|
||||
method: 'GET',
|
||||
credentials: 'include',
|
||||
})
|
||||
|
||||
let data: unknown
|
||||
try {
|
||||
data = await response.json()
|
||||
}
|
||||
catch {
|
||||
data = null
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(extractAuthError(data) ?? `Auth request failed (${response.status})`)
|
||||
}
|
||||
|
||||
return parse(data, response)
|
||||
}
|
||||
|
||||
/**
|
||||
* Pull a human-readable error string out of a Better Auth JSON error response.
|
||||
*
|
||||
* Before:
|
||||
* - `{ "message": "Invalid credentials", "code": "INVALID_CREDENTIALS" }`
|
||||
* - `{ "error": { "message": "Token expired" } }`
|
||||
* - `{ "error": "Rate limit" }`
|
||||
*
|
||||
* After:
|
||||
* - `"Invalid credentials"` / `"Token expired"` / `"Rate limit"`
|
||||
*
|
||||
* Returns `null` when the payload has no message-like field, leaving the
|
||||
* caller to fall back to a status-code-only message.
|
||||
*/
|
||||
export function extractAuthError(data: unknown): string | null {
|
||||
if (!data || typeof data !== 'object')
|
||||
return null
|
||||
|
||||
const maybe = data as { error?: unknown, message?: unknown }
|
||||
if (typeof maybe.message === 'string')
|
||||
return maybe.message
|
||||
|
||||
const error = maybe.error
|
||||
if (typeof error === 'string')
|
||||
return error
|
||||
|
||||
if (
|
||||
error
|
||||
&& typeof error === 'object'
|
||||
&& 'message' in error
|
||||
&& typeof (error as { message: unknown }).message === 'string'
|
||||
) {
|
||||
return (error as { message: string }).message
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
/**
|
||||
* Email + password auth flows backed by better-auth's built-in routes.
|
||||
*
|
||||
* Use when:
|
||||
* - Driving sign-in / sign-up / forgot-password / reset-password forms in
|
||||
* the OIDC login UI (`apps/ui-server-auth`).
|
||||
*
|
||||
* Each function shares the {@link AuthFetchBase} contract via auth-fetch.ts;
|
||||
* see that module for HTTP-level expectations (credentials, error parsing).
|
||||
*/
|
||||
|
||||
import type { AuthFetchBase } from './auth-fetch'
|
||||
|
||||
import { errorMessageFrom } from '@moeru/std'
|
||||
|
||||
import { postAuthJSON } from './auth-fetch'
|
||||
|
||||
interface CheckEmailArgs extends AuthFetchBase {
|
||||
email: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of the email-first identifier probe.
|
||||
*
|
||||
* Drives whether the unified UI shows the password field (existing
|
||||
* credential user), the create-account fields (new email), or steers the
|
||||
* user toward a social provider (existing social-only user).
|
||||
*/
|
||||
export interface CheckEmailResult {
|
||||
/** A user row matches this email (case-insensitive). */
|
||||
exists: boolean
|
||||
/** That user has a `credential` account, i.e. can sign in via password. */
|
||||
hasPassword: boolean
|
||||
}
|
||||
|
||||
interface EmailSignInArgs extends AuthFetchBase {
|
||||
email: string
|
||||
password: string
|
||||
callbackURL?: string
|
||||
/** @default true */
|
||||
rememberMe?: boolean
|
||||
}
|
||||
|
||||
interface EmailSignUpArgs extends AuthFetchBase {
|
||||
email: string
|
||||
password: string
|
||||
name: string
|
||||
callbackURL?: string
|
||||
}
|
||||
|
||||
interface RequestPasswordResetArgs extends AuthFetchBase {
|
||||
email: string
|
||||
/**
|
||||
* Frontend page that better-auth redirects to with `?token=...` after
|
||||
* validating the email link.
|
||||
*/
|
||||
redirectTo: string
|
||||
}
|
||||
|
||||
interface ResetPasswordArgs extends AuthFetchBase {
|
||||
newPassword: string
|
||||
token: string
|
||||
}
|
||||
|
||||
interface SignInResult {
|
||||
/** Set when better-auth allows browser to follow the OIDC redirect itself. */
|
||||
redirectURL: string | null
|
||||
/**
|
||||
* True if email verification is still pending; UI should route to
|
||||
* the `verify-email` notice page.
|
||||
*/
|
||||
requiresVerification: boolean
|
||||
}
|
||||
|
||||
interface SignUpResult {
|
||||
/**
|
||||
* True when sendOnSignUp / requireEmailVerification fired; UI shows
|
||||
* `please check inbox` instead of an immediate session.
|
||||
*/
|
||||
requiresVerification: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Probe whether an email is already registered before showing password / sign-up fields.
|
||||
*
|
||||
* Use when:
|
||||
* - Implementing the email-first identifier step on the unified sign-in page.
|
||||
*
|
||||
* Expects:
|
||||
* - `email` is the raw user input; the server normalizes (trim + lowercase).
|
||||
*
|
||||
* Returns:
|
||||
* - {@link CheckEmailResult} indicating existence and whether a credential
|
||||
* account is attached. UI uses these to pick the second step.
|
||||
*/
|
||||
export async function checkEmail(args: CheckEmailArgs): Promise<CheckEmailResult> {
|
||||
return postAuthJSON(
|
||||
args,
|
||||
'/check-email',
|
||||
{ email: args.email },
|
||||
(data) => {
|
||||
const exists = Boolean((data as { exists?: unknown })?.exists)
|
||||
const hasPassword = Boolean((data as { hasPassword?: unknown })?.hasPassword)
|
||||
return { exists, hasPassword }
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
export async function signInWithEmail(args: EmailSignInArgs): Promise<SignInResult> {
|
||||
return postAuthJSON(
|
||||
args,
|
||||
'/sign-in/email',
|
||||
{
|
||||
email: args.email,
|
||||
password: args.password,
|
||||
callbackURL: args.callbackURL,
|
||||
rememberMe: args.rememberMe ?? true,
|
||||
},
|
||||
(data) => {
|
||||
const url = typeof (data as { url?: unknown })?.url === 'string'
|
||||
? (data as { url: string }).url
|
||||
: null
|
||||
// NOTICE:
|
||||
// better-auth surfaces `requiresEmailVerification` (rather than throwing)
|
||||
// when emailAndPassword.requireEmailVerification is true and the user is
|
||||
// not yet verified. Frontend uses this to route into the `verify-email`
|
||||
// notice page instead of bouncing to the OIDC callback.
|
||||
// Source: node_modules/better-auth/dist/api/routes/sign-in.mjs L235+
|
||||
const requiresVerification = Boolean(
|
||||
(data as { requiresEmailVerification?: unknown })?.requiresEmailVerification,
|
||||
)
|
||||
return { redirectURL: url, requiresVerification }
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
export async function signUpWithEmail(args: EmailSignUpArgs): Promise<SignUpResult> {
|
||||
return postAuthJSON(
|
||||
args,
|
||||
'/sign-up/email',
|
||||
{
|
||||
email: args.email,
|
||||
password: args.password,
|
||||
name: args.name,
|
||||
callbackURL: args.callbackURL,
|
||||
},
|
||||
(data) => {
|
||||
// When verification is required, better-auth returns `{ token: null, user: ... }`
|
||||
// and queues the verification email; otherwise it returns a session token.
|
||||
const token = (data as { token?: unknown })?.token
|
||||
return { requiresVerification: token === null || token === undefined }
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
export async function requestPasswordReset(args: RequestPasswordResetArgs): Promise<void> {
|
||||
await postAuthJSON(
|
||||
args,
|
||||
'/request-password-reset',
|
||||
{ email: args.email, redirectTo: args.redirectTo },
|
||||
() => undefined,
|
||||
)
|
||||
}
|
||||
|
||||
export async function resetPasswordWithToken(args: ResetPasswordArgs): Promise<void> {
|
||||
// NOTICE:
|
||||
// /reset-password takes the token from the query string in addition to
|
||||
// the JSON body — the body alone is not enough. Encode it in both spots
|
||||
// so we match the better-auth contract regardless of which one the
|
||||
// current version reads.
|
||||
// Source: node_modules/better-auth/dist/api/routes/password.mjs L120+
|
||||
await postAuthJSON(
|
||||
args,
|
||||
`/reset-password?token=${encodeURIComponent(args.token)}`,
|
||||
{ newPassword: args.newPassword, token: args.token },
|
||||
() => undefined,
|
||||
)
|
||||
}
|
||||
|
||||
export function describeAuthError(error: unknown): string {
|
||||
return errorMessageFrom(error) ?? 'Unexpected error'
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { changePassword, getCurrentSession, signOut, updateUserProfile } from './profile'
|
||||
|
||||
function jsonResponse(body: unknown, status = 200): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
}
|
||||
|
||||
describe('ui-server-auth profile flow helpers', () => {
|
||||
it('parses the better-auth get-session response into a flat user shape', async () => {
|
||||
const fetchImpl = vi.fn<typeof fetch>(async () => jsonResponse({
|
||||
session: { id: 'sess-1' },
|
||||
user: {
|
||||
id: 'user-1',
|
||||
name: 'Alice',
|
||||
email: 'alice@example.test',
|
||||
emailVerified: true,
|
||||
image: 'https://cdn.example.test/avatar.png',
|
||||
createdAt: '2025-04-01T00:00:00.000Z',
|
||||
// Field intentionally not in ProfileUser — must be ignored.
|
||||
twoFactorEnabled: true,
|
||||
},
|
||||
}))
|
||||
|
||||
await expect(getCurrentSession({
|
||||
apiServerUrl: 'https://api.airi.test',
|
||||
fetchImpl,
|
||||
})).resolves.toEqual({
|
||||
user: {
|
||||
id: 'user-1',
|
||||
name: 'Alice',
|
||||
email: 'alice@example.test',
|
||||
emailVerified: true,
|
||||
image: 'https://cdn.example.test/avatar.png',
|
||||
createdAt: '2025-04-01T00:00:00.000Z',
|
||||
},
|
||||
})
|
||||
|
||||
expect(fetchImpl).toHaveBeenCalledTimes(1)
|
||||
expect(fetchImpl).toHaveBeenCalledWith(
|
||||
'https://api.airi.test/api/auth/get-session',
|
||||
expect.objectContaining({ method: 'GET', credentials: 'include' }),
|
||||
)
|
||||
})
|
||||
|
||||
it('returns user=null when better-auth reports no session', async () => {
|
||||
const fetchImpl = vi.fn<typeof fetch>(async () => jsonResponse(null))
|
||||
|
||||
await expect(getCurrentSession({
|
||||
apiServerUrl: 'https://api.airi.test',
|
||||
fetchImpl,
|
||||
})).resolves.toEqual({ user: null })
|
||||
})
|
||||
|
||||
it('omits undefined fields from the update-user body', async () => {
|
||||
const fetchImpl = vi.fn<typeof fetch>(async () => jsonResponse({ status: true }))
|
||||
|
||||
await updateUserProfile({
|
||||
apiServerUrl: 'https://api.airi.test',
|
||||
fetchImpl,
|
||||
name: 'Alice Renamed',
|
||||
})
|
||||
|
||||
const init = fetchImpl.mock.calls[0]?.[1]
|
||||
expect(JSON.parse(String(init?.body))).toEqual({ name: 'Alice Renamed' })
|
||||
})
|
||||
|
||||
it('passes image=null through so callers can clear avatars explicitly', async () => {
|
||||
const fetchImpl = vi.fn<typeof fetch>(async () => jsonResponse({ status: true }))
|
||||
|
||||
await updateUserProfile({
|
||||
apiServerUrl: 'https://api.airi.test',
|
||||
fetchImpl,
|
||||
image: null,
|
||||
})
|
||||
|
||||
const init = fetchImpl.mock.calls[0]?.[1]
|
||||
expect(JSON.parse(String(init?.body))).toEqual({ image: null })
|
||||
})
|
||||
|
||||
it('defaults change-password to revoking other sessions', async () => {
|
||||
const fetchImpl = vi.fn<typeof fetch>(async () => jsonResponse({ status: true }))
|
||||
|
||||
await changePassword({
|
||||
apiServerUrl: 'https://api.airi.test',
|
||||
fetchImpl,
|
||||
currentPassword: 'old-pw',
|
||||
newPassword: 'new-pw',
|
||||
})
|
||||
|
||||
const init = fetchImpl.mock.calls[0]?.[1]
|
||||
expect(JSON.parse(String(init?.body))).toEqual({
|
||||
currentPassword: 'old-pw',
|
||||
newPassword: 'new-pw',
|
||||
revokeOtherSessions: true,
|
||||
})
|
||||
})
|
||||
|
||||
it('surfaces server-side error messages for change-password', async () => {
|
||||
const fetchImpl = vi.fn<typeof fetch>(async () => jsonResponse({
|
||||
message: 'Invalid current password',
|
||||
}, 400))
|
||||
|
||||
await expect(changePassword({
|
||||
apiServerUrl: 'https://api.airi.test',
|
||||
fetchImpl,
|
||||
currentPassword: 'wrong',
|
||||
newPassword: 'new-pw',
|
||||
})).rejects.toThrow('Invalid current password')
|
||||
})
|
||||
|
||||
it('posts to /sign-out with credentials included', async () => {
|
||||
const fetchImpl = vi.fn<typeof fetch>(async () => jsonResponse({ success: true }))
|
||||
|
||||
await signOut({ apiServerUrl: 'https://api.airi.test', fetchImpl })
|
||||
|
||||
expect(fetchImpl).toHaveBeenCalledWith(
|
||||
'https://api.airi.test/api/auth/sign-out',
|
||||
expect.objectContaining({ method: 'POST', credentials: 'include' }),
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,174 @@
|
||||
/**
|
||||
* Account profile flows backed by better-auth's built-in user routes.
|
||||
*
|
||||
* Use when:
|
||||
* - Driving the profile page in `apps/ui-server-auth` (load current user,
|
||||
* update display name, change password, sign out).
|
||||
*
|
||||
* Each function shares the {@link AuthFetchBase} contract via auth-fetch.ts;
|
||||
* see that module for HTTP-level expectations (credentials, error parsing).
|
||||
*/
|
||||
|
||||
import type { AuthFetchBase } from './auth-fetch'
|
||||
|
||||
import { errorMessageFrom } from '@moeru/std'
|
||||
|
||||
import { getAuthJSON, postAuthJSON } from './auth-fetch'
|
||||
|
||||
/**
|
||||
* Subset of the better-auth `user` row needed to render the profile page.
|
||||
*
|
||||
* Mirrors the shape returned by `/api/auth/get-session`; extra fields are
|
||||
* ignored intentionally so this module doesn't drift if better-auth adds
|
||||
* unrelated columns.
|
||||
*/
|
||||
export interface ProfileUser {
|
||||
id: string
|
||||
/** Display name set on sign-up or via {@link updateUserProfile}. */
|
||||
name: string
|
||||
email: string
|
||||
/** True once the user clicked the verification link sent on sign-up. */
|
||||
emailVerified: boolean
|
||||
/** Avatar URL — usually populated by social providers; may be empty. */
|
||||
image: string | null
|
||||
/** ISO timestamp from `created_at`. */
|
||||
createdAt: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of a `/get-session` probe.
|
||||
*
|
||||
* `user` is `null` when no session cookie is present (or it expired). Caller
|
||||
* uses that to redirect to the sign-in page instead of rendering the form.
|
||||
*/
|
||||
export interface CurrentSessionResult {
|
||||
user: ProfileUser | null
|
||||
}
|
||||
|
||||
interface UpdateUserProfileArgs extends AuthFetchBase {
|
||||
/** Trim before passing — server stores the value as-is. */
|
||||
name?: string
|
||||
/** Optional avatar URL. Pass `null` to clear it. */
|
||||
image?: string | null
|
||||
}
|
||||
|
||||
interface ChangePasswordArgs extends AuthFetchBase {
|
||||
currentPassword: string
|
||||
newPassword: string
|
||||
/**
|
||||
* Revoke other active sessions after password change.
|
||||
*
|
||||
* @default true
|
||||
*/
|
||||
revokeOtherSessions?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the current session from `/api/auth/get-session`.
|
||||
*
|
||||
* Use when:
|
||||
* - Bootstrapping the profile page; decides whether to render the form or
|
||||
* bounce the user to the sign-in page.
|
||||
*
|
||||
* Expects:
|
||||
* - Browser sends the better-auth session cookie (`credentials: include`).
|
||||
*
|
||||
* Returns:
|
||||
* - `user: null` when there's no active session (better-auth returns an empty
|
||||
* body for unauthenticated GETs).
|
||||
* - {@link CurrentSessionResult} with the trimmed user fields otherwise.
|
||||
*/
|
||||
export async function getCurrentSession(args: AuthFetchBase): Promise<CurrentSessionResult> {
|
||||
return getAuthJSON(args, '/get-session', (data) => {
|
||||
// NOTICE:
|
||||
// better-auth returns either `null` or an empty object for an
|
||||
// unauthenticated GET to `/get-session`, not a 401. Treat both as
|
||||
// "no session" so the caller can branch on user === null without a
|
||||
// separate try/catch.
|
||||
// Source: node_modules/better-auth/dist/api/routes/session.mjs (`getSession`)
|
||||
if (!data || typeof data !== 'object' || !('user' in data) || !data.user)
|
||||
return { user: null }
|
||||
|
||||
const raw = (data as { user: unknown }).user as Record<string, unknown>
|
||||
const user: ProfileUser = {
|
||||
id: typeof raw.id === 'string' ? raw.id : '',
|
||||
name: typeof raw.name === 'string' ? raw.name : '',
|
||||
email: typeof raw.email === 'string' ? raw.email : '',
|
||||
emailVerified: Boolean(raw.emailVerified),
|
||||
image: typeof raw.image === 'string' ? raw.image : null,
|
||||
createdAt: typeof raw.createdAt === 'string' ? raw.createdAt : null,
|
||||
}
|
||||
return { user }
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the signed-in user's display name and/or avatar.
|
||||
*
|
||||
* Use when:
|
||||
* - Saving the "display name" form on the profile page.
|
||||
*
|
||||
* Expects:
|
||||
* - Caller has already trimmed `name` and confirmed it's non-empty.
|
||||
* - `image` is either an absolute URL or `null` (clear).
|
||||
*
|
||||
* Returns:
|
||||
* - Resolves on 2xx; throws with the better-auth error message otherwise.
|
||||
*/
|
||||
export async function updateUserProfile(args: UpdateUserProfileArgs): Promise<void> {
|
||||
const body: Record<string, unknown> = {}
|
||||
if (args.name !== undefined)
|
||||
body.name = args.name
|
||||
if (args.image !== undefined)
|
||||
body.image = args.image
|
||||
|
||||
await postAuthJSON(args, '/update-user', body, () => undefined)
|
||||
}
|
||||
|
||||
/**
|
||||
* Change the signed-in user's password using their current credential.
|
||||
*
|
||||
* Use when:
|
||||
* - User is signed in and wants to rotate their password from the profile
|
||||
* page (not the forgot-password email flow).
|
||||
*
|
||||
* Expects:
|
||||
* - The user has a `credential` account; social-only users get a server-side
|
||||
* error which surfaces as a thrown `Error` here.
|
||||
*
|
||||
* Returns:
|
||||
* - Resolves on 2xx. By default, all other sessions are revoked
|
||||
* (`revokeOtherSessions = true`) so a stolen old session can't keep
|
||||
* working after a forced rotation.
|
||||
*/
|
||||
export async function changePassword(args: ChangePasswordArgs): Promise<void> {
|
||||
await postAuthJSON(
|
||||
args,
|
||||
'/change-password',
|
||||
{
|
||||
currentPassword: args.currentPassword,
|
||||
newPassword: args.newPassword,
|
||||
revokeOtherSessions: args.revokeOtherSessions ?? true,
|
||||
},
|
||||
() => undefined,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Sign the current user out via `/api/auth/sign-out`.
|
||||
*
|
||||
* Use when:
|
||||
* - User clicks "Sign out" on the profile page.
|
||||
*
|
||||
* Returns:
|
||||
* - Resolves once the better-auth session cookie has been cleared by the
|
||||
* server. Caller is expected to navigate the user back to the sign-in
|
||||
* page after this resolves.
|
||||
*/
|
||||
export async function signOut(args: AuthFetchBase): Promise<void> {
|
||||
await postAuthJSON(args, '/sign-out', {}, () => undefined)
|
||||
}
|
||||
|
||||
export function describeProfileError(error: unknown): string {
|
||||
return errorMessageFrom(error) ?? 'Unexpected error'
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
import type { OAuthProvider } from '@proj-airi/stage-ui/libs/auth'
|
||||
|
||||
import { extractAuthError } from './auth-fetch'
|
||||
|
||||
export interface ServerSignInContext {
|
||||
callbackURL: string
|
||||
requestedProvider: string | null
|
||||
@@ -54,23 +56,10 @@ export async function requestSocialSignInRedirect(params: SocialSignInRedirectPa
|
||||
return response.headers.get('location') || '/'
|
||||
}
|
||||
|
||||
const data = await response.json() as {
|
||||
url?: unknown
|
||||
error?: unknown
|
||||
}
|
||||
const data = await response.json() as { url?: unknown }
|
||||
|
||||
if (typeof data.url === 'string')
|
||||
return data.url
|
||||
|
||||
throw new Error(getSignInErrorMessage(data.error))
|
||||
}
|
||||
|
||||
function getSignInErrorMessage(error: unknown): string {
|
||||
if (typeof error === 'string')
|
||||
return error
|
||||
|
||||
if (typeof error === 'object' && error && 'message' in error && typeof error.message === 'string')
|
||||
return error.message
|
||||
|
||||
return 'Unexpected response'
|
||||
throw new Error(extractAuthError(data) ?? 'Unexpected response')
|
||||
}
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
<script setup lang="ts">
|
||||
import { SERVER_URL } from '@proj-airi/stage-ui/libs/server'
|
||||
import { Button, FieldInput } from '@proj-airi/ui'
|
||||
import { reactive, shallowRef } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { describeAuthError, requestPasswordReset } from '../modules/email-password'
|
||||
import { getServerAuthBootstrapContext } from '../modules/server-auth-context'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const bootstrapContext = getServerAuthBootstrapContext()
|
||||
const apiServerUrl = bootstrapContext?.apiServerUrl ?? SERVER_URL
|
||||
|
||||
// Reset link must redirect back into ui-server-auth itself. Use the current
|
||||
// origin so dev (localhost) and prod (auth.airi…) both resolve correctly.
|
||||
const resetRedirect = `${window.location.origin}/auth/reset-password`
|
||||
|
||||
const form = reactive({ email: '' })
|
||||
const errorMessage = shallowRef<string | null>(null)
|
||||
const loading = shallowRef(false)
|
||||
const submitted = shallowRef(false)
|
||||
|
||||
async function handleSubmit(event: Event) {
|
||||
event.preventDefault()
|
||||
if (loading.value)
|
||||
return
|
||||
|
||||
errorMessage.value = null
|
||||
loading.value = true
|
||||
|
||||
try {
|
||||
await requestPasswordReset({
|
||||
apiServerUrl,
|
||||
email: form.email.trim(),
|
||||
redirectTo: resetRedirect,
|
||||
})
|
||||
submitted.value = true
|
||||
}
|
||||
catch (error) {
|
||||
errorMessage.value = describeAuthError(error) || t('server.auth.forgotPassword.error.fallback')
|
||||
}
|
||||
finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main
|
||||
:class="[
|
||||
'min-h-screen flex flex-col items-center justify-center px-6 py-10 font-cuteen',
|
||||
]"
|
||||
>
|
||||
<div :class="['mb-6 text-2xl font-bold']">
|
||||
{{ t('server.auth.forgotPassword.title') }}
|
||||
</div>
|
||||
|
||||
<p
|
||||
v-if="!submitted"
|
||||
:class="['mb-6 max-w-sm text-center text-sm text-neutral-600 dark:text-neutral-300']"
|
||||
>
|
||||
{{ t('server.auth.forgotPassword.description') }}
|
||||
</p>
|
||||
|
||||
<form
|
||||
v-if="!submitted"
|
||||
:class="['max-w-xs w-full flex flex-col gap-3']"
|
||||
@submit="handleSubmit"
|
||||
>
|
||||
<FieldInput
|
||||
v-model="form.email"
|
||||
type="email"
|
||||
:label="t('server.auth.forgotPassword.email.label')"
|
||||
:placeholder="t('server.auth.forgotPassword.email.placeholder')"
|
||||
required
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
:class="['w-full', 'py-2', 'flex', 'items-center', 'justify-center']"
|
||||
:loading="loading"
|
||||
>
|
||||
<span>{{ t('server.auth.forgotPassword.action.send') }}</span>
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<div
|
||||
v-else
|
||||
:class="['max-w-sm text-center text-sm text-neutral-600 dark:text-neutral-300']"
|
||||
>
|
||||
{{ t('server.auth.forgotPassword.message.sent', { email: form.email.trim() }) }}
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="errorMessage"
|
||||
:class="['mt-4 max-w-xs w-full text-center text-sm text-red-500']"
|
||||
>
|
||||
{{ errorMessage }}
|
||||
</div>
|
||||
|
||||
<RouterLink
|
||||
to="/sign-in"
|
||||
:class="['mt-8 text-xs text-neutral-500 underline']"
|
||||
>
|
||||
{{ t('server.auth.forgotPassword.action.backToSignIn') }}
|
||||
</RouterLink>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<route lang="yaml">
|
||||
meta:
|
||||
layout: plain
|
||||
</route>
|
||||
@@ -1,7 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
import { RouterView } from 'vue-router'
|
||||
// Empty placeholder. The route below redirects /auth/ to /auth/profile so a
|
||||
// directly-typed `/auth/` URL or a post-sign-in callback that fell back to
|
||||
// the auth-UI root lands on a usable page instead of an empty RouterView.
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<RouterView />
|
||||
<div />
|
||||
</template>
|
||||
|
||||
<route lang="yaml">
|
||||
redirect: /profile
|
||||
</route>
|
||||
|
||||
@@ -0,0 +1,361 @@
|
||||
<script setup lang="ts">
|
||||
import type { ProfileUser } from '../modules/profile'
|
||||
|
||||
import { SERVER_URL } from '@proj-airi/stage-ui/libs/server'
|
||||
import { Button, FieldInput } from '@proj-airi/ui'
|
||||
import { computed, onMounted, reactive, shallowRef } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
import {
|
||||
changePassword,
|
||||
describeProfileError,
|
||||
getCurrentSession,
|
||||
signOut,
|
||||
updateUserProfile,
|
||||
} from '../modules/profile'
|
||||
import { getServerAuthBootstrapContext } from '../modules/server-auth-context'
|
||||
|
||||
const { t, locale } = useI18n()
|
||||
const router = useRouter()
|
||||
|
||||
const bootstrapContext = getServerAuthBootstrapContext()
|
||||
const apiServerUrl = bootstrapContext?.apiServerUrl ?? SERVER_URL
|
||||
|
||||
const initialLoading = shallowRef(true)
|
||||
const user = shallowRef<ProfileUser | null>(null)
|
||||
|
||||
const profileForm = reactive({ name: '' })
|
||||
const profileLoading = shallowRef(false)
|
||||
const profileError = shallowRef<string | null>(null)
|
||||
const profileSuccess = shallowRef<string | null>(null)
|
||||
|
||||
const passwordForm = reactive({
|
||||
current: '',
|
||||
next: '',
|
||||
confirm: '',
|
||||
})
|
||||
const passwordLoading = shallowRef(false)
|
||||
const passwordError = shallowRef<string | null>(null)
|
||||
const passwordSuccess = shallowRef<string | null>(null)
|
||||
|
||||
const signOutLoading = shallowRef(false)
|
||||
const signOutError = shallowRef<string | null>(null)
|
||||
|
||||
const nameDirty = computed(() => {
|
||||
if (!user.value)
|
||||
return false
|
||||
return profileForm.name.trim().length > 0 && profileForm.name.trim() !== user.value.name
|
||||
})
|
||||
|
||||
// Render createdAt with the active i18n locale so dates feel native (e.g. zh
|
||||
// users see `2025年4月1日` while en users see `April 1, 2025`). Falls back to
|
||||
// the raw ISO string if Intl rejects the locale.
|
||||
const formattedCreatedAt = computed(() => {
|
||||
if (!user.value?.createdAt)
|
||||
return ''
|
||||
try {
|
||||
return new Intl.DateTimeFormat(locale.value, { dateStyle: 'long' })
|
||||
.format(new Date(user.value.createdAt))
|
||||
}
|
||||
catch {
|
||||
return user.value.createdAt
|
||||
}
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const result = await getCurrentSession({ apiServerUrl })
|
||||
if (!result.user) {
|
||||
// Preserve the original target so the user lands back on /profile after
|
||||
// sign-in, rather than the sign-in default landing.
|
||||
await router.replace({
|
||||
path: '/sign-in',
|
||||
query: { redirect: '/profile' },
|
||||
})
|
||||
return
|
||||
}
|
||||
user.value = result.user
|
||||
profileForm.name = result.user.name
|
||||
}
|
||||
catch (error) {
|
||||
profileError.value = describeProfileError(error) || t('server.auth.profile.error.loadFailed')
|
||||
}
|
||||
finally {
|
||||
initialLoading.value = false
|
||||
}
|
||||
})
|
||||
|
||||
async function handleSaveName(event: Event) {
|
||||
event.preventDefault()
|
||||
if (profileLoading.value || !user.value || !nameDirty.value)
|
||||
return
|
||||
|
||||
profileError.value = null
|
||||
profileSuccess.value = null
|
||||
profileLoading.value = true
|
||||
|
||||
const trimmed = profileForm.name.trim()
|
||||
try {
|
||||
await updateUserProfile({ apiServerUrl, name: trimmed })
|
||||
user.value = { ...user.value, name: trimmed }
|
||||
profileForm.name = trimmed
|
||||
profileSuccess.value = t('server.auth.profile.message.profileSaved')
|
||||
}
|
||||
catch (error) {
|
||||
profileError.value = describeProfileError(error) || t('server.auth.profile.error.saveFailed')
|
||||
}
|
||||
finally {
|
||||
profileLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleChangePassword(event: Event) {
|
||||
event.preventDefault()
|
||||
if (passwordLoading.value)
|
||||
return
|
||||
|
||||
passwordError.value = null
|
||||
passwordSuccess.value = null
|
||||
|
||||
if (passwordForm.next !== passwordForm.confirm) {
|
||||
passwordError.value = t('server.auth.profile.error.passwordMismatch')
|
||||
return
|
||||
}
|
||||
|
||||
if (passwordForm.next === passwordForm.current) {
|
||||
passwordError.value = t('server.auth.profile.error.passwordSameAsCurrent')
|
||||
return
|
||||
}
|
||||
|
||||
passwordLoading.value = true
|
||||
try {
|
||||
await changePassword({
|
||||
apiServerUrl,
|
||||
currentPassword: passwordForm.current,
|
||||
newPassword: passwordForm.next,
|
||||
})
|
||||
passwordForm.current = ''
|
||||
passwordForm.next = ''
|
||||
passwordForm.confirm = ''
|
||||
passwordSuccess.value = t('server.auth.profile.message.passwordChanged')
|
||||
}
|
||||
catch (error) {
|
||||
passwordError.value = describeProfileError(error) || t('server.auth.profile.error.changePasswordFailed')
|
||||
}
|
||||
finally {
|
||||
passwordLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSignOut() {
|
||||
if (signOutLoading.value)
|
||||
return
|
||||
|
||||
signOutError.value = null
|
||||
signOutLoading.value = true
|
||||
|
||||
try {
|
||||
await signOut({ apiServerUrl })
|
||||
await router.replace('/sign-in')
|
||||
}
|
||||
catch (error) {
|
||||
signOutError.value = describeProfileError(error) || t('server.auth.profile.error.signOutFailed')
|
||||
signOutLoading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main
|
||||
:class="[
|
||||
'min-h-screen flex flex-col items-center justify-center px-6 py-10 font-cuteen',
|
||||
]"
|
||||
>
|
||||
<div :class="['mb-2 text-3xl font-bold']">
|
||||
{{ t('server.auth.profile.title') }}
|
||||
</div>
|
||||
<div :class="['mb-6 max-w-sm text-center text-sm text-neutral-500']">
|
||||
{{ t('server.auth.profile.description') }}
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="initialLoading"
|
||||
:class="['max-w-sm w-full text-center text-sm text-neutral-500']"
|
||||
>
|
||||
{{ t('server.auth.profile.message.loading') }}
|
||||
</div>
|
||||
|
||||
<template v-else-if="user">
|
||||
<!-- Identity summary: read-only fields (email, verification, created at) -->
|
||||
<section
|
||||
:class="['max-w-sm w-full flex flex-col gap-2 border border-neutral-200 dark:border-neutral-700 rounded-lg p-4 mb-6']"
|
||||
>
|
||||
<div :class="['flex items-center justify-between text-sm']">
|
||||
<span :class="['text-neutral-500']">{{ t('server.auth.profile.field.email') }}</span>
|
||||
<span :class="['font-medium']">{{ user.email }}</span>
|
||||
</div>
|
||||
<div :class="['flex items-center justify-between text-sm']">
|
||||
<span :class="['text-neutral-500']">{{ t('server.auth.profile.field.emailVerified') }}</span>
|
||||
<span
|
||||
:class="[
|
||||
'rounded px-2 py-0.5 text-xs',
|
||||
user.emailVerified
|
||||
? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300'
|
||||
: 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-300',
|
||||
]"
|
||||
>
|
||||
{{
|
||||
user.emailVerified
|
||||
? t('server.auth.profile.label.verified')
|
||||
: t('server.auth.profile.label.unverified')
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="formattedCreatedAt"
|
||||
:class="['flex items-center justify-between text-sm']"
|
||||
>
|
||||
<span :class="['text-neutral-500']">{{ t('server.auth.profile.field.createdAt') }}</span>
|
||||
<span :class="['font-medium']">{{ formattedCreatedAt }}</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Display name form -->
|
||||
<form
|
||||
:class="['max-w-sm w-full flex flex-col gap-3 mb-6']"
|
||||
@submit="handleSaveName"
|
||||
>
|
||||
<h2 :class="['text-base font-semibold']">
|
||||
{{ t('server.auth.profile.section.profile') }}
|
||||
</h2>
|
||||
|
||||
<FieldInput
|
||||
v-model="profileForm.name"
|
||||
type="text"
|
||||
:label="t('server.auth.profile.name.label')"
|
||||
:placeholder="t('server.auth.profile.name.placeholder')"
|
||||
/>
|
||||
|
||||
<div
|
||||
v-if="profileError"
|
||||
:class="['text-sm text-red-500']"
|
||||
role="alert"
|
||||
aria-live="polite"
|
||||
>
|
||||
{{ profileError }}
|
||||
</div>
|
||||
<div
|
||||
v-else-if="profileSuccess"
|
||||
:class="['text-sm text-green-600 dark:text-green-400']"
|
||||
aria-live="polite"
|
||||
>
|
||||
{{ profileSuccess }}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
:class="['w-full', 'py-2', 'flex', 'items-center', 'justify-center']"
|
||||
:loading="profileLoading"
|
||||
:disabled="!nameDirty"
|
||||
>
|
||||
<span>{{ t('server.auth.profile.action.saveProfile') }}</span>
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<!-- Change password form -->
|
||||
<form
|
||||
:class="['max-w-sm w-full flex flex-col gap-3 mb-6']"
|
||||
@submit="handleChangePassword"
|
||||
>
|
||||
<h2 :class="['text-base font-semibold']">
|
||||
{{ t('server.auth.profile.section.password') }}
|
||||
</h2>
|
||||
|
||||
<FieldInput
|
||||
v-model="passwordForm.current"
|
||||
type="password"
|
||||
:label="t('server.auth.profile.password.currentLabel')"
|
||||
:placeholder="t('server.auth.profile.password.currentPlaceholder')"
|
||||
required
|
||||
hide-required-mark
|
||||
/>
|
||||
<FieldInput
|
||||
v-model="passwordForm.next"
|
||||
type="password"
|
||||
:label="t('server.auth.profile.password.newLabel')"
|
||||
:placeholder="t('server.auth.profile.password.newPlaceholder')"
|
||||
required
|
||||
hide-required-mark
|
||||
/>
|
||||
<FieldInput
|
||||
v-model="passwordForm.confirm"
|
||||
type="password"
|
||||
:label="t('server.auth.profile.password.confirmLabel')"
|
||||
:placeholder="t('server.auth.profile.password.confirmPlaceholder')"
|
||||
required
|
||||
hide-required-mark
|
||||
/>
|
||||
|
||||
<div
|
||||
v-if="passwordError"
|
||||
:class="['text-sm text-red-500']"
|
||||
role="alert"
|
||||
aria-live="polite"
|
||||
>
|
||||
{{ passwordError }}
|
||||
</div>
|
||||
<div
|
||||
v-else-if="passwordSuccess"
|
||||
:class="['text-sm text-green-600 dark:text-green-400']"
|
||||
aria-live="polite"
|
||||
>
|
||||
{{ passwordSuccess }}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
:class="['w-full', 'py-2', 'flex', 'items-center', 'justify-center']"
|
||||
:loading="passwordLoading"
|
||||
>
|
||||
<span>{{ t('server.auth.profile.action.changePassword') }}</span>
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<!-- Sign out -->
|
||||
<div :class="['max-w-sm w-full flex flex-col gap-2']">
|
||||
<Button
|
||||
:class="['w-full', 'py-2', 'flex', 'items-center', 'justify-center']"
|
||||
variant="secondary"
|
||||
:loading="signOutLoading"
|
||||
@click="handleSignOut"
|
||||
>
|
||||
<span>{{ t('server.auth.profile.action.signOut') }}</span>
|
||||
</Button>
|
||||
<div
|
||||
v-if="signOutError"
|
||||
:class="['text-sm text-red-500 text-center']"
|
||||
role="alert"
|
||||
aria-live="polite"
|
||||
>
|
||||
{{ signOutError }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- No user, no longer initial loading: bootstrap error happened. The
|
||||
router.replace already fired for unauthenticated; this branch is for
|
||||
the network/error case so the user isn't stuck on a blank page. -->
|
||||
<div
|
||||
v-else
|
||||
:class="['max-w-sm w-full text-center text-sm text-red-500']"
|
||||
>
|
||||
{{ profileError || t('server.auth.profile.error.loadFailed') }}
|
||||
</div>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<route lang="yaml">
|
||||
meta:
|
||||
layout: plain
|
||||
</route>
|
||||
@@ -0,0 +1,136 @@
|
||||
<script setup lang="ts">
|
||||
import { SERVER_URL } from '@proj-airi/stage-ui/libs/server'
|
||||
import { Button, FieldInput } from '@proj-airi/ui'
|
||||
import { computed, reactive, shallowRef } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
import { describeAuthError, resetPasswordWithToken } from '../modules/email-password'
|
||||
import { getServerAuthBootstrapContext } from '../modules/server-auth-context'
|
||||
|
||||
const { t } = useI18n()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const bootstrapContext = getServerAuthBootstrapContext()
|
||||
const apiServerUrl = bootstrapContext?.apiServerUrl ?? SERVER_URL
|
||||
|
||||
// better-auth's reset-password landing GET endpoint redirects here with
|
||||
// `?token=...` after validating the link's existence. We submit token + new
|
||||
// password to /reset-password POST.
|
||||
const token = computed(() => {
|
||||
const value = route.query.token
|
||||
return typeof value === 'string' ? value : ''
|
||||
})
|
||||
|
||||
const form = reactive({ password: '', confirmPassword: '' })
|
||||
const errorMessage = shallowRef<string | null>(null)
|
||||
const loading = shallowRef(false)
|
||||
const completed = shallowRef(false)
|
||||
|
||||
async function handleSubmit(event: Event) {
|
||||
event.preventDefault()
|
||||
if (loading.value)
|
||||
return
|
||||
|
||||
errorMessage.value = null
|
||||
|
||||
if (!token.value) {
|
||||
errorMessage.value = t('server.auth.resetPassword.error.missingToken')
|
||||
return
|
||||
}
|
||||
|
||||
if (form.password !== form.confirmPassword) {
|
||||
errorMessage.value = t('server.auth.resetPassword.error.passwordMismatch')
|
||||
return
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
await resetPasswordWithToken({
|
||||
apiServerUrl,
|
||||
newPassword: form.password,
|
||||
token: token.value,
|
||||
})
|
||||
completed.value = true
|
||||
}
|
||||
catch (error) {
|
||||
errorMessage.value = describeAuthError(error) || t('server.auth.resetPassword.error.fallback')
|
||||
}
|
||||
finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function goSignIn() {
|
||||
await router.push('/sign-in')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main
|
||||
:class="[
|
||||
'min-h-screen flex flex-col items-center justify-center px-6 py-10 font-cuteen',
|
||||
]"
|
||||
>
|
||||
<div :class="['mb-6 text-2xl font-bold']">
|
||||
{{
|
||||
completed
|
||||
? t('server.auth.resetPassword.title.success')
|
||||
: t('server.auth.resetPassword.title.default')
|
||||
}}
|
||||
</div>
|
||||
|
||||
<form
|
||||
v-if="!completed"
|
||||
:class="['max-w-xs w-full flex flex-col gap-3']"
|
||||
@submit="handleSubmit"
|
||||
>
|
||||
<FieldInput
|
||||
v-model="form.password"
|
||||
type="password"
|
||||
:label="t('server.auth.resetPassword.password.label')"
|
||||
:placeholder="t('server.auth.resetPassword.password.placeholder')"
|
||||
required
|
||||
/>
|
||||
<FieldInput
|
||||
v-model="form.confirmPassword"
|
||||
type="password"
|
||||
:label="t('server.auth.resetPassword.confirmPassword.label')"
|
||||
:placeholder="t('server.auth.resetPassword.confirmPassword.placeholder')"
|
||||
required
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
:class="['w-full', 'py-2', 'flex', 'items-center', 'justify-center']"
|
||||
:loading="loading"
|
||||
>
|
||||
<span>{{ t('server.auth.resetPassword.action.reset') }}</span>
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<div
|
||||
v-else
|
||||
:class="['max-w-sm flex flex-col items-center gap-4 text-center text-sm']"
|
||||
>
|
||||
<p :class="['text-neutral-600 dark:text-neutral-300']">
|
||||
{{ t('server.auth.resetPassword.message.success') }}
|
||||
</p>
|
||||
<Button @click="goSignIn">
|
||||
<span>{{ t('server.auth.resetPassword.action.goSignIn') }}</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="errorMessage"
|
||||
:class="['mt-4 max-w-xs w-full text-center text-sm text-red-500']"
|
||||
>
|
||||
{{ errorMessage }}
|
||||
</div>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<route lang="yaml">
|
||||
meta:
|
||||
layout: plain
|
||||
</route>
|
||||
@@ -3,28 +3,73 @@ import type { OAuthProvider } from '@proj-airi/stage-ui/libs/auth'
|
||||
|
||||
import { defaultSignInProviders } from '@proj-airi/stage-ui/components/auth'
|
||||
import { SERVER_URL } from '@proj-airi/stage-ui/libs/server'
|
||||
import { Button } from '@proj-airi/ui'
|
||||
import { computed, shallowRef, watch } from 'vue'
|
||||
import { Button, FieldInput } from '@proj-airi/ui'
|
||||
import { computed, reactive, shallowRef, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
import {
|
||||
checkEmail,
|
||||
describeAuthError,
|
||||
signInWithEmail,
|
||||
signUpWithEmail,
|
||||
} from '../modules/email-password'
|
||||
import { getServerAuthBootstrapContext } from '../modules/server-auth-context'
|
||||
import { createServerSignInContext, requestSocialSignInRedirect } from '../modules/sign-in'
|
||||
|
||||
type Step = 'identify' | 'password' | 'create'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const { t } = useI18n()
|
||||
const bootstrapContext = getServerAuthBootstrapContext()
|
||||
const apiServerUrl = bootstrapContext?.apiServerUrl ?? SERVER_URL
|
||||
const currentUrl = bootstrapContext?.currentUrl ?? window.location.href
|
||||
|
||||
const step = shallowRef<Step>('identify')
|
||||
const errorMessage = shallowRef<string | null>(null)
|
||||
const pendingProvider = shallowRef<OAuthProvider | null>(null)
|
||||
const autoStartedProvider = shallowRef<OAuthProvider | null>(null)
|
||||
const identifierLoading = shallowRef(false)
|
||||
const credentialsLoading = shallowRef(false)
|
||||
|
||||
const credentials = reactive({
|
||||
email: '',
|
||||
password: '',
|
||||
confirmPassword: '',
|
||||
name: '',
|
||||
})
|
||||
|
||||
const providerLookup = new Set<OAuthProvider>(defaultSignInProviders.map(provider => provider.id))
|
||||
|
||||
const signInContext = computed(() => createServerSignInContext(currentUrl, apiServerUrl))
|
||||
|
||||
// Outside an OIDC flow signInContext.callbackURL is bare `/` which Better Auth
|
||||
// resolves against the API server origin (404). Fall back to the UI root so
|
||||
// the user lands somewhere useful — the `/auth/` index route redirects to
|
||||
// `/auth/profile` so this is not the dead-end empty RouterView it once was.
|
||||
const uiHomeURL = `${window.location.origin}/auth/`
|
||||
const verifySuccessURL = `${window.location.origin}/auth/verify-email?verified=true`
|
||||
|
||||
const effectiveCallbackURL = computed(() =>
|
||||
signInContext.value.callbackURL === '/' ? uiHomeURL : signInContext.value.callbackURL,
|
||||
)
|
||||
// NOTICE:
|
||||
// We always send the verification email's callbackURL to the local
|
||||
// verify-email success page, never to the OIDC `/oauth2/authorize` URL.
|
||||
// Email links open in a new tab where sessionStorage (and therefore the PKCE
|
||||
// flowState saved by the OIDC client) is empty, so a direct OIDC handoff in
|
||||
// that tab would fail with "Missing OIDC flow state". Instead, the original
|
||||
// tab polls the session and resumes the OIDC flow itself once the cookie is
|
||||
// set by `autoSignInAfterVerification`.
|
||||
const signUpCallbackURL = verifySuccessURL
|
||||
// OIDC continuation URL surfaced to the verify-email page, so it can resume
|
||||
// the original flow once the session cookie appears. Empty string means there
|
||||
// was no OIDC client in the picture (just a vanilla sign-up).
|
||||
const oidcContinueURL = computed(() =>
|
||||
signInContext.value.callbackURL === '/' ? '' : signInContext.value.callbackURL,
|
||||
)
|
||||
|
||||
const requestedProvider = computed<OAuthProvider | null>(() => {
|
||||
const provider = signInContext.value.requestedProvider
|
||||
|
||||
@@ -34,6 +79,22 @@ const requestedProvider = computed<OAuthProvider | null>(() => {
|
||||
return provider as OAuthProvider
|
||||
})
|
||||
|
||||
const stepHeading = computed(() => {
|
||||
if (step.value === 'password')
|
||||
return t('server.auth.signIn.step.password.heading')
|
||||
if (step.value === 'create')
|
||||
return t('server.auth.signIn.step.create.heading')
|
||||
return t('server.auth.signIn.step.identify.heading')
|
||||
})
|
||||
|
||||
const stepDescription = computed(() => {
|
||||
if (step.value === 'password')
|
||||
return t('server.auth.signIn.step.password.description', { email: credentials.email })
|
||||
if (step.value === 'create')
|
||||
return t('server.auth.signIn.step.create.description', { email: credentials.email })
|
||||
return t('server.auth.signIn.step.identify.description')
|
||||
})
|
||||
|
||||
watch(() => route.query.error, (value) => {
|
||||
errorMessage.value = typeof value === 'string' ? value : null
|
||||
}, { immediate: true })
|
||||
@@ -46,6 +107,14 @@ watch(requestedProvider, async (provider) => {
|
||||
await handleProviderSelect(provider)
|
||||
}, { immediate: true })
|
||||
|
||||
function backToIdentify() {
|
||||
errorMessage.value = null
|
||||
credentials.password = ''
|
||||
credentials.confirmPassword = ''
|
||||
credentials.name = ''
|
||||
step.value = 'identify'
|
||||
}
|
||||
|
||||
async function handleProviderSelect(provider: OAuthProvider) {
|
||||
errorMessage.value = null
|
||||
pendingProvider.value = provider
|
||||
@@ -54,16 +123,140 @@ async function handleProviderSelect(provider: OAuthProvider) {
|
||||
const redirectUrl = await requestSocialSignInRedirect({
|
||||
apiServerUrl,
|
||||
provider,
|
||||
callbackURL: signInContext.value.callbackURL,
|
||||
callbackURL: effectiveCallbackURL.value,
|
||||
})
|
||||
|
||||
window.location.href = redirectUrl
|
||||
}
|
||||
catch (error) {
|
||||
errorMessage.value = error instanceof Error ? error.message : t('server.auth.signIn.error.fallback')
|
||||
errorMessage.value = describeAuthError(error) || t('server.auth.signIn.error.fallback')
|
||||
pendingProvider.value = null
|
||||
}
|
||||
}
|
||||
|
||||
async function handleIdentify(event: Event) {
|
||||
event.preventDefault()
|
||||
if (identifierLoading.value)
|
||||
return
|
||||
|
||||
errorMessage.value = null
|
||||
identifierLoading.value = true
|
||||
|
||||
try {
|
||||
const email = credentials.email.trim()
|
||||
const result = await checkEmail({ apiServerUrl, email })
|
||||
|
||||
if (result.exists && !result.hasPassword) {
|
||||
// User signed up via a social provider only. Stay on the identifier step
|
||||
// so the OAuth buttons remain visible, and steer them there with a hint.
|
||||
errorMessage.value = t('server.auth.signIn.error.authFailed')
|
||||
// NOTICE:
|
||||
// We avoid disclosing *which* social provider they used here. The
|
||||
// generic OAuth button row is right below; users who registered via
|
||||
// Google/GitHub will recognize and use it.
|
||||
return
|
||||
}
|
||||
|
||||
step.value = result.exists ? 'password' : 'create'
|
||||
}
|
||||
catch (error) {
|
||||
errorMessage.value = describeAuthError(error) || t('server.auth.signIn.error.fallback')
|
||||
}
|
||||
finally {
|
||||
identifierLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleEmailSignIn(event: Event) {
|
||||
event.preventDefault()
|
||||
if (credentialsLoading.value)
|
||||
return
|
||||
|
||||
errorMessage.value = null
|
||||
credentialsLoading.value = true
|
||||
|
||||
try {
|
||||
const result = await signInWithEmail({
|
||||
apiServerUrl,
|
||||
email: credentials.email.trim(),
|
||||
password: credentials.password,
|
||||
callbackURL: effectiveCallbackURL.value,
|
||||
})
|
||||
|
||||
if (result.requiresVerification) {
|
||||
// Existing-but-unverified accounts that started from /oauth2/authorize
|
||||
// must carry the OIDC continuation through verification. Without it the
|
||||
// verify-email tab would resume to /auth/profile after the cookie lands
|
||||
// and the upstream stage app never receives its auth code/tokens.
|
||||
await router.push({
|
||||
path: '/verify-email',
|
||||
query: {
|
||||
email: credentials.email.trim(),
|
||||
...(oidcContinueURL.value ? { continueURL: oidcContinueURL.value } : {}),
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// After a successful credential sign-in better-auth has set the session
|
||||
// cookie. Bounce into the OIDC `/oauth2/authorize` flow (or wherever the
|
||||
// OIDC client originally pointed) so the upstream stage app gets its tokens.
|
||||
window.location.href = result.redirectURL ?? effectiveCallbackURL.value
|
||||
}
|
||||
catch (error) {
|
||||
errorMessage.value = describeAuthError(error) || t('server.auth.signIn.error.fallback')
|
||||
}
|
||||
finally {
|
||||
credentialsLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleEmailSignUp(event: Event) {
|
||||
event.preventDefault()
|
||||
if (credentialsLoading.value)
|
||||
return
|
||||
|
||||
errorMessage.value = null
|
||||
|
||||
if (credentials.password !== credentials.confirmPassword) {
|
||||
errorMessage.value = t('server.auth.signIn.error.passwordMismatch')
|
||||
return
|
||||
}
|
||||
|
||||
credentialsLoading.value = true
|
||||
try {
|
||||
const email = credentials.email.trim()
|
||||
const name = credentials.name.trim() || email.split('@')[0]
|
||||
const result = await signUpWithEmail({
|
||||
apiServerUrl,
|
||||
email,
|
||||
password: credentials.password,
|
||||
name,
|
||||
callbackURL: signUpCallbackURL,
|
||||
})
|
||||
|
||||
if (result.requiresVerification) {
|
||||
await router.push({
|
||||
path: '/verify-email',
|
||||
query: {
|
||||
email,
|
||||
...(oidcContinueURL.value ? { continueURL: oidcContinueURL.value } : {}),
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Verification disabled at server config: session is live, fall through
|
||||
// to the OIDC continuation just like sign-in.
|
||||
window.location.href = effectiveCallbackURL.value
|
||||
}
|
||||
catch (error) {
|
||||
errorMessage.value = describeAuthError(error) || t('server.auth.signIn.error.fallback')
|
||||
}
|
||||
finally {
|
||||
credentialsLoading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -72,61 +265,158 @@ async function handleProviderSelect(provider: OAuthProvider) {
|
||||
'min-h-screen flex flex-col items-center justify-center px-6 py-10 font-cuteen',
|
||||
]"
|
||||
>
|
||||
<div
|
||||
:class="[
|
||||
'mb-8 text-3xl font-bold',
|
||||
]"
|
||||
>
|
||||
{{ t('server.auth.signIn.title') }}
|
||||
<div :class="['mb-2 text-3xl font-bold']">
|
||||
{{ stepHeading }}
|
||||
</div>
|
||||
<div :class="['mb-4 max-w-xs text-center text-sm text-neutral-500']">
|
||||
{{ stepDescription }}
|
||||
</div>
|
||||
|
||||
<!-- Reserve space for the error region so a transition into the error
|
||||
state doesn't shove the form downward. Renders an empty paragraph
|
||||
when there's nothing to show; the role swaps to alert when populated. -->
|
||||
<div
|
||||
:class="[
|
||||
'max-w-xs w-full flex flex-col gap-3',
|
||||
'mb-2 max-w-xs w-full min-h-[1.25rem] text-center text-sm',
|
||||
errorMessage ? 'text-red-500' : 'text-transparent select-none',
|
||||
]"
|
||||
:role="errorMessage ? 'alert' : undefined"
|
||||
:aria-live="errorMessage ? 'polite' : undefined"
|
||||
>
|
||||
{{ errorMessage || '·' }}
|
||||
</div>
|
||||
|
||||
<!-- Step 1: identify -->
|
||||
<form
|
||||
v-if="step === 'identify'"
|
||||
:class="['max-w-xs w-full flex flex-col gap-3']"
|
||||
@submit="handleIdentify"
|
||||
>
|
||||
<FieldInput
|
||||
v-model="credentials.email"
|
||||
type="email"
|
||||
:label="t('server.auth.signIn.email.label')"
|
||||
:placeholder="t('server.auth.signIn.email.placeholder')"
|
||||
required
|
||||
hide-required-mark
|
||||
/>
|
||||
|
||||
<Button
|
||||
v-for="provider in defaultSignInProviders"
|
||||
:key="provider.id"
|
||||
type="submit"
|
||||
:class="['w-full', 'py-2', 'flex', 'items-center', 'justify-center']"
|
||||
:icon="provider.id === 'google' ? 'i-simple-icons-google' : provider.id === 'github' ? 'i-simple-icons-github' : undefined"
|
||||
:loading="pendingProvider === provider.id"
|
||||
@click="handleProviderSelect(provider.id)"
|
||||
:loading="identifierLoading"
|
||||
>
|
||||
<span>{{ provider.name }}</span>
|
||||
<span>{{ t('server.auth.signIn.action.continue') }}</span>
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div
|
||||
v-if="errorMessage"
|
||||
:class="[
|
||||
'mt-4 max-w-xs w-full text-center text-sm text-red-500',
|
||||
]"
|
||||
<!-- Step 2A: existing user, password -->
|
||||
<form
|
||||
v-else-if="step === 'password'"
|
||||
:class="['max-w-xs w-full flex flex-col gap-3']"
|
||||
@submit="handleEmailSignIn"
|
||||
>
|
||||
{{ errorMessage }}
|
||||
</div>
|
||||
<FieldInput
|
||||
v-model="credentials.password"
|
||||
type="password"
|
||||
:label="t('server.auth.signIn.password.label')"
|
||||
:placeholder="t('server.auth.signIn.password.placeholder')"
|
||||
required
|
||||
hide-required-mark
|
||||
/>
|
||||
|
||||
<div
|
||||
:class="[
|
||||
'mt-8 text-center text-xs text-gray-400',
|
||||
]"
|
||||
>
|
||||
{{ t('server.auth.signIn.footer.prefix') }}
|
||||
<a
|
||||
href="https://airi.moeru.ai/docs/en/about/terms"
|
||||
:class="[
|
||||
'underline',
|
||||
]"
|
||||
<Button
|
||||
type="submit"
|
||||
:class="['w-full', 'py-2', 'flex', 'items-center', 'justify-center']"
|
||||
:loading="credentialsLoading"
|
||||
>
|
||||
<span>{{ t('server.auth.signIn.action.signIn') }}</span>
|
||||
</Button>
|
||||
|
||||
<div :class="['flex items-center justify-between text-xs text-neutral-500']">
|
||||
<RouterLink to="/forgot-password" :class="['underline']">
|
||||
{{ t('server.auth.signIn.action.forgotPassword') }}
|
||||
</RouterLink>
|
||||
<button type="button" :class="['underline']" @click="backToIdentify">
|
||||
{{ t('server.auth.signIn.action.useDifferentEmail') }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<!-- Step 2B: new user, sign up -->
|
||||
<form
|
||||
v-else
|
||||
:class="['max-w-xs w-full flex flex-col gap-3']"
|
||||
@submit="handleEmailSignUp"
|
||||
>
|
||||
<FieldInput
|
||||
v-model="credentials.name"
|
||||
type="text"
|
||||
:label="t('server.auth.signIn.name.label')"
|
||||
:placeholder="t('server.auth.signIn.name.placeholder')"
|
||||
/>
|
||||
<FieldInput
|
||||
v-model="credentials.password"
|
||||
type="password"
|
||||
:label="t('server.auth.signIn.newPassword.label')"
|
||||
:placeholder="t('server.auth.signIn.newPassword.placeholder')"
|
||||
required
|
||||
hide-required-mark
|
||||
/>
|
||||
<FieldInput
|
||||
v-model="credentials.confirmPassword"
|
||||
type="password"
|
||||
:label="t('server.auth.signIn.confirmPassword.label')"
|
||||
:placeholder="t('server.auth.signIn.confirmPassword.placeholder')"
|
||||
required
|
||||
hide-required-mark
|
||||
/>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
:class="['w-full', 'py-2', 'flex', 'items-center', 'justify-center']"
|
||||
:loading="credentialsLoading"
|
||||
>
|
||||
<span>{{ t('server.auth.signIn.action.createAccount') }}</span>
|
||||
</Button>
|
||||
|
||||
<div :class="['flex items-center justify-end text-xs text-neutral-500']">
|
||||
<button type="button" :class="['underline']" @click="backToIdentify">
|
||||
{{ t('server.auth.signIn.action.useDifferentEmail') }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<!-- OAuth buttons: only on identifier step. After picking an email/password
|
||||
path, the OAuth options stay one click away via "use a different email". -->
|
||||
<template v-if="step === 'identify'">
|
||||
<div :class="['my-6 max-w-xs w-full flex items-center gap-3 text-xs text-neutral-400']">
|
||||
<div :class="['h-px flex-1 bg-neutral-200 dark:bg-neutral-700']" />
|
||||
<span>{{ t('server.auth.signIn.divider.or') }}</span>
|
||||
<div :class="['h-px flex-1 bg-neutral-200 dark:bg-neutral-700']" />
|
||||
</div>
|
||||
|
||||
<div :class="['max-w-xs w-full flex flex-col gap-3']">
|
||||
<Button
|
||||
v-for="provider in defaultSignInProviders"
|
||||
:key="provider.id"
|
||||
:class="['w-full', 'py-2', 'flex', 'items-center', 'justify-center']"
|
||||
:icon="provider.id === 'google' ? 'i-simple-icons-google' : provider.id === 'github' ? 'i-simple-icons-github' : undefined"
|
||||
:loading="pendingProvider === provider.id"
|
||||
@click="handleProviderSelect(provider.id)"
|
||||
>
|
||||
<span>{{ provider.name }}</span>
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div :class="['mt-8 text-center text-xs text-gray-400']">
|
||||
{{ t('server.auth.signIn.footer.prefix') }}
|
||||
<a href="https://airi.moeru.ai/docs/en/about/terms" :class="['underline']">
|
||||
{{ t('server.auth.signIn.footer.terms') }}
|
||||
</a>
|
||||
{{ t('server.auth.signIn.footer.and') }}
|
||||
<a
|
||||
href="https://airi.moeru.ai/docs/en/about/privacy"
|
||||
:class="[
|
||||
'underline',
|
||||
]"
|
||||
>
|
||||
<a href="https://airi.moeru.ai/docs/en/about/privacy" :class="['underline']">
|
||||
{{ t('server.auth.signIn.footer.privacy') }}
|
||||
</a>.
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
<script setup lang="ts">
|
||||
import { SERVER_URL } from '@proj-airi/stage-ui/libs/server'
|
||||
import { useBroadcastChannel } from '@vueuse/core'
|
||||
import { computed, onMounted, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useRoute } from 'vue-router'
|
||||
|
||||
import { getServerAuthBootstrapContext } from '../modules/server-auth-context'
|
||||
|
||||
const { t } = useI18n()
|
||||
const route = useRoute()
|
||||
const bootstrapContext = getServerAuthBootstrapContext()
|
||||
const apiServerUrl = bootstrapContext?.apiServerUrl ?? SERVER_URL
|
||||
|
||||
// Two distinct entry shapes share this page:
|
||||
// 1) Post-sign-up notice screen: query is `?email=user@host`, no `error`.
|
||||
// 2) Verification landing after better-auth redirected from /api/auth/verify-email.
|
||||
// On success: `?verified=true`. On failure: `?error=...&status=failed`.
|
||||
const email = computed(() => {
|
||||
const value = route.query.email
|
||||
return typeof value === 'string' ? value : ''
|
||||
})
|
||||
|
||||
const error = computed(() => {
|
||||
const value = route.query.error
|
||||
return typeof value === 'string' ? value : null
|
||||
})
|
||||
|
||||
const verified = computed(() => route.query.verified === 'true')
|
||||
|
||||
// Captured at mount time on the original tab (the one that just submitted the
|
||||
// sign-up form) so that, when the verification tab signals success, we know
|
||||
// where to resume the upstream OIDC flow. Empty when the sign-up was not
|
||||
// initiated inside an OIDC handoff.
|
||||
const continueURL = computed(() => {
|
||||
const value = route.query.continueURL
|
||||
return typeof value === 'string' ? value : ''
|
||||
})
|
||||
|
||||
// NOTICE:
|
||||
// Cross-tab signal between the verification-success tab (the one opened from
|
||||
// the email link) and the original "check your inbox" tab. Both tabs live on
|
||||
// the same origin (/auth/...), so BroadcastChannel works without setup.
|
||||
//
|
||||
// Why not poll /get-session every 2s? An abandoned pending tab would burn
|
||||
// 1800 requests/hour for no reason, and the request volume scales with time
|
||||
// the user takes to check their inbox. With BroadcastChannel the only work
|
||||
// happens when verification actually finishes.
|
||||
//
|
||||
// Why still call /get-session at all? The verifying tab cannot complete the
|
||||
// OIDC handoff itself — the original tab is the only one carrying the PKCE
|
||||
// flowState in sessionStorage. So we wait for the signal, then fetch the
|
||||
// session once to make sure the cookie is live before navigating into the
|
||||
// OIDC continuation URL.
|
||||
type VerifyEmailEvent = 'verified'
|
||||
const { post, data, isSupported } = useBroadcastChannel<VerifyEmailEvent, VerifyEmailEvent>({
|
||||
name: 'airi-auth-verify-email',
|
||||
})
|
||||
|
||||
async function resumeIfSessionReady(): Promise<boolean> {
|
||||
try {
|
||||
const response = await fetch(new URL('/api/auth/get-session', apiServerUrl).toString(), {
|
||||
credentials: 'include',
|
||||
cache: 'no-store',
|
||||
})
|
||||
if (!response.ok)
|
||||
return false
|
||||
|
||||
const payload = await response.json().catch(() => null) as { session?: unknown } | null
|
||||
if (!payload?.session)
|
||||
return false
|
||||
|
||||
// Same-tab navigation preserves sessionStorage on the destination origin,
|
||||
// so the original PKCE flowState saved by the OIDC client is still
|
||||
// available when /auth/callback runs.
|
||||
window.location.href = continueURL.value || `${window.location.origin}/auth/`
|
||||
return true
|
||||
}
|
||||
catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
// Verification-success tab: announce to any sibling pending tab that the
|
||||
// session cookie has been written, then stay put so the user sees the
|
||||
// success message. The pending tab does the OIDC continuation.
|
||||
if (verified.value) {
|
||||
if (isSupported.value)
|
||||
post('verified')
|
||||
return
|
||||
}
|
||||
|
||||
if (error.value)
|
||||
return
|
||||
|
||||
// Pending tab: cover the case where verification already happened before
|
||||
// this tab subscribed (back-button navigation, page reload, etc.). One
|
||||
// session check, no recurring poll.
|
||||
await resumeIfSessionReady()
|
||||
})
|
||||
|
||||
// React to a verification event broadcast from the success tab. `data` flips
|
||||
// from null to 'verified' the moment the message arrives.
|
||||
watch(data, async (event) => {
|
||||
if (event !== 'verified' || verified.value || error.value)
|
||||
return
|
||||
|
||||
await resumeIfSessionReady()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main
|
||||
:class="[
|
||||
'min-h-screen flex flex-col items-center justify-center px-6 py-10 font-cuteen',
|
||||
]"
|
||||
>
|
||||
<div :class="['mb-6 text-2xl font-bold']">
|
||||
{{
|
||||
error
|
||||
? t('server.auth.verifyEmail.title.failed')
|
||||
: verified
|
||||
? t('server.auth.verifyEmail.title.success')
|
||||
: t('server.auth.verifyEmail.title.pending')
|
||||
}}
|
||||
</div>
|
||||
|
||||
<p
|
||||
v-if="error"
|
||||
:class="['max-w-sm text-center text-sm text-red-500']"
|
||||
>
|
||||
{{ t('server.auth.verifyEmail.message.failed', { error }) }}
|
||||
</p>
|
||||
<p
|
||||
v-else-if="verified"
|
||||
:class="['max-w-sm text-center text-sm text-neutral-600 dark:text-neutral-300']"
|
||||
>
|
||||
{{ t('server.auth.verifyEmail.message.success') }}
|
||||
</p>
|
||||
<p
|
||||
v-else
|
||||
:class="['max-w-sm text-center text-sm text-neutral-600 dark:text-neutral-300']"
|
||||
>
|
||||
{{
|
||||
email
|
||||
? t('server.auth.verifyEmail.message.pendingWithAddress', { email })
|
||||
: t('server.auth.verifyEmail.message.pending')
|
||||
}}
|
||||
</p>
|
||||
|
||||
<RouterLink
|
||||
to="/sign-in"
|
||||
:class="['mt-8 text-xs text-neutral-500 underline']"
|
||||
>
|
||||
{{ t('server.auth.verifyEmail.action.backToSignIn') }}
|
||||
</RouterLink>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<route lang="yaml">
|
||||
meta:
|
||||
layout: plain
|
||||
</route>
|
||||
@@ -13,7 +13,7 @@ import VueRouter from 'vue-router/vite'
|
||||
import { defineConfig } from 'vite'
|
||||
|
||||
export default defineConfig({
|
||||
base: '/_ui/server-auth/',
|
||||
base: '/auth/',
|
||||
optimizeDeps: {
|
||||
exclude: [
|
||||
// Internal Packages
|
||||
|
||||
Reference in New Issue
Block a user