mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
6769bf4b0f
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.
366 lines
14 KiB
Vue
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>
|