feat: add account avatar management and tighten user email requirements

This commit is contained in:
neon
2026-03-23 04:06:26 -06:00
parent 391fa46b27
commit eb5d08ad24
32 changed files with 749 additions and 4999 deletions
+14 -4
View File
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
import { ArrowLeft, Search, Palette, Upload, X, KeyRound, Settings, LogOut, PackageOpen, BarChart3 } from 'lucide-vue-next'
import { ArrowLeft, Search, Palette, Upload, X, KeyRound, Settings, LogOut, PackageOpen, BarChart3, User } from 'lucide-vue-next'
import { useRouter, useRoute } from 'vue-router'
import { toast } from 'vue-sonner'
import { SidebarTrigger } from '@/components/ui/sidebar'
@@ -28,6 +28,7 @@ import { usePermissions } from '@/features/auth/composables/usePermissions'
import BookUploadModal from '@/features/library/components/BookUploadModal.vue'
import { useLibraryUploadEvents } from '@/features/library/composables/useLibraryUploadEvents'
import { useStagingSummary } from '@/features/staging/composables/useStagingSummary'
import UserAvatar from '@/components/UserAvatar.vue'
const router = useRouter()
const route = useRoute()
@@ -48,6 +49,10 @@ function navigateToStatistics() {
router.push({ name: 'statistics' })
}
function navigateToAccount() {
router.push({ name: 'settings-account' })
}
const uploadOpen = ref(false)
const searchFocused = ref(false)
@@ -507,7 +512,7 @@ function formatBadgeClass(fmt: string): string {
variant="ghost"
size="icon"
class="h-8 w-8 rounded-xl border border-primary/35 text-foreground/70 hover:border-primary/70 hover:text-foreground transition-colors"
@click="router.push({ name: 'settings-libraries' })"
@click="router.push({ name: 'settings-account' })"
>
<Settings :size="15" />
</Button>
@@ -521,9 +526,9 @@ function formatBadgeClass(fmt: string): string {
<DropdownMenu v-if="user">
<DropdownMenuTrigger as-child>
<button
class="flex h-8 w-8 shrink-0 items-center justify-center rounded-xl border border-primary/50 bg-primary/10 text-primary text-xs font-bold hover:bg-primary/15 hover:border-primary/70 transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-primary"
class="flex h-8 w-8 shrink-0 items-center justify-center overflow-hidden rounded-xl border border-primary/50 bg-primary/10 hover:bg-primary/15 hover:border-primary/70 transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-primary"
>
{{ user.name.charAt(0).toUpperCase() }}
<UserAvatar :name="user.name" :avatar-url="user.avatarUrl ?? null" size-class="h-full w-full" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" class="w-48">
@@ -534,6 +539,11 @@ function formatBadgeClass(fmt: string): string {
</div>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem @click="navigateToAccount">
<User :size="13" class="mr-2 text-muted-foreground" />
Account
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem @click="openChangePassword()">
<KeyRound :size="13" class="mr-2 text-muted-foreground" />
Change Password
+56
View File
@@ -0,0 +1,56 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
const props = withDefaults(
defineProps<{
name?: string | null
avatarUrl?: string | null
sizeClass?: string
textClass?: string
}>(),
{
name: null,
avatarUrl: null,
sizeClass: 'h-8 w-8',
textClass: 'text-xs',
},
)
const imageFailed = ref(false)
const initials = computed(() => {
const raw = props.name?.trim()
if (!raw) return '?'
const parts = raw.split(/\s+/).filter(Boolean)
if (parts.length === 1) return parts[0]!.slice(0, 1).toUpperCase()
return `${parts[0]!.slice(0, 1)}${parts[parts.length - 1]!.slice(0, 1)}`.toUpperCase()
})
const src = computed(() => props.avatarUrl ?? null)
watch(
src,
() => {
imageFailed.value = false
},
{ immediate: true },
)
</script>
<template>
<span
:class="[
'inline-flex items-center justify-center overflow-hidden rounded-[inherit] bg-primary/15 text-primary font-semibold select-none',
sizeClass,
]"
>
<img
v-if="src && !imageFailed"
:src="src"
:alt="props.name ? `${props.name} profile picture` : 'Profile picture'"
class="h-full w-full object-cover"
@error="imageFailed = true"
/>
<span v-else :class="textClass">{{ initials }}</span>
</span>
</template>
+11 -3
View File
@@ -92,11 +92,13 @@ async function handleSubmit() {
error.value = null
loading.value = true
try {
const trimmedEmail = email.value.trim()
if (isEdit.value) {
const res = await api(`/api/v1/users/${props.user!.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: name.value, email: email.value || undefined, active: active.value }),
body: JSON.stringify({ name: name.value, email: trimmedEmail || undefined, active: active.value }),
})
if (!res.ok) {
const err = await res.json().catch(() => ({}))
@@ -126,13 +128,18 @@ async function handleSubmit() {
return
}
} else {
if (!trimmedEmail) {
error.value = 'Email is required'
return
}
const res = await api('/api/v1/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: name.value,
username: username.value,
email: email.value || undefined,
email: trimmedEmail,
permissionNames: [...selectedPermissionNames.value],
libraryIds: [...selectedLibraryIds.value],
}),
@@ -188,10 +195,11 @@ async function handleSubmit() {
</div>
<div class="space-y-1.5">
<label class="settings-label">Email <span class="text-muted-foreground">(optional)</span></label>
<label class="settings-label">Email</label>
<input
v-model="email"
type="email"
:required="!isEdit"
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>
@@ -0,0 +1,80 @@
import { ref } from 'vue'
import { api } from '@/lib/api'
import { useAuth } from './useAuth'
export const MAX_PROFILE_AVATAR_BYTES = 5 * 1024 * 1024
function extractErrorMessage(payload: unknown, fallback: string): string {
if (!payload || typeof payload !== 'object') return fallback
const message = (payload as { message?: unknown }).message
if (typeof message === 'string' && message.trim().length > 0) return message
if (Array.isArray(message) && typeof message[0] === 'string') return message[0]
return fallback
}
export function useProfileAvatar() {
const { user, me } = useAuth()
const uploading = ref(false)
const removing = ref(false)
async function uploadAvatar(file: File): Promise<string | null> {
if (!file.type.startsWith('image/')) {
throw new Error('File must be an image')
}
if (file.size > MAX_PROFILE_AVATAR_BYTES) {
throw new Error('Image exceeds 5 MB limit')
}
uploading.value = true
try {
const formData = new FormData()
formData.append('file', file)
const res = await api('/api/v1/users/me/avatar', { method: 'POST', body: formData })
if (!res.ok) {
const payload = await res.json().catch(() => null)
throw new Error(extractErrorMessage(payload, 'Failed to upload profile picture'))
}
const payload = (await res.json().catch(() => null)) as { avatarUrl?: string | null } | null
if (user.value) {
user.value = { ...user.value, avatarUrl: payload?.avatarUrl ?? null }
} else {
await me()
}
return payload?.avatarUrl ?? null
} finally {
uploading.value = false
}
}
async function removeAvatar(): Promise<string | null> {
removing.value = true
try {
const res = await api('/api/v1/users/me/avatar', { method: 'DELETE' })
if (!res.ok) {
const payload = await res.json().catch(() => null)
throw new Error(extractErrorMessage(payload, 'Failed to remove profile picture'))
}
const payload = (await res.json().catch(() => null)) as { avatarUrl?: string | null } | null
if (user.value) {
user.value = { ...user.value, avatarUrl: payload?.avatarUrl ?? null }
} else {
await me()
}
return payload?.avatarUrl ?? null
} finally {
removing.value = false
}
}
return {
uploading,
removing,
uploadAvatar,
removeAvatar,
}
}
@@ -160,7 +160,6 @@ onUnmounted(() => clearTimeout(debounceTimer))
<input
v-model="urlInput"
class="w-full h-9 rounded-lg border border-input bg-background px-3 text-xs outline-none focus:ring-1 focus:ring-ring transition-shadow"
placeholder="https://..."
@input="onUrlInput"
/>
</div>
@@ -172,7 +172,6 @@ async function autoFill() {
<input
v-model="form.title"
class="w-full h-8 rounded-lg border border-input bg-background px-3 text-sm outline-none focus:ring-1 focus:ring-ring transition-shadow"
placeholder="Book title"
/>
</div>
<div class="space-y-1">
@@ -180,7 +179,6 @@ async function autoFill() {
<input
v-model="form.subtitle"
class="w-full h-8 rounded-lg border border-input bg-background px-3 text-sm outline-none focus:ring-1 focus:ring-ring transition-shadow"
placeholder="Subtitle"
/>
</div>
</div>
@@ -189,25 +187,25 @@ async function autoFill() {
<div class="grid gap-3" :class="isPrimaryAudio ? 'grid-cols-1 sm:grid-cols-2' : 'grid-cols-1'">
<div class="space-y-1">
<label class="text-xs font-medium text-muted-foreground uppercase tracking-wider">Authors</label>
<ChipInput v-model="form.authors" placeholder="Add author..." :search-fn="searchAuthors" />
<ChipInput v-model="form.authors" :search-fn="searchAuthors" />
</div>
<div v-if="isPrimaryAudio" class="space-y-1">
<label class="text-xs font-medium text-muted-foreground uppercase tracking-wider">Narrators</label>
<ChipInput v-model="form.narrators" placeholder="Add narrator..." :search-fn="searchNarrators" />
<ChipInput v-model="form.narrators" :search-fn="searchNarrators" />
</div>
</div>
<!-- Genres -->
<div class="space-y-1">
<label class="text-xs font-medium text-muted-foreground uppercase tracking-wider">Genres</label>
<ChipInput v-model="form.genres" placeholder="Add genre..." :search-fn="searchGenres" />
<ChipInput v-model="form.genres" :search-fn="searchGenres" />
</div>
<!-- Tags | Rating -->
<div class="flex items-start gap-3">
<div class="flex-1 space-y-1">
<label class="text-xs font-medium text-muted-foreground uppercase tracking-wider">Tags</label>
<ChipInput v-model="form.tags" placeholder="Add tag..." :search-fn="searchTags" />
<ChipInput v-model="form.tags" :search-fn="searchTags" />
</div>
<div class="space-y-1 shrink-0">
<label class="text-xs font-medium text-muted-foreground uppercase tracking-wider">Rating</label>
@@ -239,7 +237,6 @@ async function autoFill() {
<input
v-model="form.seriesName"
class="w-full h-8 rounded-lg border border-input bg-background px-3 text-sm outline-none focus:ring-1 focus:ring-ring transition-shadow"
placeholder="Series name"
/>
</div>
<div class="w-16 shrink-0 space-y-1">
@@ -250,7 +247,6 @@ async function autoFill() {
step="0.1"
min="0"
class="w-full h-8 rounded-lg border border-input bg-background px-3 text-sm outline-none focus:ring-1 focus:ring-ring transition-shadow"
placeholder="1"
@input="setFloatField('seriesIndex', $event)"
/>
</div>
@@ -259,7 +255,6 @@ async function autoFill() {
<input
v-model="form.publisher"
class="w-full h-8 rounded-lg border border-input bg-background px-3 text-sm outline-none focus:ring-1 focus:ring-ring transition-shadow"
placeholder="Publisher"
/>
</div>
</div>
@@ -274,7 +269,6 @@ async function autoFill() {
min="1"
max="2100"
class="w-full h-8 rounded-lg border border-input bg-background px-3 text-sm outline-none focus:ring-1 focus:ring-ring transition-shadow"
placeholder="2024"
@input="setIntField('publishedYear', $event)"
/>
</div>
@@ -283,7 +277,6 @@ async function autoFill() {
<input
v-model="form.language"
class="w-full h-8 rounded-lg border border-input bg-background px-3 text-sm outline-none focus:ring-1 focus:ring-ring transition-shadow"
placeholder="en"
maxlength="10"
/>
</div>
@@ -294,7 +287,6 @@ async function autoFill() {
type="number"
min="1"
class="w-full h-8 rounded-lg border border-input bg-background px-3 text-sm outline-none focus:ring-1 focus:ring-ring transition-shadow"
placeholder="300"
@input="setIntField('pageCount', $event)"
/>
</div>
@@ -303,7 +295,6 @@ async function autoFill() {
<input
v-model="form.isbn13"
class="w-full h-8 rounded-lg border border-input bg-background px-3 text-sm font-mono outline-none focus:ring-1 focus:ring-ring transition-shadow"
placeholder="9780000000000"
maxlength="13"
/>
</div>
@@ -312,7 +303,6 @@ async function autoFill() {
<input
v-model="form.isbn10"
class="w-full h-8 rounded-lg border border-input bg-background px-3 text-sm font-mono outline-none focus:ring-1 focus:ring-ring transition-shadow"
placeholder="0000000000"
maxlength="10"
/>
</div>
@@ -323,7 +313,6 @@ async function autoFill() {
type="number"
min="1"
class="w-full h-8 rounded-lg border border-input bg-background px-3 text-sm outline-none focus:ring-1 focus:ring-ring transition-shadow"
placeholder="36000"
@input="setIntField('durationSeconds', $event)"
/>
</div>
@@ -345,7 +334,6 @@ async function autoFill() {
<input
v-model="form[field]"
class="w-full h-8 rounded-md border border-input bg-background px-2.5 text-xs font-mono outline-none focus:ring-1 focus:ring-ring transition-shadow"
:placeholder="label + ' ID'"
/>
</div>
</div>
@@ -358,7 +346,6 @@ async function autoFill() {
v-model="form.description"
rows="6"
class="w-full rounded-lg border border-input bg-background px-3 py-2 text-sm outline-none focus:ring-1 focus:ring-ring transition-shadow resize-y"
placeholder="Book description..."
/>
</div>
</div>
@@ -0,0 +1,177 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { KeyRound, Save, Trash2, Upload } from 'lucide-vue-next'
import { toast } from 'vue-sonner'
import UserAvatar from '@/components/UserAvatar.vue'
import { api } from '@/lib/api'
import { useAuth } from '@/features/auth/composables/useAuth'
import { MAX_PROFILE_AVATAR_BYTES, useProfileAvatar } from '@/features/auth/composables/useProfileAvatar'
import { useChangePasswordDialog } from '@/composables/useChangePasswordDialog'
import SettingsPageHeader from './SettingsPageHeader.vue'
const { user, me } = useAuth()
const { open: openChangePassword } = useChangePasswordDialog()
const { uploading, removing, uploadAvatar, removeAvatar } = useProfileAvatar()
const fileInput = ref<HTMLInputElement | null>(null)
const savingProfile = ref(false)
const formName = ref('')
const formEmail = ref('')
watch(
() => user.value,
(current) => {
formName.value = current?.name ?? ''
formEmail.value = current?.email ?? ''
},
{ immediate: true },
)
const hasAvatar = computed(() => Boolean(user.value?.avatarUrl))
const busy = computed(() => uploading.value || removing.value)
const profileBusy = computed(() => busy.value || savingProfile.value)
const profileChanged = computed(() => {
const current = user.value
if (!current) return false
return formName.value.trim() !== current.name || formEmail.value.trim() !== (current.email ?? '')
})
function triggerFileDialog() {
fileInput.value?.click()
}
async function onFileSelected(event: Event) {
const input = event.target as HTMLInputElement
const file = input.files?.[0]
input.value = ''
if (!file) return
try {
await uploadAvatar(file)
toast.success('Profile picture updated')
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to upload profile picture'
toast.error(message)
}
}
async function onRemoveAvatar() {
try {
await removeAvatar()
toast.success('Profile picture removed')
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to remove profile picture'
toast.error(message)
}
}
async function saveProfile() {
if (!user.value) return
const trimmedName = formName.value.trim()
if (!trimmedName) {
toast.error('Name is required')
return
}
savingProfile.value = true
try {
const trimmedEmail = formEmail.value.trim()
const res = await api('/api/v1/users/me', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: trimmedName,
email: trimmedEmail.length > 0 ? trimmedEmail : null,
}),
})
if (!res.ok) {
const payload = (await res.json().catch(() => null)) as { message?: string | string[] } | null
const message = Array.isArray(payload?.message)
? (payload.message[0] ?? 'Failed to update profile')
: (payload?.message ?? 'Failed to update profile')
toast.error(message)
return
}
await me()
toast.success('Profile updated')
} finally {
savingProfile.value = false
}
}
</script>
<template>
<SettingsPageHeader title="Account" subtitle="Manage your personal profile settings." />
<section class="rounded-xl border border-border bg-card/50 p-5 space-y-4 mb-4">
<div class="grid gap-4 sm:grid-cols-2">
<div class="space-y-1.5 sm:col-span-2">
<label class="settings-label">Username</label>
<input
:value="user?.username ?? ''"
type="text"
readonly
class="w-full rounded-md border border-input bg-muted/50 px-3 py-2 text-sm text-muted-foreground"
/>
</div>
<div class="space-y-1.5">
<label class="settings-label">Full name</label>
<input
v-model="formName"
type="text"
autocomplete="name"
class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-primary/50"
/>
</div>
<div class="space-y-1.5">
<label class="settings-label">Email</label>
<input
v-model="formEmail"
type="email"
autocomplete="email"
class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-primary/50"
/>
</div>
</div>
<div class="flex flex-wrap items-center gap-2">
<button class="settings-btn-primary" :disabled="!profileChanged || profileBusy" @click="saveProfile">
<Save :size="14" />
{{ savingProfile ? 'Saving...' : 'Save profile' }}
</button>
<button class="settings-btn-outline inline-flex items-center gap-2" :disabled="profileBusy" @click="openChangePassword()">
<KeyRound :size="14" />
Change password
</button>
<span class="text-xs text-muted-foreground">Account type: {{ user?.provisioningMethod === 'oidc' ? 'OIDC / SSO' : 'Local' }}</span>
</div>
</section>
<section class="rounded-xl border border-border bg-card/50 p-5 space-y-4">
<div class="flex items-center gap-4">
<UserAvatar :name="user?.name ?? null" :avatar-url="user?.avatarUrl ?? null" size-class="h-20 w-20" text-class="text-xl font-semibold" />
<div class="min-w-0">
<p class="text-sm font-medium text-foreground">{{ user?.name ?? 'Unknown user' }}</p>
<p class="text-xs text-muted-foreground">{{ user?.username ?? '' }}</p>
<p class="mt-1 text-xs text-muted-foreground">PNG/JPEG/WEBP up to {{ Math.floor(MAX_PROFILE_AVATAR_BYTES / 1024 / 1024) }} MB</p>
</div>
</div>
<div class="flex flex-wrap items-center gap-2">
<input ref="fileInput" type="file" accept="image/*" class="hidden" :disabled="profileBusy" @change="onFileSelected" />
<button class="settings-btn-primary" :disabled="profileBusy" @click="triggerFileDialog">
<Upload :size="14" />
{{ uploading ? 'Uploading...' : hasAvatar ? 'Replace picture' : 'Upload picture' }}
</button>
<button class="settings-btn-outline inline-flex items-center gap-2" :disabled="profileBusy || !hasAvatar" @click="onRemoveAvatar">
<Trash2 :size="14" />
{{ removing ? 'Removing...' : 'Remove picture' }}
</button>
</div>
</section>
</template>
+17 -11
View File
@@ -16,22 +16,18 @@ const sections = computed<Section[]>(() => {
const perms = userPermissions.value
const su = isSuperuser.value
const result: Section[] = [
{ label: 'Appearance', routeName: 'settings-appearance' },
{ label: 'Reader', routeName: 'settings-reader-general' },
]
const result: Section[] = []
if (su || perms.includes('manage_libraries')) {
result.unshift({ label: 'Libraries', routeName: 'settings-libraries' })
result.push({ label: 'Libraries', routeName: 'settings-libraries' })
}
result.push({ label: 'Appearance', routeName: 'settings-appearance' })
result.push({ label: 'Reader', routeName: 'settings-reader-general' })
if (su || perms.includes('manage_metadata_config')) {
result.push({ label: 'Metadata', routeName: 'settings-admin-metadata' })
}
if (su || perms.includes('manage_app_settings')) {
result.push({ label: 'Maintenance', routeName: 'settings-admin-maintenance' })
}
if (su) {
result.push({ label: 'Audit Log', routeName: 'settings-admin-audit-log' })
}
if (su || perms.includes('staging_access')) {
result.push({ label: 'Staging', routeName: 'settings-admin-staging' })
}
@@ -53,6 +49,16 @@ const sections = computed<Section[]>(() => {
if (su || perms.includes('kobo_sync')) {
result.push({ label: 'Kobo', routeName: 'settings-kobo' })
}
if (su || perms.includes('manage_app_settings')) {
result.push({ label: 'Maintenance', routeName: 'settings-admin-maintenance' })
}
result.push({ label: 'Account', routeName: 'settings-account' })
if (su) {
result.push({ label: 'Audit Log', routeName: 'settings-admin-audit-log' })
}
result.push({ label: 'About', routeName: 'settings-about' })
return result
+6
View File
@@ -54,6 +54,12 @@ export const routes: RouteRecordRaw[] = [
component: () => import('@/views/SettingsView.vue'),
children: [
{ path: '', redirect: { name: 'settings-appearance' } },
{
path: 'account',
name: 'settings-account',
component: () => import('@/features/settings/AccountSettings.vue'),
meta: { title: 'Account' },
},
{
path: 'libraries',
name: 'settings-libraries',