feat(users): passwordless user creation via emailed set-password link, display full name instead of username
Aegis CI / lint-and-test (push) Has been cancelled
Snyk Security Scan / Python vulnerabilities (backend) (push) Has been cancelled
Snyk Security Scan / npm vulnerabilities (frontend) (push) Has been cancelled
Snyk Security Scan / Docker image vulnerabilities (backend) (push) Has been cancelled
Aegis CI / lint-and-test (push) Has been cancelled
Snyk Security Scan / Python vulnerabilities (backend) (push) Has been cancelled
Snyk Security Scan / npm vulnerabilities (frontend) (push) Has been cancelled
Snyk Security Scan / Docker image vulnerabilities (backend) (push) Has been cancelled
Admins now create users with a name + email (no password) and role; a Send Email action (per-user, also reusable for resets) issues a one-time token and POSTs it to an admin-configurable webhook URL — intended for a Power Automate flow that delivers the actual email. The old admin-sets-the-password flow is kept server-side (LegacyUserCreateWithPassword) but no longer wired into the UI. Every user-facing surface (top bar, sidebar, user management, audit log, assignee pickers, dispute notifications) now shows full_name instead of username, falling back to username when unset. Also fixes long attack_procedure/expected_detection/suggested_text text overflowing its container in the template review panels and Red/Blue team fields (missing break-words on whitespace-pre-wrap blocks).
This commit is contained in:
@@ -6,6 +6,7 @@ import ProtectedRoute from "./components/ProtectedRoute";
|
||||
|
||||
/* ── Eagerly loaded (core pages) ──────────────────────────────────── */
|
||||
import LoginPage from "./pages/LoginPage";
|
||||
import SetPasswordPage from "./pages/SetPasswordPage";
|
||||
import DashboardPage from "./pages/DashboardPage";
|
||||
|
||||
/* ── Lazy loaded (V1-V3 pages) ────────────────────────────────────── */
|
||||
@@ -39,6 +40,7 @@ export default function App() {
|
||||
<Routes>
|
||||
{/* Public */}
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route path="/set-password" element={<SetPasswordPage />} />
|
||||
|
||||
{/* Protected — wrapped in shared Layout */}
|
||||
<Route
|
||||
|
||||
@@ -4,6 +4,7 @@ export interface AuditLogOut {
|
||||
id: string;
|
||||
user_id: string | null;
|
||||
username: string | null;
|
||||
full_name: string | null;
|
||||
action: string;
|
||||
entity_type: string | null;
|
||||
entity_id: string | null;
|
||||
|
||||
@@ -50,3 +50,19 @@ export async function changePassword(
|
||||
new_password: newPassword,
|
||||
});
|
||||
}
|
||||
|
||||
/** Check a set-password token before rendering the form. Public — no auth. */
|
||||
export async function validateSetPasswordToken(
|
||||
token: string,
|
||||
): Promise<{ valid: boolean; full_name: string | null }> {
|
||||
const { data } = await client.get<{ valid: boolean; full_name: string | null }>(
|
||||
"/auth/set-password/validate",
|
||||
{ params: { token } },
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
/** Complete a password setup/reset using a one-time token. Public — no auth. */
|
||||
export async function setPassword(token: string, newPassword: string): Promise<void> {
|
||||
await client.post("/auth/set-password", { token, new_password: newPassword });
|
||||
}
|
||||
|
||||
@@ -130,6 +130,7 @@ export interface UserPreferencesUpdate {
|
||||
export interface UserMeOut {
|
||||
id: string;
|
||||
username: string;
|
||||
full_name: string | null;
|
||||
email: string | null;
|
||||
role: string;
|
||||
is_active: boolean;
|
||||
@@ -198,6 +199,21 @@ export async function testJiraConnection(): Promise<{
|
||||
return data;
|
||||
}
|
||||
|
||||
export interface PasswordWebhookConfigOut {
|
||||
configured: boolean;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export async function getPasswordWebhookConfig(): Promise<PasswordWebhookConfigOut> {
|
||||
const { data } = await client.get<PasswordWebhookConfigOut>("/system/password-webhook-config");
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function updatePasswordWebhookConfig(url: string): Promise<PasswordWebhookConfigOut> {
|
||||
const { data } = await client.patch<PasswordWebhookConfigOut>("/system/password-webhook-config", { url });
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function testTempoConnection(): Promise<{
|
||||
status: "ok" | "error" | "disabled";
|
||||
message?: string;
|
||||
|
||||
@@ -475,6 +475,7 @@ export async function requestDiscussion(testId: string): Promise<{
|
||||
status: string;
|
||||
message: string;
|
||||
rejector_username: string;
|
||||
rejector_full_name: string | null;
|
||||
rejector_email: string | null;
|
||||
rejector_role: string;
|
||||
}> {
|
||||
|
||||
@@ -3,24 +3,28 @@ import client from "./client";
|
||||
export interface UserOut {
|
||||
id: string;
|
||||
username: string;
|
||||
full_name: string | null;
|
||||
email: string | null;
|
||||
role: string;
|
||||
is_active: boolean;
|
||||
must_change_password: boolean;
|
||||
created_at: string | null;
|
||||
last_login: string | null;
|
||||
}
|
||||
|
||||
export interface UserCreatePayload {
|
||||
username: string;
|
||||
email?: string;
|
||||
password: string;
|
||||
full_name: string;
|
||||
email: string;
|
||||
role: string;
|
||||
}
|
||||
|
||||
export interface UserUpdatePayload {
|
||||
email?: string;
|
||||
full_name?: string;
|
||||
role?: string;
|
||||
is_active?: boolean;
|
||||
// Not sent by the current UI — the set-password-email flow replaces
|
||||
// admin-set passwords — but still supported server-side.
|
||||
password?: string;
|
||||
}
|
||||
|
||||
@@ -33,6 +37,7 @@ export async function getUsers(): Promise<UserOut[]> {
|
||||
export interface OperatorOut {
|
||||
id: string;
|
||||
username: string;
|
||||
full_name: string | null;
|
||||
role: string;
|
||||
}
|
||||
|
||||
@@ -42,7 +47,7 @@ export async function getOperators(): Promise<OperatorOut[]> {
|
||||
return data;
|
||||
}
|
||||
|
||||
/** Create a new user (admin only). */
|
||||
/** Create a new user (admin only). No password — see sendPasswordEmail(). */
|
||||
export async function createUser(payload: UserCreatePayload): Promise<UserOut> {
|
||||
const { data } = await client.post<UserOut>("/users", payload);
|
||||
return data;
|
||||
@@ -53,3 +58,9 @@ export async function updateUser(userId: string, payload: UserUpdatePayload): Pr
|
||||
const { data } = await client.patch<UserOut>(`/users/${userId}`, payload);
|
||||
return data;
|
||||
}
|
||||
|
||||
/** Email a one-time set-password link to a user (admin only). Also used for password resets. */
|
||||
export async function sendPasswordEmail(userId: string): Promise<{ detail: string }> {
|
||||
const { data } = await client.post<{ detail: string }>(`/users/${userId}/send-password-email`);
|
||||
return data;
|
||||
}
|
||||
|
||||
@@ -71,7 +71,7 @@ export default function Layout() {
|
||||
{/* Header */}
|
||||
<header className="flex h-16 items-center justify-end gap-4 border-b border-gray-800 bg-gray-900 px-6">
|
||||
<NotificationBell />
|
||||
<span className="text-sm text-gray-300">{user?.username}</span>
|
||||
<span className="text-sm text-gray-300">{user?.full_name || user?.username}</span>
|
||||
<button
|
||||
onClick={logout}
|
||||
className="flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-sm text-gray-400 transition-colors hover:bg-gray-800 hover:text-white"
|
||||
|
||||
@@ -80,7 +80,7 @@ const mdComponents: Components = {
|
||||
const isBlock = className?.startsWith("language-");
|
||||
if (isBlock) {
|
||||
return (
|
||||
<code className="block rounded-md bg-gray-800 px-3 py-2 font-mono text-xs text-gray-200 whitespace-pre-wrap">
|
||||
<code className="block rounded-md bg-gray-800 px-3 py-2 font-mono text-xs text-gray-200 whitespace-pre-wrap break-words">
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
|
||||
@@ -214,7 +214,7 @@ export default function Sidebar() {
|
||||
{/* Footer */}
|
||||
<div className="border-t border-gray-800 px-5 py-4">
|
||||
<p className="truncate text-xs text-gray-500">
|
||||
{user?.username ?? "—"} · {role || "—"}
|
||||
{(user?.full_name || user?.username) ?? "—"} · {role || "—"}
|
||||
</p>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
@@ -51,7 +51,8 @@ export default function AssigneeControl({
|
||||
}: Props) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const current = operators.find((o) => o.id === assigneeId);
|
||||
const label = current?.username ?? (assigneeId ? assigneeUsername ?? "Unassigned" : "Unassigned");
|
||||
const label =
|
||||
(current?.full_name || current?.username) ?? (assigneeId ? assigneeUsername ?? "Unassigned" : "Unassigned");
|
||||
const eligible = operators.filter((o) => ELIGIBLE_ROLES[kind][side].includes(o.role));
|
||||
const sideLabel = LABEL_PREFIX[kind][side];
|
||||
|
||||
@@ -106,7 +107,7 @@ export default function AssigneeControl({
|
||||
op.id === assigneeId ? "bg-gray-800 text-white" : "text-gray-400 hover:bg-gray-800 hover:text-white"
|
||||
}`}
|
||||
>
|
||||
{op.username}
|
||||
{op.full_name || op.username}
|
||||
<span className="ml-1.5 text-[10px] text-gray-500">({op.role.replace(/_/g, " ")})</span>
|
||||
</button>
|
||||
))}
|
||||
|
||||
@@ -228,7 +228,7 @@ export default function TeamTabs({
|
||||
placeholder="Describe the attack procedure..."
|
||||
/>
|
||||
) : (
|
||||
<pre className="whitespace-pre-wrap rounded-lg bg-gray-800 p-4 font-mono text-sm text-gray-300">
|
||||
<pre className="whitespace-pre-wrap break-words rounded-lg bg-gray-800 p-4 font-mono text-sm text-gray-300">
|
||||
{test.procedure_text || "No procedure documented."}
|
||||
</pre>
|
||||
)}
|
||||
@@ -426,7 +426,7 @@ export default function TeamTabs({
|
||||
placeholder="Describe how you detected the attack..."
|
||||
/>
|
||||
) : (
|
||||
<pre className="whitespace-pre-wrap rounded-lg bg-gray-800 p-4 font-mono text-sm text-gray-300">
|
||||
<pre className="whitespace-pre-wrap break-words rounded-lg bg-gray-800 p-4 font-mono text-sm text-gray-300">
|
||||
{test.detect_procedure || "No detection procedure documented."}
|
||||
</pre>
|
||||
)}
|
||||
|
||||
@@ -133,14 +133,14 @@ export default function TestDetailHeader({
|
||||
const [showDiscussModal, setShowDiscussModal] = useState(false);
|
||||
const [discussionSent, setDiscussionSent] = useState(false);
|
||||
const [discussResult, setDiscussResult] = useState<{
|
||||
username: string; email: string | null; role: string;
|
||||
displayName: string; email: string | null; role: string;
|
||||
} | null>(null);
|
||||
|
||||
const discussMutation = useMutation({
|
||||
mutationFn: () => requestDiscussion(test.id),
|
||||
onSuccess: (data) => {
|
||||
setDiscussResult({
|
||||
username: data.rejector_username,
|
||||
displayName: data.rejector_full_name || data.rejector_username,
|
||||
email: data.rejector_email,
|
||||
role: data.rejector_role,
|
||||
});
|
||||
@@ -850,7 +850,7 @@ export default function TestDetailHeader({
|
||||
<div className="rounded-lg border border-amber-500/20 bg-amber-500/5 p-4 space-y-2">
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-amber-400">Contact details</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium text-white">{discussResult?.username}</span>
|
||||
<span className="text-sm font-medium text-white">{discussResult?.displayName}</span>
|
||||
<span className="rounded-full border border-gray-600 bg-gray-800 px-2 py-0.5 text-[10px] text-gray-400">
|
||||
{discussResult?.role}
|
||||
</span>
|
||||
|
||||
@@ -214,7 +214,7 @@ export default function AuditLogPage() {
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<span className="text-gray-200">
|
||||
{log.username || "System"}
|
||||
{log.full_name || log.username || "System"}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
import { useState, type FormEvent } from "react";
|
||||
import { useNavigate, useSearchParams } from "react-router-dom";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Loader2, CheckCircle } from "lucide-react";
|
||||
import { validateSetPasswordToken, setPassword } from "../api/auth";
|
||||
import { PasswordPolicyChecklist } from "../components/ChangePasswordModal";
|
||||
|
||||
/** Public page for completing a password setup/reset via an emailed one-time link. */
|
||||
export default function SetPasswordPage() {
|
||||
const [searchParams] = useSearchParams();
|
||||
const navigate = useNavigate();
|
||||
const token = searchParams.get("token") || "";
|
||||
|
||||
const [newPassword, setNewPassword] = useState("");
|
||||
const [confirmPassword, setConfirmPassword] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [done, setDone] = useState(false);
|
||||
|
||||
const { data: validation, isLoading } = useQuery({
|
||||
queryKey: ["set-password-validate", token],
|
||||
queryFn: () => validateSetPasswordToken(token),
|
||||
enabled: !!token,
|
||||
retry: false,
|
||||
});
|
||||
|
||||
async function handleSubmit(e: FormEvent) {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
if (newPassword !== confirmPassword) {
|
||||
setError("Passwords do not match");
|
||||
return;
|
||||
}
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await setPassword(token, newPassword);
|
||||
setDone(true);
|
||||
} catch (err) {
|
||||
const message =
|
||||
(err as { response?: { data?: { detail?: string } } })?.response?.data?.detail ||
|
||||
"Could not set your password — the link may have expired.";
|
||||
setError(message);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-gray-950 px-4">
|
||||
<div className="w-full max-w-sm space-y-8">
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<img src="/aegis-logo.png" alt="Aegis" className="h-16 w-16 rounded-full" />
|
||||
<h1 className="text-2xl font-bold tracking-wide text-white">Aegis</h1>
|
||||
<p className="text-sm text-gray-400">Set your password</p>
|
||||
</div>
|
||||
|
||||
{!token ? (
|
||||
<p className="rounded-lg bg-red-500/10 px-3 py-2 text-sm text-red-400">
|
||||
This link is missing its token — ask an admin to send a new one.
|
||||
</p>
|
||||
) : isLoading ? (
|
||||
<div className="flex justify-center py-6">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-cyan-400" />
|
||||
</div>
|
||||
) : !validation?.valid ? (
|
||||
<p className="rounded-lg bg-red-500/10 px-3 py-2 text-sm text-red-400">
|
||||
This link has expired or already been used — ask an admin to send a new one.
|
||||
</p>
|
||||
) : done ? (
|
||||
<div className="space-y-4 text-center">
|
||||
<CheckCircle className="mx-auto h-10 w-10 text-green-400" />
|
||||
<p className="text-sm text-gray-300">Your password has been set.</p>
|
||||
<button
|
||||
onClick={() => navigate("/login", { replace: true })}
|
||||
className="w-full rounded-lg bg-cyan-600 px-4 py-2.5 text-sm font-medium text-white transition-colors hover:bg-cyan-500"
|
||||
>
|
||||
Go to login
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<form onSubmit={handleSubmit} className="space-y-5">
|
||||
{validation.full_name && (
|
||||
<p className="text-center text-sm text-gray-400">Welcome, {validation.full_name}</p>
|
||||
)}
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-gray-300">New Password</label>
|
||||
<input
|
||||
type="password"
|
||||
required
|
||||
autoFocus
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
className="w-full rounded-lg border border-gray-700 bg-gray-900 px-3 py-2 text-sm text-white placeholder-gray-500 outline-none focus:border-cyan-500 focus:ring-1 focus:ring-cyan-500"
|
||||
/>
|
||||
<PasswordPolicyChecklist password={newPassword} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-gray-300">Confirm Password</label>
|
||||
<input
|
||||
type="password"
|
||||
required
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
className="w-full rounded-lg border border-gray-700 bg-gray-900 px-3 py-2 text-sm text-white placeholder-gray-500 outline-none focus:border-cyan-500 focus:ring-1 focus:ring-cyan-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p className="rounded-lg bg-red-500/10 px-3 py-2 text-sm text-red-400">{error}</p>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={submitting}
|
||||
className="w-full rounded-lg bg-cyan-600 px-4 py-2.5 text-sm font-medium text-white transition-colors hover:bg-cyan-500 disabled:opacity-50"
|
||||
>
|
||||
{submitting ? "Setting password…" : "Set Password"}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -53,6 +53,8 @@ import {
|
||||
getJiraConfig,
|
||||
updateJiraConfig,
|
||||
testJiraConnection,
|
||||
getPasswordWebhookConfig,
|
||||
updatePasswordWebhookConfig,
|
||||
testTempoConnection,
|
||||
type EmailConfigUpdate,
|
||||
type WebhookCreate,
|
||||
@@ -843,8 +845,8 @@ function ProfileSection() {
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<p className="text-xs text-gray-500 mb-1">Username</p>
|
||||
<p className="text-gray-300 font-medium">{me?.username}</p>
|
||||
<p className="text-xs text-gray-500 mb-1">Name</p>
|
||||
<p className="text-gray-300 font-medium">{me?.full_name || me?.username}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-gray-500 mb-1">Role</p>
|
||||
@@ -1683,6 +1685,76 @@ function SystemInfoSection() {
|
||||
);
|
||||
}
|
||||
|
||||
function PasswordWebhookSection() {
|
||||
const qc = useQueryClient();
|
||||
const [url, setUrl] = useState("");
|
||||
const [toast, setToast] = useState<{ msg: string; type: "success" | "error" } | null>(null);
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ["password-webhook-config"],
|
||||
queryFn: getPasswordWebhookConfig,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (data) setUrl(data.url);
|
||||
}, [data]);
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: () => updatePasswordWebhookConfig(url),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ["password-webhook-config"] });
|
||||
setToast({ msg: "Webhook URL saved", type: "success" });
|
||||
},
|
||||
onError: () => setToast({ msg: "Failed to save webhook URL", type: "error" }),
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex justify-center py-8">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-cyan-400" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-gray-800 bg-gray-900 p-6">
|
||||
<h2 className="mb-2 text-lg font-semibold text-white flex items-center gap-2">
|
||||
<Mail className="h-5 w-5 text-cyan-400" /> Password Setup Email Webhook
|
||||
</h2>
|
||||
<p className="mb-4 text-sm text-gray-500">
|
||||
When an admin sends a set-password or password-reset link from User Management, a
|
||||
payload with the link is POSTed to this URL — point it at your Power Automate flow (or
|
||||
similar) that actually delivers the email.
|
||||
</p>
|
||||
<div className="flex gap-3">
|
||||
<input
|
||||
type="url"
|
||||
value={url}
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
placeholder="https://prod-00.westeurope.logic.azure.com/..."
|
||||
className="flex-1 rounded-lg border border-gray-700 bg-gray-800 px-3 py-2 text-sm text-gray-300 placeholder-gray-500 focus:border-cyan-500 focus:outline-none"
|
||||
/>
|
||||
<button
|
||||
onClick={() => saveMutation.mutate()}
|
||||
disabled={saveMutation.isPending}
|
||||
className="flex items-center gap-2 rounded-lg bg-cyan-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-cyan-500 disabled:opacity-50"
|
||||
>
|
||||
{saveMutation.isPending && <Loader2 className="h-4 w-4 animate-spin" />}
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
{!data?.configured && (
|
||||
<p className="mt-2 text-xs text-amber-400/80">
|
||||
Not configured yet — "Send Email" in User Management will fail until a URL is saved here.
|
||||
</p>
|
||||
)}
|
||||
{toast && (
|
||||
<Toast message={toast.msg} type={toast.type} onClose={() => setToast(null)} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main SettingsPage
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -1780,6 +1852,7 @@ export default function SettingsPage() {
|
||||
{activeTab === "system" && isAdmin && (
|
||||
<div className="space-y-6">
|
||||
<SystemInfoSection />
|
||||
<PasswordWebhookSection />
|
||||
<ExportImportSection />
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -486,7 +486,7 @@ function TemplateSuggestionsPanel() {
|
||||
{s.attack_procedure && (
|
||||
<div className="mt-2">
|
||||
<p className="text-xs font-medium uppercase text-gray-500">Suggested Attack Procedure</p>
|
||||
<pre className="mt-1 whitespace-pre-wrap rounded bg-gray-900 p-2 font-mono text-xs text-gray-300">
|
||||
<pre className="mt-1 whitespace-pre-wrap break-words rounded bg-gray-900 p-2 font-mono text-xs text-gray-300">
|
||||
{s.attack_procedure}
|
||||
</pre>
|
||||
</div>
|
||||
@@ -494,7 +494,7 @@ function TemplateSuggestionsPanel() {
|
||||
{s.expected_detection && (
|
||||
<div className="mt-2">
|
||||
<p className="text-xs font-medium uppercase text-gray-500">Expected Detection</p>
|
||||
<pre className="mt-1 whitespace-pre-wrap rounded bg-gray-900 p-2 font-mono text-xs text-gray-300">
|
||||
<pre className="mt-1 whitespace-pre-wrap break-words rounded bg-gray-900 p-2 font-mono text-xs text-gray-300">
|
||||
{s.expected_detection}
|
||||
</pre>
|
||||
</div>
|
||||
@@ -502,7 +502,7 @@ function TemplateSuggestionsPanel() {
|
||||
{s.suggested_remediation && (
|
||||
<div className="mt-2">
|
||||
<p className="text-xs font-medium uppercase text-gray-500">Suggested Remediation</p>
|
||||
<pre className="mt-1 whitespace-pre-wrap rounded bg-gray-900 p-2 font-mono text-xs text-gray-300">
|
||||
<pre className="mt-1 whitespace-pre-wrap break-words rounded bg-gray-900 p-2 font-mono text-xs text-gray-300">
|
||||
{s.suggested_remediation}
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
@@ -859,14 +859,14 @@ function ProcedureSuggestionsPanel() {
|
||||
{s.template_current_text && (
|
||||
<div className="mt-2">
|
||||
<p className="text-xs font-medium uppercase text-gray-500">Current</p>
|
||||
<pre className="mt-1 whitespace-pre-wrap rounded bg-gray-900 p-2 font-mono text-xs text-gray-400">
|
||||
<pre className="mt-1 whitespace-pre-wrap break-words rounded bg-gray-900 p-2 font-mono text-xs text-gray-400">
|
||||
{s.template_current_text}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-2">
|
||||
<p className="text-xs font-medium uppercase text-gray-500">Suggested</p>
|
||||
<pre className="mt-1 whitespace-pre-wrap rounded bg-gray-900 p-2 font-mono text-xs text-green-400">
|
||||
<pre className="mt-1 whitespace-pre-wrap break-words rounded bg-gray-900 p-2 font-mono text-xs text-green-400">
|
||||
{s.suggested_text}
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
@@ -3,16 +3,23 @@ import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
Loader2,
|
||||
AlertCircle,
|
||||
Users,
|
||||
Plus,
|
||||
X,
|
||||
Check,
|
||||
UserX,
|
||||
UserCheck,
|
||||
Edit,
|
||||
Mail,
|
||||
} from "lucide-react";
|
||||
import { getUsers, createUser, updateUser, type UserOut, type UserCreatePayload } from "../api/users";
|
||||
import { PasswordPolicyChecklist } from "../components/ChangePasswordModal";
|
||||
import {
|
||||
getUsers,
|
||||
createUser,
|
||||
updateUser,
|
||||
sendPasswordEmail,
|
||||
type UserOut,
|
||||
type UserCreatePayload,
|
||||
} from "../api/users";
|
||||
import { useToast } from "../components/Toast";
|
||||
|
||||
const ROLES = [
|
||||
{ value: "viewer", label: "Viewer" },
|
||||
@@ -36,6 +43,7 @@ const roleBadgeColors: Record<string, string> = {
|
||||
|
||||
export default function UsersPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const { showToast } = useToast();
|
||||
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||
const [editingUser, setEditingUser] = useState<UserOut | null>(null);
|
||||
|
||||
@@ -65,6 +73,12 @@ export default function UsersPage() {
|
||||
},
|
||||
});
|
||||
|
||||
const sendPasswordEmailMutation = useMutation({
|
||||
mutationFn: sendPasswordEmail,
|
||||
onSuccess: (data) => showToast("success", data.detail),
|
||||
onError: (err: Error) => showToast("error", err.message),
|
||||
});
|
||||
|
||||
const toggleUserActive = (user: UserOut) => {
|
||||
updateMutation.mutate({
|
||||
userId: user.id,
|
||||
@@ -123,7 +137,7 @@ export default function UsersPage() {
|
||||
<table className="w-full text-left text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-800">
|
||||
<th className="px-6 py-4 font-medium text-gray-400">Username</th>
|
||||
<th className="px-6 py-4 font-medium text-gray-400">Name</th>
|
||||
<th className="px-6 py-4 font-medium text-gray-400">Email</th>
|
||||
<th className="px-6 py-4 font-medium text-gray-400">Role</th>
|
||||
<th className="px-6 py-4 font-medium text-gray-400">Status</th>
|
||||
@@ -139,7 +153,7 @@ export default function UsersPage() {
|
||||
className="border-b border-gray-800/50 hover:bg-gray-800/30 transition-colors"
|
||||
>
|
||||
<td className="px-6 py-4">
|
||||
<span className="font-medium text-white">{user.username}</span>
|
||||
<span className="font-medium text-white">{user.full_name || user.username}</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-gray-400">
|
||||
{user.email || "—"}
|
||||
@@ -165,6 +179,9 @@ export default function UsersPage() {
|
||||
Inactive
|
||||
</span>
|
||||
)}
|
||||
{user.must_change_password && (
|
||||
<span className="mt-0.5 block text-[10px] text-amber-400/80">Awaiting password setup</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-6 py-4 text-gray-400">
|
||||
{formatDate(user.created_at)}
|
||||
@@ -181,6 +198,14 @@ export default function UsersPage() {
|
||||
>
|
||||
<Edit className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => sendPasswordEmailMutation.mutate(user.id)}
|
||||
disabled={sendPasswordEmailMutation.isPending}
|
||||
className="rounded p-1.5 text-gray-400 hover:bg-gray-800 hover:text-cyan-400 disabled:opacity-50"
|
||||
title={user.must_change_password ? "Send set-password email" : "Send password reset email"}
|
||||
>
|
||||
<Mail className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => toggleUserActive(user)}
|
||||
disabled={updateMutation.isPending}
|
||||
@@ -238,6 +263,12 @@ export default function UsersPage() {
|
||||
}
|
||||
|
||||
// ── Create User Modal ──────────────────────────────────────────────
|
||||
//
|
||||
// No password field — the admin provides a name + email, then uses the
|
||||
// "Send Email" action (Mail icon in the table) to let the user set their
|
||||
// own password via a one-time link. The old admin-sets-the-password flow
|
||||
// is intentionally not wired up here anymore (see LegacyUserCreateWithPassword
|
||||
// on the backend, kept only so this can be restored quickly if needed).
|
||||
|
||||
interface CreateUserModalProps {
|
||||
onClose: () => void;
|
||||
@@ -248,18 +279,18 @@ interface CreateUserModalProps {
|
||||
|
||||
function CreateUserModal({ onClose, onSubmit, isSubmitting, error }: CreateUserModalProps) {
|
||||
const [formData, setFormData] = useState({
|
||||
username: "",
|
||||
full_name: "",
|
||||
email: "",
|
||||
password: "",
|
||||
role: "viewer",
|
||||
});
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
|
||||
const validate = () => {
|
||||
const newErrors: Record<string, string> = {};
|
||||
if (!formData.username.trim()) newErrors.username = "Username is required";
|
||||
if (!formData.password) newErrors.password = "Password is required";
|
||||
if (formData.password.length < 6) newErrors.password = "Password must be at least 6 characters";
|
||||
if (!formData.full_name.trim()) newErrors.full_name = "Full name is required";
|
||||
if (!formData.email.trim() || !formData.email.includes("@")) {
|
||||
newErrors.email = "A valid email is required";
|
||||
}
|
||||
setErrors(newErrors);
|
||||
return Object.keys(newErrors).length === 0;
|
||||
};
|
||||
@@ -268,9 +299,8 @@ function CreateUserModal({ onClose, onSubmit, isSubmitting, error }: CreateUserM
|
||||
e.preventDefault();
|
||||
if (validate()) {
|
||||
onSubmit({
|
||||
username: formData.username,
|
||||
email: formData.email || undefined,
|
||||
password: formData.password,
|
||||
full_name: formData.full_name,
|
||||
email: formData.email,
|
||||
role: formData.role,
|
||||
});
|
||||
}
|
||||
@@ -294,42 +324,31 @@ function CreateUserModal({ onClose, onSubmit, isSubmitting, error }: CreateUserM
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-300">Username *</label>
|
||||
<label className="block text-sm font-medium text-gray-300">Full Name *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.username}
|
||||
onChange={(e) => setFormData({ ...formData, username: e.target.value })}
|
||||
value={formData.full_name}
|
||||
onChange={(e) => setFormData({ ...formData, full_name: e.target.value })}
|
||||
className={`mt-1 w-full rounded-lg border bg-gray-800 px-3 py-2 text-gray-200 ${
|
||||
errors.username ? "border-red-500" : "border-gray-700"
|
||||
errors.full_name ? "border-red-500" : "border-gray-700"
|
||||
}`}
|
||||
/>
|
||||
{errors.username && <p className="mt-1 text-sm text-red-400">{errors.username}</p>}
|
||||
{errors.full_name && <p className="mt-1 text-sm text-red-400">{errors.full_name}</p>}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-300">Email</label>
|
||||
<label className="block text-sm font-medium text-gray-300">Email *</label>
|
||||
<input
|
||||
type="email"
|
||||
value={formData.email}
|
||||
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
|
||||
className="mt-1 w-full rounded-lg border border-gray-700 bg-gray-800 px-3 py-2 text-gray-200"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-300">Password *</label>
|
||||
<input
|
||||
type="password"
|
||||
value={formData.password}
|
||||
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
|
||||
className={`mt-1 w-full rounded-lg border bg-gray-800 px-3 py-2 text-gray-200 ${
|
||||
errors.password ? "border-red-500" : "border-gray-700"
|
||||
errors.email ? "border-red-500" : "border-gray-700"
|
||||
}`}
|
||||
/>
|
||||
<PasswordPolicyChecklist password={formData.password} />
|
||||
{errors.password && <p className="mt-1 text-sm text-red-400">{errors.password}</p>}
|
||||
<p className="mt-1 text-xs text-amber-400/70">
|
||||
The user will be required to change this password on first login.
|
||||
{errors.email && <p className="mt-1 text-sm text-red-400">{errors.email}</p>}
|
||||
<p className="mt-1 text-xs text-cyan-400/70">
|
||||
After creating the user, use the mail icon in the table to send them a link to set their password.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -372,6 +391,9 @@ function CreateUserModal({ onClose, onSubmit, isSubmitting, error }: CreateUserM
|
||||
}
|
||||
|
||||
// ── Edit User Modal ──────────────────────────────────────────────
|
||||
//
|
||||
// No password field here either — password changes go through the
|
||||
// "Send Email" action on the table instead.
|
||||
|
||||
interface EditUserModalProps {
|
||||
user: UserOut;
|
||||
@@ -383,28 +405,25 @@ interface EditUserModalProps {
|
||||
|
||||
function EditUserModal({ user, onClose, onSubmit, isSubmitting, error }: EditUserModalProps) {
|
||||
const [formData, setFormData] = useState({
|
||||
full_name: user.full_name || "",
|
||||
email: user.email || "",
|
||||
role: user.role,
|
||||
password: "",
|
||||
});
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const payload: Parameters<typeof updateUser>[1] = {
|
||||
onSubmit({
|
||||
full_name: formData.full_name || undefined,
|
||||
email: formData.email || undefined,
|
||||
role: formData.role,
|
||||
};
|
||||
if (formData.password) {
|
||||
payload.password = formData.password;
|
||||
}
|
||||
onSubmit(payload);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm">
|
||||
<div className="w-full max-w-md rounded-xl border border-gray-800 bg-gray-900 p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-semibold text-white">Edit User: {user.username}</h3>
|
||||
<h3 className="text-lg font-semibold text-white">Edit User: {user.full_name || user.username}</h3>
|
||||
<button onClick={onClose} className="text-gray-400 hover:text-white">
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
@@ -417,6 +436,16 @@ function EditUserModal({ user, onClose, onSubmit, isSubmitting, error }: EditUse
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-300">Full Name</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.full_name}
|
||||
onChange={(e) => setFormData({ ...formData, full_name: e.target.value })}
|
||||
className="mt-1 w-full rounded-lg border border-gray-700 bg-gray-800 px-3 py-2 text-gray-200"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-300">Email</label>
|
||||
<input
|
||||
@@ -442,22 +471,6 @@ function EditUserModal({ user, onClose, onSubmit, isSubmitting, error }: EditUse
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-300">
|
||||
New Password <span className="text-gray-500">(leave blank to keep current)</span>
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={formData.password}
|
||||
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
|
||||
className="mt-1 w-full rounded-lg border border-gray-700 bg-gray-800 px-3 py-2 text-gray-200"
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
{formData.password.length > 0 && (
|
||||
<PasswordPolicyChecklist password={formData.password} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3 pt-4">
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
export interface User {
|
||||
id: string;
|
||||
username: string;
|
||||
full_name?: string | null;
|
||||
role: string;
|
||||
must_change_password?: boolean;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user