mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
feat(auth): add initial setup flow and harden auth/session handling
This commit is contained in:
@@ -5,6 +5,7 @@ import { Moon, Sun } from 'lucide-vue-next'
|
||||
import { ACCENT_VIVID, ACCENT_PASTEL, ACCENT_OPTIONS, useThemeStore } from '@/stores/theme'
|
||||
import { useAuth } from './composables/useAuth'
|
||||
import { useOidc } from './composables/useOidc'
|
||||
import { useSetupStatus } from './composables/useSetupStatus'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
|
||||
const themeStore = useThemeStore()
|
||||
@@ -13,6 +14,7 @@ const currentAccent = computed(() => ACCENT_OPTIONS.find((o) => o.id === themeSt
|
||||
|
||||
const { login } = useAuth()
|
||||
const { getPublicConfig, initiateLogin } = useOidc()
|
||||
const { setupStatusError } = useSetupStatus()
|
||||
|
||||
const username = ref('')
|
||||
const password = ref('')
|
||||
@@ -157,6 +159,7 @@ async function handleOidcLogin() {
|
||||
</div>
|
||||
|
||||
<div v-if="error" class="text-sm text-destructive">{{ error }}</div>
|
||||
<div v-if="setupStatusError" class="text-sm text-destructive">{{ setupStatusError }}</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { useAuth } from './composables/useAuth'
|
||||
|
||||
const { setup } = useAuth()
|
||||
|
||||
const username = ref('')
|
||||
const name = ref('')
|
||||
const email = ref('')
|
||||
const password = ref('')
|
||||
const confirmPassword = ref('')
|
||||
const setupToken = ref('')
|
||||
|
||||
const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
async function handleSubmit() {
|
||||
error.value = null
|
||||
|
||||
if (password.value !== confirmPassword.value) {
|
||||
error.value = 'Passwords do not match'
|
||||
return
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
await setup({
|
||||
username: username.value,
|
||||
name: name.value,
|
||||
email: email.value,
|
||||
password: password.value,
|
||||
setupToken: setupToken.value || undefined,
|
||||
})
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : 'Failed to complete setup'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="min-h-screen flex items-center justify-center bg-background px-4">
|
||||
<div class="w-full max-w-md rounded-xl border border-border bg-card p-6 shadow-sm">
|
||||
<div class="mb-6">
|
||||
<h1 class="text-xl font-semibold text-foreground">Initial setup</h1>
|
||||
<p class="text-sm text-muted-foreground mt-1">Create the first administrator account.</p>
|
||||
</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">Username</label>
|
||||
<input
|
||||
id="setup-username"
|
||||
v-model="username"
|
||||
type="text"
|
||||
autocomplete="username"
|
||||
required
|
||||
class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-primary/50"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="space-y-1.5">
|
||||
<label for="setup-name" class="text-sm font-medium text-foreground">Full name</label>
|
||||
<input
|
||||
id="setup-name"
|
||||
v-model="name"
|
||||
type="text"
|
||||
autocomplete="name"
|
||||
required
|
||||
class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-primary/50"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="space-y-1.5">
|
||||
<label for="setup-email" class="text-sm font-medium text-foreground">Email</label>
|
||||
<input
|
||||
id="setup-email"
|
||||
v-model="email"
|
||||
type="email"
|
||||
autocomplete="email"
|
||||
required
|
||||
class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-primary/50"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="space-y-1.5">
|
||||
<label for="setup-password" class="text-sm font-medium text-foreground">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 px-3 py-2 text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-primary/50"
|
||||
/>
|
||||
<p class="text-xs text-muted-foreground">Min. 8 characters with uppercase, lowercase, and a digit</p>
|
||||
</div>
|
||||
|
||||
<div class="space-y-1.5">
|
||||
<label for="setup-confirm-password" class="text-sm font-medium text-foreground">Confirm password</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 px-3 py-2 text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-primary/50"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="space-y-1.5">
|
||||
<label for="setup-token" class="text-sm font-medium text-foreground">
|
||||
Setup token
|
||||
<span class="text-muted-foreground">(required in production)</span>
|
||||
</label>
|
||||
<input
|
||||
id="setup-token"
|
||||
v-model="setupToken"
|
||||
type="text"
|
||||
autocomplete="off"
|
||||
class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-primary/50"
|
||||
/>
|
||||
</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 ? 'Creating account…' : 'Create administrator account' }}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -2,6 +2,7 @@ import { ref } from 'vue'
|
||||
import type { AuthUser, AuthResponse } from '@projectx/types'
|
||||
import { api, setAccessToken, setOnAuthFailure } from '@/lib/api'
|
||||
import router from '@/router'
|
||||
import { useSetupStatus } from './useSetupStatus'
|
||||
|
||||
const user = ref<AuthUser | null>(null)
|
||||
const isLoading = ref(false)
|
||||
@@ -13,7 +14,8 @@ function clearAuth() {
|
||||
|
||||
setOnAuthFailure(() => {
|
||||
clearAuth()
|
||||
router.push('/login')
|
||||
const { needsSetup } = useSetupStatus()
|
||||
router.push(needsSetup.value ? '/setup' : '/login')
|
||||
})
|
||||
|
||||
async function me(): Promise<void> {
|
||||
@@ -56,13 +58,44 @@ export function useAuth() {
|
||||
user.value = data.user
|
||||
|
||||
if (data.user.isDefaultPassword) {
|
||||
router.push('/change-password')
|
||||
router.push('/')
|
||||
} else {
|
||||
const redirect = router.currentRoute.value.query.redirect as string | undefined
|
||||
router.push(redirect ?? '/')
|
||||
}
|
||||
}
|
||||
|
||||
async function setup(payload: { username: string; name: string; email: string; password: string; setupToken?: string }): Promise<void> {
|
||||
const headers: Record<string, string> = { 'Content-Type': 'application/json' }
|
||||
if (payload.setupToken) {
|
||||
headers['x-setup-token'] = payload.setupToken
|
||||
}
|
||||
|
||||
const res = await fetch('/api/v1/auth/setup', {
|
||||
method: 'POST',
|
||||
headers,
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({
|
||||
username: payload.username,
|
||||
name: payload.name,
|
||||
email: payload.email,
|
||||
password: payload.password,
|
||||
}),
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}))
|
||||
throw new Error(err.message ?? 'Failed to complete setup')
|
||||
}
|
||||
|
||||
const data: AuthResponse = await res.json()
|
||||
setAccessToken(data.accessToken)
|
||||
user.value = data.user
|
||||
|
||||
useSetupStatus().markSetupComplete()
|
||||
router.push('/')
|
||||
}
|
||||
|
||||
async function logout(): Promise<void> {
|
||||
try {
|
||||
const res = await fetch('/api/v1/auth/logout', { method: 'POST', credentials: 'include' })
|
||||
@@ -80,5 +113,5 @@ export function useAuth() {
|
||||
router.push('/login')
|
||||
}
|
||||
|
||||
return { user, isLoading, init, login, logout, me }
|
||||
return { user, isLoading, init, login, logout, me, setup }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { ref } from 'vue'
|
||||
|
||||
const needsSetup = ref<boolean | null>(null)
|
||||
const setupStatusError = ref<string | null>(null)
|
||||
let inFlight: Promise<boolean> | null = null
|
||||
|
||||
export function useSetupStatus() {
|
||||
async function fetchSetupStatus(force = false): Promise<boolean> {
|
||||
if (inFlight) return inFlight
|
||||
if (!force && needsSetup.value !== null) return needsSetup.value
|
||||
|
||||
inFlight = (async () => {
|
||||
try {
|
||||
const res = await fetch('/api/v1/auth/setup-status', { credentials: 'include' })
|
||||
if (!res.ok) throw new Error('Failed to load setup status')
|
||||
const data = (await res.json()) as { needsSetup?: boolean }
|
||||
setupStatusError.value = null
|
||||
needsSetup.value = data.needsSetup === true
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Failed to load setup status'
|
||||
setupStatusError.value = message
|
||||
throw err
|
||||
} finally {
|
||||
inFlight = null
|
||||
}
|
||||
return needsSetup.value ?? false
|
||||
})()
|
||||
|
||||
return inFlight
|
||||
}
|
||||
|
||||
function markSetupComplete(): void {
|
||||
needsSetup.value = false
|
||||
setupStatusError.value = null
|
||||
}
|
||||
|
||||
return { needsSetup, setupStatusError, fetchSetupStatus, markSetupComplete }
|
||||
}
|
||||
@@ -24,7 +24,8 @@ const emit = defineEmits<{
|
||||
|
||||
const initial = computed(() => props.author.name.trim().charAt(0).toUpperCase() || '?')
|
||||
const fallbackStyle = computed(() => bookCoverStyle(props.author.name || String(props.author.id)))
|
||||
const hasImage = computed(() => Boolean(props.author.imageUrl) && !imageFailed.value)
|
||||
const imageSrc = computed(() => props.author.imageUrl ?? undefined)
|
||||
const hasImage = computed(() => Boolean(imageSrc.value) && !imageFailed.value)
|
||||
|
||||
const imageFailed = ref(false)
|
||||
watch(
|
||||
@@ -67,7 +68,7 @@ function handleDelete() {
|
||||
>
|
||||
<img
|
||||
v-if="hasImage"
|
||||
:src="author.imageUrl"
|
||||
:src="imageSrc"
|
||||
:alt="`${author.name} portrait`"
|
||||
class="absolute inset-0 h-full w-full object-cover"
|
||||
loading="lazy"
|
||||
|
||||
@@ -192,13 +192,15 @@ async function refreshSelectedAuthorsMetadata() {
|
||||
markRefreshing(ids)
|
||||
try {
|
||||
const result = await bulkRefreshAuthorsMetadata(ids, (event) => {
|
||||
if (event.imageUpdated && event.imageUrl) {
|
||||
if (event.imageUpdated) {
|
||||
const index = items.value.findIndex((item) => item.id === event.authorId)
|
||||
if (index !== -1) {
|
||||
const next = [...items.value]
|
||||
const current = next[index]
|
||||
if (!current) return
|
||||
next[index] = {
|
||||
...next[index],
|
||||
imageUrl: event.imageUrl,
|
||||
...current,
|
||||
imageUrl: event.imageUrl ?? null,
|
||||
}
|
||||
items.value = next
|
||||
}
|
||||
@@ -229,7 +231,9 @@ async function refreshSingleAuthorMetadata(authorId: number) {
|
||||
const index = items.value.findIndex((item) => item.id === authorId)
|
||||
if (index !== -1) {
|
||||
const next = [...items.value]
|
||||
next[index] = { ...next[index], ...updated }
|
||||
const current = next[index]
|
||||
if (!current) return
|
||||
next[index] = { ...current, ...updated }
|
||||
items.value = next
|
||||
}
|
||||
void maybeLoadSecondaryPanels()
|
||||
|
||||
+13
-4
@@ -6,16 +6,25 @@ import { createPinia } from 'pinia'
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
import { useAuth } from './features/auth/composables/useAuth'
|
||||
import { useSetupStatus } from './features/auth/composables/useSetupStatus'
|
||||
|
||||
const app = createApp(App)
|
||||
|
||||
app.use(createPinia())
|
||||
|
||||
// Init auth before installing router — app.use(router) triggers the initial
|
||||
// navigation which runs the beforeEach guard. If auth isn't resolved yet,
|
||||
// user.value is null and the guard redirects every reload to /login.
|
||||
// Resolve setup status/auth before installing router.
|
||||
// app.use(router) triggers initial navigation and guard execution.
|
||||
const { fetchSetupStatus, needsSetup } = useSetupStatus()
|
||||
try {
|
||||
await fetchSetupStatus()
|
||||
} catch {
|
||||
// If setup-status check fails, continue with normal auth bootstrap.
|
||||
}
|
||||
|
||||
const { init } = useAuth()
|
||||
await init()
|
||||
if (needsSetup.value !== true) {
|
||||
await init()
|
||||
}
|
||||
|
||||
app.use(router)
|
||||
app.mount('#app')
|
||||
|
||||
@@ -1,9 +1,26 @@
|
||||
import type { Router } from 'vue-router'
|
||||
import { useAuth } from '@/features/auth/composables/useAuth'
|
||||
import { useChangePasswordDialog } from '@/composables/useChangePasswordDialog'
|
||||
import { useSetupStatus } from '@/features/auth/composables/useSetupStatus'
|
||||
|
||||
export function registerAuthGuard(router: Router): void {
|
||||
router.beforeEach((to) => {
|
||||
router.beforeEach(async (to) => {
|
||||
const { fetchSetupStatus } = useSetupStatus()
|
||||
let requiresSetup = false
|
||||
try {
|
||||
requiresSetup = await fetchSetupStatus()
|
||||
} catch {
|
||||
// If setup-status cannot be loaded, fall back to normal auth checks.
|
||||
}
|
||||
if (requiresSetup && to.path !== '/setup') {
|
||||
return { path: '/setup' }
|
||||
}
|
||||
|
||||
if (!requiresSetup && to.path === '/setup') {
|
||||
const { user } = useAuth()
|
||||
return user.value ? { path: '/' } : { path: '/login' }
|
||||
}
|
||||
|
||||
if (to.meta.public) return true
|
||||
|
||||
const { user } = useAuth()
|
||||
|
||||
@@ -71,6 +71,12 @@ const router = createRouter({
|
||||
component: () => import('@/features/auth/LoginPage.vue'),
|
||||
meta: { public: true },
|
||||
},
|
||||
{
|
||||
path: '/setup',
|
||||
name: 'setup',
|
||||
component: () => import('@/features/auth/SetupPage.vue'),
|
||||
meta: { public: true },
|
||||
},
|
||||
{
|
||||
path: '/forgot-password',
|
||||
name: 'forgot-password',
|
||||
|
||||
Reference in New Issue
Block a user