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:
@@ -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
|
||||
Reference in New Issue
Block a user