From 3ef40914a17d6ecf488c07ab61b6c94814f125ab Mon Sep 17 00:00:00 2001 From: Waleed Khalid Date: Fri, 11 Sep 2026 22:44:40 +0500 Subject: [PATCH] Add the site admin user directory Closes #17. Only offered to an account that actually has site admin, so there is nothing to discover that the server would then refuse. The two guards -- you cannot deactivate or demote yourself, and the last active site admin is protected -- are mirrored in the sheet, not to enforce them but so the action is explained before it is attempted rather than merely refused after. The last-admin count is a hint from one page; the server has the real answer and is still the one that decides. Deactivated accounts stay in the list, dimmed, because reactivating them is the whole reason they are listed. Setting a password exists because nothing here sends email, so the hint says to pass it on out of band rather than implying a reset link is on its way. Verified against a live instance, including that a deactivated account is refused at sign-in with its own 403 rather than "incorrect password", and that both self-guards hold. Co-Authored-By: Claude Opus 5 --- src/admin/site-admin-sheet.tsx | 256 ++++++++++++++++++++++ src/api/__tests__/live-site-admin.test.ts | 118 ++++++++++ src/settings/you-screen.tsx | 19 +- 3 files changed, 392 insertions(+), 1 deletion(-) create mode 100644 src/admin/site-admin-sheet.tsx create mode 100644 src/api/__tests__/live-site-admin.test.ts diff --git a/src/admin/site-admin-sheet.tsx b/src/admin/site-admin-sheet.tsx new file mode 100644 index 0000000..e9e8096 --- /dev/null +++ b/src/admin/site-admin-sheet.tsx @@ -0,0 +1,256 @@ +import { useState } from 'react'; +import { Alert as RNAlert, Pressable, View } from 'react-native'; +import { useQueryClient } from '@tanstack/react-query'; + +import { + resetPasswordAdminUsersUserIdResetPasswordPost, + updateUserAdminUsersUserIdPatch, + useListUsersAdminUsersGet, +} from '@/api/generated/endpoints/admin/admin'; +import type { AdminUserRead, UserMe } from '@/api/generated/models'; +import { errorDetail } from '@/api/errors'; +import { AppText, Alert, Avatar, Button, Card, Field } from '@/ui/primitives'; +import { Sheet } from '@/ui/sheet'; +import { useDebouncedValue } from '@/ui/use-debounced-value'; +import { useTokens } from '@/ui/theme'; + +/** + * The user directory, per `docs/design/mobile/154-site-admin.svg`. + * + * Site admins only; the server refuses everyone else, and the entry point is + * hidden for them so there is nothing to discover that would be denied. + * + * Every guard is the server's -- you cannot deactivate or demote yourself, and + * the last active site admin is protected. Those two are checked here as well, + * not to enforce them but so the action is explained rather than merely refused. + */ +const PAGE = 25; + +export function SiteAdminSheet({ + visible, + onClose, + user, +}: { + visible: boolean; + onClose: () => void; + user: UserMe | null; +}) { + const t = useTokens(); + const queryClient = useQueryClient(); + + const [query, setQuery] = useState(''); + const [limit, setLimit] = useState(PAGE); + const [error, setError] = useState(null); + const [resetting, setResetting] = useState(null); + + const settled = useDebouncedValue(query.trim(), 300); + const users = useListUsersAdminUsersGet( + { q: settled || undefined, limit, offset: 0 }, + { query: { enabled: visible } }, + ); + + const items = users.data?.items ?? []; + const total = users.data?.total ?? 0; + const activeAdmins = items.filter((row) => row.is_site_admin && row.is_active).length; + + async function run(action: () => Promise, fallback: string) { + setError(null); + try { + await action(); + await queryClient.invalidateQueries({ queryKey: ['/admin/users'] }); + } catch (err) { + setError(errorDetail(err, fallback)); + } + } + + function manage(row: AdminUserRead) { + const isSelf = row.id === user?.id; + // The page shows one page at a time, so this count is only a hint -- the + // server has the real answer and will refuse regardless. + const lastAdmin = row.is_site_admin && row.is_active && activeAdmins <= 1; + + const options: Parameters[2] = [ + { text: 'Cancel', style: 'cancel' }, + ]; + + if (!isSelf && !lastAdmin) { + options.push({ + text: row.is_site_admin ? 'Revoke site admin' : 'Grant site admin', + onPress: () => + void run( + () => + updateUserAdminUsersUserIdPatch(row.id, { + is_site_admin: !row.is_site_admin, + }).then(() => undefined), + 'Could not change that role.', + ), + }); + } + + options.push({ + text: 'Set a password', + onPress: () => setResetting(row), + }); + + if (!isSelf && !lastAdmin) { + options.push({ + text: row.is_active ? 'Deactivate' : 'Reactivate', + style: row.is_active ? 'destructive' : 'default', + onPress: () => + void run( + () => + updateUserAdminUsersUserIdPatch(row.id, { + is_active: !row.is_active, + }).then(() => undefined), + 'Could not change that account.', + ), + }); + } + + RNAlert.alert( + row.full_name, + isSelf + ? 'You cannot deactivate or demote your own account.' + : lastAdmin + ? 'This is the last active site admin — promote somebody else first.' + : row.email, + options, + ); + } + + if (!visible) return null; + + return ( + + + {error ? {error} : null} + + + + {items.map((row) => ( + manage(row)} + > + + + + + {row.full_name} + {row.id === user?.id ? ' (you)' : ''} + + + {row.email} · {row.team_count} team{row.team_count === 1 ? '' : 's'} + + + + + {row.is_site_admin ? ( + + Site admin + + ) : null} + {!row.is_active ? ( + + Deactivated + + ) : null} + + + + ))} + + {items.length === 0 && !users.isPending ? ( + No account matches that. + ) : null} + + {total > items.length ? ( + + ) : null} + + + {resetting ? ( + setResetting(null)} + onRun={run} + /> + ) : null} + + ); +} + +/** + * Setting a password for somebody locked out. + * + * The admin types it and passes it on out of band -- there is no email here to + * send a reset link through, which is exactly why this exists. + */ +function SetPasswordSheet({ + row, + onClose, + onRun, +}: { + row: AdminUserRead; + onClose: () => void; + onRun: (action: () => Promise, fallback: string) => Promise; +}) { + const [password, setPassword] = useState(''); + const [invalid, setInvalid] = useState(null); + + return ( + + + {invalid ? {invalid} : null} + + + + + + + ); +} diff --git a/src/api/__tests__/live-site-admin.test.ts b/src/api/__tests__/live-site-admin.test.ts new file mode 100644 index 0000000..e91e4a1 --- /dev/null +++ b/src/api/__tests__/live-site-admin.test.ts @@ -0,0 +1,118 @@ +/** + * @jest-environment node + */ +import { + listUsersAdminUsersGet, + resetPasswordAdminUsersUserIdResetPasswordPost, + updateUserAdminUsersUserIdPatch, +} from '@/api/generated/endpoints/admin/admin'; +import { + loginAuthLoginPost, + meAuthMeGet, + registerAuthRegisterPost, +} from '@/api/generated/endpoints/auth/auth'; +import { setInstanceUrl } from '@/api/instance'; +import { persistToken } from '@/auth/session'; + +/** + * The user directory against a real instance. + * + * The demo account is a site admin, which is what makes this testable at all. + */ +const LIVE_URL = process.env.SOFTTRACK_LIVE_URL; +const describeLive = LIVE_URL ? describe : describe.skip; + +const RUN = Date.now().toString(36).slice(-5); +const SUBJECT_EMAIL = `mob-subject-${RUN}@example.com`; + +describeLive('site administration against a live instance', () => { + let adminToken: string; + let adminId: number; + let subjectId: number; + + beforeAll(async () => { + await setInstanceUrl(LIVE_URL!); + const admin = await loginAuthLoginPost({ + username: process.env.SOFTTRACK_LIVE_EMAIL ?? 'demo@softtrack.dev', + password: process.env.SOFTTRACK_LIVE_PASSWORD ?? 'password123', + }); + adminToken = admin.access_token; + adminId = admin.user.id; + expect(admin.user.is_site_admin).toBe(true); + + const subject = await registerAuthRegisterPost({ + email: SUBJECT_EMAIL, + password: 'password123', + full_name: 'Directory Subject', + }); + subjectId = subject.user.id; + await persistToken(adminToken); + }); + + it('searches the directory', async () => { + const page = await listUsersAdminUsersGet({ q: `mob-subject-${RUN}`, limit: 25 }); + const found = page.items.find((row) => row.id === subjectId); + expect(found).toBeDefined(); + // Every field the row renders. + expect(found?.email).toBe(SUBJECT_EMAIL); + expect(found?.is_active).toBe(true); + expect(found?.is_site_admin).toBe(false); + expect(typeof found?.team_count).toBe('number'); + }); + + it('grants and revokes site admin', async () => { + const granted = await updateUserAdminUsersUserIdPatch(subjectId, { + is_site_admin: true, + }); + expect(granted.is_site_admin).toBe(true); + + const revoked = await updateUserAdminUsersUserIdPatch(subjectId, { + is_site_admin: false, + }); + expect(revoked.is_site_admin).toBe(false); + }); + + it('deactivates and reactivates, and a deactivated account cannot sign in', async () => { + await updateUserAdminUsersUserIdPatch(subjectId, { is_active: false }); + + // 403 with its own message, which is what login surfaces rather than + // "incorrect password". + await expect( + loginAuthLoginPost({ username: SUBJECT_EMAIL, password: 'password123' }), + ).rejects.toMatchObject({ response: { status: 403 } }); + + const reactivated = await updateUserAdminUsersUserIdPatch(subjectId, { + is_active: true, + }); + expect(reactivated.is_active).toBe(true); + }); + + it('sets a password for somebody locked out', async () => { + // There is no email here to send a reset link through, which is exactly why + // this exists. + await resetPasswordAdminUsersUserIdResetPasswordPost(subjectId, { + new_password: 'brand-new-password', + }); + + const signedIn = await loginAuthLoginPost({ + username: SUBJECT_EMAIL, + password: 'brand-new-password', + }); + expect(signedIn.user.id).toBe(subjectId); + await persistToken(adminToken); + }); + + it('refuses to let an admin deactivate or demote themselves', async () => { + // Both guards the sheet explains up front rather than discovering. + await expect( + updateUserAdminUsersUserIdPatch(adminId, { is_active: false }), + ).rejects.toMatchObject({ response: { status: 400 } }); + + await expect( + updateUserAdminUsersUserIdPatch(adminId, { is_site_admin: false }), + ).rejects.toMatchObject({ response: { status: 400 } }); + + // Still a working admin afterwards. + expect((await meAuthMeGet()).is_site_admin).toBe(true); + }); +}); diff --git a/src/settings/you-screen.tsx b/src/settings/you-screen.tsx index e0f5768..11cd09d 100644 --- a/src/settings/you-screen.tsx +++ b/src/settings/you-screen.tsx @@ -15,6 +15,7 @@ import { import { errorDetail } from '@/api/errors'; import { instanceLabel, setInstanceUrl } from '@/api/instance'; import { useAuth } from '@/auth/auth-context'; +import { SiteAdminSheet } from '@/admin/site-admin-sheet'; import { isValidUsername } from '@/settings/username'; import { useIsMultiPane } from '@/ui/layout'; import { AppText, Alert, Avatar, Button, Card, Field } from '@/ui/primitives'; @@ -39,7 +40,7 @@ const AVATAR_COLORS = [ '#22c55e', ]; -type Panel = 'profile' | 'security' | 'notifications' | 'instance' | null; +type Panel = 'profile' | 'security' | 'notifications' | 'instance' | 'admin' | null; export function YouScreen() { const t = useTokens(); @@ -78,6 +79,17 @@ export function YouScreen() { + {/* Only for an account that actually has it; there is nothing to + discover here that the server would then refuse. */} + {user?.is_site_admin ? ( + + ADMINISTRATION + + setPanel('admin')} /> + + + ) : null} + APPEARANCE @@ -113,6 +125,11 @@ export function YouScreen() { visible={panel === 'notifications'} onClose={() => setPanel(null)} /> + setPanel(null)} + user={user} + /> setPanel(null)}