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

@@ -14,6 +14,61 @@ const api = axios.create({
},
})
// ── Auth interceptors ──────────────────────────────────────────────────
// Attach the stored JWT to every outgoing request.
api.interceptors.request.use((config) => {
const token = localStorage.getItem('access_token')
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
return config
})
// On 401 responses, attempt one silent token refresh. If that also
// fails, clear stored credentials so the AuthContext falls back to the
// login screen on its next render.
let isRefreshing = false
let refreshSubscribers: ((token: string) => void)[] = []
api.interceptors.response.use(
(response) => response,
async (error) => {
const original = error.config
if (error.response?.status !== 401 || original._retry) {
return Promise.reject(error)
}
// Skip retry for auth endpoints themselves to avoid loops.
if (original.url?.startsWith('/auth/')) {
return Promise.reject(error)
}
original._retry = true
if (!isRefreshing) {
isRefreshing = true
// The refresh token lives in AuthContext memory, not in
// localStorage. The interceptor can't access it directly, so we
// rely on the AuthContext's scheduled refresh to keep the access
// token fresh. If the access token is truly expired and no
// refresh has happened, we just force a logout.
localStorage.removeItem('access_token')
isRefreshing = false
// Reject — AuthContext will detect the missing token and show login.
return Promise.reject(error)
}
// Another request is already refreshing — queue this one.
return new Promise((resolve) => {
refreshSubscribers.push((token: string) => {
original.headers.Authorization = `Bearer ${token}`
resolve(api(original))
})
})
},
)
// Source Folders API. Source roots are config-driven now (PHOTO_DIRS in
// .env → bootstrap on backend startup), so the UI only reads them and
// optionally renames the display label.
@@ -394,15 +449,19 @@ export const library = {
return response.data
},
stats: async (): Promise<LibraryStats> => {
const response = await api.get('/library/stats')
stats: async (scope?: 'global'): Promise<LibraryStats> => {
const response = await api.get('/library/stats', {
params: scope ? { scope } : undefined,
})
return response.data
},
/** Maintenance / admin actions surfaced via the Settings panel. */
maintenance: {
thumbnailStats: async (): Promise<ThumbnailStats> => {
const response = await api.get('/library/maintenance/thumbnail-stats')
thumbnailStats: async (scope?: 'global'): Promise<ThumbnailStats> => {
const response = await api.get('/library/maintenance/thumbnail-stats', {
params: scope ? { scope } : undefined,
})
return response.data
},
@@ -413,28 +472,30 @@ export const library = {
media_types?: MediaType[]
only_failed?: boolean
only_pending?: boolean
} = {}
} = {},
scope?: 'global',
): Promise<RegenerateResult> => {
const response = await api.post(
'/library/maintenance/regenerate-thumbnails',
body
body,
{ params: scope ? { scope } : undefined },
)
return response.data
},
/** Celery worker fleet diagnostics + recent task failures. Surfaced
* in the Settings panel so users can debug stuck queues without
* tailing container logs. */
workerStatus: async (): Promise<WorkerStatus> => {
const response = await api.get('/library/maintenance/worker-status')
/** Celery worker fleet diagnostics + recent task failures. */
workerStatus: async (scope?: 'global'): Promise<WorkerStatus> => {
const response = await api.get('/library/maintenance/worker-status', {
params: scope ? { scope } : undefined,
})
return response.data
},
/** Per-stage ingestion progress — thumbnails, EXIF, GPS, phash,
* embeddings, object tags, OCR, faces, face clusters, duplicate
* groups. Drives the Pipeline Progress card in Settings. */
pipelineStats: async (): Promise<PipelineStats> => {
const response = await api.get('/library/maintenance/pipeline-stats')
/** Per-stage ingestion progress. */
pipelineStats: async (scope?: 'global'): Promise<PipelineStats> => {
const response = await api.get('/library/maintenance/pipeline-stats', {
params: scope ? { scope } : undefined,
})
return response.data
},
@@ -477,8 +538,10 @@ export const library = {
/** Duplicate groups computed by app.services.duplicates.regroup_duplicates.
* Drives the grouped grid view in the Duplicates section. */
duplicates: {
groups: async (): Promise<DuplicateGroupsResponse> => {
const response = await api.get('/library/duplicates/groups')
groups: async (scope?: 'global'): Promise<DuplicateGroupsResponse> => {
const response = await api.get('/library/duplicates/groups', {
params: scope ? { scope } : undefined,
})
return response.data
},
},
@@ -725,4 +788,45 @@ export const discard = {
},
}
// Admin API — user management (admin only)
export interface AdminUser {
id: string
username: string
email: string | null
role: 'admin' | 'user'
is_active: boolean
media_path: string
created_at: string | null
photo_count: number
}
export const admin = {
listUsers: async (): Promise<{ users: AdminUser[]; total: number }> => {
const response = await api.get('/admin/users')
return response.data
},
createUser: async (data: {
username: string
password: string
role: string
}): Promise<AdminUser> => {
const response = await api.post('/admin/users', data)
return response.data
},
updateUser: async (
userId: string,
data: { role?: string; is_active?: boolean; new_password?: string },
): Promise<AdminUser> => {
const response = await api.patch(`/admin/users/${userId}`, data)
return response.data
},
deleteUser: async (userId: string): Promise<{ status: string }> => {
const response = await api.delete(`/admin/users/${userId}`)
return response.data
},
}
export default api