feat: multi-user auth with per-user media isolation

Introduce username/password authentication with admin and user roles.
Each user gets their own media directory under /photos/{username}/ with
isolated photos, folders, heaps, and tags. Admins manage users and
observe the full library from a dedicated Settings page.

Backend:
- User model with bcrypt passwords and JWT access/refresh tokens
- Auth router (login, refresh, setup, change-password, status)
- Admin router (user CRUD with last-admin protection)
- user_id FK added to photos, folders, source_roots, heaps, tags
- All data routers scoped by authenticated user
- Scanner inherits user_id from source root owner
- Thumbnails stored under user-prefixed paths for isolation
- Library endpoints accept ?scope=global for admin cross-user view
- Alembic migration 0009 with data migration for existing installs
- Defensive bootstrap.py handles fresh vs existing DB startup

Frontend:
- AuthContext with token lifecycle, auto-refresh, login/logout
- Login page, first-run setup page, auth gate in App.tsx
- Bearer token interceptor on all API requests
- User identity + logout in left sidebar
- Admin-only Settings page with Library Management and Users tabs
- UserManagement panel (add, edit role, reset password, deactivate)
- Settings shows global stats across all users for admin
- Filter bar, right sidebar, keyboard hints hidden on settings page

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-12 21:46:52 +02:00
parent 03a4c75e3e
commit 348e9c3585
40 changed files with 2313 additions and 440 deletions

View File

@@ -20,6 +20,9 @@ import {
Users,
Eye,
EyeOff,
User as UserIcon,
LogOut,
Shield,
} from 'lucide-react'
import clsx from 'clsx'
import { sourceFolders, photos as photosApi, type FolderTreeNode } from '../../services/api'
@@ -40,6 +43,7 @@ import {
import { registerUndoable } from '../../store/undoStore'
import type { Photo } from '../../types/photo'
import { DeleteFolderDialog } from '../dialogs/DeleteFolderDialog'
import { useAuth } from '../../contexts/AuthContext'
interface TreeItem {
id: string
@@ -55,10 +59,10 @@ interface TreeItem {
interface LeftSidebarProps {
onCollapse: () => void
onOpenSettings: () => void
}
export function LeftSidebar({ onCollapse, onOpenSettings }: LeftSidebarProps) {
export function LeftSidebar({ onCollapse }: LeftSidebarProps) {
const { user, isAdmin, logout } = useAuth()
const [expandedItems, setExpandedItems] = useState<Set<string>>(new Set(['library', 'folders', 'heaps']))
// Inline rename state for source-root rows. Stores the id being edited
// and the draft name. Double-click a folder row to start.
@@ -797,17 +801,40 @@ export function LeftSidebar({ onCollapse, onOpenSettings }: LeftSidebarProps) {
<HeapsPanel />
</div>
{/* Settings entry point — pinned to the bottom of the panel so it
* sits out of the way of the library tree but is always reachable. */}
<div className="border-t border-border p-1.5">
<button
onClick={onOpenSettings}
className="flex w-full items-center gap-2 rounded px-2 py-1 text-[12px] text-text-muted hover:bg-surface-2 hover:text-text"
title="Settings"
>
<Settings className="h-3.5 w-3.5" />
Settings
</button>
{/* Bottom panel — user identity + settings, pinned below the tree. */}
<div className="border-t border-border p-1.5 space-y-0.5">
{/* User row */}
<div className="flex items-center gap-2 rounded px-2 py-1 text-[12px] text-text-muted">
<UserIcon className="h-3.5 w-3.5 flex-shrink-0" />
<span className="flex-1 truncate text-text">{user?.username}</span>
{isAdmin && (
<span className="rounded bg-accent/20 px-1 py-px text-[10px] leading-none text-accent flex-shrink-0">
<Shield className="inline h-2.5 w-2.5" />
</span>
)}
<button
onClick={logout}
className="rounded p-0.5 text-text-muted hover:bg-surface-2 hover:text-red-400 flex-shrink-0"
title="Sign out"
>
<LogOut className="h-3 w-3" />
</button>
</div>
{/* Settings — admin only, navigates to the settings section */}
{isAdmin && (
<button
onClick={() => navigateToSection('settings', {})}
className={clsx(
'flex w-full items-center gap-2 rounded px-2 py-1 text-[12px] hover:bg-surface-2 hover:text-text',
currentSection === 'settings' ? 'text-primary' : 'text-text-muted',
)}
title="Settings"
>
<Settings className="h-3.5 w-3.5" />
Settings
</button>
)}
</div>
<DeleteFolderDialog

View File

@@ -96,22 +96,20 @@ export function TopBar({
{MULIMAGO_ASCII}
</pre>
</div>
<div className="flex h-full items-end gap-2 self-stretch pb-1">
<div className="flex items-center gap-2">
<span className="text-[10px] font-serif text-black/80">
Built with hubris {toRoman(new Date().getFullYear())}
</span>
{!rightSidebarOpen && (
<button
onClick={onExpandRight}
className="self-center rounded bg-black/30 p-1.5 text-text-muted backdrop-blur-sm transition-colors hover:bg-black/50 hover:text-text"
className="rounded bg-black/30 p-1.5 text-text-muted backdrop-blur-sm transition-colors hover:bg-black/50 hover:text-text"
title="Expand panel (I)"
aria-label="Expand right panel"
>
<PanelRightOpen className="h-4 w-4" />
</button>
)}
<span
className="text-[10px] font-serif text-black/80"
>
Built with hubris {toRoman(new Date().getFullYear())}
</span>
</div>
</header>
)