2 Commits

Author SHA1 Message Date
Claudio
b0c8c06b2b Sidebar: suppress global count badges for non-admin users
PhotoPrism's /api/v1/config.count returns library-wide aggregates to
any authenticated session, with no per-user scoping. The timeline
itself IS scoped (a guest sees zero photos), but the sidebar was
rendering admin-side totals next to Review / Hidden / Archive / Tags /
Map / root for non-admins — including a freshly-registered "test"
user with role=guest and BasePath="".

Until PhotoPrism gains per-user counters, the SPA now derives an
`isAdminUser` flag and gates every count that's drawn from
configQuery on it. Non-admin users see the labels without badges;
counts re-appear automatically when promoted. Per-folder counts from
the sidecar (which DO scope to BasePath) are unaffected.
2026-05-18 20:02:20 +00:00
Claudio
986dab7334 Clear TanStack Query cache on session change
Sidebar counts, marks, folder counts, etc. were keyed only on query
name, not on the authenticated user. Logging in as a non-admin kept
rendering the previous admin session's data because the cache was
never invalidated. clearSession and adoptSession now wipe the cache
so each identity starts fresh.

User-observed: the "test" user (role guest, BasePath="") saw the
admin library counts in the left sidebar after signing in.
2026-05-18 20:01:17 +00:00
2 changed files with 32 additions and 4 deletions

View File

@@ -92,6 +92,16 @@
enabled: isAuthenticated() enabled: isAuthenticated()
})); }));
// PhotoPrism's /api/v1/config.count returns library-wide aggregates
// to any authenticated session regardless of role — the timeline
// itself IS scoped per-user, but the precomputed counters aren't.
// Showing those numbers in a non-admin's sidebar is misleading
// (e.g. the `test` user with role=guest saw the admin's library
// totals next to Review / Hidden / Archive). Until PhotoPrism gains
// per-user count scoping, just suppress the global-derived badges
// for anyone who isn't the admin.
const isAdminUser = $derived(session.user?.Role === 'admin');
const marksQuery = createQuery<PhotoMarksMap>(() => ({ const marksQuery = createQuery<PhotoMarksMap>(() => ({
queryKey: ['marks'], queryKey: ['marks'],
queryFn: getAllMarks, queryFn: getAllMarks,
@@ -213,7 +223,9 @@
})); }));
const rootCount = $derived( const rootCount = $derived(
userBasePath() === '' userBasePath() === ''
? isAdminUser
? (configQuery.data?.count?.all ?? 0) ? (configQuery.data?.count?.all ?? 0)
: 0
: (scopedRootCountQuery.data?.[''] ?? 0) : (scopedRootCountQuery.data?.[''] ?? 0)
); );
@@ -425,16 +437,19 @@
// separate "everything regardless of folder" destination would just // separate "everything regardless of folder" destination would just
// duplicate it for users whose photos live under the root. // duplicate it for users whose photos live under the root.
const views: ViewItem[] = [ const views: ViewItem[] = [
{ kind: 'route', href: '/map', label: 'Map', getCount: () => geoQuery.data?.features?.length }, { kind: 'route', href: '/map', label: 'Map', getCount: () => (isAdminUser ? geoQuery.data?.features?.length : undefined) },
// Tags hosts four tabs (Labels (auto) / Keywords / Ratings / Colors); // Tags hosts four tabs (Labels (auto) / Keywords / Ratings / Colors);
// the badge sums each tab's badge so the sidebar number is the // the badge sums each tab's badge so the sidebar number is the
// total of what the inner tabs show. Keywords is lazy — it only // total of what the inner tabs show. Keywords is lazy — it only
// contributes after /tags?tab=keywords has been visited once. // contributes after /tags?tab=keywords has been visited once.
// Suppressed for non-admins because every contributing query is
// library-wide rather than per-user.
{ {
kind: 'route', kind: 'route',
href: '/tags', href: '/tags',
label: 'Tags', label: 'Tags',
getCount: () => { getCount: () => {
if (!isAdminUser) return undefined;
const labels = configQuery.data?.count?.labels; const labels = configQuery.data?.count?.labels;
if (labels === undefined) return undefined; if (labels === undefined) return undefined;
const keywords = keywordsQuery.data?.length ?? 0; const keywords = keywordsQuery.data?.length ?? 0;
@@ -449,13 +464,14 @@
href: '/review', href: '/review',
label: 'Review', label: 'Review',
getCount: () => { getCount: () => {
if (!isAdminUser) return undefined;
const review = configQuery.data?.count?.review; const review = configQuery.data?.count?.review;
if (review === undefined) return undefined; if (review === undefined) return undefined;
return review + (stacksQuery.data?.length ?? 0) + (crossFolderQuery.data?.groups.length ?? 0); return review + (stacksQuery.data?.length ?? 0) + (crossFolderQuery.data?.groups.length ?? 0);
} }
}, },
{ kind: 'section', id: 'hidden', label: 'Hidden', getCount: () => configQuery.data?.count?.hidden }, { kind: 'section', id: 'hidden', label: 'Hidden', getCount: () => (isAdminUser ? configQuery.data?.count?.hidden : undefined) },
{ kind: 'section', id: 'archive', label: 'Archive', getCount: () => configQuery.data?.count?.archived } { kind: 'section', id: 'archive', label: 'Archive', getCount: () => (isAdminUser ? configQuery.data?.count?.archived : undefined) }
]; ];
function isRouteActive(href: string): boolean { function isRouteActive(href: string): boolean {

View File

@@ -1,4 +1,5 @@
import { browser } from '$app/environment'; import { browser } from '$app/environment';
import { queryClient } from '$lib/queryClient';
import type { PpClientConfig, PpSessionResponse, PpUser } from '$lib/types/photoprism'; import type { PpClientConfig, PpSessionResponse, PpUser } from '$lib/types/photoprism';
const STORAGE_KEY = 'pp_session'; const STORAGE_KEY = 'pp_session';
@@ -49,6 +50,13 @@ export function isAuthenticated(): boolean {
} }
export function adoptSession(resp: PpSessionResponse, cfg?: PpClientConfig): void { export function adoptSession(resp: PpSessionResponse, cfg?: PpClientConfig): void {
// Drop any cached data from the prior identity before installing the
// new session. The TanStack cache is keyed on query name, not user —
// so without an explicit clear, the new login keeps showing the
// previous user's `/api/v1/config.count`, marks, folder counts, etc.
// (Hit this with the `test` user seeing the admin's library counts
// in the left sidebar.)
queryClient.clear();
session.id = resp.id; session.id = resp.id;
session.accessToken = resp.access_token; session.accessToken = resp.access_token;
session.previewToken = (cfg ?? resp.config)?.previewToken ?? ''; session.previewToken = (cfg ?? resp.config)?.previewToken ?? '';
@@ -64,6 +72,10 @@ export function clearSession(): void {
session.downloadToken = null; session.downloadToken = null;
session.user = null; session.user = null;
if (browser) localStorage.removeItem(STORAGE_KEY); if (browser) localStorage.removeItem(STORAGE_KEY);
// Same reasoning as adoptSession — wipe the cache so the next user
// who logs in (or the login screen itself) doesn't render with the
// previous identity's data.
queryClient.clear();
} }
function persist(): void { function persist(): void {