mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
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:
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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}`
|
||||
}
|
||||
@@ -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.",
|
||||
|
||||
Reference in New Issue
Block a user