mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 08:21:39 -06:00
feat: add auth, RBAC, admin panel, and JWT-protected routes
This commit is contained in:
+9
-8
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 ─────────────────────────── -->
|
||||
|
||||
@@ -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
@@ -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')
|
||||
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user