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