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

@@ -15,17 +15,19 @@ import { KeyboardHints } from './components/KeyboardHints'
import { PreviewView } from './components/preview/PreviewView'
import { FilterBar } from './components/filter/FilterBar'
import { DiscardActionBar } from './components/discard/DiscardActionBar'
import { SettingsDialog } from './components/dialogs/SettingsDialog'
import { SettingsPage } from './components/dialogs/SettingsDialog'
import { usePhotoStore } from './store/photoStore'
import { useFilterStore } from './store/filterStore'
import { useKeyboardShortcuts } from './hooks/useKeyboardShortcuts'
import { useFilterUrlSync } from './hooks/useFilterUrlSync'
import { usePhotosQuery } from './hooks/usePhotosQuery'
import { AuthProvider, useAuth } from './contexts/AuthContext'
import { LoginPage } from './components/auth/LoginPage'
import { SetupPage } from './components/auth/SetupPage'
function App() {
function MainApp() {
const [leftSidebarOpen, setLeftSidebarOpen] = useState(true)
const [rightSidebarOpen, setRightSidebarOpen] = useState(true)
const [settingsOpen, setSettingsOpen] = useState(false)
const viewMode = usePhotoStore((state) => state.viewMode)
const currentSection = useFilterStore((s) => s.currentSection)
@@ -44,13 +46,13 @@ function App() {
getFirstPhotoId: () => allPhotos?.[0]?.id ?? null,
})
// Settings page is a full-page section — hide filter bar, right sidebar,
// and keyboard hints when it's active.
const isSettings = currentSection === 'settings'
// Right sidebar stays open by default and shows whatever's selected
// (or an empty state if nothing is). User can still toggle it manually.
// Note: deliberately NOT gated on viewMode — the preview overlay sits
// on top with z-[1000], so leaving the sidebar mounted underneath
// costs nothing visually and avoids the collapse-then-reopen layout
// shift the user would otherwise see every time they exit preview.
const showRightSidebar = rightSidebarOpen
const showRightSidebar = rightSidebarOpen && !isSettings
return (
<div className="flex flex-col h-screen bg-bg text-text">
@@ -70,7 +72,6 @@ function App() {
>
<LeftSidebar
onCollapse={() => setLeftSidebarOpen(false)}
onOpenSettings={() => setSettingsOpen(true)}
/>
</div>
@@ -79,14 +80,12 @@ function App() {
* across the sidebar. relative so the KeyboardHints overlay
* centers against this column, not the viewport. */}
<div className="relative flex min-w-0 flex-1 flex-col">
<FilterBar />
<DiscardActionBar />
{!isSettings && <FilterBar />}
{!isSettings && <DiscardActionBar />}
<div className="flex-1 overflow-auto">
{/* Section-level routing. The Map view replaces the timeline
* with a Leaflet map of GPS-tagged photos; Duplicates gets its
* own grouped grid; everything else falls through to the
* filter-driven Timeline. */}
{currentSection === 'map' ? (
{currentSection === 'settings' ? (
<SettingsPage />
) : currentSection === 'map' ? (
<MapView />
) : currentSection === 'duplicates' ? (
<DuplicatesView />
@@ -102,10 +101,7 @@ function App() {
<Timeline />
)}
</div>
{/* Floating keyboard hints — bottom-center of the main column,
* glassy. Mounted here so it's centered against the timeline,
* not the viewport (which would be offset by the sidebars). */}
<KeyboardHints />
{!isSettings && <KeyboardHints />}
</div>
{/* Right Sidebar */}
@@ -127,13 +123,33 @@ function App() {
{/* Preview overlay — covers TopBar when active */}
{viewMode === 'preview' && <PreviewView />}
{/* Settings panel — admin/maintenance actions */}
<SettingsDialog
isOpen={settingsOpen}
onClose={() => setSettingsOpen(false)}
/>
</div>
)
}
/** Auth-gated shell: shows setup, login, or the main app. */
function App() {
return (
<AuthProvider>
<AuthGate />
</AuthProvider>
)
}
function AuthGate() {
const { user, isLoading, needsSetup } = useAuth()
if (isLoading) {
return (
<div className="flex min-h-screen items-center justify-center bg-bg">
<div className="text-text-muted">Loading&hellip;</div>
</div>
)
}
if (needsSetup) return <SetupPage />
if (!user) return <LoginPage />
return <MainApp />
}
export default App