mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
feat: replace roles system with flat permissions and isSuperuser flag
This commit is contained in:
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user