users: admin CRUD API and admin shell UI
ci / check (push) Canceled after 0s
ci / images (push) Canceled after 0s

Login, forced password change, admin layout with every 05 §2 page in the
sidebar (unbuilt ones name their milestone), Users table/cards + drawer
(General tab), one-time temporary passwords, reset password. The last
active admin can't be demoted, deactivated or deleted. Audit diffs only
mask secret strings. scripts/e2e has a dependency-free CDP browser check
for this task's acceptance flow.

Task: M1-T4
This commit is contained in:
iamdoubz committed 2026-10-17 15:18:08 -05:00
1 parent 13733ac262
commit 9a81e37725
25 files changed
+1774 -33

No files matched your search

+101
View File
@@ -0,0 +1,101 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import { zodResolver } from '@hookform/resolvers/zod';
import { useQueryClient } from '@tanstack/react-query';
import { useState } from 'react';
import { useForm } from 'react-hook-form';
import { Navigate, useNavigate } from 'react-router';
import { z } from 'zod';
import { ChangePasswordSchema } from '@selfphoneng/shared';
import { authApi } from '../api/client.ts';
import { homeFor, useMe } from '../auth/RequireAuth.tsx';
import { Alert, Button, Field, inputClass } from '../components/ui.tsx';
import { AuthCard } from './LoginPage.tsx';
const FormSchema = ChangePasswordSchema.extend({
confirm: z.string(),
}).refine((v) => v.newPassword === v.confirm, {
path: ['confirm'],
message: 'Passwords do not match',
});
export function ChangePasswordPage() {
const me = useMe();
const nav = useNavigate();
const qc = useQueryClient();
const [error, setError] = useState<string>();
const form = useForm({ resolver: zodResolver(FormSchema) });
const { errors, isSubmitting } = form.formState;
if (me.isPending) return null;
if (me.isError) return <Navigate to="/login" replace />;
const forced = me.data.mustChangePassword;
const onSubmit = form.handleSubmit(async ({ currentPassword, newPassword }) => {
setError(undefined);
try {
await authApi.changePassword(currentPassword, newPassword);
qc.setQueryData(['me'], { ...me.data, mustChangePassword: false });
nav(homeFor(me.data), { replace: true });
} catch (e) {
setError((e as Error).message);
}
});
return (
<AuthCard title="Change your password">
{forced && (
<p className="mb-4 text-sm text-zinc-600 dark:text-zinc-400">
You signed in with a temporary password. Choose a new one to continue.
</p>
)}
<form onSubmit={onSubmit} className="flex flex-col gap-4" noValidate>
{error && <Alert>{error}</Alert>}
<Field label="Current password" error={errors.currentPassword?.message}>
{(a) => (
<input
{...a}
{...form.register('currentPassword')}
type="password"
autoComplete="current-password"
className={inputClass}
/>
)}
</Field>
<Field
label="New password"
error={errors.newPassword?.message}
hint="At least 10 characters. A few random words work well."
>
{(a) => (
<input
{...a}
{...form.register('newPassword')}
type="password"
autoComplete="new-password"
className={inputClass}
/>
)}
</Field>
<Field label="Repeat new password" error={errors.confirm?.message}>
{(a) => (
<input
{...a}
{...form.register('confirm')}
type="password"
autoComplete="new-password"
className={inputClass}
/>
)}
</Field>
<Button type="submit" variant="primary" disabled={isSubmitting}>
{isSubmitting ? 'Saving…' : 'Change password'}
</Button>
{!forced && (
<Button variant="ghost" onClick={() => nav(-1)}>
Cancel
</Button>
)}
</form>
</AuthCard>
);
}
+79
View File
@@ -0,0 +1,79 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import { zodResolver } from '@hookform/resolvers/zod';
import { useQueryClient } from '@tanstack/react-query';
import { useState } from 'react';
import { useForm } from 'react-hook-form';
import { useNavigate, useSearchParams } from 'react-router';
import { APP_NAME, LoginSchema } from '@selfphoneng/shared';
import { authApi } from '../api/client.ts';
import { homeFor } from '../auth/RequireAuth.tsx';
import { Alert, Button, Field, inputClass } from '../components/ui.tsx';
export function AuthCard({ title, children }: { title: string; children: React.ReactNode }) {
return (
<main className="flex min-h-dvh items-center justify-center p-4">
<div className="w-full max-w-sm rounded-lg border border-zinc-200 bg-white p-6 shadow-sm dark:border-zinc-800 dark:bg-zinc-900">
<p className="mb-1 text-sm font-semibold text-sky-600">{APP_NAME}</p>
<h1 className="mb-5 text-xl font-semibold">{title}</h1>
{children}
</div>
</main>
);
}
export function LoginPage() {
const nav = useNavigate();
const [params] = useSearchParams();
const qc = useQueryClient();
const [error, setError] = useState<string>();
const form = useForm({ resolver: zodResolver(LoginSchema.omit({ totp: true })) });
const { errors, isSubmitting } = form.formState;
const onSubmit = form.handleSubmit(async ({ email, password }) => {
setError(undefined);
try {
const user = await authApi.login(email, password);
qc.setQueryData(['me'], user);
const next = params.get('next');
// Only same-site paths: never redirect to another origin.
nav(next?.startsWith('/') && !next.startsWith('//') ? next : homeFor(user), {
replace: true,
});
} catch (e) {
setError((e as Error).message);
}
});
return (
<AuthCard title="Sign in">
<form onSubmit={onSubmit} className="flex flex-col gap-4" noValidate>
{error && <Alert>{error}</Alert>}
<Field label="Email" error={errors.email?.message}>
{(a) => (
<input
{...a}
{...form.register('email')}
type="email"
autoComplete="username"
className={inputClass}
/>
)}
</Field>
<Field label="Password" error={errors.password?.message}>
{(a) => (
<input
{...a}
{...form.register('password')}
type="password"
autoComplete="current-password"
className={inputClass}
/>
)}
</Field>
<Button type="submit" variant="primary" disabled={isSubmitting}>
{isSubmitting ? 'Signing in…' : 'Sign in'}
</Button>
</form>
</AuthCard>
);
}
+136
View File
@@ -0,0 +1,136 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Admin shell (05 §2): sidebar in spec order; unbuilt pages say which milestone brings them.
import { useQueryClient } from '@tanstack/react-query';
import { useState } from 'react';
import { NavLink, Outlet, useNavigate } from 'react-router';
import { APP_NAME, type AuthUser } from '@selfphoneng/shared';
import { authApi } from '../../api/client.ts';
import { ThemeToggle } from '../../components/ThemeToggle.tsx';
import { Button } from '../../components/ui.tsx';
export const ADMIN_PAGES = [
{ path: '', label: 'Dashboard', milestone: 'M2' },
{ path: 'users', label: 'Users & Extensions', milestone: null },
{ path: 'trunks', label: 'Trunks', milestone: 'M3' },
{ path: 'numbers', label: 'Phone numbers', milestone: 'M3' },
{ path: 'outbound', label: 'Outbound rules', milestone: 'M3' },
{ path: 'ring-groups', label: 'Ring groups', milestone: 'M6' },
{ path: 'queues', label: 'Queues', milestone: 'M8' },
{ path: 'ivrs', label: 'Digital receptionist', milestone: 'M6' },
{ path: 'hours', label: 'Office hours', milestone: 'M6' },
{ path: 'conferences', label: 'Conferences', milestone: 'M8' },
{ path: 'paging', label: 'Paging', milestone: 'M8' },
{ path: 'sounds', label: 'Sounds & music', milestone: 'M6' },
{ path: 'devices', label: 'Phones', milestone: 'M10' },
{ path: 'contacts', label: 'Contacts', milestone: 'M10' },
{ path: 'calls', label: 'Call history', milestone: 'M7' },
{ path: 'recordings', label: 'Recordings', milestone: 'M7' },
{ path: 'reports', label: 'Reports', milestone: 'M11' },
{ path: 'sms', label: 'SMS', milestone: 'M9' },
{ path: 'blocked', label: 'Blocked numbers', milestone: 'M6' },
{ path: 'settings', label: 'Settings', milestone: 'M3' },
{ path: 'system', label: 'System', milestone: 'M2' },
] as const;
export function AdminLayout({ user }: { user: AuthUser }) {
const [navOpen, setNavOpen] = useState(false);
const qc = useQueryClient();
const nav = useNavigate();
async function logout() {
await authApi.logout().catch(() => undefined);
qc.clear();
nav('/login', { replace: true });
}
const links = (
<nav aria-label="Admin" className="flex-1 overflow-y-auto px-2 pb-4">
<ul className="flex flex-col gap-0.5">
{ADMIN_PAGES.map((p) => (
<li key={p.path}>
<NavLink
to={`/admin${p.path ? `/${p.path}` : ''}`}
end={p.path === ''}
onClick={() => setNavOpen(false)}
className={({ isActive }) =>
`flex items-center justify-between rounded-md px-3 py-1.5 text-sm ${isActive ? 'bg-sky-100 font-medium text-sky-900 dark:bg-sky-950 dark:text-sky-100' : 'hover:bg-zinc-200/60 dark:hover:bg-zinc-800'} ${p.milestone ? 'text-zinc-500' : ''}`
}
>
{p.label}
{p.milestone && <span className="text-[10px] text-zinc-400">{p.milestone}</span>}
</NavLink>
</li>
))}
</ul>
</nav>
);
return (
<div className="flex min-h-dvh">
<aside className="sticky top-0 hidden h-dvh w-60 flex-col border-r border-zinc-200 bg-zinc-50 md:flex dark:border-zinc-800 dark:bg-zinc-900">
<p className="px-4 py-4 text-lg font-semibold">{APP_NAME}</p>
{links}
</aside>
<div className="flex min-w-0 flex-1 flex-col">
<header className="flex items-center gap-1 border-b border-zinc-200 px-2 py-2 sm:gap-2 sm:px-4 dark:border-zinc-800">
<Button
variant="ghost"
className="md:hidden"
aria-expanded={navOpen}
aria-controls="mobile-nav"
aria-label="Menu"
onClick={() => setNavOpen((o) => !o)}
>
☰
</Button>
<span className="font-semibold md:hidden">{APP_NAME}</span>
<div className="ml-auto flex items-center gap-1 whitespace-nowrap sm:gap-2">
<span className="hidden text-sm text-zinc-600 sm:inline dark:text-zinc-400">
{user.displayName}
</span>
<ThemeToggle />
{/* On phones this lives in the menu instead. */}
<span className="hidden sm:contents">
<Button variant="ghost" onClick={() => nav('/change-password')}>
Password
</Button>
</span>
<Button variant="ghost" onClick={logout}>
Sign out
</Button>
</div>
</header>
{navOpen && (
<div
id="mobile-nav"
className="border-b border-zinc-200 bg-zinc-50 pt-2 md:hidden dark:border-zinc-800 dark:bg-zinc-900"
>
{links}
<div className="border-t border-zinc-200 px-2 py-2 dark:border-zinc-800">
<Button
variant="ghost"
className="w-full justify-start"
onClick={() => nav('/change-password')}
>
Change password
</Button>
</div>
</div>
)}
<main className="flex-1 p-4 md:p-6">
<Outlet />
</main>
</div>
</div>
);
}
export function ComingSoon({ label, milestone }: { label: string; milestone: string }) {
return (
<section>
<h1 className="mb-2 text-xl font-semibold">{label}</h1>
<p className="text-sm text-zinc-500">Coming in {milestone}.</p>
</section>
);
}
+325
View File
@@ -0,0 +1,325 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Users & Extensions (05 §2). M1: the General tab only; extension tabs arrive in M2.
import { zodResolver } from '@hookform/resolvers/zod';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useState } from 'react';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { RoleSchema, type UserDto } from '@selfphoneng/shared';
import { usersApi } from '../../api/client.ts';
import { ConfirmDialog, Dialog } from '../../components/Dialog.tsx';
import { Alert, Button, Field, inputClass } from '../../components/ui.tsx';
const FormSchema = z.object({
displayName: z.string().trim().min(1, 'Required').max(100),
email: z.email('Enter a valid email'),
role: RoleSchema,
isActive: z.boolean(),
});
type FormValues = z.infer<typeof FormSchema>;
const fmtDate = (iso: string | null) => (iso ? new Date(iso).toLocaleString() : 'Never');
export function UsersPage({ currentUserId }: { currentUserId: string }) {
const qc = useQueryClient();
const users = useQuery({ queryKey: ['users'], queryFn: usersApi.list });
const [editing, setEditing] = useState<UserDto | 'new' | null>(null);
const [deleting, setDeleting] = useState<UserDto | null>(null);
const [tempPassword, setTempPassword] = useState<{ email: string; password: string } | null>(
null,
);
const refresh = () => qc.invalidateQueries({ queryKey: ['users'] });
const remove = useMutation({
mutationFn: (id: string) => usersApi.remove(id),
onSuccess: () => {
setDeleting(null);
void refresh();
},
});
const reset = useMutation({
mutationFn: (u: UserDto) => usersApi.resetPassword(u.id),
onSuccess: (r, u) => setTempPassword({ email: u.email, password: r.temporaryPassword }),
});
return (
<section className="flex flex-col gap-4">
<div className="flex flex-wrap items-center justify-between gap-2">
<h1 className="text-xl font-semibold">Users & Extensions</h1>
<Button variant="primary" onClick={() => setEditing('new')}>
New user
</Button>
</div>
{users.isError && <Alert>{users.error.message}</Alert>}
{users.isPending && <p className="text-sm text-zinc-500">Loading…</p>}
{users.data && (
<UsersTable
users={users.data}
onEdit={setEditing}
onDelete={(u) => {
remove.reset();
setDeleting(u);
}}
/>
)}
{editing && (
<UserDrawer
user={editing === 'new' ? null : editing}
isSelf={editing !== 'new' && editing.id === currentUserId}
onClose={() => setEditing(null)}
onSaved={(created) => {
setEditing(null);
void refresh();
if (created) setTempPassword(created);
}}
onResetPassword={(u) => reset.mutate(u)}
/>
)}
<ConfirmDialog
open={deleting !== null}
title="Delete user"
message={
<p>
Delete <strong>{deleting?.displayName}</strong> ({deleting?.email})? They will be signed
out and can no longer log in.
</p>
}
confirmLabel="Delete"
busy={remove.isPending}
error={remove.error?.message}
onCancel={() => setDeleting(null)}
onConfirm={() => deleting && remove.mutate(deleting.id)}
/>
<Dialog
open={tempPassword !== null}
onClose={() => setTempPassword(null)}
title="Temporary password"
footer={
<Button variant="primary" onClick={() => setTempPassword(null)}>
Done
</Button>
}
>
<div className="flex flex-col gap-3 text-sm">
<p>
Give this password to <strong>{tempPassword?.email}</strong>. It is shown only once, and
they must change it when they first sign in.
</p>
<div className="flex items-center gap-2">
<code className="flex-1 rounded-md bg-zinc-100 px-3 py-2 font-mono text-base dark:bg-zinc-800">
{tempPassword?.password}
</code>
<Button
onClick={() => void navigator.clipboard?.writeText(tempPassword?.password ?? '')}
>
Copy
</Button>
</div>
</div>
</Dialog>
</section>
);
}
function UsersTable({
users,
onEdit,
onDelete,
}: {
users: UserDto[];
onEdit: (u: UserDto) => void;
onDelete: (u: UserDto) => void;
}) {
if (users.length === 0) return <p className="text-sm text-zinc-500">No users yet.</p>;
const badge = (u: UserDto) => (
<span className="flex flex-wrap gap-1">
{u.role === 'admin' && (
<span className="rounded bg-sky-100 px-1.5 py-0.5 text-xs text-sky-800 dark:bg-sky-950 dark:text-sky-200">
admin
</span>
)}
{!u.isActive && (
<span className="rounded bg-zinc-200 px-1.5 py-0.5 text-xs dark:bg-zinc-700">inactive</span>
)}
</span>
);
const actions = (u: UserDto) => (
<span className="flex gap-1">
<Button variant="ghost" onClick={() => onEdit(u)} aria-label={`Edit ${u.displayName}`}>
Edit
</Button>
<Button variant="ghost" onClick={() => onDelete(u)} aria-label={`Delete ${u.displayName}`}>
Delete
</Button>
</span>
);
return (
<>
{/* Stacked cards on small screens (05 §1). */}
<ul className="flex flex-col gap-2 md:hidden">
{users.map((u) => (
<li key={u.id} className="rounded-lg border border-zinc-200 p-3 dark:border-zinc-800">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<p className="font-medium">{u.displayName}</p>
<p className="truncate text-sm text-zinc-500">{u.email}</p>
</div>
{badge(u)}
</div>
<div className="mt-2 flex items-center justify-between text-xs text-zinc-500">
<span>Last login: {fmtDate(u.lastLoginAt)}</span>
{actions(u)}
</div>
</li>
))}
</ul>
<table className="hidden w-full text-left text-sm md:table">
<thead className="border-b border-zinc-200 text-zinc-500 dark:border-zinc-800">
<tr>
<th className="py-2 font-medium">Name</th>
<th className="py-2 font-medium">Email</th>
<th className="py-2 font-medium">Role</th>
<th className="py-2 font-medium">Last login</th>
<th className="py-2 font-medium">
<span className="sr-only">Actions</span>
</th>
</tr>
</thead>
<tbody>
{users.map((u) => (
<tr key={u.id} className="border-b border-zinc-100 dark:border-zinc-800/60">
<td className="py-2 font-medium">{u.displayName}</td>
<td className="py-2">{u.email}</td>
<td className="py-2">{badge(u)}</td>
<td className="py-2 text-zinc-500">{fmtDate(u.lastLoginAt)}</td>
<td className="py-2 text-right">{actions(u)}</td>
</tr>
))}
</tbody>
</table>
</>
);
}
function UserDrawer({
user,
isSelf,
onClose,
onSaved,
onResetPassword,
}: {
user: UserDto | null;
isSelf: boolean;
onClose: () => void;
onSaved: (created?: { email: string; password: string }) => void;
onResetPassword: (u: UserDto) => void;
}) {
const [error, setError] = useState<string>();
const form = useForm<FormValues>({
resolver: zodResolver(FormSchema),
defaultValues: user
? {
displayName: user.displayName,
email: user.email,
role: user.role,
isActive: user.isActive,
}
: { displayName: '', email: '', role: 'user', isActive: true },
});
const { errors, isSubmitting } = form.formState;
const onSubmit = form.handleSubmit(async (v) => {
setError(undefined);
try {
if (user) {
await usersApi.update(user.id, v);
onSaved();
} else {
const r = await usersApi.create({
displayName: v.displayName,
email: v.email,
role: v.role,
});
onSaved({ email: r.user.email, password: r.temporaryPassword });
}
} catch (e) {
setError((e as Error).message);
}
});
return (
<Dialog
open
drawer
onClose={onClose}
title={user ? `Edit ${user.displayName}` : 'New user'}
footer={
<>
<Button onClick={onClose}>Cancel</Button>
<Button variant="primary" type="submit" form="user-form" disabled={isSubmitting}>
{user ? 'Save' : 'Create user'}
</Button>
</>
}
>
{/* Tabs (05 §2): General now; Phone, Voicemail, Calling, Emergency, Credentials and
Forwarding arrive with extensions in M2. */}
<div role="tablist" className="mb-4 flex gap-1 border-b border-zinc-200 dark:border-zinc-800">
<span
role="tab"
aria-selected="true"
className="border-b-2 border-sky-600 px-3 py-1.5 text-sm font-medium"
>
General
</span>
</div>
<form id="user-form" onSubmit={onSubmit} className="flex flex-col gap-4" noValidate>
{error && <Alert>{error}</Alert>}
<Field label="Name" error={errors.displayName?.message}>
{(a) => <input {...a} {...form.register('displayName')} className={inputClass} />}
</Field>
<Field label="Email" error={errors.email?.message}>
{(a) => <input {...a} {...form.register('email')} type="email" className={inputClass} />}
</Field>
<Field
label="Role"
error={errors.role?.message}
hint={
isSelf ? 'You cannot remove your own admin role if you are the last admin.' : undefined
}
>
{(a) => (
<select {...a} {...form.register('role')} className={inputClass}>
<option value="user">User</option>
<option value="admin">Admin</option>
</select>
)}
</Field>
{user && (
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
{...form.register('isActive')}
className="size-4"
disabled={isSelf}
/>
Active (inactive users cannot sign in)
</label>
)}
<p className="text-xs text-zinc-500">Extension number and phone settings: coming in M2.</p>
{user && (
<div className="border-t border-zinc-200 pt-4 dark:border-zinc-800">
<Button onClick={() => onResetPassword(user)}>Reset password</Button>
<p className="mt-1 text-xs text-zinc-500">
Creates a new temporary password and signs them out everywhere.
</p>
</div>
)}
</form>
</Dialog>
);
}
+47
View File
@@ -0,0 +1,47 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// My Phone (05 §4) arrives in M4/M5; for now a signed-in landing page for non-admins.
import { useQueryClient } from '@tanstack/react-query';
import { Link, useNavigate } from 'react-router';
import { APP_NAME, type AuthUser } from '@selfphoneng/shared';
import { authApi } from '../../api/client.ts';
import { ThemeToggle } from '../../components/ThemeToggle.tsx';
import { Button } from '../../components/ui.tsx';
export function MePage({ user }: { user: AuthUser }) {
const qc = useQueryClient();
const nav = useNavigate();
return (
<main className="mx-auto flex min-h-dvh max-w-3xl flex-col gap-6 p-4 sm:p-8">
<header className="flex items-center justify-between gap-2">
<h1 className="text-xl font-semibold">{APP_NAME}</h1>
<div className="flex items-center gap-2">
<ThemeToggle />
<Button
variant="ghost"
onClick={async () => {
await authApi.logout().catch(() => undefined);
qc.clear();
nav('/login', { replace: true });
}}
>
Sign out
</Button>
</div>
</header>
<p>
Hi {user.displayName}. Voicemail, call history and the browser softphone are coming in
M4–M5.
</p>
<p className="flex gap-4 text-sm">
<Link className="text-sky-700 underline dark:text-sky-400" to="/change-password">
Change password
</Link>
{user.role === 'admin' && (
<Link className="text-sky-700 underline dark:text-sky-400" to="/admin">
Admin
</Link>
)}
</p>
</main>
);
}