feat(auth): add initial setup flow and harden auth/session handling

This commit is contained in:
neon
2026-03-10 04:17:33 -06:00
parent 17e8ba0f49
commit 86a1b512cc
18 changed files with 599 additions and 109 deletions
+3
View File
@@ -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"
+138
View File
@@ -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
View File
@@ -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')
+18 -1
View File
@@ -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()
+6
View File
@@ -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',