feat: replace roles system with flat permissions and isSuperuser flag

This commit is contained in:
neon
2026-03-10 04:19:33 -06:00
parent dbed51fcd0
commit 652dcba69a
111 changed files with 2730 additions and 3329 deletions
@@ -1,133 +0,0 @@
<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'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
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/v1/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/v1/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/v1/permissions/${perm.id}`, { method: 'DELETE' })
if (res.ok) loadData()
}
</script>
<template>
<div class="mb-8">
<h2 class="settings-title">Permissions</h2>
<p class="settings-subtitle">Create granular permissions to assign to roles.</p>
</div>
<!-- Create form -->
<div class="mb-6 rounded-lg border border-border p-4">
<h2 class="settings-label 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">
<Tooltip>
<TooltipTrigger as-child>
<button
v-if="!perm.isSystem"
@click="deletePermission(perm)"
class="p-1.5 rounded text-muted-foreground hover:text-destructive hover:bg-muted transition-colors"
>
<Trash2 :size="14" />
</button>
</TooltipTrigger>
<TooltipContent>Delete</TooltipContent>
</Tooltip>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</template>
@@ -1,175 +0,0 @@
<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/v1/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/v1/roles/${props.role!.id}/permissions`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ permissionId }),
}),
),
...toRevoke.map((permId) => api(`/api/v1/roles/${props.role!.id}/permissions/${permId}`, { method: 'DELETE' })),
])
} else {
const res = await api('/api/v1/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="settings-label">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="settings-label">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="settings-label">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 settings-label 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>
-133
View File
@@ -1,133 +0,0 @@
<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'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
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/v1/roles'), api('/api/v1/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/v1/roles/${role.id}`, { method: 'DELETE' })
if (res.ok) loadData()
}
function onSaved() {
drawerOpen.value = false
loadData()
}
</script>
<template>
<div class="flex items-start justify-between mb-8">
<div>
<h2 class="settings-title">Roles</h2>
<p class="settings-subtitle">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">
<Tooltip>
<TooltipTrigger as-child>
<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"
>
<Pencil :size="14" />
</button>
</TooltipTrigger>
<TooltipContent>{{ role.isSuperuser ? 'Superuser roles cannot be edited' : 'Edit' }}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger as-child>
<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"
>
<Trash2 :size="14" />
</button>
</TooltipTrigger>
<TooltipContent>{{ role.isSystem ? 'System roles cannot be deleted' : 'Delete' }}</TooltipContent>
</Tooltip>
</div>
</td>
</tr>
</tbody>
</table>
</div>
<RoleFormDrawer v-if="drawerOpen" :role="editingRole" :permissions="permissions" @close="drawerOpen = false" @saved="onSaved" />
</template>
+97 -49
View File
@@ -1,12 +1,18 @@
<script setup lang="ts">
import { ref, watch } from 'vue'
import { ref, watch, computed } from 'vue'
import { X } from 'lucide-vue-next'
import { api } from '@/lib/api'
import type { AuthUser, Role } from '@projectx/types'
import { Permission, PERMISSION_LABELS } from '@projectx/types'
import type { AuthUser } from '@projectx/types'
interface Library {
id: number
name: string
}
const props = defineProps<{
user: Partial<AuthUser> | null
roles: Role[]
libraries: Library[]
}>()
const emit = defineEmits<{
@@ -14,14 +20,42 @@ const emit = defineEmits<{
saved: [resetUrl?: string]
}>()
const PERMISSION_GROUPS: { label: string; permissions: Permission[] }[] = [
{
label: 'Content',
permissions: [
Permission.LibraryView,
Permission.LibraryDownload,
Permission.LibraryUpload,
Permission.LibraryEditMetadata,
Permission.LibraryDeleteBooks,
],
},
{
label: 'Devices & Access',
permissions: [Permission.KoboSync, Permission.OpdsAccess, Permission.StagingAccess],
},
{
label: 'Email',
permissions: [Permission.EmailSend],
},
{
label: 'Administration',
permissions: [Permission.ManageLibraries, Permission.ManageMetadataConfig, Permission.ManageAppSettings, Permission.ManageUsers],
},
]
const name = ref('')
const username = ref('')
const email = ref('')
const active = ref(true)
const selectedRoleIds = ref<number[]>([])
const selectedPermissionNames = ref<Set<string>>(new Set())
const selectedLibraryIds = ref<Set<number>>(new Set())
const error = ref<string | null>(null)
const loading = ref(false)
const isEdit = computed(() => !!props.user?.id)
watch(
() => props.user,
(u) => {
@@ -29,18 +63,26 @@ watch(
username.value = u?.username ?? ''
email.value = u?.email ?? ''
active.value = u?.active ?? true
selectedRoleIds.value = u?.roles?.map((r) => r.id) ?? []
selectedPermissionNames.value = new Set(u?.permissions?.filter((p) => p !== '*') ?? [])
selectedLibraryIds.value = new Set()
error.value = null
},
{ immediate: true },
)
function toggleRole(roleId: number) {
const idx = selectedRoleIds.value.indexOf(roleId)
if (idx === -1) {
selectedRoleIds.value.push(roleId)
function togglePermission(permName: string) {
if (selectedPermissionNames.value.has(permName)) {
selectedPermissionNames.value.delete(permName)
} else {
selectedRoleIds.value.splice(idx, 1)
selectedPermissionNames.value.add(permName)
}
}
function toggleLibrary(libraryId: number) {
if (selectedLibraryIds.value.has(libraryId)) {
selectedLibraryIds.value.delete(libraryId)
} else {
selectedLibraryIds.value.add(libraryId)
}
}
@@ -48,9 +90,7 @@ async function handleSubmit() {
error.value = null
loading.value = true
try {
const isEdit = !!props.user?.id
if (isEdit) {
if (isEdit.value) {
const res = await api(`/api/v1/users/${props.user!.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
@@ -62,21 +102,16 @@ async function handleSubmit() {
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/v1/users/${props.user!.id}/roles`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ roleId }),
}),
),
...toRevoke.map((roleId) => api(`/api/v1/users/${props.user!.id}/roles/${roleId}`, { method: 'DELETE' })),
])
const permRes = await api(`/api/v1/users/${props.user!.id}/permissions`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ permissionNames: [...selectedPermissionNames.value] }),
})
if (!permRes.ok) {
const err = await permRes.json().catch(() => ({}))
error.value = err.message ?? 'Failed to update permissions'
return
}
} else {
const res = await api('/api/v1/users', {
method: 'POST',
@@ -85,7 +120,8 @@ async function handleSubmit() {
name: name.value,
username: username.value,
email: email.value || undefined,
roleIds: selectedRoleIds.value,
permissionNames: [...selectedPermissionNames.value],
libraryIds: [...selectedLibraryIds.value],
}),
})
if (!res.ok) {
@@ -110,14 +146,15 @@ async function handleSubmit() {
<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>
<h2 class="text-base font-semibold text-foreground">{{ isEdit ? '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">
<form @submit.prevent="handleSubmit" class="flex-1 overflow-y-auto space-y-5 px-6 py-6">
<!-- Basic info -->
<div v-if="!isEdit" class="space-y-1.5">
<label class="settings-label">Username</label>
<input
v-model="username"
@@ -146,35 +183,46 @@ async function handleSubmit() {
/>
</div>
<div v-if="user?.id" class="flex items-center gap-3">
<div v-if="isEdit" 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="settings-label">Active</label>
</div>
<div class="space-y-2">
<p class="settings-label">Roles</p>
<!-- Library access (create only) -->
<div v-if="!isEdit && libraries.length > 0" class="space-y-2">
<p class="settings-label">Library access</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 v-for="lib in libraries" :key="lib.id" class="flex cursor-pointer items-center gap-2">
<input type="checkbox" :checked="selectedLibraryIds.has(lib.id)" @change="toggleLibrary(lib.id)" class="h-4 w-4 rounded border-input" />
<span class="text-sm text-foreground">{{ lib.name }}</span>
</label>
</div>
</div>
<!-- Permissions grouped -->
<div class="space-y-4">
<p class="settings-label">Permissions</p>
<div v-for="group in PERMISSION_GROUPS" :key="group.label" class="space-y-1.5">
<p class="text-xs font-medium uppercase tracking-wide text-muted-foreground">{{ group.label }}</p>
<div class="space-y-1">
<label v-for="permName in group.permissions" :key="permName" class="flex cursor-pointer items-center gap-2">
<input
type="checkbox"
:checked="selectedPermissionNames.has(permName)"
@change="togglePermission(permName)"
class="h-4 w-4 rounded border-input"
/>
<span class="text-sm text-foreground">{{ PERMISSION_LABELS[permName] ?? permName }}</span>
</label>
</div>
</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 settings-label hover:bg-muted transition-colors"
>
<button @click="emit('close')" class="rounded-md border border-border px-4 py-2 settings-label hover:bg-muted transition-colors">
Cancel
</button>
<button
@@ -182,7 +230,7 @@ async function 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' }}
{{ loading ? 'Saving...' : 'Save' }}
</button>
</div>
</div>
+106 -98
View File
@@ -1,13 +1,18 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { UserPlus, Pencil, KeyRound, Trash2 } from 'lucide-vue-next'
import { UserPlus, Pencil, KeyRound, Trash2, ShieldCheck } from 'lucide-vue-next'
import { api } from '@/lib/api'
import type { AuthUser, Role } from '@projectx/types'
import type { AuthUser } from '@projectx/types'
import { usePermissions } from '@/features/auth/composables/usePermissions'
import UserFormDrawer from './UserFormDrawer.vue'
import ResetLinkModal from './ResetLinkModal.vue'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
interface Library {
id: number
name: string
}
interface UserRow extends AuthUser {
id: number
}
@@ -15,7 +20,7 @@ interface UserRow extends AuthUser {
const { isSuperuser } = usePermissions()
const users = ref<UserRow[]>([])
const roles = ref<Role[]>([])
const libraries = ref<Library[]>([])
const total = ref(0)
const page = ref(0)
const loading = ref(false)
@@ -29,12 +34,13 @@ async function loadData() {
loading.value = true
error.value = null
try {
const [usersRes, rolesRes] = await Promise.all([api(`/api/v1/users?page=${page.value}&pageSize=50`), api('/api/v1/roles')])
if (!usersRes.ok || !rolesRes.ok) throw new Error('Failed to load data')
const [usersRes, libsRes] = await Promise.all([api(`/api/v1/users?page=${page.value}&pageSize=50`), api('/api/v1/libraries')])
if (!usersRes.ok || !libsRes.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()
const libData = await libsRes.json()
libraries.value = libData.libraries ?? libData.items ?? libData
} catch (e) {
error.value = e instanceof Error ? e.message : 'Failed to load'
} finally {
@@ -80,105 +86,107 @@ function onSaved(newResetUrl?: string) {
</script>
<template>
<div class="flex items-start justify-between mb-8">
<div>
<h2 class="settings-title">Users</h2>
<p class="settings-subtitle">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 class="flex items-start justify-between mb-8">
<div>
<h2 class="settings-title">Users</h2>
<p class="settings-subtitle">Manage user accounts and permission 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-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">
<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">Access</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 items-center gap-1.5 flex-wrap">
<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'"
v-if="user.isSuperuser"
class="inline-flex items-center gap-1 rounded-full bg-primary/15 px-2 py-0.5 text-xs font-medium text-primary"
>
{{ user.active ? 'Active' : 'Inactive' }}
<ShieldCheck :size="11" />
Admin
</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)">
<Tooltip>
<TooltipTrigger as-child>
<button
@click="openEdit(user)"
class="p-1.5 rounded text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
>
<Pencil :size="14" />
</button>
</TooltipTrigger>
<TooltipContent>Edit</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger as-child>
<button
@click="handleResetPassword(user.id)"
class="p-1.5 rounded text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
>
<KeyRound :size="14" />
</button>
</TooltipTrigger>
<TooltipContent>Reset password</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger as-child>
<button
@click="deleteUser(user)"
class="p-1.5 rounded text-muted-foreground hover:text-destructive hover:bg-destructive/10 transition-colors"
>
<Trash2 :size="14" />
</button>
</TooltipTrigger>
<TooltipContent>Delete user</TooltipContent>
</Tooltip>
</template>
</div>
</td>
</tr>
</tbody>
</table>
</div>
<span v-else class="inline-flex items-center rounded-full bg-muted px-2 py-0.5 text-xs text-muted-foreground">
{{ user.permissions?.length ?? 0 }} permissions
</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.isSuperuser">
<Tooltip>
<TooltipTrigger as-child>
<button
@click="openEdit(user)"
class="p-1.5 rounded text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
>
<Pencil :size="14" />
</button>
</TooltipTrigger>
<TooltipContent>Edit</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger as-child>
<button
@click="handleResetPassword(user.id)"
class="p-1.5 rounded text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
>
<KeyRound :size="14" />
</button>
</TooltipTrigger>
<TooltipContent>Reset password</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger as-child>
<button
@click="deleteUser(user)"
class="p-1.5 rounded text-muted-foreground hover:text-destructive hover:bg-destructive/10 transition-colors"
>
<Trash2 :size="14" />
</button>
</TooltipTrigger>
<TooltipContent>Delete</TooltipContent>
</Tooltip>
</template>
</div>
</td>
</tr>
</tbody>
</table>
</div>
<UserFormDrawer v-if="drawerOpen" :user="editingUser" :roles="roles" @close="drawerOpen = false" @saved="onSaved" />
<UserFormDrawer v-if="drawerOpen" :user="editingUser" :libraries="libraries" @close="drawerOpen = false" @saved="onSaved" />
<ResetLinkModal v-if="resetUrl" :reset-url="resetUrl" @close="resetUrl = null" />
</template>
+237 -6
View File
@@ -1,6 +1,49 @@
<script setup lang="ts">
import { ref } from 'vue'
import { computed, ref } from 'vue'
import { Moon, Sun, Wallpaper } from 'lucide-vue-next'
import { ACCENT_VIVID, ACCENT_PASTEL, ACCENT_OPTIONS, RADIUS_OPTIONS, BACKGROUND_OPTIONS, useThemeStore } from '@/stores/theme'
import { api } from '@/lib/api'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
const themeStore = useThemeStore()
const accentOpen = ref(false)
const radiusOpen = ref(false)
const backgroundOpen = ref(false)
const currentAccent = computed(() => ACCENT_OPTIONS.find((o) => o.id === themeStore.accent))
function radiusPreview(id: string): string {
const map: Record<string, string> = {
sharp: '0px',
default: '4px',
rounded: '8px',
pill: '999px',
}
return map[id] ?? '4px'
}
function openRadius() {
radiusOpen.value = !radiusOpen.value
accentOpen.value = false
backgroundOpen.value = false
}
function openBackground() {
backgroundOpen.value = !backgroundOpen.value
accentOpen.value = false
radiusOpen.value = false
}
function openAccent() {
accentOpen.value = !accentOpen.value
radiusOpen.value = false
backgroundOpen.value = false
}
function closeAll() {
accentOpen.value = false
radiusOpen.value = false
backgroundOpen.value = false
}
const email = ref('')
const submitted = ref(false)
@@ -30,14 +73,147 @@ async function handleSubmit() {
</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="login-bg min-h-screen flex items-center justify-center px-4 overflow-hidden">
<!-- Compact theme picker -->
<div class="fixed bottom-5 right-5 z-20 flex items-center gap-1.5">
<!-- Dark / light toggle -->
<Tooltip>
<TooltipTrigger as-child>
<button class="theme-btn" @click="themeStore.toggleTheme()">
<Sun v-if="themeStore.theme === 'dark'" :size="14" />
<Moon v-else :size="14" />
</button>
</TooltipTrigger>
<TooltipContent>{{ themeStore.theme === 'dark' ? 'Switch to light' : 'Switch to dark' }}</TooltipContent>
</Tooltip>
<!-- Radius picker -->
<div class="relative">
<Transition name="popover">
<div v-if="radiusOpen" class="accent-popover absolute bottom-full right-0 mb-2 p-2.5 rounded-xl">
<div class="flex items-center gap-1.5">
<Tooltip v-for="opt in RADIUS_OPTIONS" :key="opt.id">
<TooltipTrigger as-child>
<button
class="flex items-center justify-center w-8 h-8 rounded-lg transition-all focus:outline-none"
:class="
themeStore.radius === opt.id
? 'text-primary bg-primary/10'
: 'text-muted-foreground hover:text-foreground hover:bg-foreground/5'
"
@click="themeStore.setRadius(opt.id)"
>
<span class="w-4 h-4 border-2 border-current block" :style="{ borderRadius: radiusPreview(opt.id) }" />
</button>
</TooltipTrigger>
<TooltipContent>{{ opt.label }}</TooltipContent>
</Tooltip>
</div>
</div>
</Transition>
<Tooltip>
<TooltipTrigger as-child>
<button class="theme-btn" @click="openRadius()">
<span class="w-3.5 h-3.5 border-2 border-current block" :style="{ borderRadius: radiusPreview(themeStore.radius) }" />
</button>
</TooltipTrigger>
<TooltipContent>Change corner radius</TooltipContent>
</Tooltip>
</div>
<!-- Background picker -->
<div class="relative">
<Transition name="popover">
<div v-if="backgroundOpen" class="accent-popover absolute bottom-full right-0 mb-2 p-2.5 rounded-xl w-64 max-h-72 overflow-y-auto">
<div class="grid grid-cols-5 gap-1.5">
<Tooltip v-for="opt in BACKGROUND_OPTIONS" :key="opt.id">
<TooltipTrigger as-child>
<button
class="w-full h-9 rounded overflow-hidden ring-2 transition-all focus:outline-none shrink-0"
:class="
themeStore.background === opt.id ? 'ring-primary shadow-sm shadow-primary/20' : 'ring-border hover:ring-muted-foreground/40'
"
@click="themeStore.setBackground(opt.id)"
>
<div class="w-full h-full bg-background pattern-preview" :class="opt.cssClass" />
</button>
</TooltipTrigger>
<TooltipContent>{{ opt.label }}</TooltipContent>
</Tooltip>
</div>
</div>
</Transition>
<Tooltip>
<TooltipTrigger as-child>
<button class="theme-btn" @click="openBackground()">
<Wallpaper :size="14" />
</button>
</TooltipTrigger>
<TooltipContent>Change background</TooltipContent>
</Tooltip>
</div>
<!-- Accent picker -->
<div class="relative">
<Transition name="popover">
<div v-if="accentOpen" class="accent-popover absolute bottom-full right-0 mb-2 p-3 rounded-xl space-y-2">
<div class="flex items-center gap-1.5">
<Tooltip v-for="opt in ACCENT_VIVID" :key="opt.id">
<TooltipTrigger as-child>
<button
class="w-4 h-4 rounded-full transition-all hover:scale-125 focus:outline-none shrink-0"
:style="{
backgroundColor: opt.color,
outline: themeStore.accent === opt.id ? `2px solid ${opt.color}` : 'none',
outlineOffset: '2px',
transform: themeStore.accent === opt.id ? 'scale(1.2)' : '',
}"
@click="themeStore.setAccent(opt.id)"
/>
</TooltipTrigger>
<TooltipContent>{{ opt.label }}</TooltipContent>
</Tooltip>
</div>
<div class="flex items-center gap-1.5">
<Tooltip v-for="opt in ACCENT_PASTEL" :key="opt.id">
<TooltipTrigger as-child>
<button
class="w-4 h-4 rounded-full transition-all hover:scale-125 focus:outline-none shrink-0"
:style="{
backgroundColor: opt.color,
outline: themeStore.accent === opt.id ? `2px solid ${opt.color}` : 'none',
outlineOffset: '2px',
transform: themeStore.accent === opt.id ? 'scale(1.2)' : '',
}"
@click="themeStore.setAccent(opt.id)"
/>
</TooltipTrigger>
<TooltipContent>{{ opt.label }}</TooltipContent>
</Tooltip>
</div>
</div>
</Transition>
<Tooltip>
<TooltipTrigger as-child>
<button class="theme-btn" @click="openAccent()">
<span class="w-3.5 h-3.5 rounded-full block" :style="{ backgroundColor: currentAccent?.color }" />
</button>
</TooltipTrigger>
<TooltipContent>Change accent color</TooltipContent>
</Tooltip>
</div>
</div>
<!-- Click-outside backdrop -->
<div v-if="accentOpen || radiusOpen || backgroundOpen" class="fixed inset-0 z-10" @click="closeAll()" />
<div class="login-card relative z-10 w-full max-w-sm rounded-2xl p-8">
<div class="text-center mb-8">
<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">
<div v-if="submitted" class="rounded-md border border-border bg-card/60 backdrop-blur-sm p-4 text-sm text-foreground">
If an account with that email exists, a reset link has been sent. Check your inbox.
</div>
@@ -50,7 +226,7 @@ async function handleSubmit() {
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"
class="w-full rounded-md border border-input bg-background/60 px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary/50 backdrop-blur-sm"
/>
</div>
@@ -61,7 +237,7 @@ async function handleSubmit() {
: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' }}
{{ loading ? 'Sending...' : 'Send reset link' }}
</button>
</form>
@@ -71,3 +247,58 @@ async function handleSubmit() {
</div>
</div>
</template>
<style scoped>
.login-bg {
position: relative;
}
.theme-btn {
display: flex;
align-items: center;
justify-content: center;
width: 2rem;
height: 2rem;
border-radius: 9999px;
background: color-mix(in oklch, var(--card) 72%, transparent);
border: 1px solid color-mix(in oklch, var(--border) 55%, transparent);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
box-shadow: var(--elevation-md);
color: var(--muted-foreground);
transition: color 0.15s ease;
}
.theme-btn:hover {
color: var(--foreground);
}
.accent-popover {
background: color-mix(in oklch, var(--card) 80%, transparent);
border: 1px solid color-mix(in oklch, var(--border) 55%, transparent);
backdrop-filter: blur(24px);
-webkit-backdrop-filter: blur(24px);
box-shadow: var(--elevation-lg);
}
.popover-enter-active,
.popover-leave-active {
transition:
opacity 0.15s ease,
transform 0.15s ease;
}
.popover-enter-from,
.popover-leave-to {
opacity: 0;
transform: translateY(4px) scale(0.97);
}
.login-card {
background: color-mix(in oklch, var(--card) 72%, transparent);
border: 1px solid color-mix(in oklch, var(--border) 55%, transparent);
backdrop-filter: blur(24px);
-webkit-backdrop-filter: blur(24px);
box-shadow: var(--elevation-xl);
}
</style>
+106 -73
View File
@@ -1,8 +1,8 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import type { OidcPublicConfig } from '@projectx/types'
import { Moon, Sun } from 'lucide-vue-next'
import { ACCENT_VIVID, ACCENT_PASTEL, ACCENT_OPTIONS, useThemeStore } from '@/stores/theme'
import { Moon, Sun, Wallpaper } from 'lucide-vue-next'
import { ACCENT_VIVID, ACCENT_PASTEL, ACCENT_OPTIONS, RADIUS_OPTIONS, BACKGROUND_OPTIONS, useThemeStore } from '@/stores/theme'
import { useAuth } from './composables/useAuth'
import { useOidc } from './composables/useOidc'
import { useSetupStatus } from './composables/useSetupStatus'
@@ -10,8 +10,44 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip
const themeStore = useThemeStore()
const accentOpen = ref(false)
const radiusOpen = ref(false)
const backgroundOpen = ref(false)
const currentAccent = computed(() => ACCENT_OPTIONS.find((o) => o.id === themeStore.accent))
function radiusPreview(id: string): string {
const map: Record<string, string> = {
sharp: '0px',
default: '4px',
rounded: '8px',
pill: '999px',
}
return map[id] ?? '4px'
}
function openRadius() {
radiusOpen.value = !radiusOpen.value
accentOpen.value = false
backgroundOpen.value = false
}
function openBackground() {
backgroundOpen.value = !backgroundOpen.value
accentOpen.value = false
radiusOpen.value = false
}
function openAccent() {
accentOpen.value = !accentOpen.value
radiusOpen.value = false
backgroundOpen.value = false
}
function closeAll() {
accentOpen.value = false
radiusOpen.value = false
backgroundOpen.value = false
}
const { login } = useAuth()
const { getPublicConfig, initiateLogin } = useOidc()
const { setupStatusError } = useSetupStatus()
@@ -53,10 +89,6 @@ async function handleOidcLogin() {
<template>
<div class="login-bg min-h-screen flex items-center justify-center px-4 overflow-hidden">
<div class="blob blob-1" />
<div class="blob blob-2" />
<div class="blob blob-3" />
<!-- Compact theme picker -->
<div class="fixed bottom-5 right-5 z-20 flex items-center gap-1.5">
<!-- Dark / light toggle -->
@@ -70,6 +102,72 @@ async function handleOidcLogin() {
<TooltipContent>{{ themeStore.theme === 'dark' ? 'Switch to light' : 'Switch to dark' }}</TooltipContent>
</Tooltip>
<!-- Radius picker -->
<div class="relative">
<Transition name="popover">
<div v-if="radiusOpen" class="accent-popover absolute bottom-full right-0 mb-2 p-2.5 rounded-xl">
<div class="flex items-center gap-1.5">
<Tooltip v-for="opt in RADIUS_OPTIONS" :key="opt.id">
<TooltipTrigger as-child>
<button
class="flex items-center justify-center w-8 h-8 rounded-lg transition-all focus:outline-none"
:class="
themeStore.radius === opt.id
? 'text-primary bg-primary/10'
: 'text-muted-foreground hover:text-foreground hover:bg-foreground/5'
"
@click="themeStore.setRadius(opt.id)"
>
<span class="w-4 h-4 border-2 border-current block" :style="{ borderRadius: radiusPreview(opt.id) }" />
</button>
</TooltipTrigger>
<TooltipContent>{{ opt.label }}</TooltipContent>
</Tooltip>
</div>
</div>
</Transition>
<Tooltip>
<TooltipTrigger as-child>
<button class="theme-btn" @click="openRadius()">
<span class="w-3.5 h-3.5 border-2 border-current block" :style="{ borderRadius: radiusPreview(themeStore.radius) }" />
</button>
</TooltipTrigger>
<TooltipContent>Change corner radius</TooltipContent>
</Tooltip>
</div>
<!-- Background picker -->
<div class="relative">
<Transition name="popover">
<div v-if="backgroundOpen" class="accent-popover absolute bottom-full right-0 mb-2 p-2.5 rounded-xl w-64 max-h-72 overflow-y-auto">
<div class="grid grid-cols-5 gap-1.5">
<Tooltip v-for="opt in BACKGROUND_OPTIONS" :key="opt.id">
<TooltipTrigger as-child>
<button
class="w-full h-9 rounded overflow-hidden ring-2 transition-all focus:outline-none shrink-0"
:class="
themeStore.background === opt.id ? 'ring-primary shadow-sm shadow-primary/20' : 'ring-border hover:ring-muted-foreground/40'
"
@click="themeStore.setBackground(opt.id)"
>
<div class="w-full h-full bg-background pattern-preview" :class="opt.cssClass" />
</button>
</TooltipTrigger>
<TooltipContent>{{ opt.label }}</TooltipContent>
</Tooltip>
</div>
</div>
</Transition>
<Tooltip>
<TooltipTrigger as-child>
<button class="theme-btn" @click="openBackground()">
<Wallpaper :size="14" />
</button>
</TooltipTrigger>
<TooltipContent>Change background</TooltipContent>
</Tooltip>
</div>
<!-- Accent picker -->
<div class="relative">
<!-- Colour popover -->
@@ -115,7 +213,7 @@ async function handleOidcLogin() {
<!-- Swatch button showing current accent -->
<Tooltip>
<TooltipTrigger as-child>
<button class="theme-btn" @click="accentOpen = !accentOpen">
<button class="theme-btn" @click="openAccent()">
<span class="w-3.5 h-3.5 rounded-full block" :style="{ backgroundColor: currentAccent?.color }" />
</button>
</TooltipTrigger>
@@ -125,7 +223,7 @@ async function handleOidcLogin() {
</div>
<!-- Click-outside backdrop -->
<div v-if="accentOpen" class="fixed inset-0 z-10" @click="accentOpen = false" />
<div v-if="accentOpen || radiusOpen || backgroundOpen" class="fixed inset-0 z-10" @click="closeAll()" />
<div class="login-card relative z-10 w-full max-w-sm rounded-2xl p-8">
<div class="text-center mb-8">
@@ -196,74 +294,9 @@ async function handleOidcLogin() {
<style scoped>
.login-bg {
background: var(--background);
position: relative;
}
.blob {
position: absolute;
border-radius: 50%;
filter: blur(90px);
pointer-events: none;
}
.blob-1 {
width: 640px;
height: 520px;
background: color-mix(in oklch, var(--primary) 28%, transparent);
top: -12%;
left: -18%;
animation: drift-1 20s ease-in-out infinite alternate;
}
.blob-2 {
width: 520px;
height: 560px;
background: color-mix(in oklch, var(--primary) 18%, transparent);
bottom: -18%;
right: -12%;
animation: drift-2 26s ease-in-out infinite alternate;
}
.blob-3 {
width: 420px;
height: 420px;
background: color-mix(in oklch, var(--primary) 14%, transparent);
top: 35%;
left: 38%;
animation: drift-3 18s ease-in-out infinite alternate;
}
@keyframes drift-1 {
from {
transform: translate(0, 0) scale(1);
}
to {
transform: translate(70px, 55px) scale(1.12);
}
}
@keyframes drift-2 {
from {
transform: translate(0, 0) scale(1);
}
to {
transform: translate(-55px, -70px) scale(1.08);
}
}
@keyframes drift-3 {
0% {
transform: translate(0, 0) scale(1);
}
50% {
transform: translate(-35px, 28px) scale(0.92);
}
100% {
transform: translate(35px, -28px) scale(1.1);
}
}
.theme-btn {
display: flex;
align-items: center;
+237 -6
View File
@@ -1,6 +1,49 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { computed, onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { Moon, Sun, Wallpaper } from 'lucide-vue-next'
import { ACCENT_VIVID, ACCENT_PASTEL, ACCENT_OPTIONS, RADIUS_OPTIONS, BACKGROUND_OPTIONS, useThemeStore } from '@/stores/theme'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
const themeStore = useThemeStore()
const accentOpen = ref(false)
const radiusOpen = ref(false)
const backgroundOpen = ref(false)
const currentAccent = computed(() => ACCENT_OPTIONS.find((o) => o.id === themeStore.accent))
function radiusPreview(id: string): string {
const map: Record<string, string> = {
sharp: '0px',
default: '4px',
rounded: '8px',
pill: '999px',
}
return map[id] ?? '4px'
}
function openRadius() {
radiusOpen.value = !radiusOpen.value
accentOpen.value = false
backgroundOpen.value = false
}
function openBackground() {
backgroundOpen.value = !backgroundOpen.value
accentOpen.value = false
radiusOpen.value = false
}
function openAccent() {
accentOpen.value = !accentOpen.value
radiusOpen.value = false
backgroundOpen.value = false
}
function closeAll() {
accentOpen.value = false
radiusOpen.value = false
backgroundOpen.value = false
}
const route = useRoute()
const router = useRouter()
@@ -50,8 +93,141 @@ async function handleSubmit() {
</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="login-bg min-h-screen flex items-center justify-center px-4 overflow-hidden">
<!-- Compact theme picker -->
<div class="fixed bottom-5 right-5 z-20 flex items-center gap-1.5">
<!-- Dark / light toggle -->
<Tooltip>
<TooltipTrigger as-child>
<button class="theme-btn" @click="themeStore.toggleTheme()">
<Sun v-if="themeStore.theme === 'dark'" :size="14" />
<Moon v-else :size="14" />
</button>
</TooltipTrigger>
<TooltipContent>{{ themeStore.theme === 'dark' ? 'Switch to light' : 'Switch to dark' }}</TooltipContent>
</Tooltip>
<!-- Radius picker -->
<div class="relative">
<Transition name="popover">
<div v-if="radiusOpen" class="accent-popover absolute bottom-full right-0 mb-2 p-2.5 rounded-xl">
<div class="flex items-center gap-1.5">
<Tooltip v-for="opt in RADIUS_OPTIONS" :key="opt.id">
<TooltipTrigger as-child>
<button
class="flex items-center justify-center w-8 h-8 rounded-lg transition-all focus:outline-none"
:class="
themeStore.radius === opt.id
? 'text-primary bg-primary/10'
: 'text-muted-foreground hover:text-foreground hover:bg-foreground/5'
"
@click="themeStore.setRadius(opt.id)"
>
<span class="w-4 h-4 border-2 border-current block" :style="{ borderRadius: radiusPreview(opt.id) }" />
</button>
</TooltipTrigger>
<TooltipContent>{{ opt.label }}</TooltipContent>
</Tooltip>
</div>
</div>
</Transition>
<Tooltip>
<TooltipTrigger as-child>
<button class="theme-btn" @click="openRadius()">
<span class="w-3.5 h-3.5 border-2 border-current block" :style="{ borderRadius: radiusPreview(themeStore.radius) }" />
</button>
</TooltipTrigger>
<TooltipContent>Change corner radius</TooltipContent>
</Tooltip>
</div>
<!-- Background picker -->
<div class="relative">
<Transition name="popover">
<div v-if="backgroundOpen" class="accent-popover absolute bottom-full right-0 mb-2 p-2.5 rounded-xl w-64 max-h-72 overflow-y-auto">
<div class="grid grid-cols-5 gap-1.5">
<Tooltip v-for="opt in BACKGROUND_OPTIONS" :key="opt.id">
<TooltipTrigger as-child>
<button
class="w-full h-9 rounded overflow-hidden ring-2 transition-all focus:outline-none shrink-0"
:class="
themeStore.background === opt.id ? 'ring-primary shadow-sm shadow-primary/20' : 'ring-border hover:ring-muted-foreground/40'
"
@click="themeStore.setBackground(opt.id)"
>
<div class="w-full h-full bg-background pattern-preview" :class="opt.cssClass" />
</button>
</TooltipTrigger>
<TooltipContent>{{ opt.label }}</TooltipContent>
</Tooltip>
</div>
</div>
</Transition>
<Tooltip>
<TooltipTrigger as-child>
<button class="theme-btn" @click="openBackground()">
<Wallpaper :size="14" />
</button>
</TooltipTrigger>
<TooltipContent>Change background</TooltipContent>
</Tooltip>
</div>
<!-- Accent picker -->
<div class="relative">
<Transition name="popover">
<div v-if="accentOpen" class="accent-popover absolute bottom-full right-0 mb-2 p-3 rounded-xl space-y-2">
<div class="flex items-center gap-1.5">
<Tooltip v-for="opt in ACCENT_VIVID" :key="opt.id">
<TooltipTrigger as-child>
<button
class="w-4 h-4 rounded-full transition-all hover:scale-125 focus:outline-none shrink-0"
:style="{
backgroundColor: opt.color,
outline: themeStore.accent === opt.id ? `2px solid ${opt.color}` : 'none',
outlineOffset: '2px',
transform: themeStore.accent === opt.id ? 'scale(1.2)' : '',
}"
@click="themeStore.setAccent(opt.id)"
/>
</TooltipTrigger>
<TooltipContent>{{ opt.label }}</TooltipContent>
</Tooltip>
</div>
<div class="flex items-center gap-1.5">
<Tooltip v-for="opt in ACCENT_PASTEL" :key="opt.id">
<TooltipTrigger as-child>
<button
class="w-4 h-4 rounded-full transition-all hover:scale-125 focus:outline-none shrink-0"
:style="{
backgroundColor: opt.color,
outline: themeStore.accent === opt.id ? `2px solid ${opt.color}` : 'none',
outlineOffset: '2px',
transform: themeStore.accent === opt.id ? 'scale(1.2)' : '',
}"
@click="themeStore.setAccent(opt.id)"
/>
</TooltipTrigger>
<TooltipContent>{{ opt.label }}</TooltipContent>
</Tooltip>
</div>
</div>
</Transition>
<Tooltip>
<TooltipTrigger as-child>
<button class="theme-btn" @click="openAccent()">
<span class="w-3.5 h-3.5 rounded-full block" :style="{ backgroundColor: currentAccent?.color }" />
</button>
</TooltipTrigger>
<TooltipContent>Change accent color</TooltipContent>
</Tooltip>
</div>
</div>
<!-- Click-outside backdrop -->
<div v-if="accentOpen || radiusOpen || backgroundOpen" class="fixed inset-0 z-10" @click="closeAll()" />
<div class="login-card relative z-10 w-full max-w-sm rounded-2xl p-8">
<div class="text-center mb-8">
<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>
@@ -66,7 +242,7 @@ async function handleSubmit() {
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"
class="w-full rounded-md border border-input bg-background/60 px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary/50 backdrop-blur-sm"
/>
<p class="text-xs text-muted-foreground">Min. 8 characters with uppercase, lowercase, and a digit</p>
</div>
@@ -79,7 +255,7 @@ async function handleSubmit() {
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"
class="w-full rounded-md border border-input bg-background/60 px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary/50 backdrop-blur-sm"
/>
</div>
@@ -90,7 +266,7 @@ async function handleSubmit() {
: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' }}
{{ loading ? 'Saving...' : 'Set new password' }}
</button>
</form>
@@ -100,3 +276,58 @@ async function handleSubmit() {
</div>
</div>
</template>
<style scoped>
.login-bg {
position: relative;
}
.theme-btn {
display: flex;
align-items: center;
justify-content: center;
width: 2rem;
height: 2rem;
border-radius: 9999px;
background: color-mix(in oklch, var(--card) 72%, transparent);
border: 1px solid color-mix(in oklch, var(--border) 55%, transparent);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
box-shadow: var(--elevation-md);
color: var(--muted-foreground);
transition: color 0.15s ease;
}
.theme-btn:hover {
color: var(--foreground);
}
.accent-popover {
background: color-mix(in oklch, var(--card) 80%, transparent);
border: 1px solid color-mix(in oklch, var(--border) 55%, transparent);
backdrop-filter: blur(24px);
-webkit-backdrop-filter: blur(24px);
box-shadow: var(--elevation-lg);
}
.popover-enter-active,
.popover-leave-active {
transition:
opacity 0.15s ease,
transform 0.15s ease;
}
.popover-enter-from,
.popover-leave-to {
opacity: 0;
transform: translateY(4px) scale(0.97);
}
.login-card {
background: color-mix(in oklch, var(--card) 72%, transparent);
border: 1px solid color-mix(in oklch, var(--border) 55%, transparent);
backdrop-filter: blur(24px);
-webkit-backdrop-filter: blur(24px);
box-shadow: var(--elevation-xl);
}
</style>
@@ -4,10 +4,7 @@ import { useAuth } from './useAuth'
export function usePermissions() {
const { user } = useAuth()
const isSuperuser = computed(() => {
if (!user.value) return false
return user.value.permissions.includes('*') || user.value.roles.some((role) => role.isSuperuser)
})
const isSuperuser = computed(() => user.value?.isSuperuser ?? false)
const userPermissions = computed(() => user.value?.permissions ?? [])
function hasPermission(name: string): boolean {
+197 -197
View File
@@ -10,7 +10,7 @@ interface OidcConfig {
clientSecret: string
scopes: string
claimMapping: { username: string; name: string; email: string; groups: string }
autoProvision: { enabled: boolean; allowLocalLinking: boolean; defaultRoleId: number | null }
autoProvision: { enabled: boolean; allowLocalLinking: boolean; defaultPermissionNames: string[] }
}
const loading = ref(true)
@@ -28,7 +28,7 @@ const form = reactive<OidcConfig>({
clientSecret: '',
scopes: 'openid profile email',
claimMapping: { username: 'preferred_username', name: 'name', email: 'email', groups: 'groups' },
autoProvision: { enabled: false, allowLocalLinking: true, defaultRoleId: null },
autoProvision: { enabled: false, allowLocalLinking: true, defaultPermissionNames: [] },
})
onMounted(async () => {
@@ -92,213 +92,213 @@ async function testConnection() {
</script>
<template>
<div class="mb-8">
<h2 class="settings-title">OIDC / SSO</h2>
<p class="settings-subtitle">Configure an OpenID Connect provider for single sign-on.</p>
<div class="mb-8">
<h2 class="settings-title">OIDC / SSO</h2>
<p class="settings-subtitle">Configure an OpenID Connect provider for single sign-on.</p>
</div>
<div v-if="loading" class="text-sm text-muted-foreground">Loading...</div>
<form v-else class="space-y-6" @submit.prevent="save">
<!-- Enable -->
<div>
<p class="settings-group-label">Status</p>
<div class="border border-border rounded-lg overflow-hidden divide-y divide-border">
<div class="flex items-center justify-between px-5 py-4 bg-card">
<div>
<p class="settings-label">Enable OIDC</p>
<p class="settings-hint">Show SSO login button and allow OIDC authentication.</p>
</div>
<button
type="button"
role="switch"
:aria-checked="form.enabled"
class="relative inline-flex h-5 w-9 shrink-0 items-center rounded-full transition-colors"
:class="form.enabled ? 'bg-primary' : 'bg-input'"
@click="form.enabled = !form.enabled"
>
<span
class="inline-block h-4 w-4 rounded-full bg-white shadow-sm transition-transform"
:class="form.enabled ? 'translate-x-4' : 'translate-x-0.5'"
/>
</button>
</div>
</div>
</div>
<div v-if="loading" class="text-sm text-muted-foreground">Loading...</div>
<form v-else class="space-y-6" @submit.prevent="save">
<!-- Enable -->
<div>
<p class="settings-group-label">Status</p>
<div class="border border-border rounded-lg overflow-hidden divide-y divide-border">
<div class="flex items-center justify-between px-5 py-4 bg-card">
<div>
<p class="settings-label">Enable OIDC</p>
<p class="settings-hint">Show SSO login button and allow OIDC authentication.</p>
</div>
<button
type="button"
role="switch"
:aria-checked="form.enabled"
class="relative inline-flex h-5 w-9 shrink-0 items-center rounded-full transition-colors"
:class="form.enabled ? 'bg-primary' : 'bg-input'"
@click="form.enabled = !form.enabled"
>
<span
class="inline-block h-4 w-4 rounded-full bg-white shadow-sm transition-transform"
:class="form.enabled ? 'translate-x-4' : 'translate-x-0.5'"
/>
</button>
<!-- Provider -->
<div>
<p class="settings-group-label">Provider</p>
<div class="border border-border rounded-lg overflow-hidden divide-y divide-border">
<div class="flex items-center justify-between gap-8 px-5 py-4 bg-card">
<div class="shrink-0">
<p class="settings-label">Provider Name</p>
<p class="settings-hint">Shown on the login button.</p>
</div>
<input
v-model="form.providerName"
type="text"
placeholder="Authentik"
class="w-52 rounded-md border border-input bg-background px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-primary/50"
/>
</div>
</div>
<!-- Provider -->
<div>
<p class="settings-group-label">Provider</p>
<div class="border border-border rounded-lg overflow-hidden divide-y divide-border">
<div class="flex items-center justify-between gap-8 px-5 py-4 bg-card">
<div class="shrink-0">
<p class="settings-label">Provider Name</p>
<p class="settings-hint">Shown on the login button.</p>
</div>
<input
v-model="form.providerName"
type="text"
placeholder="Authentik"
class="w-52 rounded-md border border-input bg-background px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-primary/50"
/>
<div class="flex items-start justify-between gap-8 px-5 py-4 bg-card">
<div class="shrink-0 pt-0.5">
<p class="settings-label">Issuer URI</p>
<p class="settings-hint">The provider's base URL.</p>
</div>
<div class="flex items-start justify-between gap-8 px-5 py-4 bg-card">
<div class="shrink-0 pt-0.5">
<p class="settings-label">Issuer URI</p>
<p class="settings-hint">The provider's base URL.</p>
</div>
<div class="flex flex-col items-end gap-2">
<div class="flex items-center gap-2">
<input
v-model="form.issuerUri"
type="url"
placeholder="https://accounts.example.com"
class="w-64 rounded-md border border-input bg-background px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-primary/50"
/>
<button
type="button"
:disabled="testing || !form.issuerUri"
class="rounded-md border border-input bg-background px-3 py-1.5 text-xs font-medium hover:bg-muted disabled:opacity-50 transition-colors shrink-0"
@click="testConnection"
>
{{ testing ? 'Testing...' : 'Test' }}
</button>
</div>
<div
v-if="testResult"
class="text-xs px-3 py-1.5 rounded-md border"
:class="
testResult.success ? 'border-green-500/30 text-green-600 bg-green-500/5' : 'border-destructive/30 text-destructive bg-destructive/5'
"
<div class="flex flex-col items-end gap-2">
<div class="flex items-center gap-2">
<input
v-model="form.issuerUri"
type="url"
placeholder="https://accounts.example.com"
class="w-64 rounded-md border border-input bg-background px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-primary/50"
/>
<button
type="button"
:disabled="testing || !form.issuerUri"
class="rounded-md border border-input bg-background px-3 py-1.5 text-xs font-medium hover:bg-muted disabled:opacity-50 transition-colors shrink-0"
@click="testConnection"
>
{{ testResult.message }}
</div>
{{ testing ? 'Testing...' : 'Test' }}
</button>
</div>
</div>
<div class="flex items-center justify-between gap-8 px-5 py-4 bg-card">
<p class="settings-label shrink-0">Client ID</p>
<input
v-model="form.clientId"
type="text"
class="w-52 rounded-md border border-input bg-background px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-primary/50"
/>
</div>
<div class="flex items-center justify-between gap-8 px-5 py-4 bg-card">
<p class="settings-label shrink-0">Client Secret</p>
<input
v-model="form.clientSecret"
type="password"
placeholder="Leave blank to keep existing"
autocomplete="new-password"
class="w-52 rounded-md border border-input bg-background px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-primary/50"
/>
</div>
<div class="flex items-center justify-between gap-8 px-5 py-4 bg-card">
<p class="settings-label shrink-0">Scopes</p>
<input
v-model="form.scopes"
type="text"
class="w-52 rounded-md border border-input bg-background px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-primary/50"
/>
</div>
</div>
</div>
<!-- Claim mapping -->
<div>
<p class="settings-group-label">Claim Mapping</p>
<div class="border border-border rounded-lg overflow-hidden divide-y divide-border">
<div class="flex items-center justify-between gap-8 px-5 py-4 bg-card">
<p class="settings-label shrink-0">Username claim</p>
<input
v-model="form.claimMapping.username"
type="text"
class="w-52 rounded-md border border-input bg-background px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-primary/50"
/>
</div>
<div class="flex items-center justify-between gap-8 px-5 py-4 bg-card">
<p class="settings-label shrink-0">Name claim</p>
<input
v-model="form.claimMapping.name"
type="text"
class="w-52 rounded-md border border-input bg-background px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-primary/50"
/>
</div>
<div class="flex items-center justify-between gap-8 px-5 py-4 bg-card">
<p class="settings-label shrink-0">Email claim</p>
<input
v-model="form.claimMapping.email"
type="text"
class="w-52 rounded-md border border-input bg-background px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-primary/50"
/>
</div>
<div class="flex items-center justify-between gap-8 px-5 py-4 bg-card">
<p class="settings-label shrink-0">Groups claim</p>
<input
v-model="form.claimMapping.groups"
type="text"
class="w-52 rounded-md border border-input bg-background px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-primary/50"
/>
</div>
</div>
</div>
<!-- Auto-provisioning -->
<div>
<p class="settings-group-label">Auto-Provisioning</p>
<div class="border border-border rounded-lg overflow-hidden divide-y divide-border">
<div class="flex items-center justify-between px-5 py-4 bg-card">
<div>
<p class="settings-label">Auto-provision users</p>
<p class="settings-hint">Create accounts on first OIDC login if user does not exist.</p>
</div>
<button
type="button"
role="switch"
:aria-checked="form.autoProvision.enabled"
class="relative inline-flex h-5 w-9 shrink-0 items-center rounded-full transition-colors"
:class="form.autoProvision.enabled ? 'bg-primary' : 'bg-input'"
@click="form.autoProvision.enabled = !form.autoProvision.enabled"
<div
v-if="testResult"
class="text-xs px-3 py-1.5 rounded-md border"
:class="
testResult.success ? 'border-green-500/30 text-green-600 bg-green-500/5' : 'border-destructive/30 text-destructive bg-destructive/5'
"
>
<span
class="inline-block h-4 w-4 rounded-full bg-white shadow-sm transition-transform"
:class="form.autoProvision.enabled ? 'translate-x-4' : 'translate-x-0.5'"
/>
</button>
</div>
<div class="flex items-center justify-between px-5 py-4 bg-card">
<div>
<p class="settings-label">Allow local account linking</p>
<p class="settings-hint">Link OIDC identity to an existing local account by username match.</p>
{{ testResult.message }}
</div>
<button
type="button"
role="switch"
:aria-checked="form.autoProvision.allowLocalLinking"
class="relative inline-flex h-5 w-9 shrink-0 items-center rounded-full transition-colors"
:class="form.autoProvision.allowLocalLinking ? 'bg-primary' : 'bg-input'"
@click="form.autoProvision.allowLocalLinking = !form.autoProvision.allowLocalLinking"
>
<span
class="inline-block h-4 w-4 rounded-full bg-white shadow-sm transition-transform"
:class="form.autoProvision.allowLocalLinking ? 'translate-x-4' : 'translate-x-0.5'"
/>
</button>
</div>
</div>
<div class="flex items-center justify-between gap-8 px-5 py-4 bg-card">
<p class="settings-label shrink-0">Client ID</p>
<input
v-model="form.clientId"
type="text"
class="w-52 rounded-md border border-input bg-background px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-primary/50"
/>
</div>
<div class="flex items-center justify-between gap-8 px-5 py-4 bg-card">
<p class="settings-label shrink-0">Client Secret</p>
<input
v-model="form.clientSecret"
type="password"
placeholder="Leave blank to keep existing"
autocomplete="new-password"
class="w-52 rounded-md border border-input bg-background px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-primary/50"
/>
</div>
<div class="flex items-center justify-between gap-8 px-5 py-4 bg-card">
<p class="settings-label shrink-0">Scopes</p>
<input
v-model="form.scopes"
type="text"
class="w-52 rounded-md border border-input bg-background px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-primary/50"
/>
</div>
</div>
</div>
<!-- Save -->
<div class="flex items-center gap-3">
<button
type="submit"
:disabled="saving"
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"
>
{{ saving ? 'Saving...' : 'Save changes' }}
</button>
<p v-if="saveSuccess" class="text-sm text-green-600">Saved.</p>
<p v-if="saveError" class="text-sm text-destructive">{{ saveError }}</p>
<!-- Claim mapping -->
<div>
<p class="settings-group-label">Claim Mapping</p>
<div class="border border-border rounded-lg overflow-hidden divide-y divide-border">
<div class="flex items-center justify-between gap-8 px-5 py-4 bg-card">
<p class="settings-label shrink-0">Username claim</p>
<input
v-model="form.claimMapping.username"
type="text"
class="w-52 rounded-md border border-input bg-background px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-primary/50"
/>
</div>
<div class="flex items-center justify-between gap-8 px-5 py-4 bg-card">
<p class="settings-label shrink-0">Name claim</p>
<input
v-model="form.claimMapping.name"
type="text"
class="w-52 rounded-md border border-input bg-background px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-primary/50"
/>
</div>
<div class="flex items-center justify-between gap-8 px-5 py-4 bg-card">
<p class="settings-label shrink-0">Email claim</p>
<input
v-model="form.claimMapping.email"
type="text"
class="w-52 rounded-md border border-input bg-background px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-primary/50"
/>
</div>
<div class="flex items-center justify-between gap-8 px-5 py-4 bg-card">
<p class="settings-label shrink-0">Groups claim</p>
<input
v-model="form.claimMapping.groups"
type="text"
class="w-52 rounded-md border border-input bg-background px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-primary/50"
/>
</div>
</div>
</form>
</div>
<!-- Auto-provisioning -->
<div>
<p class="settings-group-label">Auto-Provisioning</p>
<div class="border border-border rounded-lg overflow-hidden divide-y divide-border">
<div class="flex items-center justify-between px-5 py-4 bg-card">
<div>
<p class="settings-label">Auto-provision users</p>
<p class="settings-hint">Create accounts on first OIDC login if user does not exist.</p>
</div>
<button
type="button"
role="switch"
:aria-checked="form.autoProvision.enabled"
class="relative inline-flex h-5 w-9 shrink-0 items-center rounded-full transition-colors"
:class="form.autoProvision.enabled ? 'bg-primary' : 'bg-input'"
@click="form.autoProvision.enabled = !form.autoProvision.enabled"
>
<span
class="inline-block h-4 w-4 rounded-full bg-white shadow-sm transition-transform"
:class="form.autoProvision.enabled ? 'translate-x-4' : 'translate-x-0.5'"
/>
</button>
</div>
<div class="flex items-center justify-between px-5 py-4 bg-card">
<div>
<p class="settings-label">Allow local account linking</p>
<p class="settings-hint">Link OIDC identity to an existing local account by username match.</p>
</div>
<button
type="button"
role="switch"
:aria-checked="form.autoProvision.allowLocalLinking"
class="relative inline-flex h-5 w-9 shrink-0 items-center rounded-full transition-colors"
:class="form.autoProvision.allowLocalLinking ? 'bg-primary' : 'bg-input'"
@click="form.autoProvision.allowLocalLinking = !form.autoProvision.allowLocalLinking"
>
<span
class="inline-block h-4 w-4 rounded-full bg-white shadow-sm transition-transform"
:class="form.autoProvision.allowLocalLinking ? 'translate-x-4' : 'translate-x-0.5'"
/>
</button>
</div>
</div>
</div>
<!-- Save -->
<div class="flex items-center gap-3">
<button
type="submit"
:disabled="saving"
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"
>
{{ saving ? 'Saving...' : 'Save changes' }}
</button>
<p v-if="saveSuccess" class="text-sm text-green-600">Saved.</p>
<p v-if="saveError" class="text-sm text-destructive">{{ saveError }}</p>
</div>
</form>
</template>
@@ -13,8 +13,6 @@ import {
Settings,
Info,
Users,
ShieldCheck,
KeyRound,
LogIn,
Rss,
Tablet,
@@ -35,8 +33,6 @@ 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'
import FileNamingSettings from './FileNamingSettings.vue'
import OidcSettings from './OidcSettings.vue'
import OpdsSettings from './OpdsSettings.vue'
@@ -62,7 +58,6 @@ type SectionId =
| 'comics'
| 'about'
| 'users'
| 'roles'
| 'permissions'
| 'oidc'
| 'file-naming'
@@ -111,10 +106,7 @@ const navGroups = computed(() => {
if (su || perms.includes('manage_users')) {
adminItems.push({ id: 'users', label: 'Users', icon: Users, component: UsersPage, maxWidth: 'max-w-4xl' })
}
if (su || perms.includes('manage_roles')) {
adminItems.push({ id: 'roles', label: 'Roles', icon: ShieldCheck, component: RolesPage, maxWidth: 'max-w-3xl' })
adminItems.push({ id: 'permissions', label: 'Permissions', icon: KeyRound, component: PermissionsPage })
}
if (su || perms.includes('manage_metadata_config')) {
adminItems.push({
id: 'metadata-preferences',