feat: add auth, RBAC, admin panel, and JWT-protected routes

This commit is contained in:
neon
2026-01-12 15:05:39 -06:00
parent 015028240c
commit 45f7c73ecb
109 changed files with 3813 additions and 180 deletions
+9 -8
View File
@@ -16,6 +16,7 @@
"format": "prettier --write --experimental-cli src/"
},
"dependencies": {
"@projectx/types": "workspace:*",
"@fontsource-variable/fraunces": "^5.2.9",
"@fontsource-variable/inter": "^5.2.8",
"@tailwindcss/vite": "^4.1.18",
@@ -23,7 +24,7 @@
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-vue-next": "^0.562.0",
"pdfjs-dist": "^5.4.449",
"pdfjs-dist": "^5.4.530",
"pinia": "^3.0.4",
"reka-ui": "^2.7.0",
"tailwind-merge": "^3.4.0",
@@ -35,27 +36,27 @@
"devDependencies": {
"@tsconfig/node24": "^24.0.3",
"@types/jsdom": "^27.0.0",
"@types/node": "^22.19.3",
"@types/node": "^22.19.5",
"@vitejs/plugin-vue": "^6.0.3",
"@vitest/eslint-plugin": "^1.6.4",
"@vitest/eslint-plugin": "^1.6.6",
"@vue/eslint-config-typescript": "^14.6.0",
"@vue/test-utils": "^2.4.6",
"@vue/tsconfig": "^0.8.1",
"eslint": "^9.39.2",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-oxlint": "~1.35.0",
"eslint-plugin-oxlint": "~1.39.0",
"eslint-plugin-vue": "~10.6.2",
"jiti": "^2.6.1",
"jsdom": "^27.4.0",
"npm-run-all2": "^8.0.4",
"oxlint": "~1.35.0",
"oxlint": "~1.39.0",
"prettier": "3.7.4",
"tw-animate-css": "^1.4.0",
"typescript": "~5.9.3",
"vite": "^7.3.0",
"vite": "^7.3.1",
"vite-plugin-vue-devtools": "^8.0.5",
"vitest": "^4.0.16",
"vue-tsc": "^3.2.1"
"vitest": "^4.0.17",
"vue-tsc": "^3.2.2"
},
"engines": {
"node": "^20.19.0 || >=22.12.0"
+4
View File
@@ -1,9 +1,13 @@
<script setup lang="ts">
import { useThemeStore } from '@/stores/theme'
import { useChangePasswordDialog } from '@/composables/useChangePasswordDialog'
import ChangePasswordDialog from '@/features/auth/ChangePasswordDialog.vue'
useThemeStore()
const { isOpen } = useChangePasswordDialog()
</script>
<template>
<RouterView />
<ChangePasswordDialog v-if="isOpen" />
</template>
+35 -1
View File
@@ -1,5 +1,6 @@
<script setup lang="ts">
import { Library, BookMarked, Clock, BookOpen, Settings } from 'lucide-vue-next'
import { Library, BookMarked, Clock, BookOpen, Settings, LogOut, KeyRound } from 'lucide-vue-next'
import { useRouter } from 'vue-router'
import {
Sidebar,
SidebarContent,
@@ -12,10 +13,16 @@ import {
SidebarMenuButton,
SidebarMenuItem,
SidebarRail,
SidebarSeparator,
} from '@/components/ui/sidebar'
import { useSettingsDrawer } from '@/composables/useSettingsDrawer'
import { useChangePasswordDialog } from '@/composables/useChangePasswordDialog'
import { useAuth } from '@/features/auth/composables/useAuth'
const { open: openSettings } = useSettingsDrawer()
const { open: openChangePassword } = useChangePasswordDialog()
const { user, logout } = useAuth()
const router = useRouter()
const navItems = [
{ id: 'library', label: 'All Books', icon: Library, active: true },
@@ -82,12 +89,39 @@ const navItems = [
<SidebarFooter class="border-t border-sidebar-border">
<SidebarMenu>
<!-- User info -->
<SidebarMenuItem v-if="user">
<div class="flex items-center gap-2.5 px-2 py-1.5 group-data-[collapsible=icon]:hidden">
<div class="flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-primary/15 text-primary text-xs font-semibold">
{{ user.name.charAt(0).toUpperCase() }}
</div>
<div class="flex flex-col min-w-0 leading-tight">
<span class="text-xs font-medium text-sidebar-foreground truncate">{{ user.name }}</span>
<span class="text-[10px] text-sidebar-foreground/40 truncate">{{ user.username }}</span>
</div>
</div>
</SidebarMenuItem>
<SidebarMenuItem>
<SidebarMenuButton tooltip="Change Password" class="gap-2.5" @click="openChangePassword()">
<KeyRound :size="15" class="text-sidebar-foreground/50" />
<span class="text-sidebar-foreground/70">Change Password</span>
</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenuItem>
<SidebarMenuButton tooltip="Settings" class="gap-2.5" @click="openSettings">
<Settings :size="15" class="text-sidebar-foreground/50" />
<span class="text-sidebar-foreground/70">Settings</span>
</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenuItem>
<SidebarMenuButton tooltip="Sign out" class="gap-2.5" @click="logout">
<LogOut :size="15" class="text-sidebar-foreground/50" />
<span class="text-sidebar-foreground/70">Sign out</span>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarFooter>
@@ -0,0 +1,19 @@
import { ref } from 'vue'
const isOpen = ref(false)
const isForced = ref(false)
export function useChangePasswordDialog() {
return {
isOpen,
isForced,
open: (forced = false) => {
isForced.value = forced
isOpen.value = true
},
close: () => {
isOpen.value = false
isForced.value = false
},
}
}
@@ -0,0 +1,130 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { Plus, Trash2 } from 'lucide-vue-next'
import { api } from '@/lib/api'
import type { Permission } from '@projectx/types'
const permissions = ref<Permission[]>([])
const loading = ref(false)
const error = ref<string | null>(null)
const newName = ref('')
const newDescription = ref('')
const creating = ref(false)
const createError = ref<string | null>(null)
async function loadData() {
loading.value = true
error.value = null
try {
const res = await api('/api/permissions')
if (!res.ok) throw new Error('Failed to load')
permissions.value = await res.json()
} catch (e) {
error.value = e instanceof Error ? e.message : 'Failed to load'
} finally {
loading.value = false
}
}
onMounted(loadData)
async function createPermission() {
createError.value = null
creating.value = true
try {
const res = await api('/api/permissions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: newName.value, description: newDescription.value || undefined }),
})
if (!res.ok) {
const err = await res.json().catch(() => ({}))
createError.value = err.message ?? 'Failed to create permission'
return
}
newName.value = ''
newDescription.value = ''
loadData()
} finally {
creating.value = false
}
}
async function deletePermission(perm: Permission) {
if (!confirm(`Delete permission "${perm.name}"?`)) return
const res = await api(`/api/permissions/${perm.id}`, { method: 'DELETE' })
if (res.ok) loadData()
}
</script>
<template>
<div class="px-5 py-6 sm:px-10 sm:py-8">
<div class="mb-8">
<h2 class="font-serif font-semibold text-foreground text-2xl tracking-tight">Permissions</h2>
<p class="mt-1 text-sm text-muted-foreground">Create granular permissions to assign to roles.</p>
</div>
<!-- Create form -->
<div class="mb-6 rounded-lg border border-border p-4">
<h2 class="text-sm font-medium text-foreground mb-3">Create permission</h2>
<form @submit.prevent="createPermission" class="flex gap-2 flex-wrap">
<input
v-model="newName"
type="text"
placeholder="permission_name"
required
class="flex-1 min-w-[160px] 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"
/>
<input
v-model="newDescription"
type="text"
placeholder="Description (optional)"
class="flex-1 min-w-[200px] 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"
/>
<button
type="submit"
:disabled="creating"
class="flex items-center gap-1.5 rounded-md bg-primary px-3 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50 transition-colors"
>
<Plus :size="14" />
Add
</button>
</form>
<p v-if="createError" class="mt-2 text-sm text-destructive">{{ createError }}</p>
</div>
<div v-if="error" class="mb-4 text-sm text-destructive">{{ error }}</div>
<div v-if="loading" class="text-sm text-muted-foreground">Loading…</div>
<div v-else class="rounded-lg border border-border overflow-hidden">
<table class="w-full text-sm">
<thead class="bg-muted/50">
<tr>
<th class="px-4 py-3 text-left font-medium text-muted-foreground">Name</th>
<th class="px-4 py-3 text-left font-medium text-muted-foreground hidden sm:table-cell">Description</th>
<th class="px-4 py-3" />
</tr>
</thead>
<tbody class="divide-y divide-border">
<tr v-for="perm in permissions" :key="perm.id" class="hover:bg-muted/30 transition-colors">
<td class="px-4 py-3 font-mono text-xs text-foreground">{{ perm.name }}</td>
<td class="px-4 py-3 text-muted-foreground hidden sm:table-cell">{{ perm.description ?? '-' }}</td>
<td class="px-4 py-3">
<div class="flex justify-end">
<button
v-if="!perm.isSystem"
@click="deletePermission(perm)"
class="p-1.5 rounded text-muted-foreground hover:text-destructive hover:bg-muted transition-colors"
title="Delete"
>
<Trash2 :size="14" />
</button>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</template>
@@ -0,0 +1,49 @@
<script setup lang="ts">
import { ref } from 'vue'
import { X } from 'lucide-vue-next'
defineProps<{ resetUrl: string }>()
const emit = defineEmits<{ close: [] }>()
const copied = ref(false)
function copy(url: string) {
navigator.clipboard.writeText(url).then(() => {
copied.value = true
setTimeout(() => {
copied.value = false
}, 2000)
})
}
</script>
<template>
<div class="fixed inset-0 z-[60] flex items-center justify-center bg-black/50 px-4" @click.self="emit('close')">
<div class="w-full max-w-md rounded-lg border border-border bg-card p-6 shadow-xl">
<div class="flex items-start justify-between mb-4">
<h2 class="text-base font-semibold text-foreground">Password Reset Link</h2>
<button @click="emit('close')" class="text-muted-foreground hover:text-foreground transition-colors">
<X :size="16" />
</button>
</div>
<p class="text-sm text-muted-foreground mb-3">Share this link with the user. It expires in 15 minutes.</p>
<div class="flex gap-2">
<input
:value="resetUrl"
readonly
class="flex-1 rounded-md border border-input bg-background px-3 py-2 text-xs text-foreground focus:outline-none overflow-x-auto"
/>
<button
@click="copy(resetUrl)"
class="rounded-md bg-primary px-3 py-2 text-xs font-medium text-primary-foreground hover:bg-primary/90 transition-colors whitespace-nowrap"
>
{{ copied ? 'Copied!' : 'Copy' }}
</button>
</div>
<p class="mt-3 text-xs text-amber-600 dark:text-amber-400">This link will not be shown again.</p>
</div>
</div>
</template>
@@ -0,0 +1,174 @@
<script setup lang="ts">
import { ref, watch } from 'vue'
import { X } from 'lucide-vue-next'
import { api } from '@/lib/api'
import type { Role, Permission } from '@projectx/types'
const props = defineProps<{
role: Partial<Role> | null
permissions: Permission[]
}>()
const emit = defineEmits<{
close: []
saved: []
}>()
const name = ref('')
const description = ref('')
const selectedPermissionIds = ref<number[]>([])
const error = ref<string | null>(null)
const loading = ref(false)
watch(
() => props.role,
(r) => {
name.value = r?.name ?? ''
description.value = r?.description ?? ''
selectedPermissionIds.value = r?.permissions?.map((p) => p.id) ?? []
error.value = null
},
{ immediate: true },
)
function togglePermission(permId: number) {
const idx = selectedPermissionIds.value.indexOf(permId)
if (idx === -1) {
selectedPermissionIds.value.push(permId)
} else {
selectedPermissionIds.value.splice(idx, 1)
}
}
async function handleSubmit() {
error.value = null
loading.value = true
try {
const isEdit = !!props.role?.id
if (isEdit) {
const res = await api(`/api/roles/${props.role!.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: name.value, description: description.value || undefined }),
})
if (!res.ok) {
const err = await res.json().catch(() => ({}))
error.value = err.message ?? 'Failed to update role'
return
}
// Sync permissions
const currentIds = props.role!.permissions?.map((p) => p.id) ?? []
const toAssign = selectedPermissionIds.value.filter((id) => !currentIds.includes(id))
const toRevoke = currentIds.filter((id) => !selectedPermissionIds.value.includes(id))
await Promise.all([
...toAssign.map((permissionId) =>
api(`/api/roles/${props.role!.id}/permissions`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ permissionId }),
}),
),
...toRevoke.map((permId) => api(`/api/roles/${props.role!.id}/permissions/${permId}`, { method: 'DELETE' })),
])
} else {
const res = await api('/api/roles', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: name.value,
description: description.value || undefined,
permissionIds: selectedPermissionIds.value,
}),
})
if (!res.ok) {
const err = await res.json().catch(() => ({}))
error.value = err.message ?? 'Failed to create role'
return
}
}
emit('saved')
} finally {
loading.value = false
}
}
</script>
<template>
<div class="fixed inset-0 z-[60] flex">
<div class="fixed inset-0 bg-black/40" @click="emit('close')" />
<div class="relative ml-auto flex h-full w-full max-w-md flex-col bg-card shadow-xl">
<div class="flex items-center justify-between border-b border-border px-6 py-4">
<h2 class="text-base font-semibold text-foreground">{{ role?.id ? 'Edit Role' : 'Create Role' }}</h2>
<button @click="emit('close')" class="text-muted-foreground hover:text-foreground">
<X :size="16" />
</button>
</div>
<form @submit.prevent="handleSubmit" class="flex-1 overflow-y-auto space-y-4 px-6 py-6">
<div class="space-y-1.5">
<label class="text-sm font-medium text-foreground">Role name</label>
<input
v-model="name"
type="text"
required
:disabled="role?.isSystem"
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 disabled:opacity-50"
/>
<p v-if="role?.isSystem" class="text-xs text-muted-foreground">System role names cannot be changed.</p>
</div>
<div class="space-y-1.5">
<label class="text-sm font-medium text-foreground">Description <span class="text-muted-foreground">(optional)</span></label>
<input
v-model="description"
type="text"
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="role?.isSuperuser" class="rounded-md border border-border bg-muted/40 px-4 py-3 text-sm text-muted-foreground">
This is a superuser role. It implicitly has all permissions, including any added in the future. Individual permission assignments are not applicable.
</div>
<div v-else class="space-y-2">
<p class="text-sm font-medium text-foreground">Permissions</p>
<div class="space-y-1.5">
<label v-for="perm in permissions" :key="perm.id" class="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
:checked="selectedPermissionIds.includes(perm.id)"
@change="togglePermission(perm.id)"
class="h-4 w-4 rounded border-input"
/>
<span class="text-sm text-foreground">{{ perm.name }}</span>
<span v-if="perm.description" class="text-xs text-muted-foreground">{{ perm.description }}</span>
</label>
</div>
</div>
<div v-if="error" class="text-sm text-destructive">{{ error }}</div>
</form>
<div class="border-t border-border px-6 py-4 flex gap-3 justify-end">
<button
@click="emit('close')"
class="rounded-md border border-border px-4 py-2 text-sm font-medium text-foreground hover:bg-muted transition-colors"
>
Cancel
</button>
<button
v-if="!role?.isSuperuser"
@click="handleSubmit"
:disabled="loading"
class="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 ? 'Saving…' : 'Save' }}
</button>
</div>
</div>
</div>
</template>
+124
View File
@@ -0,0 +1,124 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { Plus, Pencil, Trash2 } from 'lucide-vue-next'
import { api } from '@/lib/api'
import type { Role, Permission } from '@projectx/types'
import RoleFormDrawer from './RoleFormDrawer.vue'
const roles = ref<Role[]>([])
const permissions = ref<Permission[]>([])
const loading = ref(false)
const error = ref<string | null>(null)
const drawerOpen = ref(false)
const editingRole = ref<Partial<Role> | null>(null)
async function loadData() {
loading.value = true
error.value = null
try {
const [rolesRes, permsRes] = await Promise.all([api('/api/roles'), api('/api/permissions')])
if (!rolesRes.ok || !permsRes.ok) throw new Error('Failed to load data')
roles.value = await rolesRes.json()
permissions.value = await permsRes.json()
} catch (e) {
error.value = e instanceof Error ? e.message : 'Failed to load'
} finally {
loading.value = false
}
}
onMounted(loadData)
function openCreate() {
editingRole.value = null
drawerOpen.value = true
}
function openEdit(role: Role) {
editingRole.value = role
drawerOpen.value = true
}
async function deleteRole(role: Role) {
if (!confirm(`Delete role "${role.name}"?`)) return
const res = await api(`/api/roles/${role.id}`, { method: 'DELETE' })
if (res.ok) loadData()
}
function onSaved() {
drawerOpen.value = false
loadData()
}
</script>
<template>
<div class="px-5 py-6 sm:px-10 sm:py-8">
<div class="flex items-start justify-between mb-8">
<div>
<h2 class="font-serif font-semibold text-foreground text-2xl tracking-tight">Roles</h2>
<p class="mt-1 text-sm text-muted-foreground">Define roles and their permission sets.</p>
</div>
<button
@click="openCreate"
class="flex items-center gap-2 rounded-md bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground hover:bg-primary/90 transition-colors shrink-0 ml-4"
>
<Plus :size="14" />
Create role
</button>
</div>
<div v-if="error" class="mb-4 text-sm text-destructive">{{ error }}</div>
<div v-if="loading" class="text-sm text-muted-foreground">Loading…</div>
<div v-else class="rounded-lg border border-border overflow-hidden">
<table class="w-full text-sm">
<thead class="bg-muted/50">
<tr>
<th class="px-4 py-3 text-left font-medium text-muted-foreground">Name</th>
<th class="px-4 py-3 text-left font-medium text-muted-foreground">Type</th>
<th class="px-4 py-3 text-left font-medium text-muted-foreground">Permissions</th>
<th class="px-4 py-3" />
</tr>
</thead>
<tbody class="divide-y divide-border">
<tr v-for="role in roles" :key="role.id" class="hover:bg-muted/30 transition-colors">
<td class="px-4 py-3 text-foreground font-medium">{{ role.name }}</td>
<td class="px-4 py-3">
<span v-if="role.isSuperuser" class="inline-flex items-center rounded-full bg-primary/15 px-2 py-0.5 text-xs font-medium text-primary"
>Superuser</span
>
<span v-else class="text-muted-foreground text-xs">Standard</span>
</td>
<td class="px-4 py-3">
<span v-if="role.isSuperuser" class="inline-flex items-center rounded-full bg-muted px-2 py-0.5 text-xs text-muted-foreground">All</span>
<span v-else class="text-muted-foreground text-sm">{{ role.permissions.length }}</span>
</td>
<td class="px-4 py-3">
<div class="flex items-center gap-2 justify-end">
<button
@click="openEdit(role)"
:disabled="role.isSuperuser"
class="p-1.5 rounded text-muted-foreground hover:text-foreground hover:bg-muted transition-colors disabled:opacity-30 disabled:cursor-not-allowed"
:title="role.isSuperuser ? 'Superuser roles cannot be edited' : 'Edit'"
>
<Pencil :size="14" />
</button>
<button
@click="deleteRole(role)"
:disabled="role.isSystem"
class="p-1.5 rounded text-muted-foreground hover:text-destructive hover:bg-muted transition-colors disabled:opacity-30 disabled:cursor-not-allowed"
:title="role.isSystem ? 'System roles cannot be deleted' : 'Delete'"
>
<Trash2 :size="14" />
</button>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<RoleFormDrawer v-if="drawerOpen" :role="editingRole" :permissions="permissions" @close="drawerOpen = false" @saved="onSaved" />
</template>
@@ -0,0 +1,190 @@
<script setup lang="ts">
import { ref, watch } from 'vue'
import { X } from 'lucide-vue-next'
import { api } from '@/lib/api'
import type { AuthUser, Role } from '@projectx/types'
const props = defineProps<{
user: Partial<AuthUser> | null
roles: Role[]
}>()
const emit = defineEmits<{
close: []
saved: [resetUrl?: string]
}>()
const name = ref('')
const username = ref('')
const email = ref('')
const active = ref(true)
const selectedRoleIds = ref<number[]>([])
const error = ref<string | null>(null)
const loading = ref(false)
watch(
() => props.user,
(u) => {
name.value = u?.name ?? ''
username.value = u?.username ?? ''
email.value = u?.email ?? ''
active.value = u?.active ?? true
selectedRoleIds.value = u?.roles?.map((r) => r.id) ?? []
error.value = null
},
{ immediate: true },
)
function toggleRole(roleId: number) {
const idx = selectedRoleIds.value.indexOf(roleId)
if (idx === -1) {
selectedRoleIds.value.push(roleId)
} else {
selectedRoleIds.value.splice(idx, 1)
}
}
async function handleSubmit() {
error.value = null
loading.value = true
try {
const isEdit = !!props.user?.id
if (isEdit) {
const res = await api(`/api/users/${props.user!.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: name.value, email: email.value || undefined, active: active.value }),
})
if (!res.ok) {
const err = await res.json().catch(() => ({}))
error.value = err.message ?? 'Failed to update user'
return
}
// Sync roles: assign missing, revoke removed
const currentIds = props.user!.roles?.map((r) => r.id) ?? []
const toAssign = selectedRoleIds.value.filter((id) => !currentIds.includes(id))
const toRevoke = currentIds.filter((id) => !selectedRoleIds.value.includes(id))
await Promise.all([
...toAssign.map((roleId) =>
api(`/api/users/${props.user!.id}/roles`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ roleId }),
}),
),
...toRevoke.map((roleId) => api(`/api/users/${props.user!.id}/roles/${roleId}`, { method: 'DELETE' })),
])
} else {
const res = await api('/api/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: name.value,
username: username.value,
email: email.value || undefined,
roleIds: selectedRoleIds.value,
}),
})
if (!res.ok) {
const err = await res.json().catch(() => ({}))
error.value = err.message ?? 'Failed to create user'
return
}
const data = await res.json()
emit('saved', data.resetUrl)
return
}
emit('saved')
} finally {
loading.value = false
}
}
</script>
<template>
<div class="fixed inset-0 z-[60] flex" @click.self="emit('close')">
<div class="fixed inset-0 bg-black/40" @click="emit('close')" />
<div class="relative ml-auto flex h-full w-full max-w-md flex-col bg-card shadow-xl">
<div class="flex items-center justify-between border-b border-border px-6 py-4">
<h2 class="text-base font-semibold text-foreground">{{ user?.id ? 'Edit User' : 'Create User' }}</h2>
<button @click="emit('close')" class="text-muted-foreground hover:text-foreground">
<X :size="16" />
</button>
</div>
<form @submit.prevent="handleSubmit" class="flex-1 overflow-y-auto space-y-4 px-6 py-6">
<div v-if="!user?.id" class="space-y-1.5">
<label class="text-sm font-medium text-foreground">Username</label>
<input
v-model="username"
type="text"
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 class="text-sm font-medium text-foreground">Full name</label>
<input
v-model="name"
type="text"
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 class="text-sm font-medium text-foreground">Email <span class="text-muted-foreground">(optional)</span></label>
<input
v-model="email"
type="email"
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="user?.id" class="flex items-center gap-3">
<input id="active" v-model="active" type="checkbox" class="h-4 w-4 rounded border-input" />
<label for="active" class="text-sm font-medium text-foreground">Active</label>
</div>
<div class="space-y-2">
<p class="text-sm font-medium text-foreground">Roles</p>
<div class="space-y-1.5">
<label v-for="role in roles" :key="role.id" class="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
:checked="selectedRoleIds.includes(role.id)"
@change="toggleRole(role.id)"
class="h-4 w-4 rounded border-input"
/>
<span class="text-sm text-foreground">{{ role.name }}</span>
<span v-if="role.isSuperuser" class="text-xs text-primary">(superuser)</span>
</label>
</div>
</div>
<div v-if="error" class="text-sm text-destructive">{{ error }}</div>
</form>
<div class="border-t border-border px-6 py-4 flex gap-3 justify-end">
<button
@click="emit('close')"
class="rounded-md border border-border px-4 py-2 text-sm font-medium text-foreground hover:bg-muted transition-colors"
>
Cancel
</button>
<button
@click="handleSubmit"
:disabled="loading"
class="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 ? 'Saving…' : 'Save' }}
</button>
</div>
</div>
</div>
</template>
+173
View File
@@ -0,0 +1,173 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { UserPlus, Pencil, KeyRound, Trash2 } from 'lucide-vue-next'
import { api } from '@/lib/api'
import type { AuthUser, Role } from '@projectx/types'
import { usePermissions } from '@/features/auth/composables/usePermissions'
import UserFormDrawer from './UserFormDrawer.vue'
import ResetLinkModal from './ResetLinkModal.vue'
interface UserRow extends AuthUser {
id: number
}
const { isSuperuser } = usePermissions()
const users = ref<UserRow[]>([])
const roles = ref<Role[]>([])
const total = ref(0)
const page = ref(0)
const loading = ref(false)
const error = ref<string | null>(null)
const drawerOpen = ref(false)
const editingUser = ref<Partial<AuthUser> | null>(null)
const resetUrl = ref<string | null>(null)
async function loadData() {
loading.value = true
error.value = null
try {
const [usersRes, rolesRes] = await Promise.all([api(`/api/users?page=${page.value}&pageSize=50`), api('/api/roles')])
if (!usersRes.ok || !rolesRes.ok) throw new Error('Failed to load data')
const ud = await usersRes.json()
users.value = ud.users ?? ud.items ?? ud
total.value = ud.total ?? users.value.length
roles.value = await rolesRes.json()
} catch (e) {
error.value = e instanceof Error ? e.message : 'Failed to load'
} finally {
loading.value = false
}
}
onMounted(loadData)
function openCreate() {
editingUser.value = null
drawerOpen.value = true
}
function openEdit(user: UserRow) {
editingUser.value = user
drawerOpen.value = true
}
async function handleResetPassword(userId: number) {
const res = await api(`/api/users/${userId}/reset-password`, { method: 'POST' })
if (!res.ok) return
const data = await res.json()
resetUrl.value = data.resetUrl
}
async function deleteUser(user: UserRow) {
if (!confirm(`Delete user "${user.username}"? This cannot be undone.`)) return
const res = await api(`/api/users/${user.id}`, { method: 'DELETE' })
if (!res.ok) {
const data = await res.json().catch(() => ({}))
error.value = data.message ?? 'Failed to delete user'
return
}
loadData()
}
function onSaved(newResetUrl?: string) {
drawerOpen.value = false
if (newResetUrl) resetUrl.value = newResetUrl
loadData()
}
</script>
<template>
<div class="px-5 py-6 sm:px-10 sm:py-8">
<div class="flex items-start justify-between mb-8">
<div>
<h2 class="font-serif font-semibold text-foreground text-2xl tracking-tight">Users</h2>
<p class="mt-1 text-sm text-muted-foreground">Manage user accounts and role assignments.</p>
</div>
<button
@click="openCreate"
class="flex items-center gap-2 rounded-md bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground hover:bg-primary/90 transition-colors shrink-0 ml-4"
>
<UserPlus :size="14" />
Create user
</button>
</div>
<div v-if="error" class="mb-4 text-sm text-destructive">{{ error }}</div>
<div v-if="loading" class="text-sm text-muted-foreground">Loading…</div>
<div v-else class="rounded-lg border border-border overflow-hidden">
<table class="w-full text-sm">
<thead class="bg-muted/50">
<tr>
<th class="px-4 py-3 text-left font-medium text-muted-foreground">Name</th>
<th class="px-4 py-3 text-left font-medium text-muted-foreground">Username</th>
<th class="px-4 py-3 text-left font-medium text-muted-foreground hidden sm:table-cell">Email</th>
<th class="px-4 py-3 text-left font-medium text-muted-foreground">Roles</th>
<th class="px-4 py-3 text-left font-medium text-muted-foreground">Status</th>
<th class="px-4 py-3" />
</tr>
</thead>
<tbody class="divide-y divide-border">
<tr v-for="user in users" :key="user.id" class="hover:bg-muted/30 transition-colors">
<td class="px-4 py-3 text-foreground font-medium">{{ user.name }}</td>
<td class="px-4 py-3 text-muted-foreground font-mono text-xs">{{ user.username }}</td>
<td class="px-4 py-3 text-muted-foreground hidden sm:table-cell">{{ user.email ?? '-' }}</td>
<td class="px-4 py-3">
<div class="flex flex-wrap gap-1">
<span
v-for="role in user.roles"
:key="role.id"
class="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium"
:class="role.isSuperuser ? 'bg-primary/15 text-primary' : 'bg-muted text-muted-foreground'"
>
{{ role.name }}
</span>
</div>
</td>
<td class="px-4 py-3">
<span
class="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium"
:class="user.active ? 'bg-green-500/15 text-green-600 dark:text-green-400' : 'bg-destructive/15 text-destructive'"
>
{{ user.active ? 'Active' : 'Inactive' }}
</span>
</td>
<td class="px-4 py-3">
<div class="flex items-center gap-2 justify-end">
<template v-if="isSuperuser || !user.roles.some((r) => r.isSuperuser)">
<button
@click="openEdit(user)"
class="p-1.5 rounded text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
title="Edit"
>
<Pencil :size="14" />
</button>
<button
@click="handleResetPassword(user.id)"
class="p-1.5 rounded text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
title="Reset password"
>
<KeyRound :size="14" />
</button>
<button
@click="deleteUser(user)"
class="p-1.5 rounded text-muted-foreground hover:text-destructive hover:bg-destructive/10 transition-colors"
title="Delete user"
>
<Trash2 :size="14" />
</button>
</template>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<UserFormDrawer v-if="drawerOpen" :user="editingUser" :roles="roles" @close="drawerOpen = false" @saved="onSaved" />
<ResetLinkModal v-if="resetUrl" :reset-url="resetUrl" @close="resetUrl = null" />
</template>
@@ -0,0 +1,127 @@
<script setup lang="ts">
import { ref } from 'vue'
import { X } from 'lucide-vue-next'
import { api } from '@/lib/api'
import { useAuth } from './composables/useAuth'
import { useChangePasswordDialog } from '@/composables/useChangePasswordDialog'
const { me } = useAuth()
const { isForced, close } = useChangePasswordDialog()
const currentPassword = ref('')
const newPassword = ref('')
const confirmPassword = ref('')
const error = ref<string | null>(null)
const loading = ref(false)
async function handleSubmit() {
error.value = null
if (newPassword.value !== confirmPassword.value) {
error.value = 'Passwords do not match'
return
}
loading.value = true
try {
const res = await api('/api/auth/change-password', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ currentPassword: currentPassword.value, newPassword: newPassword.value }),
})
if (!res.ok) {
const err = await res.json().catch(() => ({}))
error.value = err.message ?? 'Failed to change password'
return
}
// After password change, token version increments — re-login is required.
// me() will 401, triggering onAuthFailure which redirects to /login.
await me().catch(() => {})
close()
} catch {
error.value = 'Something went wrong. Please try again.'
} finally {
loading.value = false
}
}
</script>
<template>
<Teleport to="body">
<div class="fixed inset-0 z-[70] flex items-center justify-center bg-black/50 px-4">
<div class="w-full max-w-sm rounded-lg border border-border bg-card shadow-xl p-6">
<div class="flex items-center justify-between mb-5">
<h2 class="text-base font-semibold text-foreground">Change password</h2>
<button v-if="!isForced" @click="close" class="text-muted-foreground hover:text-foreground transition-colors">
<X :size="16" />
</button>
</div>
<div v-if="isForced" class="mb-4 rounded-md border border-amber-500/40 bg-amber-500/10 px-4 py-3 text-sm text-amber-700 dark:text-amber-400">
You must set a new password before continuing.
</div>
<form @submit.prevent="handleSubmit" class="space-y-4">
<div class="space-y-1.5">
<label for="cp-current" class="text-sm font-medium text-foreground">Current password</label>
<input
id="cp-current"
v-model="currentPassword"
type="password"
autocomplete="current-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="cp-new" class="text-sm font-medium text-foreground">New password</label>
<input
id="cp-new"
v-model="newPassword"
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="cp-confirm" class="text-sm font-medium text-foreground">Confirm new password</label>
<input
id="cp-confirm"
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 v-if="error" class="text-sm text-destructive">{{ error }}</div>
<div class="flex gap-3 justify-end pt-1">
<button
v-if="!isForced"
type="button"
@click="close"
class="rounded-md border border-border px-4 py-2 text-sm font-medium text-foreground hover:bg-muted transition-colors"
>
Cancel
</button>
<button
type="submit"
:disabled="loading"
class="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 ? 'Saving…' : 'Change password' }}
</button>
</div>
</form>
</div>
</div>
</Teleport>
</template>
@@ -0,0 +1,73 @@
<script setup lang="ts">
import { ref } from 'vue'
import { api } from '@/lib/api'
const email = ref('')
const submitted = ref(false)
const error = ref<string | null>(null)
const loading = ref(false)
async function handleSubmit() {
error.value = null
loading.value = true
try {
const res = await api('/api/auth/forgot-password', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: email.value }),
})
if (res.status === 503) {
error.value = 'Password reset via email is not available. Contact your administrator.'
return
}
submitted.value = true
} catch {
error.value = 'Something went wrong. Please try again.'
} 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-sm">
<div class="text-center mb-8">
<h1 class="text-2xl font-serif font-semibold text-foreground">project<span class="text-primary">x</span></h1>
<p class="text-sm text-muted-foreground mt-1">Reset your password</p>
</div>
<div v-if="submitted" class="rounded-md border border-border bg-card p-4 text-sm text-foreground">
If an account with that email exists, a reset link has been sent. Check your inbox.
</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">Email address</label>
<input
id="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 placeholder:text-muted-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 ? 'Sending…' : 'Send reset link' }}
</button>
</form>
<p class="mt-4 text-center text-sm text-muted-foreground">
<RouterLink to="/login" class="text-primary hover:underline">Back to sign in</RouterLink>
</p>
</div>
</div>
</template>
+74
View File
@@ -0,0 +1,74 @@
<script setup lang="ts">
import { ref } from 'vue'
import { useAuth } from './composables/useAuth'
const { login } = useAuth()
const username = ref('')
const password = ref('')
const error = ref<string | null>(null)
const loading = ref(false)
async function handleSubmit() {
error.value = null
loading.value = true
try {
await login(username.value, password.value)
} catch {
error.value = 'Invalid username or password'
} 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-sm">
<div class="text-center mb-8">
<h1 class="text-2xl font-serif font-semibold text-foreground">project<span class="text-primary">x</span></h1>
<p class="text-sm text-muted-foreground mt-1">Sign in to your account</p>
</div>
<form @submit.prevent="handleSubmit" class="space-y-4">
<div class="space-y-1.5">
<label for="username" class="text-sm font-medium text-foreground">Username</label>
<input
id="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 placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary/50"
/>
</div>
<div class="space-y-1.5">
<label for="password" class="text-sm font-medium text-foreground">Password</label>
<input
id="password"
v-model="password"
type="password"
autocomplete="current-password"
required
class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-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 ? 'Signing in…' : 'Sign in' }}
</button>
</form>
<p class="mt-4 text-center text-sm text-muted-foreground">
<RouterLink to="/forgot-password" class="text-primary hover:underline">Forgot password?</RouterLink>
</p>
</div>
</div>
</template>
@@ -0,0 +1,102 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
const route = useRoute()
const router = useRouter()
const token = ref('')
const newPassword = ref('')
const confirmPassword = ref('')
const error = ref<string | null>(null)
const loading = ref(false)
onMounted(() => {
token.value = (route.query.token as string) ?? ''
if (!token.value) {
error.value = 'Invalid reset link. Please request a new one.'
}
})
async function handleSubmit() {
error.value = null
if (newPassword.value !== confirmPassword.value) {
error.value = 'Passwords do not match'
return
}
loading.value = true
try {
const res = await fetch('/api/auth/reset-password', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token: token.value, newPassword: newPassword.value }),
})
if (!res.ok) {
const err = await res.json().catch(() => ({}))
error.value = err.message ?? 'Invalid or expired reset link. Please request a new one.'
return
}
router.push({ path: '/login', query: { reset: '1' } })
} catch {
error.value = 'Something went wrong. Please try again.'
} 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-sm">
<div class="text-center mb-8">
<h1 class="text-2xl font-serif font-semibold text-foreground">project<span class="text-primary">x</span></h1>
<p class="text-sm text-muted-foreground mt-1">Set a new password</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">New password</label>
<input
id="newPassword"
v-model="newPassword"
type="password"
autocomplete="new-password"
required
class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-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="confirmPassword" class="text-sm font-medium text-foreground">Confirm password</label>
<input
id="confirmPassword"
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 placeholder:text-muted-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 || !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 ? 'Saving…' : 'Set new password' }}
</button>
</form>
<p class="mt-4 text-center text-sm text-muted-foreground">
<RouterLink to="/login" class="text-primary hover:underline">Back to sign in</RouterLink>
</p>
</div>
</div>
</template>
@@ -0,0 +1,73 @@
import { ref } from 'vue'
import type { AuthUser, AuthResponse } from '@projectx/types'
import { api, setAccessToken, setOnAuthFailure } from '@/lib/api'
import router from '@/router'
const user = ref<AuthUser | null>(null)
const isLoading = ref(false)
function clearAuth() {
user.value = null
setAccessToken(null)
}
setOnAuthFailure(() => {
clearAuth()
router.push('/login')
})
async function me(): Promise<void> {
const res = await api('/api/auth/me')
if (!res.ok) throw new Error('Failed to load user')
user.value = await res.json()
}
export function useAuth() {
async function init(): Promise<void> {
isLoading.value = true
try {
const res = await fetch('/api/auth/refresh', { method: 'POST', credentials: 'include' })
if (!res.ok) return
const { accessToken } = await res.json()
setAccessToken(accessToken)
await me()
} catch {
// no valid session
} finally {
isLoading.value = false
}
}
async function login(username: string, password: string): Promise<void> {
const res = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ username, password }),
})
if (!res.ok) {
const err = await res.json().catch(() => ({}))
throw new Error(err.message ?? 'Invalid credentials')
}
const data: AuthResponse = await res.json()
setAccessToken(data.accessToken)
user.value = data.user
if (data.user.isDefaultPassword) {
router.push('/change-password')
} else {
const redirect = router.currentRoute.value.query.redirect as string | undefined
router.push(redirect ?? '/')
}
}
async function logout(): Promise<void> {
await fetch('/api/auth/logout', { method: 'POST', credentials: 'include' }).catch(() => {})
clearAuth()
router.push('/login')
}
return { user, isLoading, init, login, logout, me }
}
@@ -0,0 +1,15 @@
import { computed } from 'vue'
import { useAuth } from './useAuth'
export function usePermissions() {
const { user } = useAuth()
const isSuperuser = computed(() => user.value?.permissions.includes('*') ?? false)
const userPermissions = computed(() => user.value?.permissions ?? [])
function hasPermission(name: string): boolean {
return userPermissions.value.includes('*') || userPermissions.value.includes(name)
}
return { hasPermission, isSuperuser, userPermissions }
}
@@ -1,7 +1,8 @@
<script setup lang="ts">
import type { Book, BookFile } from '../composables/useBooks'
import { bookCoverStyle } from '../composables/useBooks'
import { computed, ref } from 'vue'
import { useBookCover } from '../composables/useBookCover'
import { computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
const router = useRouter()
@@ -20,9 +21,10 @@ const primaryFile = computed(() => props.book.files.find((f) => f.role === 'prim
// Only show format chips when there are multiple files
const extraFiles = computed(() => (props.book.files.length > 1 ? props.book.files : []))
const coverUrl = `/api/books/${props.book.id}/thumbnail`
const coverLoaded = ref(false)
const coverFailed = ref(false)
const { src: coverUrl, failed: coverFailed, load: loadCover } = useBookCover(props.book.id, 'thumbnail')
const coverLoaded = computed(() => coverUrl.value !== null)
onMounted(loadCover)
function openFile(file: BookFile) {
router.push({
@@ -45,15 +47,7 @@ function openFile(file: BookFile) {
style="aspect-ratio: 2/3"
:style="coverLoaded ? {} : coverStyle"
>
<img
v-if="!coverFailed"
:src="coverUrl"
class="absolute inset-0 w-full h-full object-cover"
:class="{ 'opacity-0': !coverLoaded }"
@load="coverLoaded = true"
@error="coverFailed = true"
:alt="book.title ?? ''"
/>
<img v-if="coverUrl && !coverFailed" :src="coverUrl" class="absolute inset-0 w-full h-full object-cover" :alt="book.title ?? ''" />
<!-- Series badge -->
<div v-if="seriesLine" class="absolute top-1.5 left-1.5 right-1.5">
@@ -89,7 +83,7 @@ function openFile(file: BookFile) {
<!-- Title + author (shown when no real cover) -->
<div v-if="!coverLoaded" class="absolute bottom-0 left-0 right-0 p-2 bg-gradient-to-t from-black/60 to-transparent">
<p class="text-xs font-bold leading-tight line-clamp-3" :style="{ color: coverStyle.color }">
{{ book.title ?? '—' }}
{{ book.title ?? '-' }}
</p>
<p v-if="authorLine" class="text-[10px] mt-0.5 opacity-80 truncate" :style="{ color: coverStyle.color }">
{{ authorLine }}
@@ -100,7 +94,7 @@ function openFile(file: BookFile) {
<!-- Text below -->
<div class="px-0.5">
<p class="text-xs font-medium text-foreground truncate leading-tight">
{{ book.title ?? '—' }}
{{ book.title ?? '-' }}
</p>
<p v-if="authorLine" class="text-[11px] text-muted-foreground truncate">
{{ authorLine }}
@@ -0,0 +1,20 @@
<script setup lang="ts">
import { onMounted } from 'vue'
import { useBookCover } from '../composables/useBookCover'
const props = defineProps<{
bookId: number
type?: 'thumbnail' | 'cover'
class?: string
alt?: string
}>()
const { src, failed, load } = useBookCover(props.bookId, props.type ?? 'thumbnail')
onMounted(load)
</script>
<template>
<img v-if="src && !failed" :src="src" :class="props.class" :alt="props.alt ?? ''" />
<div v-else :class="props.class" class="bg-muted" />
</template>
@@ -0,0 +1,29 @@
import { ref, onUnmounted } from 'vue'
import { api } from '@/lib/api'
export function useBookCover(bookId: number, type: 'thumbnail' | 'cover' = 'thumbnail') {
const src = ref<string | null>(null)
const failed = ref(false)
let objectUrl: string | null = null
async function load() {
try {
const res = await api(`/api/books/${bookId}/${type}`)
if (!res.ok) {
failed.value = true
return
}
const blob = await res.blob()
objectUrl = URL.createObjectURL(blob)
src.value = objectUrl
} catch {
failed.value = true
}
}
onUnmounted(() => {
if (objectUrl) URL.revokeObjectURL(objectUrl)
})
return { src, failed, load }
}
@@ -1,4 +1,5 @@
import { ref, computed, type Ref } from 'vue'
import { api } from '@/lib/api'
export interface BookFile {
id: number
@@ -62,7 +63,7 @@ export function useBooks(libraryId: Ref<number | null>) {
})
if (search.value.trim()) params.set('search', search.value.trim())
const res = await fetch(`/api/books?${params}`)
const res = await api(`/api/books?${params}`)
if (!res.ok) throw new Error(`HTTP ${res.status}`)
const data: BooksPage = await res.json()
@@ -1,4 +1,5 @@
import { ref } from 'vue'
import { api } from '@/lib/api'
export interface Annotation {
id: number
@@ -18,7 +19,7 @@ export function useAnnotations() {
async function load(bookId: number) {
loadError.value = null
const res = await fetch(`/api/books/${bookId}/annotations`)
const res = await api(`/api/books/${bookId}/annotations`)
if (!res.ok) {
loadError.value = 'Failed to load'
return
@@ -30,7 +31,7 @@ export function useAnnotations() {
bookId: number,
data: { cfi: string; text: string; color: string; style: string; note?: string | null; chapterTitle?: string | null },
): Promise<Annotation | null> {
const res = await fetch(`/api/books/${bookId}/annotations`, {
const res = await api(`/api/books/${bookId}/annotations`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
@@ -42,7 +43,7 @@ export function useAnnotations() {
}
async function updateNote(bookId: number, id: number, note: string | null) {
const res = await fetch(`/api/books/${bookId}/annotations/${id}`, {
const res = await api(`/api/books/${bookId}/annotations/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ note }),
@@ -54,7 +55,7 @@ export function useAnnotations() {
}
async function remove(bookId: number, id: number) {
const res = await fetch(`/api/books/${bookId}/annotations/${id}`, {
const res = await api(`/api/books/${bookId}/annotations/${id}`, {
method: 'DELETE',
})
if (res.ok) {
@@ -1,4 +1,5 @@
import { computed, ref, watch } from 'vue'
import { api } from '@/lib/api'
export interface Bookmark {
id: number
@@ -24,7 +25,7 @@ export function useBookmarks() {
async function load(bookId: number) {
loadError.value = null
const res = await fetch(`/api/books/${bookId}/bookmarks`)
const res = await api(`/api/books/${bookId}/bookmarks`)
if (!res.ok) {
loadError.value = 'Failed to load'
return
@@ -37,7 +38,7 @@ export function useBookmarks() {
if (existing) {
await remove(bookId, existing.id)
} else {
const res = await fetch(`/api/books/${bookId}/bookmarks`, {
const res = await api(`/api/books/${bookId}/bookmarks`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ cfi, title }),
@@ -50,7 +51,7 @@ export function useBookmarks() {
}
async function remove(bookId: number, bookmarkId: number) {
const res = await fetch(`/api/books/${bookId}/bookmarks/${bookmarkId}`, {
const res = await api(`/api/books/${bookId}/bookmarks/${bookmarkId}`, {
method: 'DELETE',
})
if (res.ok) {
@@ -1,4 +1,5 @@
import { ref } from 'vue'
import { api } from '@/lib/api'
export function useCbz(fileId: number, bookId: number) {
const pageCount = ref(0)
@@ -11,7 +12,7 @@ export function useCbz(fileId: number, bookId: number) {
}
async function load(): Promise<void> {
const [pagesRes, bookRes] = await Promise.all([fetch(`/api/cbz/files/${fileId}/pages`), fetch(`/api/books/${bookId}`)])
const [pagesRes, bookRes] = await Promise.all([api(`/api/cbz/files/${fileId}/pages`), api(`/api/books/${bookId}`)])
if (!pagesRes.ok) {
error.value = 'Failed to load comic'
loading.value = false
@@ -1,4 +1,5 @@
import { onUnmounted, ref } from 'vue'
import { api } from '@/lib/api'
import { useFoliateAnnotations } from './useFoliateAnnotations'
import { useFoliateSelection } from './useFoliateSelection'
import { useFoliateInput } from './useFoliateInput'
@@ -105,7 +106,7 @@ export function useFoliate(
const mimeType = format === 'pdf' ? 'application/pdf' : format === 'cbz' ? 'application/zip' : 'application/epub+zip'
const ext = format === 'pdf' ? 'pdf' : format === 'cbz' ? 'cbz' : 'epub'
const res = await fetch(`/api/books/files/${fileId}/serve`)
const res = await api(`/api/books/files/${fileId}/serve`)
if (!res.ok) throw new Error(`Failed to fetch book file: ${res.status}`)
const blob = await res.blob()
const file = new File([blob], `book-file-${fileId}.${ext}`, { type: mimeType })
@@ -1,4 +1,5 @@
import { ref } from 'vue'
import { api } from '@/lib/api'
import type { FoliateRenderer, RelocateDetail } from './useFoliate'
export function useReaderProgress(bookId: number, fileId: number) {
@@ -13,7 +14,7 @@ export function useReaderProgress(bookId: number, fileId: number) {
let saveTimer: ReturnType<typeof setTimeout> | null = null
async function load() {
const res = await fetch(`/api/books/files/${fileId}/progress`)
const res = await api(`/api/books/files/${fileId}/progress`)
if (!res.ok) return
const data = await res.json()
cfi.value = data.cfi ?? null
@@ -34,7 +35,7 @@ export function useReaderProgress(bookId: number, fileId: number) {
}
async function save() {
await fetch(`/api/books/files/${fileId}/progress`, {
await api(`/api/books/files/${fileId}/progress`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ cfi: cfi.value, pageNumber: pageNumber.value, percentage: percentage.value }),
@@ -11,7 +11,7 @@ const stack = [
</script>
<template>
<div class="px-5 py-6 sm:px-10 sm:py-8 max-w-3xl">
<div class="px-5 py-6 sm:px-10 sm:py-8 max-w-3xl mx-auto">
<div class="mb-8">
<h2 class="font-serif font-semibold text-foreground text-2xl tracking-tight">About</h2>
<p class="mt-1 text-sm text-muted-foreground">Information about this application.</p>
@@ -25,7 +25,7 @@ const stack = [
</div>
<div>
<p class="text-base font-semibold text-foreground font-serif">projectx</p>
<p class="text-xs text-muted-foreground mt-0.5">Version 0.1.0 — early development</p>
<p class="text-xs text-muted-foreground mt-0.5">Version 0.1.0 (early development)</p>
<p class="text-xs text-muted-foreground mt-2 leading-relaxed max-w-lg">
A self-hosted book and library management application with support for EPUB, PDF, and comic formats.
</p>
@@ -8,7 +8,7 @@ const { coverSize, gridGap } = useDisplaySettings()
</script>
<template>
<div class="px-5 py-6 sm:px-10 sm:py-8 max-w-3xl">
<div class="px-5 py-6 sm:px-10 sm:py-8 max-w-3xl mx-auto">
<div class="mb-8">
<h2 class="font-serif font-semibold text-foreground text-2xl tracking-tight">Appearance</h2>
<p class="mt-1 text-sm text-muted-foreground">Customize how the app looks and feels.</p>
@@ -16,7 +16,7 @@ const backgrounds = [
</script>
<template>
<div class="px-5 py-6 sm:px-10 sm:py-8 max-w-3xl">
<div class="px-5 py-6 sm:px-10 sm:py-8 max-w-3xl mx-auto">
<div class="mb-8">
<h2 class="font-serif font-semibold text-foreground text-2xl tracking-tight">Comics Reader</h2>
<p class="mt-1 text-sm text-muted-foreground">Default settings applied when opening CBZ, CBR, and CB7 files.</p>
@@ -17,7 +17,7 @@ const themes = [
</script>
<template>
<div class="px-5 py-6 sm:px-10 sm:py-8 max-w-3xl">
<div class="px-5 py-6 sm:px-10 sm:py-8 max-w-3xl mx-auto">
<div class="mb-8">
<h2 class="font-serif font-semibold text-foreground text-2xl tracking-tight">eBook Reader</h2>
<p class="mt-1 text-sm text-muted-foreground">Default settings applied when opening EPUB and MOBI files.</p>
@@ -1,6 +1,7 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { FolderOpen, Plus, RefreshCw } from 'lucide-vue-next'
import { api } from '@/lib/api'
interface Library {
id: number
@@ -13,14 +14,14 @@ const scanning = ref<Record<number, boolean>>({})
const scanningAll = ref(false)
onMounted(async () => {
const res = await fetch('/api/libraries')
const res = await api('/api/libraries')
if (res.ok) libraries.value = await res.json()
})
async function scan(lib: Library) {
scanning.value[lib.id] = true
try {
await fetch(`/api/scanner/libraries/${lib.id}/scan`, { method: 'POST' })
await api(`/api/scanner/libraries/${lib.id}/scan`, { method: 'POST' })
} finally {
scanning.value[lib.id] = false
}
@@ -29,7 +30,7 @@ async function scan(lib: Library) {
async function scanAll() {
scanningAll.value = true
try {
await Promise.all(libraries.value.map((lib) => fetch(`/api/scanner/libraries/${lib.id}/scan`, { method: 'POST' })))
await Promise.all(libraries.value.map((lib) => api(`/api/scanner/libraries/${lib.id}/scan`, { method: 'POST' })))
} finally {
scanningAll.value = false
}
@@ -37,7 +38,7 @@ async function scanAll() {
</script>
<template>
<div class="px-5 py-6 sm:px-10 sm:py-8 max-w-3xl">
<div class="px-5 py-6 sm:px-10 sm:py-8 max-w-3xl mx-auto">
<!-- Header -->
<div class="flex items-start justify-between mb-8">
<div>
+1 -1
View File
@@ -18,7 +18,7 @@ const zoomOptions = [
</script>
<template>
<div class="px-5 py-6 sm:px-10 sm:py-8 max-w-3xl">
<div class="px-5 py-6 sm:px-10 sm:py-8 max-w-3xl mx-auto">
<div class="mb-8">
<h2 class="font-serif font-semibold text-foreground text-2xl tracking-tight">PDF Reader</h2>
<p class="mt-1 text-sm text-muted-foreground">Default settings applied when opening PDF files.</p>
@@ -7,7 +7,7 @@ const preloadPages = ref(true)
</script>
<template>
<div class="max-w-2xl mx-auto px-10 py-10">
<div class="px-5 py-6 sm:px-10 sm:py-8">
<div class="mb-8">
<h2 class="font-serif font-semibold text-foreground text-2xl tracking-tight">Reading</h2>
<p class="mt-1 text-sm text-muted-foreground">Default behavior and preferences for the reader.</p>
@@ -18,7 +18,7 @@ const fileTypes = ref([
</script>
<template>
<div class="px-5 py-6 sm:px-10 sm:py-8 max-w-3xl">
<div class="px-5 py-6 sm:px-10 sm:py-8 max-w-3xl mx-auto">
<div class="mb-8">
<h2 class="font-serif font-semibold text-foreground text-2xl tracking-tight">Scanner</h2>
<p class="mt-1 text-sm text-muted-foreground">Configure when and how your libraries are scanned for new content.</p>
+64 -28
View File
@@ -1,7 +1,22 @@
<script setup lang="ts">
import { ref, shallowRef, watch } from 'vue'
import { X, ChevronLeft, ChevronRight, Library, ScanLine, Paintbrush, BookOpen, FileText, BookImage, Info } from 'lucide-vue-next'
import { ref, shallowRef, watch, computed } from 'vue'
import {
X,
ChevronLeft,
ChevronRight,
Library,
ScanLine,
Paintbrush,
BookOpen,
FileText,
BookImage,
Info,
Users,
ShieldCheck,
KeyRound,
} from 'lucide-vue-next'
import { useSettingsDrawer } from '@/composables/useSettingsDrawer'
import { usePermissions } from '@/features/auth/composables/usePermissions'
import LibrariesSettings from './LibrariesSettings.vue'
import ScannerSettings from './ScannerSettings.vue'
import AppearanceSettings from './AppearanceSettings.vue'
@@ -9,42 +24,63 @@ import EbookSettings from './EbookSettings.vue'
import PdfSettings from './PdfSettings.vue'
import ComicsSettings from './ComicsSettings.vue'
import AboutSettings from './AboutSettings.vue'
import UsersPage from '@/features/admin/UsersPage.vue'
import RolesPage from '@/features/admin/RolesPage.vue'
import PermissionsPage from '@/features/admin/PermissionsPage.vue'
const { isOpen, close } = useSettingsDrawer()
const { isSuperuser, hasPermission, userPermissions } = usePermissions()
type SectionId = 'libraries' | 'scanner' | 'appearance' | 'ebook' | 'pdf' | 'comics' | 'about'
type SectionId = 'libraries' | 'scanner' | 'appearance' | 'ebook' | 'pdf' | 'comics' | 'about' | 'users' | 'roles' | 'permissions'
const navGroups = [
{
label: 'General',
items: [
{ id: 'libraries' as SectionId, label: 'Libraries', icon: Library, component: LibrariesSettings },
{ id: 'scanner' as SectionId, label: 'Scanner', icon: ScanLine, component: ScannerSettings },
{ id: 'appearance' as SectionId, label: 'Appearance', icon: Paintbrush, component: AppearanceSettings },
],
},
{
label: 'Reader',
items: [
{ id: 'ebook' as SectionId, label: 'eBook', icon: BookOpen, component: EbookSettings },
{ id: 'pdf' as SectionId, label: 'PDF', icon: FileText, component: PdfSettings },
{ id: 'comics' as SectionId, label: 'Comics', icon: BookImage, component: ComicsSettings },
],
},
{
label: 'System',
items: [{ id: 'about' as SectionId, label: 'About', icon: Info, component: AboutSettings }],
},
]
const navGroups = computed(() => {
const groups = [
{
label: 'General',
items: [
{ id: 'libraries' as SectionId, label: 'Libraries', icon: Library, component: LibrariesSettings },
{ id: 'scanner' as SectionId, label: 'Scanner', icon: ScanLine, component: ScannerSettings },
{ id: 'appearance' as SectionId, label: 'Appearance', icon: Paintbrush, component: AppearanceSettings },
],
},
{
label: 'Reader',
items: [
{ id: 'ebook' as SectionId, label: 'eBook', icon: BookOpen, component: EbookSettings },
{ id: 'pdf' as SectionId, label: 'PDF', icon: FileText, component: PdfSettings },
{ id: 'comics' as SectionId, label: 'Comics', icon: BookImage, component: ComicsSettings },
],
},
{
label: 'System',
items: [{ id: 'about' as SectionId, label: 'About', icon: Info, component: AboutSettings }],
},
]
// Explicitly reference userPermissions.value so Vue tracks it as a dependency
const perms = userPermissions.value
const su = isSuperuser.value
const adminItems: { id: SectionId; label: string; icon: unknown; component: unknown }[] = []
if (su || perms.includes('manage_users')) {
adminItems.push({ id: 'users', label: 'Users', icon: Users, component: UsersPage })
}
if (su || perms.includes('manage_roles')) {
adminItems.push({ id: 'roles', label: 'Roles', icon: ShieldCheck, component: RolesPage })
adminItems.push({ id: 'permissions', label: 'Permissions', icon: KeyRound, component: PermissionsPage })
}
if (adminItems.length) {
groups.push({ label: 'Administration', items: adminItems })
}
return groups
})
const allItems = navGroups.flatMap((g) => g.items)
const allItems = computed(() => navGroups.value.flatMap((g) => g.items))
const activeId = ref<SectionId>('libraries')
const activeLabel = ref('Libraries')
const ActiveComponent = shallowRef(LibrariesSettings)
const mobileView = ref<'nav' | 'content'>('nav')
function navigate(id: SectionId) {
const item = allItems.find((i) => i.id === id)!
const item = allItems.value.find((i) => i.id === id)!
activeId.value = id
activeLabel.value = item.label
ActiveComponent.value = item.component
@@ -72,7 +108,7 @@ watch(isOpen, (v) => {
<Transition name="drawer-slide">
<div
v-if="isOpen"
class="relative flex h-full w-full md:max-w-[900px] shadow-2xl overflow-hidden bg-background"
class="relative flex h-full w-full md:max-w-[66.666vw] shadow-2xl overflow-hidden bg-background"
style="border-left: 1px solid var(--border)"
>
<!-- ── MOBILE: Nav list ─────────────────────────── -->
+56
View File
@@ -0,0 +1,56 @@
import type { RefreshResponse } from '@projectx/types'
let _accessToken: string | null = null
let _onAuthFailure: (() => void) | null = null
let _refreshPromise: Promise<void> | null = null
export function setAccessToken(token: string | null): void {
_accessToken = token
}
export function getAccessToken(): string | null {
return _accessToken
}
export function setOnAuthFailure(fn: () => void): void {
_onAuthFailure = fn
}
function rawFetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response> {
const headers = new Headers(init?.headers)
if (_accessToken) headers.set('Authorization', `Bearer ${_accessToken}`)
return fetch(input, { ...init, headers, credentials: 'include' })
}
async function attemptRefresh(): Promise<void> {
const res = await rawFetch('/api/auth/refresh', { method: 'POST' })
if (!res.ok) throw new Error('refresh failed')
const data: RefreshResponse = await res.json()
_accessToken = data.accessToken
}
export async function api(input: RequestInfo | URL, init?: RequestInit & { _isRetry?: boolean }): Promise<Response> {
const res = await rawFetch(input, init)
if (res.status !== 401) return res
if (init?._isRetry) {
_onAuthFailure?.()
throw new Error('Session expired')
}
if (!_refreshPromise) {
_refreshPromise = attemptRefresh().finally(() => {
_refreshPromise = null
})
}
try {
await _refreshPromise
} catch {
_onAuthFailure?.()
throw new Error('Session expired')
}
return rawFetch(input, { ...init, _isRetry: true })
}
+8 -1
View File
@@ -5,10 +5,17 @@ import { createPinia } from 'pinia'
import App from './App.vue'
import router from './router'
import { useAuth } from './features/auth/composables/useAuth'
const app = createApp(App)
app.use(createPinia())
app.use(router)
// 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.
const { init } = useAuth()
await init()
app.use(router)
app.mount('#app')
+23
View File
@@ -0,0 +1,23 @@
import type { Router } from 'vue-router'
import { useAuth } from '@/features/auth/composables/useAuth'
import { useChangePasswordDialog } from '@/composables/useChangePasswordDialog'
export function registerAuthGuard(router: Router): void {
router.beforeEach((to) => {
if (to.meta.public) return true
const { user } = useAuth()
if (!user.value) {
return { path: '/login', query: { redirect: to.fullPath } }
}
if (user.value.isDefaultPassword) {
useChangePasswordDialog().open(true)
// Allow navigation to '/' but block everything else
if (to.path !== '/') return { path: '/' }
}
return true
})
}
+21
View File
@@ -1,4 +1,5 @@
import { createRouter, createWebHistory } from 'vue-router'
import { registerAuthGuard } from './guards/auth.guard'
import HomeView from '@/views/HomeView.vue'
const router = createRouter({
@@ -14,7 +15,27 @@ const router = createRouter({
name: 'reader',
component: () => import('@/features/reader/ReaderView.vue'),
},
{
path: '/login',
name: 'login',
component: () => import('@/features/auth/LoginPage.vue'),
meta: { public: true },
},
{
path: '/forgot-password',
name: 'forgot-password',
component: () => import('@/features/auth/ForgotPasswordPage.vue'),
meta: { public: true },
},
{
path: '/reset-password',
name: 'reset-password',
component: () => import('@/features/auth/ResetPasswordPage.vue'),
meta: { public: true },
},
],
})
registerAuthGuard(router)
export default router
+5 -3
View File
@@ -1,5 +1,7 @@
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
import { api } from '@/lib/api'
import BookCoverImage from '@/features/book/components/BookCoverImage.vue'
import BookCoverCard from '@/features/book/components/BookCoverCard.vue'
import AppHeader from '@/components/AppHeader.vue'
import AppSidebar from '@/components/AppSidebar.vue'
@@ -15,7 +17,7 @@ const { coverSize, gridGap, viewMode } = useDisplaySettings()
const libraryId = ref<number | null>(null)
async function loadLibrary() {
const res = await fetch('/api/libraries')
const res = await api('/api/libraries')
if (!res.ok) return
const libs: { id: number }[] = await res.json()
if (libs.length > 0) libraryId.value = libs[0]!.id
@@ -82,9 +84,9 @@ watch(search, () => {
:key="book.id"
class="flex items-center gap-3 py-2.5 px-1 hover:bg-muted/50 rounded-md transition-colors cursor-pointer"
>
<img :src="`/api/books/${book.id}/cover`" class="h-12 w-9 object-cover rounded shrink-0 bg-muted" :alt="book.title ?? ''" />
<BookCoverImage :book-id="book.id" type="cover" class="h-12 w-9 object-cover rounded shrink-0 bg-muted" :alt="book.title ?? ''" />
<div class="flex flex-col min-w-0">
<span class="text-sm font-medium text-foreground truncate">{{ book.title ?? '—' }}</span>
<span class="text-sm font-medium text-foreground truncate">{{ book.title ?? '-' }}</span>
<span v-if="book.authors.length" class="text-xs text-muted-foreground truncate">{{ book.authors.join(', ') }}</span>
</div>
</div>