feat(settings): open Library + AI tabs to non-admin users

Non-admins now see Library and AI Features tabs with data scoped to
themselves; only Users (admin management) stays admin-only.

Library tab: queries pass scope=global only when isAdmin, otherwise
omit scope so the backend _owner_filter falls back to current_user.
Stats, worker status, pipeline progress, duplicates, regenerate-thumbs
all respect this. Re-scan + maintenance buttons that hit user-scoped
endpoints continue to work for non-admins.

AI Features tab: feature flag state read via the public /features
endpoint for non-admins (just effective values, no override metadata),
admin-only flag toggle Switches show as disabled with an explanatory
tooltip, and the "Manual pipeline triggers" section (bulk classifier
backfill + rescan-all-source-roots) is hidden entirely for non-admins
since those are admin-bulk operations across every user.

Users tab: stays adminOnly as today.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Claudio
2026-04-26 09:45:21 +02:00
parent 65f6c14487
commit 4c7e981daf

View File

@@ -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() {
</Section>
</>)}
{activeTab === 'ai' && isAdmin && (
{activeTab === 'ai' && (
<AiFeaturesTab
busy={busy}
runAction={runAction}
isAdmin={isAdmin}
/>
)}
@@ -1113,6 +1122,7 @@ interface AiFeaturesTabProps {
successTitle: string,
describe?: (result: T) => string | undefined,
) => Promise<void>
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<FeaturesMap>({
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) {
<>
<Section icon={<Brain className="h-4 w-4" />} title="AI feature flags">
<p className="text-xs text-text-muted">
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.'}
</p>
{flagsQuery.isLoading && (
{(isAdmin ? flagsQuery.isLoading : featuresQuery.isLoading) && (
<div className="mt-3 flex items-center gap-2 text-xs text-text-muted">
<Loader2 className="h-3 w-3 animate-spin" />
Loading feature flags
</div>
)}
{flagsQuery.error && (
{(isAdmin ? flagsQuery.error : featuresQuery.error) && (
<ErrorBanner
title="Could not load feature flags"
detail={String((flagsQuery.error as Error).message || flagsQuery.error)}
detail={String(
((isAdmin ? flagsQuery.error : featuresQuery.error) as Error).message
|| (isAdmin ? flagsQuery.error : featuresQuery.error),
)}
/>
)}
{!flagsQuery.isLoading && !flagsQuery.error && (
{!(isAdmin ? flagsQuery.isLoading : featuresQuery.isLoading)
&& !(isAdmin ? flagsQuery.error : featuresQuery.error) && (
<div className="mt-3 space-y-2">
{FLAG_META.map((meta) => {
const state = flags[meta.id]
@@ -1226,10 +1256,16 @@ function AiFeaturesTab({ busy, runAction }: AiFeaturesTabProps) {
<Switch
checked={state.effective}
onCheckedChange={(v) => 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 && (
<Button
variant="outline"
size="icon"
@@ -1252,36 +1288,41 @@ function AiFeaturesTab({ busy, runAction }: AiFeaturesTabProps) {
)}
</Section>
<Section icon={<Cpu className="h-4 w-4" />} title="Manual pipeline triggers">
<p className="text-xs text-text-muted">
Run the classifier over any photos that haven't been classified yet,
or force a fresh filesystem scan. Both are safe to run repeatedly.
</p>
<div className="mt-3 flex flex-wrap gap-2">
<ActionButton
loading={!!busy['ai-backfill:all']}
onClick={() => runBackfill()}
disabled={masterOff}
>
<Sparkles className="h-4 w-4" />
Run classifier backfill
</ActionButton>
<ActionButton
loading={!!busy['rescan-full']}
onClick={() =>
runAction(
'rescan-full',
() => adminApi.triggerFullRescan(),
'Rescan queued',
(r) => `Celery task ${r.task_id}`,
)
}
>
<RefreshCw className="h-4 w-4" />
Rescan all source roots
</ActionButton>
</div>
</Section>
{isAdmin && (
<Section icon={<Cpu className="h-4 w-4" />} title="Manual pipeline triggers">
<p className="text-xs text-text-muted">
Run the classifier over any photos that haven't been classified yet,
or force a fresh filesystem scan. Both are safe to run repeatedly.
These are bulk admin operations across every user; non-admins
should use "Re-scan source folders" in the Library tab to
re-scan their own libraries.
</p>
<div className="mt-3 flex flex-wrap gap-2">
<ActionButton
loading={!!busy['ai-backfill:all']}
onClick={() => runBackfill()}
disabled={masterOff}
>
<Sparkles className="h-4 w-4" />
Run classifier backfill
</ActionButton>
<ActionButton
loading={!!busy['rescan-full']}
onClick={() =>
runAction(
'rescan-full',
() => adminApi.triggerFullRescan(),
'Rescan queued',
(r) => `Celery task ${r.task_id}`,
)
}
>
<RefreshCw className="h-4 w-4" />
Rescan all source roots
</ActionButton>
</div>
</Section>
)}
</>
)
}