users: admin CRUD API and admin shell UI
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:
1 parent
13733ac262
commit
9a81e37725
25 files changed
+1774
-33
No files matched your search
@@ -10,9 +10,12 @@
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hookform/resolvers": "^5.9.1",
|
||||
"@selfphoneng/shared": "workspace:*",
|
||||
"@tanstack/react-query": "^5.104.3",
|
||||
"react": "^19.3.1",
|
||||
"react-dom": "^19.3.1",
|
||||
"react-hook-form": "^7.90.0",
|
||||
"react-router": "^8.4.0",
|
||||
"zod": "^4.6.7"
|
||||
},
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// Typed fetch wrappers (05 §1). Every request sends X-Requested-With (CSRF, 04 §2.2).
|
||||
import type { AuthUser, ListResponse, UserCreate, UserDto, UserUpdate } from '@selfphoneng/shared';
|
||||
|
||||
export class ApiError extends Error {
|
||||
readonly status: number;
|
||||
readonly code: string;
|
||||
readonly details: unknown;
|
||||
constructor(status: number, code: string, message: string, details: unknown) {
|
||||
super(message);
|
||||
this.status = status;
|
||||
this.code = code;
|
||||
this.details = details;
|
||||
}
|
||||
}
|
||||
|
||||
async function api<T>(method: string, url: string, body?: unknown): Promise<T> {
|
||||
const res = await fetch(url, {
|
||||
method,
|
||||
credentials: 'same-origin',
|
||||
headers: {
|
||||
'X-Requested-With': 'selfphoneng',
|
||||
...(body === undefined ? {} : { 'Content-Type': 'application/json' }),
|
||||
},
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
});
|
||||
const data = res.headers.get('content-type')?.includes('json') ? await res.json() : null;
|
||||
if (!res.ok) {
|
||||
const e = data?.error ?? {};
|
||||
throw new ApiError(res.status, e.code ?? 'internal', e.message ?? res.statusText, e.details);
|
||||
}
|
||||
return data as T;
|
||||
}
|
||||
|
||||
export const authApi = {
|
||||
me: () => api<{ user: AuthUser }>('GET', '/api/auth/me').then((r) => r.user),
|
||||
login: (email: string, password: string) =>
|
||||
api<{ user: AuthUser }>('POST', '/api/auth/login', { email, password }).then((r) => r.user),
|
||||
logout: () => api<{ ok: true }>('POST', '/api/auth/logout'),
|
||||
changePassword: (currentPassword: string, newPassword: string) =>
|
||||
api<{ ok: true }>('POST', '/api/auth/change-password', { currentPassword, newPassword }),
|
||||
};
|
||||
|
||||
export const usersApi = {
|
||||
list: () => api<ListResponse<UserDto>>('GET', '/api/users').then((r) => r.items),
|
||||
create: (u: UserCreate) =>
|
||||
api<{ user: UserDto; temporaryPassword: string }>('POST', '/api/users', u),
|
||||
update: (id: string, u: UserUpdate) => api<UserDto>('PATCH', `/api/users/${id}`, u),
|
||||
remove: (id: string) => api<{ ok: true }>('DELETE', `/api/users/${id}`),
|
||||
resetPassword: (id: string) =>
|
||||
api<{ temporaryPassword: string }>('POST', `/api/users/${id}/reset-password`),
|
||||
};
|
||||
@@ -0,0 +1,41 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import type { ReactNode } from 'react';
|
||||
import { Navigate, useLocation } from 'react-router';
|
||||
import type { AuthUser } from '@selfphoneng/shared';
|
||||
import { ApiError, authApi } from '../api/client.ts';
|
||||
|
||||
export const meQuery = {
|
||||
queryKey: ['me'],
|
||||
queryFn: authApi.me,
|
||||
retry: (n: number, e: Error) => !(e instanceof ApiError && e.status === 401) && n < 2,
|
||||
staleTime: 60_000,
|
||||
};
|
||||
|
||||
export function useMe() {
|
||||
return useQuery(meQuery);
|
||||
}
|
||||
|
||||
/** Gate for signed-in pages: login first, then a forced password change, then the role check. */
|
||||
export function RequireAuth({
|
||||
role,
|
||||
children,
|
||||
}: {
|
||||
role?: AuthUser['role'];
|
||||
children: (user: AuthUser) => ReactNode;
|
||||
}) {
|
||||
const me = useMe();
|
||||
const loc = useLocation();
|
||||
if (me.isPending) return <p className="p-6 text-sm text-zinc-500">Loading…</p>;
|
||||
if (me.isError) {
|
||||
if (me.error instanceof ApiError && me.error.status === 401)
|
||||
return <Navigate to={`/login?next=${encodeURIComponent(loc.pathname)}`} replace />;
|
||||
return <p className="p-6 text-sm text-red-600">Could not reach the server.</p>;
|
||||
}
|
||||
const user = me.data;
|
||||
if (user.mustChangePassword) return <Navigate to="/change-password" replace />;
|
||||
if (role && user.role !== role) return <Navigate to="/" replace />;
|
||||
return <>{children(user)}</>;
|
||||
}
|
||||
|
||||
export const homeFor = (user: AuthUser) => (user.role === 'admin' ? '/admin' : '/me');
|
||||
@@ -0,0 +1,99 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// Native <dialog> (focus trap + Esc for free) as a centered modal or a right-hand drawer.
|
||||
import { useEffect, useRef, type ReactNode } from 'react';
|
||||
import { Button } from './ui.tsx';
|
||||
|
||||
export function Dialog({
|
||||
open,
|
||||
onClose,
|
||||
title,
|
||||
children,
|
||||
footer,
|
||||
drawer = false,
|
||||
}: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
title: string;
|
||||
children: ReactNode;
|
||||
footer?: ReactNode;
|
||||
drawer?: boolean;
|
||||
}) {
|
||||
const ref = useRef<HTMLDialogElement>(null);
|
||||
useEffect(() => {
|
||||
const d = ref.current;
|
||||
if (!d) return;
|
||||
if (open && !d.open) d.showModal();
|
||||
if (!open && d.open) d.close();
|
||||
}, [open]);
|
||||
|
||||
const shape = drawer
|
||||
? 'ml-auto mr-0 h-dvh max-h-dvh w-full max-w-md'
|
||||
: 'm-auto w-[calc(100%-2rem)] max-w-md rounded-lg';
|
||||
return (
|
||||
<dialog
|
||||
ref={ref}
|
||||
onClose={onClose}
|
||||
onClick={(e) => e.target === ref.current && onClose()}
|
||||
aria-labelledby="dialog-title"
|
||||
className={`${shape} bg-white p-0 text-zinc-900 shadow-xl backdrop:bg-black/40 dark:bg-zinc-900 dark:text-zinc-100`}
|
||||
>
|
||||
<div className="flex h-full flex-col">
|
||||
<header className="flex items-center justify-between border-b border-zinc-200 px-4 py-3 dark:border-zinc-800">
|
||||
<h2 id="dialog-title" className="text-base font-semibold">
|
||||
{title}
|
||||
</h2>
|
||||
<Button variant="ghost" onClick={onClose} aria-label="Close" className="px-2 py-1">
|
||||
✕
|
||||
</Button>
|
||||
</header>
|
||||
<div className="flex-1 overflow-y-auto p-4">{children}</div>
|
||||
{footer && (
|
||||
<footer className="flex justify-end gap-2 border-t border-zinc-200 px-4 py-3 dark:border-zinc-800">
|
||||
{footer}
|
||||
</footer>
|
||||
)}
|
||||
</div>
|
||||
</dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export function ConfirmDialog({
|
||||
open,
|
||||
title,
|
||||
message,
|
||||
confirmLabel,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
busy,
|
||||
error,
|
||||
}: {
|
||||
open: boolean;
|
||||
title: string;
|
||||
message: ReactNode;
|
||||
confirmLabel: string;
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
busy?: boolean;
|
||||
error?: string;
|
||||
}) {
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onClose={onCancel}
|
||||
title={title}
|
||||
footer={
|
||||
<>
|
||||
<Button onClick={onCancel}>Cancel</Button>
|
||||
<Button variant="danger" onClick={onConfirm} disabled={busy}>
|
||||
{confirmLabel}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="flex flex-col gap-3 text-sm">
|
||||
{message}
|
||||
{error && <p className="text-red-600 dark:text-red-400">{error}</p>}
|
||||
</div>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
import { useState } from 'react';
|
||||
import { toggleTheme } from '../theme.ts';
|
||||
import { Button } from './ui.tsx';
|
||||
|
||||
export function ThemeToggle() {
|
||||
const [dark, setDark] = useState(() => document.documentElement.classList.contains('dark'));
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
aria-pressed={dark}
|
||||
aria-label={dark ? 'Switch to light mode' : 'Switch to dark mode'}
|
||||
onClick={() => setDark(toggleTheme())}
|
||||
>
|
||||
{dark ? '☀' : '☾'}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// Small form building blocks shared by every page.
|
||||
import { useId, type ComponentProps, type ReactNode } from 'react';
|
||||
|
||||
const focus =
|
||||
'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-sky-500';
|
||||
|
||||
const variants = {
|
||||
primary: 'bg-sky-600 text-white hover:bg-sky-700 disabled:bg-sky-600/50',
|
||||
secondary:
|
||||
'border border-zinc-300 bg-white hover:bg-zinc-100 dark:border-zinc-700 dark:bg-zinc-900 dark:hover:bg-zinc-800',
|
||||
danger: 'bg-red-600 text-white hover:bg-red-700 disabled:bg-red-600/50',
|
||||
ghost: 'hover:bg-zinc-100 dark:hover:bg-zinc-800',
|
||||
};
|
||||
|
||||
export function Button({
|
||||
variant = 'secondary',
|
||||
className = '',
|
||||
...props
|
||||
}: ComponentProps<'button'> & { variant?: keyof typeof variants }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={`inline-flex items-center justify-center gap-2 rounded-md px-3 py-2 text-sm font-medium disabled:cursor-not-allowed ${variants[variant]} ${focus} ${className}`}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export const inputClass = `w-full rounded-md border border-zinc-300 bg-white px-3 py-2 text-sm dark:border-zinc-700 dark:bg-zinc-900 ${focus} aria-invalid:border-red-500`;
|
||||
|
||||
/** Label + control + error text. `children` gets the id and aria props to spread on the control. */
|
||||
export function Field({
|
||||
label,
|
||||
error,
|
||||
hint,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
error?: string;
|
||||
hint?: string;
|
||||
children: (a: { id: string; 'aria-invalid': boolean; 'aria-describedby'?: string }) => ReactNode;
|
||||
}) {
|
||||
const id = useId();
|
||||
const msgId = `${id}-msg`;
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
<label htmlFor={id} className="text-sm font-medium">
|
||||
{label}
|
||||
</label>
|
||||
{children({
|
||||
id,
|
||||
'aria-invalid': Boolean(error),
|
||||
'aria-describedby': error || hint ? msgId : undefined,
|
||||
})}
|
||||
{(error || hint) && (
|
||||
<p
|
||||
id={msgId}
|
||||
className={`text-xs ${error ? 'text-red-600 dark:text-red-400' : 'text-zinc-500'}`}
|
||||
>
|
||||
{error ?? hint}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Alert({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<p
|
||||
role="alert"
|
||||
className="rounded-md border border-red-300 bg-red-50 px-3 py-2 text-sm text-red-800 dark:border-red-900 dark:bg-red-950 dark:text-red-200"
|
||||
>
|
||||
{children}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
+35
-27
@@ -1,39 +1,47 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
import { StrictMode, useState } from 'react';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { StrictMode } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { createBrowserRouter } from 'react-router';
|
||||
import { Navigate, createBrowserRouter } from 'react-router';
|
||||
import { RouterProvider } from 'react-router/dom';
|
||||
import { APP_NAME } from '@selfphoneng/shared';
|
||||
import { toggleTheme } from './theme.ts';
|
||||
import { RequireAuth, homeFor } from './auth/RequireAuth.tsx';
|
||||
import { ChangePasswordPage } from './pages/ChangePasswordPage.tsx';
|
||||
import { LoginPage } from './pages/LoginPage.tsx';
|
||||
import { ADMIN_PAGES, AdminLayout, ComingSoon } from './pages/admin/AdminLayout.tsx';
|
||||
import { UsersPage } from './pages/admin/UsersPage.tsx';
|
||||
import { MePage } from './pages/user/MePage.tsx';
|
||||
import './index.css';
|
||||
|
||||
function Home() {
|
||||
const [dark, setDark] = useState(() => document.documentElement.classList.contains('dark'));
|
||||
return (
|
||||
<main className="mx-auto flex min-h-dvh max-w-3xl flex-col items-start gap-6 p-4 sm:p-8">
|
||||
<header className="flex w-full items-center justify-between gap-4">
|
||||
<h1 className="text-2xl font-semibold sm:text-3xl">{APP_NAME}</h1>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDark(toggleTheme())}
|
||||
aria-pressed={dark}
|
||||
className="rounded-md border border-zinc-300 px-3 py-1.5 text-sm hover:bg-zinc-100 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-sky-500 dark:border-zinc-700 dark:hover:bg-zinc-800"
|
||||
>
|
||||
{dark ? 'Light mode' : 'Dark mode'}
|
||||
</button>
|
||||
</header>
|
||||
<p className="text-zinc-600 dark:text-zinc-400">
|
||||
Open source, self-hosted business phone system.
|
||||
</p>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
const router = createBrowserRouter([
|
||||
{ path: '/login', Component: LoginPage },
|
||||
{ path: '/change-password', Component: ChangePasswordPage },
|
||||
{
|
||||
path: '/admin',
|
||||
element: <RequireAuth role="admin">{(user) => <AdminLayout user={user} />}</RequireAuth>,
|
||||
children: ADMIN_PAGES.map((p) => ({
|
||||
...(p.path ? { path: p.path } : { index: true }),
|
||||
element:
|
||||
p.path === 'users' ? (
|
||||
<RequireAuth>{(user) => <UsersPage currentUserId={user.id} />}</RequireAuth>
|
||||
) : (
|
||||
<ComingSoon label={p.label} milestone={p.milestone ?? ''} />
|
||||
),
|
||||
})),
|
||||
},
|
||||
{ path: '/me/*', element: <RequireAuth>{(user) => <MePage user={user} />}</RequireAuth> },
|
||||
{
|
||||
path: '*',
|
||||
element: <RequireAuth>{(user) => <Navigate to={homeFor(user)} replace />}</RequireAuth>,
|
||||
},
|
||||
]);
|
||||
|
||||
const router = createBrowserRouter([{ path: '*', Component: Home }]);
|
||||
const queryClient = new QueryClient();
|
||||
|
||||
document.body.className = 'bg-white text-zinc-900 dark:bg-zinc-950 dark:text-zinc-100';
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<RouterProvider router={router} />
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<RouterProvider router={router} />
|
||||
</QueryClientProvider>
|
||||
</StrictMode>,
|
||||
);
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user