Files
Magent/frontend/app/users/FeatureControls.tsx
T
Assclaw f852e7c941
Magent CI/CD / verify (push) Failing after 9m34s
Magent CI/CD / deploy-beta (push) Skipped
chore: standardize security and quality foundations
2026-09-17 20:03:47 +12:00

120 lines
4.7 KiB
TypeScript

"use client";
import { useCallback, useEffect, useState } from "react";
import { authFetch, getApiBase } from "../lib/auth";
import { FEATURES, type Feature, type FeatureAccess } from "../lib/features";
type Account = { username: string; role: string; features: FeatureAccess };
export default function FeatureControls({ username, onSaved }: { username?: string; onSaved: () => void }) {
const [accounts, setAccounts] = useState<Account[] | null>(null);
const [changes, setChanges] = useState<Partial<FeatureAccess>>({});
const [busy, setBusy] = useState(false);
const [message, setMessage] = useState("");
const [error, setError] = useState("");
const load = useCallback(async () => {
const response = await authFetch(
`${getApiBase()}/admin/users/${username ? encodeURIComponent(username) : "summary"}`,
);
if (!response.ok) throw new Error("Could not load feature permissions.");
const data = await response.json();
setAccounts(username ? [data.user] : data.users.filter((user: Account) => user.role !== "admin"));
}, [username]);
useEffect(() => {
void load().catch((err) => setError(err.message));
}, [load]);
const save = async () => {
setBusy(true);
setError("");
setMessage("");
try {
const response = await authFetch(
`${getApiBase()}/admin/users/${username ? `${encodeURIComponent(username)}/features` : "features/bulk"}`,
{
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(changes),
},
);
if (!response.ok) throw new Error((await response.json()).detail || "Could not save permissions.");
const result = await response.json();
setChanges({});
setMessage(username ? "Feature access saved." : `Feature access saved for ${result.updated} non-admin accounts.`);
await load();
onSaved();
} catch (err) {
setError(err instanceof Error ? err.message : "Could not save permissions.");
} finally {
setBusy(false);
}
};
const admin = accounts?.some((account) => account.role === "admin");
return (
<section className="user-management-panel feature-controls">
<h3>Feature access</h3>
<p>
{username
? "Choose which features this person can use in Magent."
: "Apply feature access to every existing non-admin account, including users outside the current search. Only the checkboxes you change will be applied."}
</p>
<p>
{admin
? "Administrators always have access to all features."
: "Changes take effect on the next page or API request. These permissions control Magent access; linked services keep their own permissions."}
</p>
{!accounts && !error && <p>Loading permissions...</p>}
{FEATURES.map(({ key, label, description }) => {
const enabled = accounts?.filter((account) => account.features?.[key]).length ?? 0;
const mixed = !!accounts?.length && enabled > 0 && enabled < accounts.length;
const changed = Object.hasOwn(changes, key);
return (
<label key={key} className="feature-access-row">
<input
type="checkbox"
ref={(input) => {
if (input) input.indeterminate = !changed && mixed;
}}
checked={changes[key] ?? (!!accounts?.length && enabled === accounts.length)}
disabled={busy || !accounts?.length || admin}
onChange={(event) => setChanges((previous) => ({ ...previous, [key as Feature]: event.target.checked }))}
/>
<span>
<strong>{label}</strong>
<small>{description}</small>
{!username && (
<small>
{enabled} of {accounts?.length ?? 0} enabled{mixed && !changed ? " · Mixed access" : ""}
{changed ? ` · Will ${changes[key] ? "enable" : "disable"} for everyone` : ""}
</small>
)}
</span>
</label>
);
})}
{error && (
<p className="error-banner" role="alert">
{error}
</p>
)}
{message && (
<p className="status-banner" role="status">
{message}
</p>
)}
<div className="admin-inline-actions">
<button type="button" disabled={busy || !Object.keys(changes).length} onClick={() => void save()}>
{busy ? "Saving..." : username ? "Save feature access" : "Apply changed features to all users"}
</button>
<button
type="button"
className="ghost-button"
disabled={busy || !Object.keys(changes).length}
onClick={() => setChanges({})}
>
Reset changes
</button>
</div>
</section>
);
}