feat(auth): rebuild the sign-in pages as a bookplate on an endpaper

Put every auth page on a shared Ex Libris plate with signature-line fields, a show-password toggle and a Caps Lock hint.
Show sign-in messages outside the password form, with Retry, a reset link on lockout and a reset confirmation.
Add language and endpaper pickers (eight repeats) to the auth toolbar, replacing the background and corner controls.
This commit is contained in:
neon
2026-10-16 20:11:55 +00:00
parent 7d823039b0
commit a72179ad4d
22 changed files with 1118 additions and 562 deletions
+9
View File
@@ -331,6 +331,11 @@
--medal-blank-glyph: color-mix(in oklch, var(--muted-foreground) 70%, var(--card));
--medal-shadow: oklch(0.2 0.02 260 / 0.22);
--shelf-line: color-mix(in oklch, var(--foreground) 14%, transparent);
/* Sign-in pages: a bookplate pasted on an endpaper. Light mode prints the plate on a warm sheet. */
--plate: oklch(0.975 0.012 84);
--plate-rule: color-mix(in oklch, var(--foreground) 42%, transparent);
--endpaper: color-mix(in oklch, var(--primary) 5%, var(--background));
--endpaper-ink: color-mix(in oklch, var(--primary) 14%, var(--background));
}
@media (min-width: 40rem) {
@@ -548,4 +553,8 @@
--medal-blank-glyph: color-mix(in oklch, var(--muted-foreground) 62%, var(--card));
--medal-shadow: oklch(0 0 0 / 0.55);
--shelf-line: color-mix(in oklch, var(--foreground) 12%, transparent);
--plate: var(--card);
--plate-rule: color-mix(in oklch, var(--foreground) 34%, transparent);
--endpaper: color-mix(in oklch, var(--primary) 7%, var(--background));
--endpaper-ink: color-mix(in oklch, var(--primary) 17%, var(--background));
}
+22 -57
View File
@@ -1,9 +1,12 @@
<script setup lang="ts">
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { ArrowLeft, CircleAlert, Mail } from '@lucide/vue'
import { api } from '@/lib/api'
import PublicLegalNotices from '@/components/legal/PublicLegalNotices.vue'
import AuthAppearanceToolbar from './components/AuthAppearanceToolbar.vue'
import AuthAlert from './components/AuthAlert.vue'
import AuthField from './components/AuthField.vue'
import AuthPlate from './components/AuthPlate.vue'
import AuthSubmit from './components/AuthSubmit.vue'
const { t } = useI18n()
const email = ref('')
@@ -34,61 +37,23 @@ async function handleSubmit() {
</script>
<template>
<div class="login-bg min-h-screen flex flex-col items-center justify-center gap-3 px-4 overflow-hidden">
<AuthAppearanceToolbar placement="bottom" />
<AuthPlate :heading="t('auth.forgotPassword.subtitle')">
<AuthAlert v-if="submitted" tone="notice" :icon="Mail">{{ t('auth.forgotPassword.submitted') }}</AuthAlert>
<div class="login-card relative z-10 w-full max-w-sm rounded-2xl p-8">
<div class="text-center mb-8">
<h1 class="text-2xl font-serif font-semibold text-foreground">Book<span class="text-primary"> Orbit</span></h1>
<p class="text-sm text-muted-foreground mt-1">{{ t('auth.forgotPassword.subtitle') }}</p>
</div>
<form v-else class="flex flex-col gap-[1.125rem]" @submit.prevent="handleSubmit">
<AuthAlert v-if="error" :icon="CircleAlert">{{ error }}</AuthAlert>
<AuthField id="email" v-model="email" :label="t('auth.fields.emailAddress')" type="email" autocomplete="email" required :disabled="loading" />
<AuthSubmit :label="t('auth.forgotPassword.sendResetLink')" :loading-label="t('auth.forgotPassword.sending')" :loading="loading" />
</form>
<div v-if="submitted" class="rounded-md border border-border bg-card/60 backdrop-blur-sm p-4 text-sm text-foreground">
{{ t('auth.forgotPassword.submitted') }}
</div>
<form v-else @submit.prevent="handleSubmit" class="space-y-4">
<div class="space-y-1.5">
<label for="email" class="text-sm font-medium text-foreground">{{ t('auth.fields.emailAddress') }}</label>
<input
id="email"
v-model="email"
type="email"
autocomplete="email"
required
class="w-full rounded-md border border-input bg-background/60 px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary/50 backdrop-blur-sm"
/>
</div>
<div v-if="error" class="text-sm text-destructive">{{ error }}</div>
<button
type="submit"
:disabled="loading"
class="w-full rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50 transition-colors"
>
{{ loading ? t('auth.forgotPassword.sending') : t('auth.forgotPassword.sendResetLink') }}
</button>
</form>
<p class="mt-4 text-center text-sm text-muted-foreground">
<RouterLink to="/login" class="text-primary hover:underline">{{ t('auth.backToSignIn') }}</RouterLink>
</p>
</div>
<PublicLegalNotices />
</div>
<p class="mt-[1.375rem] text-center font-serif text-[0.9375rem] text-muted-foreground">
<RouterLink
to="/login"
class="inline-flex items-center gap-1.5 font-medium text-primary hover:underline focus-visible:rounded-sm focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none"
>
<ArrowLeft :size="14" class="rtl:-scale-x-100" aria-hidden="true" />
{{ t('auth.backToSignIn') }}
</RouterLink>
</p>
</AuthPlate>
</template>
<style scoped>
.login-bg {
position: relative;
}
.login-card {
background: color-mix(in oklch, var(--card) 72%, transparent);
border: 1px solid color-mix(in oklch, var(--border) 55%, transparent);
backdrop-filter: blur(24px);
-webkit-backdrop-filter: blur(24px);
box-shadow: var(--elevation-xl);
}
</style>
+139 -104
View File
@@ -1,32 +1,44 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { computed, nextTick, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRoute } from 'vue-router'
import { ArrowRight, CircleAlert, CircleCheck, KeyRound, Lock, RefreshCw } from '@lucide/vue'
import { LoginErrorCode, type OidcProviderPublic } from '@bookorbit/types'
import { LoginError, useAuth } from './composables/useAuth'
import { useOidc } from './composables/useOidc'
import { useSetupStatus } from './composables/useSetupStatus'
import { useLoginOptions } from './composables/useLoginOptions'
import PublicLegalNotices from '@/components/legal/PublicLegalNotices.vue'
import AuthAppearanceToolbar from './components/AuthAppearanceToolbar.vue'
import AuthAlert from './components/AuthAlert.vue'
import AuthField from './components/AuthField.vue'
import AuthPlate from './components/AuthPlate.vue'
import AuthSubmit from './components/AuthSubmit.vue'
const { t } = useI18n()
const { login } = useAuth()
const { initiateLogin } = useOidc()
const { setupStatusError } = useSetupStatus()
const { setupStatusError, fetchSetupStatus } = useSetupStatus()
const { loginOptions, loginOptionsError, fetchLoginOptions } = useLoginOptions()
const passwordLoginEnabled = computed(() => loginOptions.value?.passwordLoginEnabled === true)
const allowRegistration = computed(() => loginOptions.value?.allowRegistration === true)
const route = useRoute()
const justRegistered = computed(() => route.query.registered === '1')
const justReset = computed(() => route.query.reset === '1')
const username = ref('')
const password = ref('')
const error = ref<string | null>(null)
const errorCode = ref<string | null>(null)
const credentialsRejected = ref(false)
const optionsFailed = ref(false)
const retrying = ref(false)
const loading = ref(false)
const oidcProviders = ref<OidcProviderPublic[]>([])
const oidcLoadingSlug = ref<string | null>(null)
const passwordField = ref<InstanceType<typeof AuthField> | null>(null)
const showOptionsError = computed(() => optionsFailed.value || setupStatusError.value !== null || loginOptionsError.value !== null)
const accountLocked = computed(() => errorCode.value === LoginErrorCode.ACCOUNT_LOCKED)
function resolveLoginError(err: unknown): string {
if (!(err instanceof Error)) return t('auth.login.errors.invalidCredentials')
@@ -45,34 +57,56 @@ function resolveLoginError(err: unknown): string {
return t('auth.login.errors.tooManyAttempts')
}
if (normalizedMessage.includes('invalid credentials')) {
credentialsRejected.value = true
return t('auth.login.errors.invalidCredentials')
}
return err.message || t('auth.login.errors.invalidCredentials')
}
onMounted(async () => {
async function loadOptions(force: boolean) {
try {
oidcProviders.value = (await fetchLoginOptions()).oidcProviders
oidcProviders.value = (await fetchLoginOptions(force)).oidcProviders
optionsFailed.value = false
} catch {
error.value = t('auth.login.errors.optionsUnavailable')
optionsFailed.value = true
}
})
}
onMounted(() => loadOptions(false))
async function handleRetry() {
retrying.value = true
try {
await Promise.allSettled([loadOptions(true), setupStatusError.value ? fetchSetupStatus(true) : Promise.resolve()])
} finally {
retrying.value = false
}
}
async function handleSubmit() {
error.value = null
errorCode.value = null
credentialsRejected.value = false
loading.value = true
try {
await login(username.value, password.value)
} catch (err) {
errorCode.value = err instanceof LoginError ? (err.errorCode ?? null) : null
error.value = resolveLoginError(err)
} finally {
loading.value = false
}
if (credentialsRejected.value) {
// The field is still disabled from the request until the next render.
await nextTick()
passwordField.value?.focusAndSelect()
}
}
async function handleOidcLogin(provider: OidcProviderPublic) {
error.value = null
errorCode.value = null
oidcLoadingSlug.value = provider.slug
try {
await initiateLogin(provider)
@@ -84,104 +118,105 @@ async function handleOidcLogin(provider: OidcProviderPublic) {
</script>
<template>
<div class="login-bg min-h-screen flex flex-col items-center justify-center gap-3 px-4 overflow-hidden">
<AuthAppearanceToolbar placement="top" />
<AuthPlate :heading="t('auth.login.subtitle')">
<div class="flex flex-col gap-4">
<AuthAlert v-if="justRegistered" tone="notice" :icon="CircleCheck">{{ t('auth.login.registeredNotice') }}</AuthAlert>
<AuthAlert v-else-if="justReset" tone="notice" :icon="CircleCheck">{{ t('auth.login.resetNotice') }}</AuthAlert>
<div class="login-card relative z-10 w-full max-w-sm rounded-2xl p-8">
<div class="text-center mb-8 animate-fade-up">
<h1 class="text-2xl font-serif font-semibold text-foreground">Book<span class="text-primary"> Orbit</span></h1>
<p class="text-sm text-muted-foreground mt-1">{{ t('auth.login.subtitle') }}</p>
</div>
<div v-if="justRegistered" role="status" class="mb-4 rounded-md border border-primary/30 bg-primary/10 px-3 py-2.5 text-sm text-foreground">
{{ t('auth.login.registeredNotice') }}
</div>
<form v-if="passwordLoginEnabled" @submit.prevent="handleSubmit" class="space-y-4">
<div class="space-y-1.5 animate-fade-up" style="animation-delay: 80ms">
<label for="username" class="text-sm font-medium text-foreground">{{ t('auth.fields.username') }}</label>
<input
id="username"
v-model="username"
type="text"
autocomplete="username"
required
class="w-full rounded-md border border-input bg-background/60 px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary/50 backdrop-blur-sm"
/>
</div>
<div class="space-y-1.5 animate-fade-up" style="animation-delay: 160ms">
<label for="password" class="text-sm font-medium text-foreground">{{ t('auth.fields.password') }}</label>
<input
id="password"
v-model="password"
type="password"
autocomplete="current-password"
required
class="w-full rounded-md border border-input bg-background/60 px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary/50 backdrop-blur-sm"
/>
</div>
<div v-if="error" role="alert" class="text-sm text-destructive animate-shake">{{ error }}</div>
<div v-if="setupStatusError || loginOptionsError" class="text-sm text-destructive animate-shake">
{{ t('auth.login.errors.optionsUnavailable') }}
</div>
<button
type="submit"
:disabled="loading"
class="w-full rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50 transition-colors animate-fade-up"
style="animation-delay: 240ms"
>
{{ loading ? t('auth.login.signingIn') : t('auth.login.signIn') }}
</button>
</form>
<template v-if="oidcProviders.length > 0">
<div v-if="passwordLoginEnabled" class="flex items-center gap-3 my-6">
<div class="flex-1 h-px bg-border" />
<span class="text-sm text-muted-foreground">{{ t('auth.login.orDivider') }}</span>
<div class="flex-1 h-px bg-border" />
</div>
<div class="space-y-3" :class="passwordLoginEnabled ? '' : 'mt-2'">
<button
v-for="provider in oidcProviders"
:key="provider.slug"
type="button"
:disabled="oidcLoadingSlug !== null"
class="flex w-full items-center justify-center gap-2 rounded-md border border-input bg-background/60 px-4 py-2 text-[14px] font-medium text-foreground hover:bg-muted/60 disabled:opacity-50 transition-colors backdrop-blur-sm"
@click="handleOidcLogin(provider)"
<AuthAlert v-if="error" id="login-error" :icon="accountLocked ? Lock : CircleAlert">
{{ error }}
<template v-if="accountLocked && passwordLoginEnabled" #action>
<RouterLink
to="/forgot-password"
class="inline-flex items-center gap-1 text-[0.78125rem] font-semibold underline decoration-foreground/35 underline-offset-[3px] hover:decoration-current focus-visible:rounded-sm focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none"
>
<img v-if="provider.iconUrl" :src="provider.iconUrl" alt="" class="h-4 w-4 shrink-0 object-contain" />
{{ oidcLoadingSlug === provider.slug ? t('auth.oidc.redirecting') : t('auth.oidc.signInWith', { provider: provider.displayName }) }}
{{ t('auth.login.resetPasswordAction') }}
<ArrowRight :size="14" class="rtl:-scale-x-100" aria-hidden="true" />
</RouterLink>
</template>
</AuthAlert>
<AuthAlert v-if="showOptionsError" :icon="CircleAlert">
{{ t('auth.login.errors.optionsUnavailable') }}
<template #action>
<button
type="button"
class="inline-flex items-center gap-1.5 text-[0.78125rem] font-semibold underline decoration-foreground/35 underline-offset-[3px] hover:decoration-current focus-visible:rounded-sm focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none disabled:cursor-progress"
:disabled="retrying"
@click="handleRetry"
>
<RefreshCw :size="13" :class="{ 'animate-spin': retrying }" aria-hidden="true" />
{{ t('common.retry') }}
</button>
</div>
</template>
<p v-if="passwordLoginEnabled" class="mt-6 text-center text-sm text-muted-foreground">
<RouterLink to="/forgot-password" class="text-primary hover:underline">{{ t('auth.login.forgotPassword') }}</RouterLink>
</p>
<p v-if="allowRegistration" class="mt-2 text-center text-sm text-muted-foreground">
{{ t('auth.login.noAccount') }}
<RouterLink to="/register" class="text-primary hover:underline">{{ t('auth.login.signUp') }}</RouterLink>
</p>
</template>
</AuthAlert>
</div>
<PublicLegalNotices />
</div>
<form
v-if="passwordLoginEnabled"
class="flex flex-col gap-[1.125rem]"
:class="{ 'mt-[1.125rem]': error || showOptionsError || justRegistered || justReset }"
@submit.prevent="handleSubmit"
>
<AuthField id="username" v-model="username" :label="t('auth.fields.username')" autocomplete="username" required :disabled="loading" />
<AuthField
id="password"
ref="passwordField"
v-model="password"
:label="t('auth.fields.password')"
type="password"
autocomplete="current-password"
required
:disabled="loading"
:invalid="credentialsRejected"
:describedby="credentialsRejected ? 'login-error' : undefined"
>
<template #aside>
<RouterLink
to="/forgot-password"
class="font-serif text-[0.84375rem] text-primary italic hover:underline focus-visible:rounded-sm focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none"
>
{{ t('auth.login.forgotPassword') }}
</RouterLink>
</template>
</AuthField>
<AuthSubmit :label="t('auth.login.signIn')" :loading-label="t('auth.login.signingIn')" :loading="loading" />
</form>
<template v-if="oidcProviders.length > 0">
<div v-if="passwordLoginEnabled" class="my-5 flex items-center gap-3 font-serif text-sm text-muted-foreground italic">
<span class="h-px flex-1 bg-border" />
{{ t('auth.login.orDivider') }}
<span class="h-px flex-1 bg-border" />
</div>
<div class="flex flex-col gap-2.5" :class="{ 'mt-1': !passwordLoginEnabled }">
<button
v-for="provider in oidcProviders"
:key="provider.slug"
type="button"
:disabled="oidcLoadingSlug !== null"
class="flex h-10 w-full items-center justify-center gap-2.5 rounded-[3px] border border-input px-4 text-sm font-medium text-foreground transition-colors hover:border-foreground/25 hover:bg-foreground/5 focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none disabled:cursor-progress disabled:opacity-70"
@click="handleOidcLogin(provider)"
>
<span class="grid size-[1.375rem] shrink-0 place-items-center rounded-md bg-foreground/8">
<img v-if="provider.iconUrl" :src="provider.iconUrl" alt="" class="size-4 object-contain" />
<KeyRound v-else :size="13" aria-hidden="true" />
</span>
{{ oidcLoadingSlug === provider.slug ? t('auth.oidc.redirecting') : t('auth.oidc.signInWith', { provider: provider.displayName }) }}
</button>
</div>
</template>
<p v-if="allowRegistration" class="mt-[1.375rem] text-center font-serif text-[0.9375rem] text-muted-foreground">
{{ t('auth.login.noAccount') }}
<RouterLink
to="/register"
class="font-medium text-primary hover:underline focus-visible:rounded-sm focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none"
>
{{ t('auth.login.signUp') }}
</RouterLink>
</p>
</AuthPlate>
</template>
<style scoped>
.login-bg {
position: relative;
}
.login-card {
background: color-mix(in oklch, var(--card) 72%, transparent);
border: 1px solid color-mix(in oklch, var(--border) 55%, transparent);
backdrop-filter: blur(24px);
-webkit-backdrop-filter: blur(24px);
box-shadow: var(--elevation-xl);
}
</style>
+15 -21
View File
@@ -2,9 +2,10 @@
import { ref, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRoute, useRouter } from 'vue-router'
import { Loader2, AlertCircle } from '@lucide/vue'
import { CircleAlert, LoaderCircle } from '@lucide/vue'
import { useAuth } from '@/features/auth/composables/useAuth'
import PublicLegalNotices from '@/components/legal/PublicLegalNotices.vue'
import AuthAlert from './components/AuthAlert.vue'
import AuthPlate from './components/AuthPlate.vue'
const { t } = useI18n()
const route = useRoute()
@@ -42,29 +43,22 @@ function goToLogin() {
</script>
<template>
<div class="min-h-screen flex flex-col items-center justify-center gap-3 bg-background px-4">
<div class="w-full max-w-sm text-center">
<div v-if="status === 'loading'" class="space-y-4">
<Loader2 :size="32" class="mx-auto text-primary animate-spin" />
<p class="text-sm text-muted-foreground">{{ t('auth.magicLink.signingIn') }}</p>
</div>
<AuthPlate :heading="status === 'loading' ? t('auth.magicLink.signingIn') : t('auth.magicLink.loginFailed')">
<div v-if="status === 'loading'" class="flex justify-center py-2" role="status" :aria-label="t('auth.magicLink.signingIn')">
<LoaderCircle :size="24" class="animate-spin text-primary" aria-hidden="true" />
</div>
<div v-else class="space-y-4">
<div class="mx-auto w-12 h-12 rounded-full bg-destructive/10 flex items-center justify-center">
<AlertCircle :size="24" class="text-destructive" />
</div>
<div>
<p class="text-base font-semibold text-foreground">{{ t('auth.magicLink.loginFailed') }}</p>
<p class="mt-1 text-sm text-muted-foreground">{{ errorMessage }}</p>
</div>
<template v-else>
<AuthAlert :icon="CircleAlert">{{ errorMessage }}</AuthAlert>
<p class="mt-[1.375rem] text-center font-serif text-[0.9375rem]">
<button
class="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 transition-colors"
type="button"
class="font-medium text-primary hover:underline focus-visible:rounded-sm focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none"
@click="goToLogin"
>
{{ t('auth.magicLink.goToLogin') }}
</button>
</div>
</div>
<PublicLegalNotices />
</div>
</p>
</template>
</AuthPlate>
</template>
+18 -18
View File
@@ -5,7 +5,9 @@ import { useRouter } from 'vue-router'
import type { OidcCallbackResponse } from '@bookorbit/types'
import { OidcErrorCode } from '@bookorbit/types'
import { setAccessToken } from '@/lib/api'
import PublicLegalNotices from '@/components/legal/PublicLegalNotices.vue'
import { CircleAlert, LoaderCircle } from '@lucide/vue'
import AuthAlert from './components/AuthAlert.vue'
import AuthPlate from './components/AuthPlate.vue'
import { useAuth } from './composables/useAuth'
import { useOidc, OidcLoginError } from './composables/useOidc'
@@ -83,22 +85,20 @@ onMounted(async () => {
</script>
<template>
<div class="min-h-screen flex flex-col items-center justify-center gap-3 bg-background px-4">
<div class="w-full max-w-sm text-center">
<h1 class="text-2xl font-serif font-semibold text-foreground mb-6">Book<span class="text-primary"> Orbit</span></h1>
<div v-if="!error" class="space-y-3">
<div class="flex justify-center">
<div class="w-6 h-6 border-2 border-primary border-t-transparent rounded-full animate-spin" />
</div>
<p class="text-sm text-muted-foreground">{{ t('auth.oidc.completingSignIn') }}</p>
</div>
<div v-else class="space-y-4">
<p class="text-sm text-destructive">{{ error }}</p>
<RouterLink to="/login" class="text-sm text-primary hover:underline">{{ t('auth.oidc.tryAgain') }}</RouterLink>
</div>
<AuthPlate :heading="error ? t('auth.oidc.loginFailed') : t('auth.oidc.completingSignIn')">
<div v-if="!error" class="flex justify-center py-2" role="status" :aria-label="t('auth.oidc.completingSignIn')">
<LoaderCircle :size="24" class="animate-spin text-primary" aria-hidden="true" />
</div>
<PublicLegalNotices />
</div>
<template v-else>
<AuthAlert :icon="CircleAlert">{{ error }}</AuthAlert>
<p class="mt-[1.375rem] text-center font-serif text-[0.9375rem]">
<RouterLink
to="/login"
class="font-medium text-primary hover:underline focus-visible:rounded-sm focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none"
>{{ t('auth.oidc.tryAgain') }}</RouterLink
>
</p>
</template>
</AuthPlate>
</template>
+60 -109
View File
@@ -1,8 +1,11 @@
<script setup lang="ts">
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
import PublicLegalNotices from '@/components/legal/PublicLegalNotices.vue'
import AuthAppearanceToolbar from './components/AuthAppearanceToolbar.vue'
import { CircleAlert } from '@lucide/vue'
import AuthAlert from './components/AuthAlert.vue'
import AuthField from './components/AuthField.vue'
import AuthPlate from './components/AuthPlate.vue'
import AuthSubmit from './components/AuthSubmit.vue'
import { RegisterError, useAuth } from './composables/useAuth'
import { checkIdentityText } from './lib/identity-text'
@@ -65,113 +68,61 @@ async function handleSubmit() {
</script>
<template>
<div class="login-bg min-h-screen flex flex-col items-center justify-center gap-3 px-4 overflow-hidden">
<AuthAppearanceToolbar placement="bottom" />
<div class="login-card relative z-10 w-full max-w-md rounded-2xl p-8">
<div class="mb-6">
<h1 class="text-xl font-semibold text-foreground">{{ t('auth.register.title') }}</h1>
<p class="text-sm text-muted-foreground mt-1">{{ t('auth.register.subtitle') }}</p>
</div>
<form class="space-y-4" @submit.prevent="handleSubmit">
<div class="space-y-1.5">
<label for="register-username" class="text-sm font-medium text-foreground">{{ t('auth.fields.username') }}</label>
<input
id="register-username"
v-model="username"
type="text"
autocomplete="username"
required
minlength="3"
maxlength="100"
class="w-full rounded-md border border-input bg-background/60 px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary/50 backdrop-blur-sm"
/>
</div>
<div class="space-y-1.5">
<label for="register-name" class="text-sm font-medium text-foreground">{{ t('auth.fields.fullName') }}</label>
<input
id="register-name"
v-model="name"
type="text"
autocomplete="name"
required
maxlength="255"
class="w-full rounded-md border border-input bg-background/60 px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary/50 backdrop-blur-sm"
/>
</div>
<div class="space-y-1.5">
<label for="register-email" class="text-sm font-medium text-foreground">{{ t('auth.fields.email') }}</label>
<input
id="register-email"
v-model="email"
type="email"
autocomplete="email"
required
class="w-full rounded-md border border-input bg-background/60 px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary/50 backdrop-blur-sm"
/>
</div>
<div class="space-y-1.5">
<label for="register-password" class="text-sm font-medium text-foreground">{{ t('auth.fields.password') }}</label>
<input
id="register-password"
v-model="password"
type="password"
autocomplete="new-password"
required
minlength="8"
class="w-full rounded-md border border-input bg-background/60 px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary/50 backdrop-blur-sm"
/>
<p class="text-xs text-muted-foreground">{{ t('auth.fields.passwordHint') }}</p>
</div>
<div class="space-y-1.5">
<label for="register-confirm-password" class="text-sm font-medium text-foreground">{{ t('auth.fields.confirmPassword') }}</label>
<input
id="register-confirm-password"
v-model="confirmPassword"
type="password"
autocomplete="new-password"
required
class="w-full rounded-md border border-input bg-background/60 px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary/50 backdrop-blur-sm"
/>
</div>
<div v-if="error" role="alert" class="text-sm text-destructive">{{ error }}</div>
<button
type="submit"
<AuthPlate :heading="t('auth.register.title')" :description="t('auth.register.subtitle')" wide>
<form class="flex flex-col gap-[1.125rem]" @submit.prevent="handleSubmit">
<AuthAlert v-if="error" :icon="CircleAlert">{{ error }}</AuthAlert>
<div class="grid gap-[1.125rem] sm:grid-cols-2">
<AuthField
id="register-username"
v-model="username"
:label="t('auth.fields.username')"
autocomplete="username"
required
:minlength="3"
:maxlength="100"
:disabled="loading"
class="w-full rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50 transition-colors"
>
{{ loading ? t('auth.register.creatingAccount') : t('auth.register.createAccount') }}
</button>
</form>
/>
<AuthField
id="register-name"
v-model="name"
:label="t('auth.fields.fullName')"
autocomplete="name"
required
:maxlength="255"
:disabled="loading"
/>
</div>
<AuthField id="register-email" v-model="email" :label="t('auth.fields.email')" type="email" autocomplete="email" required :disabled="loading" />
<AuthField
id="register-password"
v-model="password"
:label="t('auth.fields.password')"
type="password"
autocomplete="new-password"
required
:minlength="8"
:hint="t('auth.fields.passwordHint')"
:disabled="loading"
/>
<AuthField
id="register-confirm-password"
v-model="confirmPassword"
:label="t('auth.fields.confirmPassword')"
type="password"
autocomplete="new-password"
required
:disabled="loading"
/>
<AuthSubmit :label="t('auth.register.createAccount')" :loading-label="t('auth.register.creatingAccount')" :loading="loading" />
</form>
<p class="mt-6 text-center text-sm text-muted-foreground">
{{ t('auth.register.haveAccount') }}
<RouterLink to="/login" class="text-primary hover:underline focus:outline-none focus-visible:ring-2 focus-visible:ring-ring rounded-sm">
{{ t('auth.register.signIn') }}
</RouterLink>
</p>
</div>
<PublicLegalNotices />
</div>
<p class="mt-[1.375rem] text-center font-serif text-[0.9375rem] text-muted-foreground">
{{ t('auth.register.haveAccount') }}
<RouterLink
to="/login"
class="font-medium text-primary hover:underline focus-visible:rounded-sm focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none"
>{{ t('auth.register.signIn') }}</RouterLink
>
</p>
</AuthPlate>
</template>
<style scoped>
.login-bg {
position: relative;
}
.login-card {
background: color-mix(in oklch, var(--card) 72%, transparent);
border: 1px solid color-mix(in oklch, var(--border) 55%, transparent);
backdrop-filter: blur(24px);
-webkit-backdrop-filter: blur(24px);
box-shadow: var(--elevation-xl);
}
</style>
+44 -67
View File
@@ -1,10 +1,13 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { ArrowLeft, CircleAlert } from '@lucide/vue'
import { useRoute, useRouter } from 'vue-router'
import { fetchWithAuthProxyRecovery } from '@/lib/api'
import PublicLegalNotices from '@/components/legal/PublicLegalNotices.vue'
import AuthAppearanceToolbar from './components/AuthAppearanceToolbar.vue'
import AuthAlert from './components/AuthAlert.vue'
import AuthField from './components/AuthField.vue'
import AuthPlate from './components/AuthPlate.vue'
import AuthSubmit from './components/AuthSubmit.vue'
const { t } = useI18n()
const route = useRoute()
@@ -55,70 +58,44 @@ async function handleSubmit() {
</script>
<template>
<div class="login-bg min-h-screen flex flex-col items-center justify-center gap-3 px-4 overflow-hidden">
<AuthAppearanceToolbar placement="bottom" />
<AuthPlate :heading="t('auth.resetPassword.subtitle')">
<form class="flex flex-col gap-[1.125rem]" @submit.prevent="handleSubmit">
<AuthAlert v-if="error" :icon="CircleAlert">{{ error }}</AuthAlert>
<AuthField
id="newPassword"
v-model="newPassword"
:label="t('auth.fields.newPassword')"
type="password"
autocomplete="new-password"
required
:hint="t('auth.fields.passwordHint')"
:disabled="loading"
/>
<AuthField
id="confirmPassword"
v-model="confirmPassword"
:label="t('auth.fields.confirmPassword')"
type="password"
autocomplete="new-password"
required
:disabled="loading"
/>
<AuthSubmit
:label="t('auth.resetPassword.setNewPassword')"
:loading-label="t('auth.resetPassword.saving')"
:loading="loading"
:disabled="!token"
/>
</form>
<div class="login-card relative z-10 w-full max-w-sm rounded-2xl p-8">
<div class="text-center mb-8">
<h1 class="text-2xl font-serif font-semibold text-foreground">Book<span class="text-primary"> Orbit</span></h1>
<p class="text-sm text-muted-foreground mt-1">{{ t('auth.resetPassword.subtitle') }}</p>
</div>
<form @submit.prevent="handleSubmit" class="space-y-4">
<div class="space-y-1.5">
<label for="newPassword" class="text-sm font-medium text-foreground">{{ t('auth.fields.newPassword') }}</label>
<input
id="newPassword"
v-model="newPassword"
type="password"
autocomplete="new-password"
required
class="w-full rounded-md border border-input bg-background/60 px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary/50 backdrop-blur-sm"
/>
<p class="text-xs text-muted-foreground">{{ t('auth.fields.passwordHint') }}</p>
</div>
<div class="space-y-1.5">
<label for="confirmPassword" class="text-sm font-medium text-foreground">{{ t('auth.fields.confirmPassword') }}</label>
<input
id="confirmPassword"
v-model="confirmPassword"
type="password"
autocomplete="new-password"
required
class="w-full rounded-md border border-input bg-background/60 px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary/50 backdrop-blur-sm"
/>
</div>
<div v-if="error" class="text-sm text-destructive">{{ error }}</div>
<button
type="submit"
:disabled="loading || !token"
class="w-full rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50 transition-colors"
>
{{ loading ? t('auth.resetPassword.saving') : t('auth.resetPassword.setNewPassword') }}
</button>
</form>
<p class="mt-4 text-center text-sm text-muted-foreground">
<RouterLink to="/login" class="text-primary hover:underline">{{ t('auth.backToSignIn') }}</RouterLink>
</p>
</div>
<PublicLegalNotices />
</div>
<p class="mt-[1.375rem] text-center font-serif text-[0.9375rem] text-muted-foreground">
<RouterLink
to="/login"
class="inline-flex items-center gap-1.5 font-medium text-primary hover:underline focus-visible:rounded-sm focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none"
>
<ArrowLeft :size="14" class="rtl:-scale-x-100" aria-hidden="true" />
{{ t('auth.backToSignIn') }}
</RouterLink>
</p>
</AuthPlate>
</template>
<style scoped>
.login-bg {
position: relative;
}
.login-card {
background: color-mix(in oklch, var(--card) 72%, transparent);
border: 1px solid color-mix(in oklch, var(--border) 55%, transparent);
backdrop-filter: blur(24px);
-webkit-backdrop-filter: blur(24px);
box-shadow: var(--elevation-xl);
}
</style>
+39 -113
View File
@@ -1,8 +1,11 @@
<script setup lang="ts">
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
import PublicLegalNotices from '@/components/legal/PublicLegalNotices.vue'
import AuthAppearanceToolbar from './components/AuthAppearanceToolbar.vue'
import { CircleAlert } from '@lucide/vue'
import AuthAlert from './components/AuthAlert.vue'
import AuthField from './components/AuthField.vue'
import AuthPlate from './components/AuthPlate.vue'
import AuthSubmit from './components/AuthSubmit.vue'
import { useAuth } from './composables/useAuth'
const { t } = useI18n()
@@ -44,116 +47,39 @@ async function handleSubmit() {
</script>
<template>
<div class="login-bg min-h-screen flex flex-col items-center justify-center gap-3 px-4 overflow-hidden">
<AuthAppearanceToolbar placement="bottom" />
<div class="login-card relative z-10 w-full max-w-md rounded-2xl p-8">
<div class="mb-6">
<h1 class="text-xl font-semibold text-foreground">{{ t('auth.setup.title') }}</h1>
<p class="text-sm text-muted-foreground mt-1">{{ t('auth.setup.subtitle') }}</p>
<AuthPlate :heading="t('auth.setup.title')" :description="t('auth.setup.subtitle')" wide>
<form class="flex flex-col gap-[1.125rem]" @submit.prevent="handleSubmit">
<AuthAlert v-if="error" :icon="CircleAlert">{{ error }}</AuthAlert>
<div class="grid gap-[1.125rem] sm:grid-cols-2">
<AuthField id="setup-username" v-model="username" :label="t('auth.fields.username')" autocomplete="username" required :disabled="loading" />
<AuthField id="setup-name" v-model="name" :label="t('auth.fields.fullName')" autocomplete="name" required :disabled="loading" />
</div>
<form class="space-y-4" @submit.prevent="handleSubmit">
<div class="space-y-1.5">
<label for="setup-username" class="text-sm font-medium text-foreground">{{ t('auth.fields.username') }}</label>
<input
id="setup-username"
v-model="username"
type="text"
autocomplete="username"
required
class="w-full rounded-md border border-input bg-background/60 px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary/50 backdrop-blur-sm"
/>
</div>
<div class="space-y-1.5">
<label for="setup-name" class="text-sm font-medium text-foreground">{{ t('auth.fields.fullName') }}</label>
<input
id="setup-name"
v-model="name"
type="text"
autocomplete="name"
required
class="w-full rounded-md border border-input bg-background/60 px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary/50 backdrop-blur-sm"
/>
</div>
<div class="space-y-1.5">
<label for="setup-email" class="text-sm font-medium text-foreground">{{ t('auth.fields.email') }}</label>
<input
id="setup-email"
v-model="email"
type="email"
autocomplete="email"
required
class="w-full rounded-md border border-input bg-background/60 px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary/50 backdrop-blur-sm"
/>
</div>
<div class="space-y-1.5">
<label for="setup-password" class="text-sm font-medium text-foreground">{{ t('auth.fields.password') }}</label>
<input
id="setup-password"
v-model="password"
type="password"
autocomplete="new-password"
required
class="w-full rounded-md border border-input bg-background/60 px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary/50 backdrop-blur-sm"
/>
<p class="text-xs text-muted-foreground">{{ t('auth.fields.passwordHint') }}</p>
</div>
<div class="space-y-1.5">
<label for="setup-confirm-password" class="text-sm font-medium text-foreground">{{ t('auth.fields.confirmPassword') }}</label>
<input
id="setup-confirm-password"
v-model="confirmPassword"
type="password"
autocomplete="new-password"
required
class="w-full rounded-md border border-input bg-background/60 px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary/50 backdrop-blur-sm"
/>
</div>
<div class="space-y-1.5">
<label for="setup-token" class="text-sm font-medium text-foreground">
{{ t('auth.setup.setupToken') }}
<span class="text-muted-foreground">{{ t('auth.setup.setupTokenHint') }}</span>
</label>
<input
id="setup-token"
v-model="setupToken"
type="text"
autocomplete="off"
class="w-full rounded-md border border-input bg-background/60 px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary/50 backdrop-blur-sm"
/>
</div>
<div v-if="error" class="text-sm text-destructive">{{ error }}</div>
<button
type="submit"
:disabled="loading"
class="w-full rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50 transition-colors"
>
{{ loading ? t('auth.setup.creatingAccount') : t('auth.setup.createAccount') }}
</button>
</form>
</div>
<PublicLegalNotices />
</div>
<AuthField id="setup-email" v-model="email" :label="t('auth.fields.email')" type="email" autocomplete="email" required :disabled="loading" />
<AuthField
id="setup-password"
v-model="password"
:label="t('auth.fields.password')"
type="password"
autocomplete="new-password"
required
:hint="t('auth.fields.passwordHint')"
:disabled="loading"
/>
<AuthField
id="setup-confirm-password"
v-model="confirmPassword"
:label="t('auth.fields.confirmPassword')"
type="password"
autocomplete="new-password"
required
:disabled="loading"
/>
<AuthField id="setup-token" v-model="setupToken" :label="t('auth.setup.setupToken')" autocomplete="off" :disabled="loading">
<template #aside>
<span class="text-xs text-muted-foreground">{{ t('auth.setup.setupTokenHint') }}</span>
</template>
</AuthField>
<AuthSubmit :label="t('auth.setup.createAccount')" :loading-label="t('auth.setup.creatingAccount')" :loading="loading" />
</form>
</AuthPlate>
</template>
<style scoped>
.login-bg {
position: relative;
}
.login-card {
background: color-mix(in oklch, var(--card) 72%, transparent);
border: 1px solid color-mix(in oklch, var(--border) 55%, transparent);
backdrop-filter: blur(24px);
-webkit-backdrop-filter: blur(24px);
box-shadow: var(--elevation-xl);
}
</style>
@@ -2,16 +2,28 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ref } from 'vue'
import { flushPromises, mount } from '@vue/test-utils'
import { LoginErrorCode } from '@bookorbit/types'
import LoginPage from '../LoginPage.vue'
import { LoginError } from '../composables/useAuth'
const { statusState, routeState } = vi.hoisted(() => ({
statusState: { allowRegistration: false, passwordLoginEnabled: true },
const { statusState, routeState, loginMock } = vi.hoisted(() => ({
statusState: { allowRegistration: false, passwordLoginEnabled: true, optionsFail: false },
routeState: { query: {} as Record<string, unknown> },
loginMock: vi.fn<(username: string, password: string) => Promise<void>>(),
}))
// Same constructor as the real LoginError; importing the real module pulls in the router.
vi.mock('../composables/useAuth', () => ({
LoginError: class LoginError extends Error {},
useAuth: () => ({ login: vi.fn<() => void>() }),
LoginError: class LoginError extends Error {
constructor(
public readonly errorCode: string | undefined,
public readonly retryAfterSeconds: number | undefined,
message: string,
) {
super(message)
}
},
useAuth: () => ({ login: loginMock }),
}))
vi.mock('../composables/useOidc', () => ({
@@ -28,7 +40,10 @@ vi.mock('../composables/useLoginOptions', () => ({
return {
loginOptions: ref(options),
loginOptionsError: ref(null),
fetchLoginOptions: vi.fn<() => Promise<typeof options>>(async () => options),
fetchLoginOptions: vi.fn<() => Promise<typeof options>>(async () => {
if (statusState.optionsFail) throw new Error('Failed to load sign-in options')
return options
}),
}
},
}))
@@ -37,6 +52,7 @@ vi.mock('../composables/useSetupStatus', () => ({
useSetupStatus: () => ({
allowRegistration: ref(statusState.allowRegistration),
setupStatusError: ref(null),
fetchSetupStatus: vi.fn<() => Promise<boolean>>(async () => false),
}),
}))
@@ -62,7 +78,9 @@ beforeEach(() => {
vi.clearAllMocks()
statusState.allowRegistration = false
statusState.passwordLoginEnabled = true
statusState.optionsFail = false
routeState.query = {}
loginMock.mockResolvedValue(undefined)
})
describe('LoginPage sign-up affordance', () => {
@@ -129,3 +147,56 @@ describe('LoginPage post-registration notice', () => {
expect(wrapper.find('[role="status"]').exists()).toBe(false)
})
})
describe('LoginPage messages', () => {
it('confirms a password reset', async () => {
routeState.query = { reset: '1' }
const wrapper = mountPage()
await flushPromises()
expect(wrapper.find('[role="status"]').text()).toContain('Password updated')
})
it('reports a failed options load with a retry even when password sign-in is off', async () => {
statusState.passwordLoginEnabled = false
statusState.optionsFail = true
const wrapper = mountPage()
await flushPromises()
const alert = wrapper.get('[role="alert"]')
expect(alert.text()).toContain('Sign-in options could not be loaded')
statusState.optionsFail = false
await alert.get('button').trigger('click')
await flushPromises()
expect(wrapper.find('[role="alert"]').exists()).toBe(false)
})
it('offers a password reset when the account is locked', async () => {
loginMock.mockRejectedValue(new LoginError(LoginErrorCode.ACCOUNT_LOCKED, 900, 'Account locked'))
const wrapper = mountPage()
await flushPromises()
await wrapper.get('form').trigger('submit')
await flushPromises()
const alert = wrapper.get('[role="alert"]')
expect(alert.text()).toContain('15 minutes')
const reset = alert.findAll('a').find((a) => a.attributes('href') === '/forgot-password')
expect(reset?.text()).toBe('Reset password')
})
it('marks the password field invalid after rejected credentials', async () => {
loginMock.mockRejectedValue(new Error('Invalid credentials'))
const wrapper = mountPage()
await flushPromises()
await wrapper.get('form').trigger('submit')
await flushPromises()
expect(wrapper.get('[role="alert"]').text()).toBe('Invalid username or password')
expect(wrapper.get('#password').attributes('aria-invalid')).toBe('true')
expect(wrapper.get('#password').attributes('aria-describedby')).toContain('login-error')
})
})
@@ -0,0 +1,20 @@
<script setup lang="ts">
import type { Component } from 'vue'
withDefaults(defineProps<{ icon: Component; tone?: 'error' | 'notice'; id?: string }>(), { tone: 'error', id: undefined })
</script>
<template>
<div
:id="id"
:role="tone === 'error' ? 'alert' : 'status'"
class="flex items-start gap-2.5 rounded-[3px] border px-3 py-2.5 text-[0.8125rem] leading-[1.1875rem] text-foreground"
:class="tone === 'error' ? 'border-destructive/45 bg-destructive/10' : 'border-primary/40 bg-primary/10'"
>
<component :is="icon" :size="16" class="mt-0.5 shrink-0" :class="tone === 'error' ? 'text-destructive' : 'text-primary'" aria-hidden="true" />
<div class="min-w-0">
<p><slot /></p>
<div v-if="$slots.action" class="mt-1.5"><slot name="action" /></div>
</div>
</div>
</template>
@@ -1,32 +1,64 @@
<script setup lang="ts">
import { computed } from 'vue'
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { Moon, Sun, Wallpaper } from '@lucide/vue'
import { ChevronDown, Globe, Moon, Sun, Wallpaper } from '@lucide/vue'
import { LOCALE_LABELS, type Locale } from '@bookorbit/types'
import AccentPicker from '@/components/AccentPicker.vue'
import BackgroundPicker from '@/components/BackgroundPicker.vue'
import RadiusPicker from '@/components/RadiusPicker.vue'
import LanguagePicker from '@/components/LanguagePicker.vue'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { ACCENT_OPTIONS, RADIUS_OPTIONS, useThemeStore } from '@/stores/theme'
const props = withDefaults(defineProps<{ placement?: 'top' | 'bottom' }>(), { placement: 'bottom' })
import { useLocaleStore } from '@/stores/locale'
import { ACCENT_OPTIONS, useThemeStore } from '@/stores/theme'
import AuthEndpaperPicker from './AuthEndpaperPicker.vue'
const { t } = useI18n()
const themeStore = useThemeStore()
const localeStore = useLocaleStore()
const languageOpen = ref(false)
const languageError = ref(false)
const isDark = computed(() => themeStore.resolvedTheme === 'dark')
const themeToggleLabel = computed(() => (isDark.value ? t('auth.themePicker.switchToLight') : t('auth.themePicker.switchToDark')))
const currentAccent = computed(() => ACCENT_OPTIONS.find((option) => option.id === themeStore.accent))
const currentRadiusPreview = computed(() => RADIUS_OPTIONS.find((option) => option.id === themeStore.radius)?.previewRadius ?? '3px')
const popoverSide = computed(() => (props.placement === 'top' ? 'bottom' : 'top'))
const currentLanguage = computed(() => LOCALE_LABELS[localeStore.locale])
function handleToggleTheme() {
themeStore.toggleTheme()
}
async function handleSelectLanguage(locale: Locale) {
languageOpen.value = false
languageError.value = false
try {
await localeStore.setLocale(locale)
} catch {
languageError.value = true
}
}
</script>
<template>
<div class="fixed right-5 z-20 flex items-center gap-1.5" :class="placement === 'top' ? 'top-5' : 'bottom-5'">
<div class="absolute end-3 top-3 z-20 flex items-center gap-1 sm:end-5 sm:top-5 sm:gap-1.5">
<Popover v-model:open="languageOpen">
<PopoverTrigger as-child>
<button
type="button"
class="auth-appearance-button gap-1.5 px-0 sm:w-auto sm:px-2.5"
:aria-label="t('settings.appearance.language.label')"
data-testid="auth-language-control"
>
<Globe :size="14" aria-hidden="true" />
<span class="hidden text-[0.78125rem] font-medium sm:inline">{{ currentLanguage }}</span>
<ChevronDown :size="13" class="hidden text-muted-foreground sm:inline" aria-hidden="true" />
</button>
</PopoverTrigger>
<PopoverContent side="bottom" align="end" class="auth-appearance-popover w-80 max-w-[calc(100vw-1.5rem)] p-1">
<LanguagePicker :autofocus="languageOpen" class="max-h-[min(26rem,calc(100dvh-8rem))]" @select="handleSelectLanguage" />
<p v-if="languageError" role="alert" class="px-2.5 pt-1 pb-2 text-xs text-destructive">{{ t('settings.appearance.language.loadError') }}</p>
</PopoverContent>
</Popover>
<Tooltip>
<TooltipTrigger as-child>
<button type="button" class="auth-appearance-button" :aria-label="themeToggleLabel" @click="handleToggleTheme">
@@ -41,32 +73,16 @@ function handleToggleTheme() {
<Popover>
<TooltipTrigger as-child>
<PopoverTrigger as-child>
<button type="button" class="auth-appearance-button" :aria-label="t('auth.themePicker.changeRadius')">
<span class="block size-3.5 border-2 border-current" :style="{ borderRadius: currentRadiusPreview }" />
</button>
</PopoverTrigger>
</TooltipTrigger>
<PopoverContent :side="popoverSide" align="end" class="auth-appearance-popover w-auto p-2.5">
<RadiusPicker />
</PopoverContent>
</Popover>
<TooltipContent>{{ t('auth.themePicker.changeRadius') }}</TooltipContent>
</Tooltip>
<Tooltip>
<Popover>
<TooltipTrigger as-child>
<PopoverTrigger as-child>
<button type="button" class="auth-appearance-button" :aria-label="t('auth.themePicker.changeBackground')">
<button type="button" class="auth-appearance-button" :aria-label="t('auth.endpaper.change')">
<Wallpaper :size="14" />
</button>
</PopoverTrigger>
</TooltipTrigger>
<PopoverContent :side="popoverSide" align="end" class="auth-appearance-popover max-h-72 w-64 overflow-y-auto p-2.5">
<BackgroundPicker />
<PopoverContent side="bottom" align="end" class="auth-appearance-popover w-72 max-w-[calc(100vw-1.5rem)] p-2.5">
<AuthEndpaperPicker />
</PopoverContent>
</Popover>
<TooltipContent>{{ t('auth.themePicker.changeBackground') }}</TooltipContent>
<TooltipContent>{{ t('auth.endpaper.change') }}</TooltipContent>
</Tooltip>
<Tooltip>
@@ -78,7 +94,7 @@ function handleToggleTheme() {
</button>
</PopoverTrigger>
</TooltipTrigger>
<PopoverContent :side="popoverSide" align="end" class="auth-appearance-popover w-auto max-w-[calc(100vw-2rem)] overflow-x-auto p-3">
<PopoverContent side="bottom" align="end" class="auth-appearance-popover w-auto max-w-[calc(100vw-2rem)] overflow-x-auto p-3">
<AccentPicker />
</PopoverContent>
</Popover>
@@ -92,7 +108,7 @@ function handleToggleTheme() {
display: flex;
align-items: center;
justify-content: center;
width: 2rem;
min-width: 2rem;
height: 2rem;
border-radius: 9999px;
background: color-mix(in oklch, var(--card) 72%, transparent);
@@ -100,13 +116,13 @@ function handleToggleTheme() {
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
box-shadow: var(--elevation-md);
color: var(--muted-foreground);
transition: color 0.15s ease;
color: var(--foreground);
transition: background-color 0.15s ease;
}
.auth-appearance-button:hover,
.auth-appearance-button:focus-visible {
color: var(--foreground);
background: color-mix(in oklch, var(--card) 92%, transparent);
}
.auth-appearance-button:focus-visible {
@@ -0,0 +1,73 @@
<script setup lang="ts">
import { computed } from 'vue'
import { ENDPAPER_TILES, type Endpaper } from '../lib/endpapers'
// `scale` shrinks the repeat for previews, so a small swatch still shows several whole tiles.
const props = withDefaults(defineProps<{ pattern: Endpaper; scale?: number }>(), { scale: 1 })
const tile = computed(() => ENDPAPER_TILES[props.pattern])
const tileStyle = computed(() => {
if (!tile.value) return undefined
const size = `${tile.value.width * props.scale}px ${tile.value.height * props.scale}px`
return { maskImage: tile.value.mask, WebkitMaskImage: tile.value.mask, maskSize: size, WebkitMaskSize: size }
})
const fanStyle = computed(() => (props.pattern === 'seigaiha' ? { backgroundSize: `${64 * props.scale}px ${32 * props.scale}px` } : undefined))
</script>
<template>
<div class="relative bg-(--endpaper)" :class="{ 'auth-seigaiha': pattern === 'seigaiha' }" :style="fanStyle">
<div v-if="tile" class="auth-tile absolute inset-0 bg-(--endpaper-ink)" :style="tileStyle" aria-hidden="true" />
<slot />
</div>
</template>
<style scoped>
.auth-tile {
mask-repeat: repeat;
-webkit-mask-repeat: repeat;
}
/* Seigaiha fans overlap, which a one-colour mask cannot draw, so they are painted with gradients. */
.auth-seigaiha {
--a: var(--endpaper);
--b: var(--endpaper-ink);
background-color: var(--a);
background-image:
radial-gradient(circle at 100% 150%, var(--a) 24%, var(--b) 24% 28%, var(--a) 28% 36%, var(--b) 36% 40%, transparent 40%),
radial-gradient(circle at 0 150%, var(--a) 24%, var(--b) 24% 28%, var(--a) 28% 36%, var(--b) 36% 40%, transparent 40%),
radial-gradient(
circle at 50% 100%,
var(--b) 10%,
var(--a) 10% 23%,
var(--b) 23% 30%,
var(--a) 30% 43%,
var(--b) 43% 50%,
var(--a) 50% 63%,
var(--b) 63% 71%,
transparent 71%
),
radial-gradient(
circle at 100% 50%,
var(--b) 5%,
var(--a) 5% 15%,
var(--b) 15% 20%,
var(--a) 20% 29%,
var(--b) 29% 34%,
var(--a) 34% 44%,
var(--b) 44% 49%,
transparent 49%
),
radial-gradient(
circle at 0 50%,
var(--b) 5%,
var(--a) 5% 15%,
var(--b) 15% 20%,
var(--a) 20% 29%,
var(--b) 29% 34%,
var(--a) 34% 44%,
var(--b) 44% 49%,
transparent 49%
);
background-size: 64px 32px;
}
</style>
@@ -0,0 +1,39 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { ENDPAPER_IDS, endpaperLabelKey, type Endpaper } from '../lib/endpapers'
import { useEndpaper } from '../composables/useEndpaper'
import AuthEndpaper from './AuthEndpaper.vue'
const { t } = useI18n()
const { endpaper, setEndpaper } = useEndpaper()
function handleSelect(id: Endpaper) {
setEndpaper(id)
}
</script>
<template>
<div class="grid grid-cols-4 gap-2">
<Tooltip v-for="id in ENDPAPER_IDS" :key="id">
<TooltipTrigger as-child>
<button
type="button"
class="rounded-md focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none"
:aria-label="t(endpaperLabelKey(id))"
:aria-pressed="endpaper === id"
:data-endpaper="id"
@click="handleSelect(id)"
>
<AuthEndpaper
:pattern="id"
:scale="0.5"
class="h-11 w-full overflow-hidden rounded-[5px] ring-2 transition-shadow"
:class="endpaper === id ? 'ring-primary' : 'ring-border hover:ring-muted-foreground/40'"
/>
</button>
</TooltipTrigger>
<TooltipContent>{{ t(endpaperLabelKey(id)) }}</TooltipContent>
</Tooltip>
</div>
</template>
@@ -0,0 +1,146 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { ArrowBigUp, Eye, EyeOff } from '@lucide/vue'
const props = withDefaults(
defineProps<{
id: string
label: string
autocomplete: string
type?: 'text' | 'email' | 'password'
required?: boolean
minlength?: number
maxlength?: number
hint?: string
invalid?: boolean
disabled?: boolean
describedby?: string
}>(),
{
type: 'text',
required: false,
minlength: undefined,
maxlength: undefined,
hint: undefined,
invalid: false,
disabled: false,
describedby: undefined,
},
)
const model = defineModel<string>({ required: true })
const { t } = useI18n()
const input = ref<HTMLInputElement | null>(null)
const revealed = ref(false)
const capsLock = ref(false)
const isPassword = computed(() => props.type === 'password')
const inputType = computed(() => (isPassword.value && revealed.value ? 'text' : props.type))
const revealLabel = computed(() => (revealed.value ? t('auth.fields.hidePassword') : t('auth.fields.showPassword')))
const capsId = computed(() => `${props.id}-caps`)
const hintId = computed(() => `${props.id}-hint`)
const describedBy = computed(() => {
const ids = [props.describedby, props.hint ? hintId.value : undefined, isPassword.value && capsLock.value ? capsId.value : undefined]
return ids.filter(Boolean).join(' ') || undefined
})
function handleKey(event: KeyboardEvent) {
if (!isPassword.value || typeof event.getModifierState !== 'function') return
capsLock.value = event.getModifierState('CapsLock')
}
function handleBlur() {
capsLock.value = false
}
function toggleReveal() {
revealed.value = !revealed.value
}
function focusAndSelect() {
input.value?.focus()
input.value?.select()
}
defineExpose({ focusAndSelect })
</script>
<template>
<div class="flex flex-col gap-[0.3125rem]">
<div class="relative">
<input
:id="id"
ref="input"
v-model="model"
:type="inputType"
:autocomplete="autocomplete"
:required="required"
:minlength="minlength"
:maxlength="maxlength"
:disabled="disabled"
:aria-invalid="invalid || undefined"
:aria-describedby="describedBy"
spellcheck="false"
autocapitalize="none"
@keydown="handleKey"
@keyup="handleKey"
@blur="handleBlur"
class="auth-line h-[2.375rem] w-full border-0 border-b border-(--plate-rule) bg-transparent px-0.5 font-serif text-lg text-foreground italic outline-none transition-[border-color,box-shadow] duration-150 disabled:opacity-60"
:class="{ 'pe-10': isPassword, 'is-secret': isPassword && !revealed }"
/>
<button
v-if="isPassword"
type="button"
class="absolute end-0 top-1/2 grid size-8 -translate-y-1/2 place-items-center rounded-md text-muted-foreground transition-colors hover:bg-foreground/8 hover:text-foreground focus-visible:text-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none aria-pressed:text-primary"
:aria-label="revealLabel"
:aria-pressed="revealed"
:aria-controls="id"
:disabled="disabled"
@click="toggleReveal"
>
<EyeOff v-if="revealed" :size="16" aria-hidden="true" />
<Eye v-else :size="16" aria-hidden="true" />
</button>
</div>
<div class="flex items-baseline justify-between gap-3">
<label :for="id" class="text-[0.65625rem] leading-[0.875rem] font-semibold tracking-[0.16em] text-muted-foreground uppercase">{{
label
}}</label>
<slot name="aside" />
</div>
<p v-if="isPassword" :id="capsId" class="text-xs leading-4 text-muted-foreground empty:-mt-[0.3125rem]" aria-live="polite">
<span v-if="capsLock" class="inline-flex items-center gap-1.5">
<ArrowBigUp :size="14" class="shrink-0 text-(--score-orange)" aria-hidden="true" />
{{ t('auth.fields.capsLockOn') }}
</span>
</p>
<p v-if="hint" :id="hintId" class="text-xs leading-4 text-muted-foreground">{{ hint }}</p>
</div>
</template>
<style scoped>
.auth-line {
letter-spacing: 0.01em;
}
.auth-line.is-secret {
font-style: normal;
letter-spacing: 0.14em;
}
.auth-line:hover:not(:disabled) {
border-bottom-color: color-mix(in oklch, var(--foreground) 55%, transparent);
}
.auth-line:focus {
border-bottom-color: var(--primary);
box-shadow: 0 1px 0 var(--primary);
}
.auth-line[aria-invalid='true'] {
border-bottom-color: var(--destructive);
box-shadow: 0 1px 0 var(--destructive);
}
</style>
@@ -0,0 +1,92 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import PublicLegalNotices from '@/components/legal/PublicLegalNotices.vue'
import { useEndpaper } from '../composables/useEndpaper'
import AuthAppearanceToolbar from './AuthAppearanceToolbar.vue'
import AuthEndpaper from './AuthEndpaper.vue'
withDefaults(defineProps<{ heading: string; description?: string; wide?: boolean }>(), { description: undefined, wide: false })
const { t } = useI18n()
const { endpaper } = useEndpaper()
</script>
<template>
<AuthEndpaper :pattern="endpaper" class="flex min-h-svh flex-col items-center px-3 pt-[4.25rem] pb-6 sm:justify-center sm:px-4 sm:pt-20">
<AuthAppearanceToolbar />
<section
class="auth-plate relative w-full rounded-[3px] bg-(--plate) p-2 sm:p-3"
:class="wide ? 'max-w-[32.5rem]' : 'max-w-[28rem]'"
aria-labelledby="auth-plate-heading"
>
<div class="auth-frame relative px-5 pt-6 pb-5 sm:px-10 sm:pt-[2.125rem] sm:pb-[1.875rem]">
<header class="mb-[1.625rem] text-center">
<p class="auth-ex flex items-center justify-center gap-3.5 font-serif text-xs font-semibold uppercase text-primary">
<span>{{ t('auth.plate.exLibris') }}</span>
</p>
<p
class="mt-3.5 font-serif text-[2.125rem] leading-10 font-medium italic tracking-[-0.01em] text-foreground sm:text-[2.5rem] sm:leading-[2.875rem]"
>
Book<span class="text-primary"> Orbit</span>
</p>
<p class="mt-3.5 mb-3 flex items-center justify-center gap-2" aria-hidden="true">
<span class="h-px w-[3.375rem] bg-(--plate-rule)" />
<span class="size-1.5 rotate-45 bg-primary" />
<span class="h-px w-[3.375rem] bg-(--plate-rule)" />
</p>
<h1 id="auth-plate-heading" class="font-serif text-base leading-[1.375rem] font-normal italic text-muted-foreground">{{ heading }}</h1>
<p v-if="description" class="mt-1 text-[0.8125rem] leading-[1.125rem] text-muted-foreground">{{ description }}</p>
</header>
<slot />
</div>
</section>
<PublicLegalNotices class="mt-5 rounded-full bg-background/85 px-3 py-1" />
</AuthEndpaper>
</template>
<style scoped>
.auth-plate {
box-shadow:
0 0 0 1px color-mix(in oklch, var(--foreground) 8%, transparent),
0 1px 1px color-mix(in oklch, black 14%, transparent),
0 26px 50px -24px color-mix(in oklch, black 45%, transparent);
}
/* A thick-and-thin rule with a square at each inner corner, as on a printed bookplate. */
.auth-frame {
--sq: linear-gradient(var(--plate-rule), var(--plate-rule));
border: 2.5px solid var(--plate-rule);
outline: 1px solid var(--plate-rule);
outline-offset: -7px;
background:
var(--sq) 3px 3px / 6px 6px no-repeat,
var(--sq) calc(100% - 3px) 3px / 6px 6px no-repeat,
var(--sq) 3px calc(100% - 3px) / 6px 6px no-repeat,
var(--sq) calc(100% - 3px) calc(100% - 3px) / 6px 6px no-repeat;
}
.auth-ex {
letter-spacing: 0.34em;
}
.auth-ex span {
margin-inline-end: -0.34em;
}
.auth-ex::before,
.auth-ex::after {
content: '';
width: 2.25rem;
height: 1px;
background: currentColor;
}
@media (min-width: 40rem) {
.auth-frame {
outline-offset: -8px;
}
}
</style>
@@ -0,0 +1,27 @@
<script setup lang="ts">
import { ArrowRight, LoaderCircle } from '@lucide/vue'
withDefaults(defineProps<{ label: string; loadingLabel: string; loading?: boolean; disabled?: boolean }>(), { loading: false, disabled: false })
</script>
<template>
<button
type="submit"
class="group mt-2.5 flex h-10 w-full items-center justify-center gap-2 rounded-[3px] bg-primary px-4 text-sm font-semibold tracking-[0.02em] text-primary-foreground shadow-sm transition-[filter,transform] hover:brightness-105 focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-(--plate) focus-visible:outline-none active:translate-y-px disabled:cursor-progress disabled:saturate-[0.85] disabled:hover:brightness-100 disabled:active:translate-y-0"
:disabled="loading || disabled"
:aria-busy="loading || undefined"
>
<template v-if="loading">
<LoaderCircle :size="16" class="animate-spin" aria-hidden="true" />
<span>{{ loadingLabel }}</span>
</template>
<template v-else>
<span>{{ label }}</span>
<ArrowRight
:size="16"
class="transition-transform group-hover:translate-x-0.5 rtl:-scale-x-100 rtl:group-hover:-translate-x-0.5"
aria-hidden="true"
/>
</template>
</button>
</template>
@@ -14,10 +14,10 @@ class ResizeObserverMock {
disconnect() {}
}
function mountToolbar(placement: 'top' | 'bottom') {
function mountToolbar() {
return mount(TooltipProvider, {
props: { delayDuration: 0 },
slots: { default: () => h(AuthAppearanceToolbar, { placement }) },
slots: { default: () => h(AuthAppearanceToolbar) },
attachTo: document.body,
})
}
@@ -44,17 +44,28 @@ afterEach(() => {
})
describe('AuthAppearanceToolbar', () => {
it.each([
{ placement: 'top', edge: 'top-5' },
{ placement: 'bottom', edge: 'bottom-5' },
] as const)('pins to the $placement edge', ({ placement, edge }) => {
wrapper = mountToolbar(placement)
it('names the current language on its picker button', () => {
wrapper = mountToolbar()
expect(wrapper.get('.fixed').classes()).toContain(edge)
expect(wrapper.get('[data-testid="auth-language-control"]').attributes('aria-label')).toBe('Language')
expect(wrapper.get('[data-testid="auth-language-control"]').text()).toContain('English')
})
it('offers an endpaper picker in place of the app background and corner pickers', async () => {
wrapper = mountToolbar()
expect(wrapper.find('button[aria-label="Change background"]').exists()).toBe(false)
expect(wrapper.find('button[aria-label="Change corner radius"]').exists()).toBe(false)
await wrapper.get('button[aria-label="Change endpaper"]').trigger('click')
await flushPromises()
document.body.querySelector<HTMLButtonElement>('button[aria-label="Greek key"]')!.click()
await flushPromises()
expect(JSON.parse(localStorage.getItem('authEndpaper') ?? 'null')).toBe('greek-key')
})
it('toggles between light and dark', async () => {
wrapper = mountToolbar('top')
wrapper = mountToolbar()
const store = useThemeStore()
await wrapper.get('button[aria-label="Switch to dark"]').trigger('click')
@@ -62,27 +73,4 @@ describe('AuthAppearanceToolbar', () => {
expect(store.resolvedTheme).toBe('dark')
expect(wrapper.find('button[aria-label="Switch to light"]').exists()).toBe(true)
})
it('picks a corner radius from its popover', async () => {
wrapper = mountToolbar('bottom')
const store = useThemeStore()
await wrapper.get('button[aria-label="Change corner radius"]').trigger('click')
await flushPromises()
const pill = document.body.querySelector<HTMLButtonElement>('button[aria-label="Pill-shaped corners"]')
pill!.click()
expect(store.radius).toBe('pill')
})
it('picks a background from its popover', async () => {
wrapper = mountToolbar('bottom')
const store = useThemeStore()
await wrapper.get('button[aria-label="Change background"]').trigger('click')
await flushPromises()
document.body.querySelector<HTMLButtonElement>('button[aria-label="Aurora"]')!.click()
expect(store.background).toBe('aurora')
})
})
@@ -0,0 +1,54 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { mount } from '@vue/test-utils'
vi.mock('@/components/ui/tooltip', () => ({
Tooltip: { template: '<div><slot /></div>' },
TooltipContent: { template: '<span hidden><slot /></span>' },
TooltipTrigger: { template: '<div><slot /></div>' },
}))
beforeEach(() => {
localStorage.clear()
vi.resetModules()
})
async function mountPicker() {
const { default: AuthEndpaperPicker } = await import('../AuthEndpaperPicker.vue')
return mount(AuthEndpaperPicker)
}
describe('AuthEndpaperPicker', () => {
it('offers every endpaper by name and marks waves as the default', async () => {
const wrapper = await mountPicker()
const buttons = wrapper.findAll('button')
expect(buttons.map((b) => b.attributes('aria-label'))).toEqual([
'Waves',
'Linked circles',
'Hemp leaf',
'Quatrefoil',
'Ogee',
'Harlequin',
'Greek key',
'Plain',
])
expect(wrapper.get('button[aria-label="Waves"]').attributes('aria-pressed')).toBe('true')
})
it('remembers the chosen endpaper in this browser', async () => {
const wrapper = await mountPicker()
await wrapper.get('button[aria-label="Hemp leaf"]').trigger('click')
expect(wrapper.get('button[aria-label="Hemp leaf"]').attributes('aria-pressed')).toBe('true')
expect(wrapper.get('button[aria-label="Waves"]').attributes('aria-pressed')).toBe('false')
expect(JSON.parse(localStorage.getItem('authEndpaper') ?? 'null')).toBe('asanoha')
})
it('falls back to waves when the stored endpaper is unknown', async () => {
localStorage.setItem('authEndpaper', JSON.stringify('marbled'))
const { useEndpaper } = await import('../../composables/useEndpaper')
expect(useEndpaper().endpaper.value).toBe('seigaiha')
})
})
@@ -0,0 +1,54 @@
import { describe, expect, it } from 'vitest'
import { mount } from '@vue/test-utils'
import AuthField from '../AuthField.vue'
function mountField(type: 'text' | 'password' = 'password', hint?: string) {
return mount(AuthField, {
props: { id: 'secret', label: 'Password', autocomplete: 'current-password', type, hint, modelValue: '', 'onUpdate:modelValue': () => {} },
})
}
describe('AuthField', () => {
it('labels the input it draws', () => {
const wrapper = mountField('text')
expect(wrapper.get('label').attributes('for')).toBe('secret')
expect(wrapper.get('input').attributes('id')).toBe('secret')
expect(wrapper.find('button').exists()).toBe(false)
})
it('reveals and hides a password', async () => {
const wrapper = mountField()
const toggle = wrapper.get('button[aria-controls="secret"]')
expect(wrapper.get('input').attributes('type')).toBe('password')
expect(toggle.attributes('aria-label')).toBe('Show password')
await toggle.trigger('click')
expect(wrapper.get('input').attributes('type')).toBe('text')
expect(toggle.attributes('aria-pressed')).toBe('true')
expect(toggle.attributes('aria-label')).toBe('Hide password')
})
it('warns while Caps Lock is on and stops warning on blur', async () => {
const wrapper = mountField()
const input = wrapper.get('input')
await input.trigger('keydown', { key: 'A', modifierCapsLock: true })
expect(wrapper.text()).toContain('Caps Lock is on')
expect(input.attributes('aria-describedby')).toBe('secret-caps')
await input.trigger('blur')
expect(wrapper.text()).not.toContain('Caps Lock is on')
})
it('describes the input with its hint', () => {
const wrapper = mountField('password', 'Min. 8 characters')
expect(wrapper.get('input').attributes('aria-describedby')).toBe('secret-hint')
expect(wrapper.get('#secret-hint').text()).toBe('Min. 8 characters')
})
})
@@ -0,0 +1,22 @@
import { ref, watch } from 'vue'
import { storage } from '@/services/storage'
import { DEFAULT_ENDPAPER, ENDPAPER_IDS, type Endpaper } from '../lib/endpapers'
const STORAGE_KEY = 'authEndpaper'
function stored(): Endpaper {
const value = storage.get<unknown>(STORAGE_KEY, DEFAULT_ENDPAPER)
return (ENDPAPER_IDS as readonly unknown[]).includes(value) ? (value as Endpaper) : DEFAULT_ENDPAPER
}
// A per-browser choice, like the other appearance controls shown before sign-in.
const endpaper = ref<Endpaper>(stored())
watch(endpaper, (value) => storage.set(STORAGE_KEY, value))
export function useEndpaper() {
function setEndpaper(value: Endpaper) {
endpaper.value = value
}
return { endpaper, setEndpaper }
}
+76
View File
@@ -0,0 +1,76 @@
// Endpapers for the sign-in pages: traditional bookbinding repeats, drawn as one-colour
// SVG tiles. The tile is a mask over --endpaper-ink, so every pattern follows the accent and the theme.
export const ENDPAPER_IDS = ['seigaiha', 'shippo', 'asanoha', 'quatrefoil', 'ogee', 'harlequin', 'greek-key', 'plain'] as const
export type Endpaper = (typeof ENDPAPER_IDS)[number]
export const DEFAULT_ENDPAPER: Endpaper = 'seigaiha'
export interface EndpaperTile {
/** Data URI of a black-on-transparent SVG tile; opacity inside it sets the tone. */
mask: string
width: number
height: number
}
const svg = (width: number, height: number, body: string): EndpaperTile => ({
mask: `url("data:image/svg+xml,${encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">${body}</svg>`)}")`,
width,
height,
})
const LINE = 'fill="none" stroke="#000" stroke-width="1.4" stroke-linecap="square"'
const DOT = 'fill="#000" fill-opacity=".6"'
const R = 28.284
const CELL_CORNERS: readonly (readonly [number, number])[] = [
[0, 0],
[48, 0],
[0, 48],
[48, 48],
]
/** Seigaiha is drawn with CSS gradients instead: its fans overlap, which a mask cannot express. Plain has no tile. */
export const ENDPAPER_TILES: Partial<Record<Endpaper, EndpaperTile>> = {
// Shippō: circles through their neighbours' centres, a dot where four meet.
shippo: svg(
40,
40,
`<g ${LINE}>${[
[0, 0],
[40, 0],
[0, 40],
[40, 40],
[20, 20],
]
.map(([x, y]) => `<circle cx="${x}" cy="${y}" r="${R}"/>`)
.join(
'',
)}</g><g ${DOT}><circle cx="20" cy="0" r="2"/><circle cx="0" cy="20" r="2"/><circle cx="40" cy="20" r="2"/><circle cx="20" cy="40" r="2"/></g>`,
),
// Asanoha: a triangular grid with every corner joined to its triangle's centre, the hemp-leaf star.
asanoha: svg(
28,
48.5,
`<path ${LINE.replace('1.4', '1.1')} d="M0 0L14 -24.25M-14 -24.25L0 0M0 -16.17L0 0M-28 0L0 0M-14 -8.08L0 0M28 0L42 -24.25M14 -24.25L28 0M28 -16.17L28 0M0 0L28 0M14 -8.08L28 0M0 0L14 -8.08M28 0L56 0M28 0L42 -8.08M-14 24.25L0 0M-14 8.08L0 0M0 0L14 8.08M14 24.25L28 0M14 8.08L28 0M0 0L14 24.25M14 24.25L14 8.08M0 0L0 16.17M-14 24.25L14 24.25M0 16.17L14 24.25M-14 24.25L0 16.17M28 0L42 8.08M28 0L42 24.25M28 0L28 16.17M14 24.25L42 24.25M28 16.17L42 24.25M14 24.25L28 16.17M-14 24.25L0 32.33M0 48.5L14 24.25M0 32.33L14 24.25M-14 24.25L0 48.5M0 32.33L0 48.5M-28 48.5L0 48.5M-14 40.41L0 48.5M14 24.25L28 32.33M28 48.5L42 24.25M28 32.33L42 24.25M14 24.25L28 48.5M28 32.33L28 48.5M14 24.25L14 40.41M0 48.5L28 48.5M14 40.41L28 48.5M0 48.5L14 40.41M28 48.5L56 48.5M28 48.5L42 40.41M-14 72.75L0 48.5M-14 56.58L0 48.5M0 48.5L14 56.58M14 72.75L28 48.5M14 56.58L28 48.5M0 48.5L14 72.75M0 48.5L0 64.66M28 48.5L42 56.58M28 48.5L42 72.75M28 48.5L28 64.66"/>`,
),
// Gothic tracery: a quatrefoil per cell with a diamond where four cells meet.
quatrefoil: svg(
48,
48,
`<path ${LINE} d="M32 32A8 8 0 0 0 32 16A8 8 0 0 0 16 16A8 8 0 0 0 16 32A8 8 0 0 0 32 32Z"/><circle cx="24" cy="24" r="2.5" ${DOT}/><g ${DOT}>${CELL_CORNERS.map(([x, y]) => `<path d="M${x} ${y - 5}L${x + 5} ${y}L${x} ${y + 5}L${x - 5} ${y}Z"/>`).join('')}</g>`,
),
// Ogee: mirrored S-curves that close into onion-domed cells, the damask lattice.
ogee: svg(
40,
56,
`<g ${LINE}><path d="M0 0C0 14 20 14 20 28C20 42 40 42 40 56"/><path d="M40 0C40 14 20 14 20 28C20 42 0 42 0 56"/></g><g ${DOT}><circle cx="0" cy="28" r="2"/><circle cx="40" cy="28" r="2"/><circle cx="20" cy="0" r="2"/><circle cx="20" cy="56" r="2"/></g>`,
),
// Harlequin: alternating diamonds, the old playing-card and marbled-board ground.
harlequin: svg(32, 48, `<path d="M16 0L32 24L16 48L0 24Z" fill="#000" fill-opacity=".42" stroke="#000" stroke-width="1"/>`),
// Greek key: a running fret, one spiral per cell hung from a continuous rail.
'greek-key': svg(40, 40, `<path fill="none" stroke="#000" stroke-width="2" stroke-linecap="square" d="M0 36H40M4 36V4H36V28H12V12H28V20H20"/>`),
}
export function endpaperLabelKey(id: Endpaper): string {
return `auth.endpaper.options.${id === 'greek-key' ? 'greekKey' : id}`
}
+22 -1
View File
@@ -5564,6 +5564,22 @@
},
"auth": {
"backToSignIn": "Back to sign in",
"plate": {
"exLibris": "Ex Libris"
},
"endpaper": {
"change": "Change endpaper",
"options": {
"seigaiha": "Waves",
"shippo": "Linked circles",
"asanoha": "Hemp leaf",
"quatrefoil": "Quatrefoil",
"ogee": "Ogee",
"harlequin": "Harlequin",
"greekKey": "Greek key",
"plain": "Plain"
}
},
"themePicker": {
"switchToLight": "Switch to light",
"switchToDark": "Switch to dark",
@@ -5581,7 +5597,10 @@
"confirmPassword": "Confirm password",
"confirmNewPassword": "Confirm new password",
"currentPassword": "Current password",
"passwordHint": "Min. 8 characters with uppercase, lowercase, and a digit"
"passwordHint": "Min. 8 characters with uppercase, lowercase, and a digit",
"showPassword": "Show password",
"hidePassword": "Hide password",
"capsLockOn": "Caps Lock is on"
},
"errors": {
"generic": "Something went wrong. Please try again.",
@@ -5593,9 +5612,11 @@
"signingIn": "Signing in...",
"orDivider": "or",
"forgotPassword": "Forgot password?",
"resetPasswordAction": "Reset password",
"noAccount": "Don't have an account?",
"signUp": "Sign up",
"registeredNotice": "Account created. Sign in to continue. An administrator needs to grant permissions before you can access the library.",
"resetNotice": "Password updated. Sign in with your new password.",
"errors": {
"invalidCredentials": "Invalid username or password",
"tooManyAttempts": "Too many login attempts. Please wait a minute and try again.",