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)}