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.'}

- {flagsQuery.isLoading && ( + {(isAdmin ? flagsQuery.isLoading : featuresQuery.isLoading) && (
Loading feature flags…
)} - {flagsQuery.error && ( + {(isAdmin ? flagsQuery.error : featuresQuery.error) && ( )} - {!flagsQuery.isLoading && !flagsQuery.error && ( + {!(isAdmin ? flagsQuery.isLoading : featuresQuery.isLoading) + && !(isAdmin ? flagsQuery.error : featuresQuery.error) && (
{FLAG_META.map((meta) => { const state = flags[meta.id] @@ -1226,10 +1256,16 @@ function AiFeaturesTab({ busy, runAction }: AiFeaturesTabProps) { applyFlag(meta.id, v)} - disabled={isBusy || (dimmed && !isMaster)} - title={state.effective ? 'Click to disable' : 'Click to enable'} + disabled={!isAdmin || isBusy || (dimmed && !isMaster)} + title={ + !isAdmin + ? 'Admin only — flags are system-wide' + : state.effective + ? 'Click to disable' + : 'Click to enable' + } /> - {state.overridden && ( + {isAdmin && state.overridden && (