chore: standardize security and quality foundations
This commit is contained in:
@@ -1,58 +1,119 @@
|
||||
'use client'
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { authFetch, getApiBase } from '../lib/auth'
|
||||
import { FEATURES, type Feature, type FeatureAccess } from '../lib/features'
|
||||
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 }
|
||||
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 = 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'))
|
||||
}
|
||||
useEffect(() => { void load().catch((err) => setError(err.message)) }, [username])
|
||||
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('')
|
||||
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>
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user