Add compliance controls
This commit is contained in:
1 parent
78dbcbe7ce
commit
38c68ca4af
19 files changed
+882
-7
No files matched your search
@@ -7,6 +7,7 @@ import { BillingPanel } from "./settings/BillingPanel";
|
||||
import { ModelsPanel } from "./settings/ModelsPanel";
|
||||
import { PoliciesPanel } from "./settings/PoliciesPanel";
|
||||
import { MigrationPanel } from "./settings/MigrationPanel";
|
||||
import { CompliancePanel } from "./settings/CompliancePanel";
|
||||
import { NotificationsPanel, ProfilePanel, SecurityPanel } from "./settings/AccountPanels";
|
||||
import type { PageProps } from "./types";
|
||||
import { activeOrgId, requireActiveEmployee, requireActiveOrganization } from "./types";
|
||||
@@ -19,6 +20,7 @@ type SettingsTab =
|
||||
| "providers"
|
||||
| "skills"
|
||||
| "policies"
|
||||
| "compliance"
|
||||
| "migration"
|
||||
| "profile"
|
||||
| "notifications"
|
||||
@@ -39,6 +41,7 @@ const AGENTS_GROUP: NavSpec[] = [
|
||||
{ id: "providers", label: "Providers" },
|
||||
{ id: "skills", label: "Skills" },
|
||||
{ id: "policies", label: "Policies" },
|
||||
{ id: "compliance", label: "Compliance" },
|
||||
{ id: "migration", label: "Migration" },
|
||||
];
|
||||
const ACCOUNT_GROUP: NavSpec[] = [
|
||||
@@ -64,9 +67,15 @@ export function SettingsPage({ snapshot, actions }: PageProps) {
|
||||
const skillBindings = useMemo(() => snapshot.skillBindings.filter((item) => item.orgId === orgId), [snapshot.skillBindings, orgId]);
|
||||
const policies = useMemo(() => snapshot.policyRules.filter((item) => item.orgId === orgId), [snapshot.policyRules, orgId]);
|
||||
const workspaces = useMemo(() => snapshot.workspaces.filter((item) => item.orgId === orgId), [snapshot.workspaces, orgId]);
|
||||
const chatSpaces = useMemo(() => snapshot.chatSpaces.filter((item) => item.orgId === orgId), [snapshot.chatSpaces, orgId]);
|
||||
const chatThreads = useMemo(() => snapshot.chatThreads.filter((item) => item.orgId === orgId), [snapshot.chatThreads, orgId]);
|
||||
const files = useMemo(() => snapshot.files.filter((item) => item.orgId === orgId), [snapshot.files, orgId]);
|
||||
const providerRequests = useMemo(() => snapshot.providerRequests.filter((item) => item.orgId === orgId), [snapshot.providerRequests, orgId]);
|
||||
const auditEvents = useMemo(() => snapshot.auditEvents.filter((event) => event.orgId === orgId), [snapshot.auditEvents, orgId]);
|
||||
const migrationReports = useMemo(() => snapshot.migrationReports.filter((report) => report.orgId === orgId), [snapshot.migrationReports, orgId]);
|
||||
const complianceExports = useMemo(() => snapshot.complianceExports.filter((item) => item.orgId === orgId), [snapshot.complianceExports, orgId]);
|
||||
const legalHolds = useMemo(() => snapshot.legalHolds.filter((item) => item.orgId === orgId), [snapshot.legalHolds, orgId]);
|
||||
const retentionFreezes = useMemo(() => snapshot.retentionFreezes.filter((item) => item.orgId === orgId), [snapshot.retentionFreezes, orgId]);
|
||||
|
||||
return (
|
||||
<div className="split-main" style={{ height: "calc(100vh - 44px)", margin: "-28px -32px -40px" }}>
|
||||
@@ -104,6 +113,21 @@ export function SettingsPage({ snapshot, actions }: PageProps) {
|
||||
/>
|
||||
)}
|
||||
{tab === "policies" && <PoliciesPanel policies={policies} />}
|
||||
{tab === "compliance" && (
|
||||
<CompliancePanel
|
||||
orgId={orgId}
|
||||
employeeId={employee.id}
|
||||
employees={employees}
|
||||
agents={agents}
|
||||
chatSpaces={chatSpaces}
|
||||
chatThreads={chatThreads}
|
||||
files={files}
|
||||
exports={complianceExports}
|
||||
legalHolds={legalHolds}
|
||||
retentionFreezes={retentionFreezes}
|
||||
actions={actions}
|
||||
/>
|
||||
)}
|
||||
{tab === "migration" && <MigrationPanel orgId={orgId} reports={migrationReports} actions={actions} />}
|
||||
{tab === "profile" && <ProfilePanel employee={employee} />}
|
||||
{tab === "notifications" && <NotificationsPanel events={auditEvents} employee={employee} />}
|
||||
|
||||
@@ -0,0 +1,240 @@
|
||||
import { Children, type FormEvent, type ReactNode } from "react";
|
||||
import { Button, Icon, Pill } from "../../shared/ui";
|
||||
import type {
|
||||
AgentRecord,
|
||||
ChatSpaceRecord,
|
||||
ChatThreadRecord,
|
||||
ComplianceExportRecord,
|
||||
EmployeeRecord,
|
||||
FileObjectRecord,
|
||||
LegalHoldRecord,
|
||||
RetentionFreezeRecord,
|
||||
} from "../../../../shared/domain";
|
||||
import type { SnapshotActions } from "../../app/useSnapshot";
|
||||
import { formatRelative } from "../types";
|
||||
|
||||
export function CompliancePanel({
|
||||
orgId,
|
||||
employeeId,
|
||||
employees,
|
||||
agents,
|
||||
chatSpaces,
|
||||
chatThreads,
|
||||
files,
|
||||
exports,
|
||||
legalHolds,
|
||||
retentionFreezes,
|
||||
actions,
|
||||
}: {
|
||||
orgId: string;
|
||||
employeeId: string;
|
||||
employees: EmployeeRecord[];
|
||||
agents: AgentRecord[];
|
||||
chatSpaces: ChatSpaceRecord[];
|
||||
chatThreads: ChatThreadRecord[];
|
||||
files: FileObjectRecord[];
|
||||
exports: ComplianceExportRecord[];
|
||||
legalHolds: LegalHoldRecord[];
|
||||
retentionFreezes: RetentionFreezeRecord[];
|
||||
actions: SnapshotActions;
|
||||
}) {
|
||||
const submitExport = (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
const form = new FormData(event.currentTarget);
|
||||
const [subjectType, subjectId] = splitPair(String(form.get("subject") ?? ""));
|
||||
void actions.run(() => window.agentCompany.createComplianceExport({
|
||||
orgId,
|
||||
subjectType: subjectType as "employee" | "agent",
|
||||
subjectId,
|
||||
exportType: String(form.get("exportType")) as "gdpr_export" | "gdpr_delete",
|
||||
requestedByEmployeeId: employeeId,
|
||||
}));
|
||||
};
|
||||
|
||||
const submitHold = (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
const form = new FormData(event.currentTarget);
|
||||
const [entityType, entityId] = splitPair(String(form.get("entity") ?? ""));
|
||||
const reason = String(form.get("reason") ?? "").trim();
|
||||
void actions.run(() => window.agentCompany.createLegalHold({
|
||||
orgId,
|
||||
entityType: entityType as "organization" | "employee" | "agent" | "chat_thread" | "file_object",
|
||||
entityId,
|
||||
reason,
|
||||
createdByEmployeeId: employeeId,
|
||||
})).then((result) => { if (result) event.currentTarget.reset(); });
|
||||
};
|
||||
|
||||
const submitFreeze = (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
const form = new FormData(event.currentTarget);
|
||||
const [scopeType, scopeId] = splitPair(String(form.get("scope") ?? ""));
|
||||
const reason = String(form.get("reason") ?? "").trim();
|
||||
void actions.run(() => window.agentCompany.createRetentionFreeze({
|
||||
orgId,
|
||||
scopeType: scopeType as "organization" | "chat_space" | "file_object",
|
||||
scopeId,
|
||||
reason,
|
||||
createdByEmployeeId: employeeId,
|
||||
})).then((result) => { if (result) event.currentTarget.reset(); });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
<h1 className="page-h1">Compliance</h1>
|
||||
<p className="page-sub">GDPR exports, legal holds, and retention freezes.</p>
|
||||
<div style={{ display: "grid", gridTemplateColumns: "minmax(0, 1fr) minmax(0, 1fr)", gap: 16 }}>
|
||||
<section className="card">
|
||||
<SectionTitle icon="docs" title="Export" />
|
||||
<form onSubmit={submitExport} style={{ display: "grid", gap: 10 }}>
|
||||
<LabeledSelect label="Type" name="exportType">
|
||||
<option value="gdpr_export">GDPR export</option>
|
||||
<option value="gdpr_delete">GDPR delete</option>
|
||||
</LabeledSelect>
|
||||
<LabeledSelect label="Subject" name="subject">
|
||||
{employees.map((employee) => <option key={employee.id} value={`employee:${employee.id}`}>Employee · {employee.displayName}</option>)}
|
||||
{agents.map((agent) => <option key={agent.id} value={`agent:${agent.id}`}>Agent · {agent.name}</option>)}
|
||||
</LabeledSelect>
|
||||
<div className="row" style={{ justifyContent: "flex-end" }}>
|
||||
<Button tone="primary" size="small" type="submit"><Icon name="upload" size={12} /> Create</Button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section className="card">
|
||||
<SectionTitle icon="shield" title="Legal hold" />
|
||||
<form onSubmit={submitHold} style={{ display: "grid", gap: 10 }}>
|
||||
<LabeledSelect label="Target" name="entity">
|
||||
<option value={`organization:${orgId}`}>Organization</option>
|
||||
{employees.map((employee) => <option key={employee.id} value={`employee:${employee.id}`}>Employee · {employee.displayName}</option>)}
|
||||
{agents.map((agent) => <option key={agent.id} value={`agent:${agent.id}`}>Agent · {agent.name}</option>)}
|
||||
{chatThreads.map((thread) => <option key={thread.id} value={`chat_thread:${thread.id}`}>Thread · {thread.title}</option>)}
|
||||
{files.map((file) => <option key={file.id} value={`file_object:${file.id}`}>File · {file.name}</option>)}
|
||||
</LabeledSelect>
|
||||
<LabeledInput label="Reason" name="reason" />
|
||||
<div className="row" style={{ justifyContent: "flex-end" }}>
|
||||
<Button tone="primary" size="small" type="submit"><Icon name="plus" size={12} /> Hold</Button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section className="card">
|
||||
<SectionTitle icon="pause" title="Retention freeze" />
|
||||
<form onSubmit={submitFreeze} style={{ display: "grid", gap: 10 }}>
|
||||
<LabeledSelect label="Scope" name="scope">
|
||||
<option value={`organization:${orgId}`}>Organization</option>
|
||||
{chatSpaces.map((space) => <option key={space.id} value={`chat_space:${space.id}`}>Space · {space.name}</option>)}
|
||||
{files.map((file) => <option key={file.id} value={`file_object:${file.id}`}>File · {file.name}</option>)}
|
||||
</LabeledSelect>
|
||||
<LabeledInput label="Reason" name="reason" />
|
||||
<div className="row" style={{ justifyContent: "flex-end" }}>
|
||||
<Button tone="primary" size="small" type="submit"><Icon name="pause" size={12} /> Freeze</Button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section className="card">
|
||||
<SectionTitle icon="inbox" title="Export history" />
|
||||
<RecordList emptyLabel="0 exports">
|
||||
{exports.map((item) => (
|
||||
<RecordRow
|
||||
key={item.id}
|
||||
title={`${item.exportType.replaceAll("_", " ")} · ${item.subjectType}`}
|
||||
meta={`${item.state} · ${formatRelative(item.createdAt)} · ${item.payloadJson.length} bytes`}
|
||||
/>
|
||||
))}
|
||||
</RecordList>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section className="card" style={{ marginTop: 16 }}>
|
||||
<SectionTitle icon="approve" title="Active controls" />
|
||||
<RecordList emptyLabel="0 active controls">
|
||||
{legalHolds.map((hold) => (
|
||||
<RecordRow
|
||||
key={hold.id}
|
||||
title={`Legal hold · ${hold.entityType}`}
|
||||
meta={`${hold.reason} · ${formatRelative(hold.createdAt)}`}
|
||||
active={hold.active}
|
||||
actionLabel={hold.active ? "Release" : "Activate"}
|
||||
onAction={() => void actions.run(() => window.agentCompany.setLegalHoldState({ orgId, legalHoldId: hold.id, active: !hold.active, employeeId }))}
|
||||
/>
|
||||
))}
|
||||
{retentionFreezes.map((freeze) => (
|
||||
<RecordRow
|
||||
key={freeze.id}
|
||||
title={`Retention freeze · ${freeze.scopeType}`}
|
||||
meta={`${freeze.reason} · ${formatRelative(freeze.createdAt)}`}
|
||||
active={freeze.active}
|
||||
actionLabel={freeze.active ? "Release" : "Activate"}
|
||||
onAction={() => void actions.run(() => window.agentCompany.setRetentionFreezeState({ orgId, retentionFreezeId: freeze.id, active: !freeze.active, employeeId }))}
|
||||
/>
|
||||
))}
|
||||
</RecordList>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SectionTitle({ icon, title }: { icon: "approve" | "docs" | "inbox" | "pause" | "shield"; title: string }) {
|
||||
return (
|
||||
<div className="row" style={{ marginBottom: 12 }}>
|
||||
<Icon name={icon} size={14} color="var(--text-3)" />
|
||||
<div style={{ fontSize: 13, fontWeight: 600 }}>{title}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LabeledSelect({ label, name, children }: { label: string; name: string; children: ReactNode }) {
|
||||
return (
|
||||
<label className="field" style={{ margin: 0 }}>
|
||||
<span>{label}</span>
|
||||
<select className="input" name={name} required>{children}</select>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
function LabeledInput({ label, name }: { label: string; name: string }) {
|
||||
return (
|
||||
<label className="field" style={{ margin: 0 }}>
|
||||
<span>{label}</span>
|
||||
<input className="input" name={name} required />
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
function RecordList({ emptyLabel, children }: { emptyLabel: string; children: ReactNode }) {
|
||||
const items = Children.toArray(children).filter(Boolean);
|
||||
return items.length > 0 ? <div style={{ display: "grid", gap: 8 }}>{items}</div> : <Pill>{emptyLabel}</Pill>;
|
||||
}
|
||||
|
||||
function RecordRow({
|
||||
title,
|
||||
meta,
|
||||
active,
|
||||
actionLabel,
|
||||
onAction,
|
||||
}: {
|
||||
title: string;
|
||||
meta: string;
|
||||
active?: boolean;
|
||||
actionLabel?: string;
|
||||
onAction?: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="row" style={{ borderTop: "1px solid var(--border-soft)", paddingTop: 8, alignItems: "center" }}>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontSize: 12.5, fontWeight: 540, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{title}</div>
|
||||
<div style={{ fontSize: 11, color: "var(--text-3)", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{meta}</div>
|
||||
</div>
|
||||
{active !== undefined && <Pill tone={active ? "red" : undefined}>{active ? "active" : "released"}</Pill>}
|
||||
{onAction && actionLabel && <Button size="small" onClick={onAction}>{actionLabel}</Button>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function splitPair(value: string): [string, string] {
|
||||
const index = value.indexOf(":");
|
||||
if (index < 1) throw new Error("Compliance selector value is invalid.");
|
||||
return [value.slice(0, index), value.slice(index + 1)];
|
||||
}
|
||||
Reference in new issue
Block a user