feat(auth): email login & profile (#1745)

Co-authored-by: Liet Blue <127093491+lietblue@users.noreply.github.com>
This commit is contained in:
RainbowBird
2026-04-28 00:07:38 +08:00
committed by GitHub
co-authored by Liet Blue
parent 0346aa729e
commit 172e4ce59c
50 changed files with 3469 additions and 385 deletions
+2 -2
View File
@@ -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' }),
)
})
})
+174
View File
@@ -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'
}
+4 -15
View File
@@ -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>
+8 -2
View File
@@ -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>
+361
View File
@@ -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>
+333 -43
View File
@@ -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>
+1 -1
View File
@@ -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