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,
|
library,
|
||||||
admin as adminApi,
|
admin as adminApi,
|
||||||
account as accountApi,
|
account as accountApi,
|
||||||
|
features as featuresApi,
|
||||||
nextcloud as nextcloudApi,
|
nextcloud as nextcloudApi,
|
||||||
type MediaType,
|
type MediaType,
|
||||||
type PipelineStage,
|
type PipelineStage,
|
||||||
type ScanStatus,
|
type ScanStatus,
|
||||||
type WorkerStatus,
|
type WorkerStatus,
|
||||||
type FeatureFlagSnapshot,
|
type FeatureFlagSnapshot,
|
||||||
|
type FeaturesMap,
|
||||||
type NextcloudSourceRoot,
|
type NextcloudSourceRoot,
|
||||||
} from '../../services/api'
|
} from '../../services/api'
|
||||||
import { toast } from '../ToastContainer'
|
import { toast } from '../ToastContainer'
|
||||||
@@ -59,7 +61,10 @@ type SettingsTab = 'library' | 'ai' | 'users'
|
|||||||
|
|
||||||
const TABS: { id: SettingsTab; label: string; adminOnly?: boolean }[] = [
|
const TABS: { id: SettingsTab; label: string; adminOnly?: boolean }[] = [
|
||||||
{ id: 'library', label: 'Library Management' },
|
{ 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 },
|
{ id: 'users', label: 'Users', adminOnly: true },
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -81,20 +86,23 @@ export function SettingsPage() {
|
|||||||
|
|
||||||
// All four panels fetch through React Query so cached data shows
|
// All four panels fetch through React Query so cached data shows
|
||||||
// instantly on reopen while a background refetch updates the numbers.
|
// 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({
|
const thumbStatsQuery = useQuery({
|
||||||
queryKey: SETTINGS_THUMB_STATS_KEY,
|
queryKey: [...SETTINGS_THUMB_STATS_KEY, libScope ?? 'self'],
|
||||||
queryFn: () => library.maintenance.thumbnailStats('global'),
|
queryFn: () => library.maintenance.thumbnailStats(libScope),
|
||||||
staleTime: 0,
|
staleTime: 0,
|
||||||
})
|
})
|
||||||
const libStatsQuery = useQuery({
|
const libStatsQuery = useQuery({
|
||||||
queryKey: SETTINGS_LIB_STATS_KEY,
|
queryKey: [...SETTINGS_LIB_STATS_KEY, libScope ?? 'self'],
|
||||||
queryFn: () => library.stats('global'),
|
queryFn: () => library.stats(libScope),
|
||||||
staleTime: 0,
|
staleTime: 0,
|
||||||
})
|
})
|
||||||
const workerStatusQuery = useQuery({
|
const workerStatusQuery = useQuery({
|
||||||
queryKey: SETTINGS_WORKER_STATUS_KEY,
|
queryKey: [...SETTINGS_WORKER_STATUS_KEY, libScope ?? 'self'],
|
||||||
queryFn: () => library.maintenance.workerStatus('global'),
|
queryFn: () => library.maintenance.workerStatus(libScope),
|
||||||
refetchInterval: 5000,
|
refetchInterval: 5000,
|
||||||
staleTime: 0,
|
staleTime: 0,
|
||||||
})
|
})
|
||||||
@@ -105,8 +113,8 @@ export function SettingsPage() {
|
|||||||
staleTime: 0,
|
staleTime: 0,
|
||||||
})
|
})
|
||||||
const pipelineStatsQuery = useQuery({
|
const pipelineStatsQuery = useQuery({
|
||||||
queryKey: SETTINGS_PIPELINE_STATS_KEY,
|
queryKey: [...SETTINGS_PIPELINE_STATS_KEY, libScope ?? 'self'],
|
||||||
queryFn: () => library.maintenance.pipelineStats('global'),
|
queryFn: () => library.maintenance.pipelineStats(libScope),
|
||||||
refetchInterval: 5000,
|
refetchInterval: 5000,
|
||||||
staleTime: 0,
|
staleTime: 0,
|
||||||
})
|
})
|
||||||
@@ -118,8 +126,8 @@ export function SettingsPage() {
|
|||||||
staleTime: 0,
|
staleTime: 0,
|
||||||
})
|
})
|
||||||
const duplicatesQuery = useQuery({
|
const duplicatesQuery = useQuery({
|
||||||
queryKey: [...DUPLICATE_GROUPS_QUERY_KEY, 'global'],
|
queryKey: [...DUPLICATE_GROUPS_QUERY_KEY, libScope ?? 'self'],
|
||||||
queryFn: () => library.duplicates.groups('global'),
|
queryFn: () => library.duplicates.groups(libScope),
|
||||||
staleTime: 0,
|
staleTime: 0,
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -211,7 +219,7 @@ export function SettingsPage() {
|
|||||||
) =>
|
) =>
|
||||||
runAction(
|
runAction(
|
||||||
key,
|
key,
|
||||||
() => library.maintenance.regenerateThumbnails(body, 'global'),
|
() => library.maintenance.regenerateThumbnails(body, libScope),
|
||||||
'Regeneration queued',
|
'Regeneration queued',
|
||||||
(r) => `${r.queued} photos queued, ${r.cleared_dirs} thumb dirs cleared`
|
(r) => `${r.queued} photos queued, ${r.cleared_dirs} thumb dirs cleared`
|
||||||
),
|
),
|
||||||
@@ -861,10 +869,11 @@ export function SettingsPage() {
|
|||||||
</Section>
|
</Section>
|
||||||
</>)}
|
</>)}
|
||||||
|
|
||||||
{activeTab === 'ai' && isAdmin && (
|
{activeTab === 'ai' && (
|
||||||
<AiFeaturesTab
|
<AiFeaturesTab
|
||||||
busy={busy}
|
busy={busy}
|
||||||
runAction={runAction}
|
runAction={runAction}
|
||||||
|
isAdmin={isAdmin}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -1113,6 +1122,7 @@ interface AiFeaturesTabProps {
|
|||||||
successTitle: string,
|
successTitle: string,
|
||||||
describe?: (result: T) => string | undefined,
|
describe?: (result: T) => string | undefined,
|
||||||
) => Promise<void>
|
) => Promise<void>
|
||||||
|
isAdmin: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const FLAG_META: Array<{
|
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()
|
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 }>({
|
const flagsQuery = useQuery<{ flags: FeatureFlagSnapshot }>({
|
||||||
queryKey: SETTINGS_FEATURE_FLAGS_KEY,
|
queryKey: SETTINGS_FEATURE_FLAGS_KEY,
|
||||||
queryFn: adminApi.listFeatureFlags,
|
queryFn: adminApi.listFeatureFlags,
|
||||||
staleTime: 5_000,
|
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 masterOff = flags['vision.enabled'] && !flags['vision.enabled'].effective
|
||||||
|
|
||||||
const applyFlag = async (name: string, value: boolean | null) => {
|
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">
|
<Section icon={<Brain className="h-4 w-4" />} title="AI feature flags">
|
||||||
<p className="text-xs text-text-muted">
|
<p className="text-xs text-text-muted">
|
||||||
Toggle each stage at runtime. Changes are observed by Celery
|
{isAdmin
|
||||||
workers on the next task — no restart needed. "Default" means
|
? '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.'
|
||||||
the flag hasn\'t been overridden and is tracking the YAML config;
|
: 'Effective AI features for your library. Flags are system-wide; only an admin can toggle them.'}
|
||||||
an overridden flag is pinned to the value shown until cleared.
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{flagsQuery.isLoading && (
|
{(isAdmin ? flagsQuery.isLoading : featuresQuery.isLoading) && (
|
||||||
<div className="mt-3 flex items-center gap-2 text-xs text-text-muted">
|
<div className="mt-3 flex items-center gap-2 text-xs text-text-muted">
|
||||||
<Loader2 className="h-3 w-3 animate-spin" />
|
<Loader2 className="h-3 w-3 animate-spin" />
|
||||||
Loading feature flags…
|
Loading feature flags…
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{flagsQuery.error && (
|
{(isAdmin ? flagsQuery.error : featuresQuery.error) && (
|
||||||
<ErrorBanner
|
<ErrorBanner
|
||||||
title="Could not load feature flags"
|
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">
|
<div className="mt-3 space-y-2">
|
||||||
{FLAG_META.map((meta) => {
|
{FLAG_META.map((meta) => {
|
||||||
const state = flags[meta.id]
|
const state = flags[meta.id]
|
||||||
@@ -1226,10 +1256,16 @@ function AiFeaturesTab({ busy, runAction }: AiFeaturesTabProps) {
|
|||||||
<Switch
|
<Switch
|
||||||
checked={state.effective}
|
checked={state.effective}
|
||||||
onCheckedChange={(v) => applyFlag(meta.id, v)}
|
onCheckedChange={(v) => applyFlag(meta.id, v)}
|
||||||
disabled={isBusy || (dimmed && !isMaster)}
|
disabled={!isAdmin || isBusy || (dimmed && !isMaster)}
|
||||||
title={state.effective ? 'Click to disable' : 'Click to enable'}
|
title={
|
||||||
|
!isAdmin
|
||||||
|
? 'Admin only — flags are system-wide'
|
||||||
|
: state.effective
|
||||||
|
? 'Click to disable'
|
||||||
|
: 'Click to enable'
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
{state.overridden && (
|
{isAdmin && state.overridden && (
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="icon"
|
size="icon"
|
||||||
@@ -1252,36 +1288,41 @@ function AiFeaturesTab({ busy, runAction }: AiFeaturesTabProps) {
|
|||||||
)}
|
)}
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
<Section icon={<Cpu className="h-4 w-4" />} title="Manual pipeline triggers">
|
{isAdmin && (
|
||||||
<p className="text-xs text-text-muted">
|
<Section icon={<Cpu className="h-4 w-4" />} title="Manual pipeline triggers">
|
||||||
Run the classifier over any photos that haven't been classified yet,
|
<p className="text-xs text-text-muted">
|
||||||
or force a fresh filesystem scan. Both are safe to run repeatedly.
|
Run the classifier over any photos that haven't been classified yet,
|
||||||
</p>
|
or force a fresh filesystem scan. Both are safe to run repeatedly.
|
||||||
<div className="mt-3 flex flex-wrap gap-2">
|
These are bulk admin operations across every user; non-admins
|
||||||
<ActionButton
|
should use "Re-scan source folders" in the Library tab to
|
||||||
loading={!!busy['ai-backfill:all']}
|
re-scan their own libraries.
|
||||||
onClick={() => runBackfill()}
|
</p>
|
||||||
disabled={masterOff}
|
<div className="mt-3 flex flex-wrap gap-2">
|
||||||
>
|
<ActionButton
|
||||||
<Sparkles className="h-4 w-4" />
|
loading={!!busy['ai-backfill:all']}
|
||||||
Run classifier backfill
|
onClick={() => runBackfill()}
|
||||||
</ActionButton>
|
disabled={masterOff}
|
||||||
<ActionButton
|
>
|
||||||
loading={!!busy['rescan-full']}
|
<Sparkles className="h-4 w-4" />
|
||||||
onClick={() =>
|
Run classifier backfill
|
||||||
runAction(
|
</ActionButton>
|
||||||
'rescan-full',
|
<ActionButton
|
||||||
() => adminApi.triggerFullRescan(),
|
loading={!!busy['rescan-full']}
|
||||||
'Rescan queued',
|
onClick={() =>
|
||||||
(r) => `Celery task ${r.task_id}`,
|
runAction(
|
||||||
)
|
'rescan-full',
|
||||||
}
|
() => adminApi.triggerFullRescan(),
|
||||||
>
|
'Rescan queued',
|
||||||
<RefreshCw className="h-4 w-4" />
|
(r) => `Celery task ${r.task_id}`,
|
||||||
Rescan all source roots
|
)
|
||||||
</ActionButton>
|
}
|
||||||
</div>
|
>
|
||||||
</Section>
|
<RefreshCw className="h-4 w-4" />
|
||||||
|
Rescan all source roots
|
||||||
|
</ActionButton>
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user