diff --git a/frontend/src/components/dialogs/SettingsDialog.tsx b/frontend/src/components/dialogs/SettingsDialog.tsx
index 2455d1b..46f4d65 100644
--- a/frontend/src/components/dialogs/SettingsDialog.tsx
+++ b/frontend/src/components/dialogs/SettingsDialog.tsx
@@ -26,12 +26,14 @@ import {
library,
admin as adminApi,
account as accountApi,
+ features as featuresApi,
nextcloud as nextcloudApi,
type MediaType,
type PipelineStage,
type ScanStatus,
type WorkerStatus,
type FeatureFlagSnapshot,
+ type FeaturesMap,
type NextcloudSourceRoot,
} from '../../services/api'
import { toast } from '../ToastContainer'
@@ -59,7 +61,10 @@ type SettingsTab = 'library' | 'ai' | 'users'
const TABS: { id: SettingsTab; label: string; adminOnly?: boolean }[] = [
{ id: 'library', label: 'Library Management' },
- { id: 'ai', label: 'AI Features', adminOnly: true },
+ // AI is open to non-admins; per-user scope. Admin-only mutations
+ // (system-wide flag toggles, bulk backfill, rescan-all) are hidden
+ // inside the panel for non-admins.
+ { id: 'ai', label: 'AI Features' },
{ id: 'users', label: 'Users', adminOnly: true },
]
@@ -81,20 +86,23 @@ export function SettingsPage() {
// All four panels fetch through React Query so cached data shows
// instantly on reopen while a background refetch updates the numbers.
- // All queries use scope=global so the admin sees cross-user totals.
+ // Admins request scope=global to see cross-user totals; non-admins get
+ // user-scoped data automatically (the backend's _owner_filter falls
+ // back to current_user when scope is omitted).
+ const libScope: 'global' | undefined = isAdmin ? 'global' : undefined
const thumbStatsQuery = useQuery({
- queryKey: SETTINGS_THUMB_STATS_KEY,
- queryFn: () => library.maintenance.thumbnailStats('global'),
+ queryKey: [...SETTINGS_THUMB_STATS_KEY, libScope ?? 'self'],
+ queryFn: () => library.maintenance.thumbnailStats(libScope),
staleTime: 0,
})
const libStatsQuery = useQuery({
- queryKey: SETTINGS_LIB_STATS_KEY,
- queryFn: () => library.stats('global'),
+ queryKey: [...SETTINGS_LIB_STATS_KEY, libScope ?? 'self'],
+ queryFn: () => library.stats(libScope),
staleTime: 0,
})
const workerStatusQuery = useQuery({
- queryKey: SETTINGS_WORKER_STATUS_KEY,
- queryFn: () => library.maintenance.workerStatus('global'),
+ queryKey: [...SETTINGS_WORKER_STATUS_KEY, libScope ?? 'self'],
+ queryFn: () => library.maintenance.workerStatus(libScope),
refetchInterval: 5000,
staleTime: 0,
})
@@ -105,8 +113,8 @@ export function SettingsPage() {
staleTime: 0,
})
const pipelineStatsQuery = useQuery({
- queryKey: SETTINGS_PIPELINE_STATS_KEY,
- queryFn: () => library.maintenance.pipelineStats('global'),
+ queryKey: [...SETTINGS_PIPELINE_STATS_KEY, libScope ?? 'self'],
+ queryFn: () => library.maintenance.pipelineStats(libScope),
refetchInterval: 5000,
staleTime: 0,
})
@@ -118,8 +126,8 @@ export function SettingsPage() {
staleTime: 0,
})
const duplicatesQuery = useQuery({
- queryKey: [...DUPLICATE_GROUPS_QUERY_KEY, 'global'],
- queryFn: () => library.duplicates.groups('global'),
+ queryKey: [...DUPLICATE_GROUPS_QUERY_KEY, libScope ?? 'self'],
+ queryFn: () => library.duplicates.groups(libScope),
staleTime: 0,
})
@@ -211,7 +219,7 @@ export function SettingsPage() {
) =>
runAction(
key,
- () => library.maintenance.regenerateThumbnails(body, 'global'),
+ () => library.maintenance.regenerateThumbnails(body, libScope),
'Regeneration queued',
(r) => `${r.queued} photos queued, ${r.cleared_dirs} thumb dirs cleared`
),
@@ -861,10 +869,11 @@ export function SettingsPage() {
>)}
- {activeTab === 'ai' && isAdmin && (
+ {activeTab === 'ai' && (
)}
@@ -1113,6 +1122,7 @@ interface AiFeaturesTabProps {
successTitle: string,
describe?: (result: T) => string | undefined,
) => Promise
+ isAdmin: boolean
}
const FLAG_META: Array<{
@@ -1131,15 +1141,32 @@ const FLAG_META: Array<{
},
]
-function AiFeaturesTab({ busy, runAction }: AiFeaturesTabProps) {
+function AiFeaturesTab({ busy, runAction, isAdmin }: AiFeaturesTabProps) {
const queryClient = useQueryClient()
+ // Admins get the full snapshot (default + effective + overridden) so
+ // they can toggle. Non-admins only read effective values via the
+ // public /features endpoint — no leaked override metadata, no 403s.
const flagsQuery = useQuery<{ flags: FeatureFlagSnapshot }>({
queryKey: SETTINGS_FEATURE_FLAGS_KEY,
queryFn: adminApi.listFeatureFlags,
staleTime: 5_000,
+ enabled: isAdmin,
+ })
+ const featuresQuery = useQuery({
+ queryKey: ['features'],
+ queryFn: featuresApi.list,
+ staleTime: 5_000,
+ enabled: !isAdmin,
})
- const flags = flagsQuery.data?.flags ?? {}
+ const flags: FeatureFlagSnapshot = isAdmin
+ ? (flagsQuery.data?.flags ?? {})
+ : Object.fromEntries(
+ Object.entries(featuresQuery.data ?? {}).map(([name, on]) => [
+ name,
+ { effective: on, default: on, overridden: false },
+ ]),
+ )
const masterOff = flags['vision.enabled'] && !flags['vision.enabled'].effective
const applyFlag = async (name: string, value: boolean | null) => {
@@ -1167,26 +1194,29 @@ function AiFeaturesTab({ busy, runAction }: AiFeaturesTabProps) {
<>
} title="AI feature flags">
- Toggle each stage at runtime. Changes are observed by Celery
- workers on the next task — no restart needed. "Default" means
- the flag hasn\'t been overridden and is tracking the YAML config;
- an overridden flag is pinned to the value shown until cleared.
+ {isAdmin
+ ? 'Toggle each stage at runtime. Changes are observed by Celery workers on the next task — no restart needed. "Default" means the flag hasn\'t been overridden and is tracking the YAML config; an overridden flag is pinned to the value shown until cleared.'
+ : 'Effective AI features for your library. Flags are system-wide; only an admin can toggle them.'}