Files
bookorbit/client/src/features/auth/LoginPage.vue
T
neon 6769bf4b0f fix(auth): make account lockout recoverable and visible
The lockout was checked before the password was verified, so a correct password on a locked
account returned the same generic invalid-credentials error as a wrong one. Users had no way to
tell the two apart, and the natural response was to keep retrying a password that was already
right.

Completing a password reset did not clear the lock either, so the one recovery path the UI
advertises appeared to fail for up to fifteen minutes after a successful reset.

Verify the password first and report the lockout, with the remaining wait, only when the supplied
password was correct. Someone guessing wrong passwords still gets the generic failure, so account
existence is not leaked. Clear the lock whenever a password is reset or changed, and give admins
an explicit unlock action for the case where waiting is not acceptable.
2026-08-09 05:12:57 +00:00

366 lines
14 KiB
Vue

<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { LoginErrorCode, type OidcProviderPublic } from '@bookorbit/types'
import { Moon, Sun, Wallpaper } from '@lucide/vue'
import { ACCENT_OPTIONS, ACCENT_ROWS, RADIUS_OPTIONS, BACKGROUND_OPTIONS, useThemeStore } from '@/stores/theme'
import { LoginError, useAuth } from './composables/useAuth'
import { useOidc } from './composables/useOidc'
import { useSetupStatus } from './composables/useSetupStatus'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
const { t } = useI18n()
const themeStore = useThemeStore()
const accentOpen = ref(false)
const radiusOpen = ref(false)
const backgroundOpen = ref(false)
const currentAccent = computed(() => ACCENT_OPTIONS.find((o) => o.id === themeStore.accent))
function radiusPreview(id: string): string {
const map: Record<string, string> = {
sharp: '0px',
default: '4px',
rounded: '8px',
pill: '999px',
}
return map[id] ?? '4px'
}
function openRadius() {
radiusOpen.value = !radiusOpen.value
accentOpen.value = false
backgroundOpen.value = false
}
function openBackground() {
backgroundOpen.value = !backgroundOpen.value
accentOpen.value = false
radiusOpen.value = false
}
function openAccent() {
accentOpen.value = !accentOpen.value
radiusOpen.value = false
backgroundOpen.value = false
}
function closeAll() {
accentOpen.value = false
radiusOpen.value = false
backgroundOpen.value = false
}
const { login } = useAuth()
const { getPublicProviders, initiateLogin } = useOidc()
const { setupStatusError } = useSetupStatus()
const username = ref('')
const password = ref('')
const error = ref<string | null>(null)
const loading = ref(false)
const oidcProviders = ref<OidcProviderPublic[]>([])
const oidcLoadingSlug = ref<string | null>(null)
function resolveLoginError(err: unknown): string {
if (!(err instanceof Error)) return t('auth.login.errors.invalidCredentials')
if (err instanceof LoginError && err.errorCode === LoginErrorCode.ACCOUNT_LOCKED) {
const minutes = Math.max(1, Math.ceil((err.retryAfterSeconds ?? 0) / 60))
return t('auth.login.errors.accountLocked', { minutes })
}
const normalizedMessage = err.message.trim().toLowerCase()
if (normalizedMessage.includes('too many requests')) {
return t('auth.login.errors.tooManyAttempts')
}
if (normalizedMessage.includes('invalid credentials')) {
return t('auth.login.errors.invalidCredentials')
}
return err.message || t('auth.login.errors.invalidCredentials')
}
onMounted(async () => {
oidcProviders.value = await getPublicProviders()
})
async function handleSubmit() {
error.value = null
loading.value = true
try {
await login(username.value, password.value)
} catch (err) {
error.value = resolveLoginError(err)
} finally {
loading.value = false
}
}
async function handleOidcLogin(provider: OidcProviderPublic) {
error.value = null
oidcLoadingSlug.value = provider.slug
try {
await initiateLogin(provider)
} catch (err) {
error.value = err instanceof Error ? err.message : t('auth.oidc.loginFailed')
oidcLoadingSlug.value = null
}
}
</script>
<template>
<div class="login-bg min-h-screen flex items-center justify-center px-4 overflow-hidden">
<!-- Compact theme picker -->
<div class="fixed top-5 right-5 z-20 flex items-center gap-1.5">
<!-- Dark / light toggle -->
<Tooltip>
<TooltipTrigger as-child>
<button class="theme-btn" @click="themeStore.toggleTheme()">
<Sun v-if="themeStore.resolvedTheme === 'dark'" :size="14" />
<Moon v-else :size="14" />
</button>
</TooltipTrigger>
<TooltipContent>{{
themeStore.resolvedTheme === 'dark' ? t('auth.themePicker.switchToLight') : t('auth.themePicker.switchToDark')
}}</TooltipContent>
</Tooltip>
<!-- Radius picker -->
<div class="relative">
<Transition name="popover">
<div v-if="radiusOpen" class="accent-popover absolute top-full right-0 mt-2 p-2.5 rounded-lg">
<div class="flex items-center gap-1.5">
<Tooltip v-for="opt in RADIUS_OPTIONS" :key="opt.id">
<TooltipTrigger as-child>
<button
class="flex items-center justify-center w-8 h-8 rounded-lg transition-all focus:outline-none"
:class="
themeStore.radius === opt.id
? 'text-primary bg-primary/10'
: 'text-muted-foreground hover:text-foreground hover:bg-foreground/5'
"
@click="themeStore.setRadius(opt.id)"
>
<span class="w-4 h-4 border-2 border-current block" :style="{ borderRadius: radiusPreview(opt.id) }" />
</button>
</TooltipTrigger>
<TooltipContent>{{ opt.label }}</TooltipContent>
</Tooltip>
</div>
</div>
</Transition>
<Tooltip>
<TooltipTrigger as-child>
<button class="theme-btn" @click="openRadius()">
<span class="w-3.5 h-3.5 border-2 border-current block" :style="{ borderRadius: radiusPreview(themeStore.radius) }" />
</button>
</TooltipTrigger>
<TooltipContent>{{ t('auth.themePicker.changeRadius') }}</TooltipContent>
</Tooltip>
</div>
<!-- Background picker -->
<div class="relative">
<Transition name="popover">
<div v-if="backgroundOpen" class="accent-popover absolute top-full right-0 mt-2 p-2.5 rounded-lg w-64 max-h-72 overflow-y-auto">
<div class="grid grid-cols-5 gap-1.5">
<Tooltip v-for="opt in BACKGROUND_OPTIONS" :key="opt.id">
<TooltipTrigger as-child>
<button
class="w-full h-9 rounded overflow-hidden ring-2 transition-all focus:outline-none shrink-0"
:class="
themeStore.background === opt.id ? 'ring-primary shadow-sm shadow-primary/20' : 'ring-border hover:ring-muted-foreground/40'
"
@click="themeStore.setBackground(opt.id)"
>
<div class="w-full h-full bg-background pattern-preview" :class="opt.cssClass" />
</button>
</TooltipTrigger>
<TooltipContent>{{ opt.label }}</TooltipContent>
</Tooltip>
</div>
</div>
</Transition>
<Tooltip>
<TooltipTrigger as-child>
<button class="theme-btn" @click="openBackground()">
<Wallpaper :size="14" />
</button>
</TooltipTrigger>
<TooltipContent>{{ t('auth.themePicker.changeBackground') }}</TooltipContent>
</Tooltip>
</div>
<!-- Accent picker -->
<div class="relative">
<!-- Colour popover -->
<Transition name="popover">
<div v-if="accentOpen" class="accent-popover absolute top-full right-0 mt-2 p-3 rounded-lg space-y-2 w-96 max-w-[calc(100vw-2rem)]">
<div class="overflow-x-auto no-scrollbar px-1 py-0.5">
<div class="space-y-2 w-max">
<div v-for="(row, rowIndex) in ACCENT_ROWS" :key="rowIndex" class="flex items-center gap-1.5">
<Tooltip v-for="opt in row" :key="opt.id">
<TooltipTrigger as-child>
<button
class="w-4 h-4 rounded-full transition-all hover:scale-125 focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-card shrink-0"
:aria-label="t(opt.labelKey)"
:style="{
backgroundColor: opt.color,
outline: themeStore.accent === opt.id ? `2px solid ${opt.color}` : 'none',
outlineOffset: '2px',
transform: themeStore.accent === opt.id ? 'scale(1.2)' : '',
}"
@click="themeStore.setAccent(opt.id)"
/>
</TooltipTrigger>
<TooltipContent>{{ t(opt.labelKey) }}</TooltipContent>
</Tooltip>
</div>
</div>
</div>
</div>
</Transition>
<!-- Swatch button showing current accent -->
<Tooltip>
<TooltipTrigger as-child>
<button class="theme-btn" @click="openAccent()">
<span class="w-3.5 h-3.5 rounded-full block" :style="{ backgroundColor: currentAccent?.color }" />
</button>
</TooltipTrigger>
<TooltipContent>{{ t('auth.themePicker.changeAccent') }}</TooltipContent>
</Tooltip>
</div>
</div>
<!-- Click-outside backdrop -->
<div v-if="accentOpen || radiusOpen || backgroundOpen" class="fixed inset-0 z-10" @click="closeAll()" />
<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>
<form @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" class="text-sm text-destructive animate-shake">{{ setupStatusError }}</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 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">
<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)"
>
<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 }) }}
</button>
</div>
</template>
<p 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>
</div>
</div>
</template>
<style scoped>
.login-bg {
position: relative;
}
.theme-btn {
display: flex;
align-items: center;
justify-content: center;
width: 2rem;
height: 2rem;
border-radius: 9999px;
background: color-mix(in oklch, var(--card) 72%, transparent);
border: 1px solid color-mix(in oklch, var(--border) 55%, transparent);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
box-shadow: var(--elevation-md);
color: var(--muted-foreground);
transition: color 0.15s ease;
}
.theme-btn:hover {
color: var(--foreground);
}
.accent-popover {
background: color-mix(in oklch, var(--card) 80%, 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-lg);
}
.popover-enter-active,
.popover-leave-active {
transition:
opacity 0.15s ease,
transform 0.15s ease;
}
.popover-enter-from,
.popover-leave-to {
opacity: 0;
transform: translateY(4px) scale(0.97);
}
.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>