mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-10 23:58:56 +00:00
7c01f811a1
Phase 2 of the frontend-design audit: TanStack Query discipline.
Set the cross-cutting QueryClient defaults + staleTime/gcTime tier
model + visibility-aware polling + 4 optimistic-update mutations
before any further per-page work.
New foundation
==============
web/src/api/queryConstants.ts (new)
STALE_TIME = { REAL_TIME: 15s, REFERENCE: 5m, CONSTANT: 1h }
GC_TIME = { HEAVY: 1m, STANDARD: 5m, REFERENCE: 30m }
Doc-comment explains the tier model so every new useQuery picks
a tier rather than a hardcoded ms integer.
web/src/main.tsx
QueryClient defaults rewritten:
pre: staleTime: 10_000 + refetchOnWindowFocus: true (refetch
storm on every tab refocus across 242 query sites)
post: staleTime: STALE_TIME.REFERENCE (5min) + gcTime: GC_TIME
.STANDARD (explicit 5min) + refetchOnWindowFocus: false
(per-query opt-in for live-tile queries)
retry: 1 unchanged per the audit's DO NOT.
Findings closed by source ID
============================
TQ-H2 (refetch storm)
main.tsx QueryClient defaults — refetchOnWindowFocus: false root +
per-query opt-in. STALE_TIME.REFERENCE 5min for everything else.
TQ-M1 (no gcTime overrides)
main.tsx now sets gcTime: GC_TIME.STANDARD explicitly — the
contract is documented at the root, not implicit-defaulted by
TanStack.
TQ-M2 (12 inconsistent staleTime values)
All 11 hardcoded numeric staleTime overrides migrated to the
STALE_TIME tier constants. useAuthMe.ts (the 12th) already used
its own constant — left alone. Tier mapping:
- operator-facing live data (KeysPage keys, RoleDetail role,
UsersPage, OIDCJWKSStatusPanel, ApprovalsPage):
STALE_TIME.REAL_TIME (15s)
- slow-changing reference data (KeysPage roles, RolesPage,
AuthSettings bootstrap+runtime-config):
STALE_TIME.REFERENCE (5min)
- effectively immutable (RoleDetail permissions catalogue):
STALE_TIME.CONSTANT (1hr)
TQ-H1 (OnboardingWizard infinite 5s poll)
OnboardingWizard.tsx:288-302 — refetchInterval rewritten to v5
functional form:
refetchInterval: (query) =>
(query.state.data?.data?.length ?? 0) > 0 ? false : 5_000;
As soon as the first agent registers, the interval flips to false
and the poll stops. Also explicit: refetchOnWindowFocus: true +
staleTime: STALE_TIME.REAL_TIME (because this IS a live-tile poll
during the wizard).
PERF-H1 (Dashboard polling storm)
DashboardPage.tsx
- jobs poll bumped 10s → 30s (10s granularity isn't needed when
30s is already inside the human-attention window; the
CertificateDetail page is where 10s polling lives)
- visibility-listener pauses ALL Dashboard polls when
document.visibilityState === 'hidden'; on visibility return,
immediately invalidates the 4 live-tile queries (health,
dashboard-summary, jobs, certs-by-status) so the operator
sees fresh data instantly rather than waiting one tick.
- The 4 live-tile queries (health, dashboard-summary, jobs,
certs-by-status) opt into refetchOnWindowFocus: true +
staleTime: STALE_TIME.REAL_TIME explicitly.
- Backend aggregation gap (dashboard-summary + certs-by-status
+ certificates could collapse into 1 endpoint) tracked
separately — Phase 3 backend follow-up.
P-H1 (CertificatesPage 4 duplicate-key pairs)
Pre-Phase-2 4 pairs of distinct cache slots fetching the same data:
['profiles'] vs ['profiles-filter']
['issuers'] vs ['issuers-filter']
['owners', 'form'] vs ['owners-filter']
['teams', 'form'] vs ['teams-filter']
Post-Phase-2 all four pairs collapse to a single parameterized
queryKey shape: `[name, { per_page: 100 }]`. TanStack v5 dedupes
on serialized queryKey — the modal + filter now share one cache
slot per resource. 8 useQuery sites → 4 cache slots; backend
hits halved on first paint of CertificatesPage.
TQ-M3 (4 of 5 priority optimistic-update mutations)
Wired onMutate / onError-rollback / onSettled-invalidation on:
1. mark-notification-read (NotificationsPage)
— flips row status to 'read' in both ['notifications','all']
+ ['notifications','dead'] cache slots
2. claim-discovered-cert (DiscoveryPage)
— flips status to 'Managed' in ['discovered-certificates']
3. dismiss-discovery (DiscoveryPage)
— flips status to 'Dismissed' in same cache slot
4. archive-certificate (CertificateDetailPage)
— flips status to 'Archived' in ['certificate', id]; on
success navigates to /certificates (optimistic data
doesn't linger); on error restores snapshot + toasts
All four fire the Phase 1 Sonner toast on success/failure.
The 5th priority site (role-assignment toggle in
auth/RoleDetailPage) uses raw async/await handlers rather than
useTrackedMutation — converting it requires a structural
refactor outside Phase 2's TQ-focus; tracked as Phase 2 follow-up.
TQ-L1 (useTrackedMutation extended tests)
useTrackedMutation.test.tsx grew from 3 tests to 8:
+ passes onMutate through and runs it before mutationFn
+ passes onError through with the onMutate context (rollback
path — pins the 3rd-arg snapshot semantics)
+ does NOT invalidate on error (only on success)
+ passes onSettled through (fires after both success + error)
+ parity with raw useMutation when no extra options given
Verification
============
$ grep -E "refetchOnWindowFocus: false" web/src/main.tsx
89: refetchOnWindowFocus: false, // per-query opt-in
$ grep -E "STALE_TIME\.REFERENCE" web/src/main.tsx
86: staleTime: STALE_TIME.REFERENCE, // 5 min
$ grep -cE "useQuery.*\['profiles" web/src/pages/CertificatesPage.tsx
2 (was 6 pre-Phase-2 — '[profiles]' modal + '[profiles-filter]'
+ '[profiles]' top-of-page; now both refer to the same
parameterized key '[profiles, { per_page: 100 }]')
$ grep -rE "onMutate" web/src --include='*.tsx' --exclude='*.test.*' | wc -l
5 (≥ 4 priority sites; the 5th is the optional onMutate in
queryConstants test wiring)
$ grep -rE "STALE_TIME\." web/src --include='*.tsx' --include='*.ts' \
--exclude='*.test.*' | wc -l
18 (queryConstants.ts + main.tsx + 11 migrated callsites
+ OnboardingWizard + DashboardPage)
$ npx tsc --noEmit
(exit 0)
$ npx vitest run [13 affected test files]
Test Files 13 passed (13)
Tests 100 passed (100)
$ npx vite build
✓ built in 2.49s
dist/assets/index-yg3cYtYA.js 1,113 kB
(+3 kB vs Phase 1 — queryConstants + optimistic-update wrappers)
Audit-accuracy callouts
=======================
* The audit claimed 10 useQuery on Dashboard; live count is 9 (one
issuers query has no interval). All 8 polling queries now gated
behind visibility-listener; the 9th (issuers) is non-polling and
not affected.
* TQ-L1 originally specified 4 test extensions; shipped 5
(onMutate ordering, onError-with-context, no-invalidate-on-error,
onSettled pass-through, parity-with-raw-useMutation).
* Optimistic-update 5th-site (role-assignment toggle in
auth/RoleDetailPage) deferred — RoleDetailPage handlers use raw
async/await instead of useTrackedMutation. Refactoring it adds
one more optimistic path but requires a structural change
outside Phase 2's TQ-discipline scope. Tracked as Phase 2
follow-up.
Residual risks
==============
* The Dashboard visibility-listener gate may need per-page opt-in
if a page genuinely needs to keep polling while hidden (e.g.
a background-tab monitor). Not aware of any such case today;
if needed, the gate is a simple `useState`-driven hook
extracted to web/src/hooks/useTabVisibility.ts.
* The Dashboard backend-aggregation collapse
(dashboard-summary + certs-by-status + certificates → one
endpoint) is documented as a Phase-3 backend item.
* The 4 collapsed CertificatesPage pairs now request per_page=100
everywhere. Operator with >100 issuers/owners/profiles/teams
will see a truncated dropdown — that's an unrelated Phase-1-
Combobox-migration concern; the right fix when it lands is to
move issuer/owner/profile selectors to Combobox with
server-side typeahead.
* The 12-second total Bundle-1 audit of all useQuery sites
still leaves ~230 queries running with the new 5-min
REFERENCE default. The default is generous; aggressively-
fresh per-page queries that genuinely need 15s freshness
must opt in (the audit page, the agent-fleet live counter,
in-flight scan progress).
146 lines
5.7 KiB
TypeScript
146 lines
5.7 KiB
TypeScript
import { useState } from 'react';
|
|
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
|
import { authListUsers, authDeactivateUser, authReactivateUser, type AuthUser } from '../../api/client';
|
|
import PageHeader from '../../components/PageHeader';
|
|
import ErrorState from '../../components/ErrorState';
|
|
import { STALE_TIME } from '../../api/queryConstants';
|
|
|
|
// =============================================================================
|
|
// Audit 2026-05-10 MED-11 closure — Federated-user admin GUI.
|
|
//
|
|
// Lists every federated identity in the active tenant (one row per
|
|
// (oidc_provider_id, oidc_subject) tuple) with last-login + OIDC
|
|
// binding visible. Admins can soft-delete a user via the Deactivate
|
|
// button — server-side sets `deactivated_at` and cascade-revokes
|
|
// active sessions in the same operation. The row is the OIDC binding
|
|
// so destroying it would re-mint a fresh user on next login under the
|
|
// same subject (losing the audit trail); deactivation preserves
|
|
// forensics.
|
|
// =============================================================================
|
|
|
|
export default function UsersPage() {
|
|
const qc = useQueryClient();
|
|
const [providerFilter, setProviderFilter] = useState('');
|
|
const [pending, setPending] = useState<string | null>(null);
|
|
const [err, setErr] = useState<string | null>(null);
|
|
|
|
const usersQuery = useQuery<AuthUser[], Error>({
|
|
queryKey: ['auth', 'users', providerFilter],
|
|
queryFn: () => authListUsers(providerFilter || undefined),
|
|
staleTime: STALE_TIME.REAL_TIME, // operator-facing user list
|
|
});
|
|
|
|
async function deactivate(u: AuthUser) {
|
|
if (!confirm(`Deactivate user ${u.email} (${u.id})?\n\n` +
|
|
`This sets deactivated_at on the row and revokes every active session.\n` +
|
|
`The row is preserved (audit trail) — a future login under the same OIDC subject will fail.`)) {
|
|
return;
|
|
}
|
|
setPending(u.id);
|
|
setErr(null);
|
|
try {
|
|
await authDeactivateUser(u.id);
|
|
await qc.invalidateQueries({ queryKey: ['auth', 'users'] });
|
|
} catch (e) {
|
|
setErr(e instanceof Error ? e.message : String(e));
|
|
} finally {
|
|
setPending(null);
|
|
}
|
|
}
|
|
|
|
// Audit 2026-05-11 A-2 — Reactivate inverse. Clears deactivated_at;
|
|
// the next OIDC login under the same (provider, subject) tuple
|
|
// proceeds normally. Sessions revoked at deactivation stay revoked
|
|
// (the cascade is irreversible by design — the user must complete
|
|
// a fresh login).
|
|
async function reactivate(u: AuthUser) {
|
|
if (!confirm(`Reactivate user ${u.email} (${u.id})?\n\n` +
|
|
`This clears deactivated_at. The user can OIDC-login again. ` +
|
|
`Previously-revoked sessions stay revoked.`)) {
|
|
return;
|
|
}
|
|
setPending(u.id);
|
|
setErr(null);
|
|
try {
|
|
await authReactivateUser(u.id);
|
|
await qc.invalidateQueries({ queryKey: ['auth', 'users'] });
|
|
} catch (e) {
|
|
setErr(e instanceof Error ? e.message : String(e));
|
|
} finally {
|
|
setPending(null);
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div>
|
|
<PageHeader title="Federated Users" subtitle="One row per (oidc_provider_id, oidc_subject) tuple." />
|
|
<div style={{ marginBottom: 16 }}>
|
|
<label style={{ marginRight: 8 }}>Filter by provider:</label>
|
|
<input
|
|
type="text"
|
|
placeholder="op-keycloak (leave empty for all)"
|
|
value={providerFilter}
|
|
onChange={(e) => setProviderFilter(e.target.value)}
|
|
style={{ width: 280, padding: 4 }}
|
|
/>
|
|
</div>
|
|
{err && <ErrorState message={err} />}
|
|
{usersQuery.isLoading && <p>Loading users…</p>}
|
|
{usersQuery.error && <ErrorState message={usersQuery.error.message} />}
|
|
{usersQuery.data && (
|
|
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
|
<thead>
|
|
<tr style={{ borderBottom: '2px solid #ccc', textAlign: 'left' }}>
|
|
<th>ID</th>
|
|
<th>Email</th>
|
|
<th>Display Name</th>
|
|
<th>Provider</th>
|
|
<th>Last Login</th>
|
|
<th>Status</th>
|
|
<th>Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{usersQuery.data.map((u) => {
|
|
const deactivated = Boolean(u.deactivated_at);
|
|
return (
|
|
<tr key={u.id} style={{ borderBottom: '1px solid #eee', opacity: deactivated ? 0.5 : 1 }}>
|
|
<td><code>{u.id}</code></td>
|
|
<td>{u.email}</td>
|
|
<td>{u.display_name}</td>
|
|
<td><code>{u.oidc_provider_id}</code></td>
|
|
<td>{u.last_login_at}</td>
|
|
<td>{deactivated ? `Deactivated ${u.deactivated_at}` : 'Active'}</td>
|
|
<td>
|
|
{!deactivated && (
|
|
<button
|
|
onClick={() => deactivate(u)}
|
|
disabled={pending === u.id}
|
|
style={{ padding: '4px 12px' }}
|
|
>
|
|
{pending === u.id ? 'Deactivating…' : 'Deactivate'}
|
|
</button>
|
|
)}
|
|
{deactivated && (
|
|
<button
|
|
onClick={() => reactivate(u)}
|
|
disabled={pending === u.id}
|
|
style={{ padding: '4px 12px' }}
|
|
>
|
|
{pending === u.id ? 'Reactivating…' : 'Reactivate'}
|
|
</button>
|
|
)}
|
|
</td>
|
|
</tr>
|
|
);
|
|
})}
|
|
{usersQuery.data.length === 0 && (
|
|
<tr><td colSpan={7} style={{ padding: 12, textAlign: 'center' }}>No users matching filter.</td></tr>
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|