Add compliance controls
This commit is contained in:
1 parent
78dbcbe7ce
commit
38c68ca4af
19 files changed
+882
-7
No files matched your search
@@ -13,6 +13,7 @@ import { SocialService } from "../services/social-service";
|
||||
import { FileService } from "../services/file-service";
|
||||
import { MigrationService } from "../services/migration-service";
|
||||
import { OnboardingService } from "../services/onboarding-service";
|
||||
import { ComplianceService } from "../services/compliance-service";
|
||||
|
||||
export interface IpcDeps {
|
||||
connection: DatabaseConnection;
|
||||
@@ -30,6 +31,7 @@ export function registerIpcHandlers(deps: IpcDeps): void {
|
||||
const social = new SocialService(deps.connection.db);
|
||||
const files = new FileService(deps.connection.db, deps.artifactRoot);
|
||||
const migrations = new MigrationService(deps.connection.db);
|
||||
const compliance = new ComplianceService(deps.connection.db);
|
||||
const onboarding = new OnboardingService({
|
||||
db: deps.connection.db,
|
||||
saveProviderCredential: (input) => providers.saveCredential(input),
|
||||
@@ -144,6 +146,11 @@ export function registerIpcHandlers(deps: IpcDeps): void {
|
||||
});
|
||||
|
||||
ipcMain.handle(IPC_CHANNELS.createMigrationReport, (_event, input) => mutate(() => migrations.createReport(input)));
|
||||
ipcMain.handle(IPC_CHANNELS.createComplianceExport, (_event, input) => mutate(() => compliance.createExport(input)));
|
||||
ipcMain.handle(IPC_CHANNELS.createLegalHold, (_event, input) => mutate(() => compliance.createLegalHold(input)));
|
||||
ipcMain.handle(IPC_CHANNELS.setLegalHoldState, (_event, input) => mutate(() => compliance.setLegalHoldState(input)));
|
||||
ipcMain.handle(IPC_CHANNELS.createRetentionFreeze, (_event, input) => mutate(() => compliance.createRetentionFreeze(input)));
|
||||
ipcMain.handle(IPC_CHANNELS.setRetentionFreezeState, (_event, input) => mutate(() => compliance.setRetentionFreezeState(input)));
|
||||
|
||||
ipcMain.handle(IPC_CHANNELS.windowMinimize, () => deps.mainWindow.minimize());
|
||||
ipcMain.handle(IPC_CHANNELS.windowMaximize, () => deps.mainWindow.isMaximized() ? deps.mainWindow.unmaximize() : deps.mainWindow.maximize());
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
export const complianceControlsSchema = `
|
||||
alter table chat_spaces add column retention_policy text not null default 'standard_365d';
|
||||
|
||||
create table if not exists compliance_exports (
|
||||
id text primary key,
|
||||
org_id text not null references organizations(id) on delete cascade,
|
||||
subject_type text not null,
|
||||
subject_id text not null,
|
||||
export_type text not null,
|
||||
state text not null,
|
||||
payload_json text not null,
|
||||
requested_by_employee_id text references employees(id) on delete set null,
|
||||
created_at text not null,
|
||||
completed_at text
|
||||
);
|
||||
|
||||
create table if not exists legal_holds (
|
||||
id text primary key,
|
||||
org_id text not null references organizations(id) on delete cascade,
|
||||
entity_type text not null,
|
||||
entity_id text not null,
|
||||
reason text not null,
|
||||
created_by_employee_id text references employees(id) on delete set null,
|
||||
active integer not null,
|
||||
created_at text not null,
|
||||
released_at text
|
||||
);
|
||||
|
||||
create table if not exists retention_freezes (
|
||||
id text primary key,
|
||||
org_id text not null references organizations(id) on delete cascade,
|
||||
scope_type text not null,
|
||||
scope_id text not null,
|
||||
reason text not null,
|
||||
created_by_employee_id text references employees(id) on delete set null,
|
||||
active integer not null,
|
||||
created_at text not null,
|
||||
released_at text
|
||||
);
|
||||
|
||||
create index if not exists idx_compliance_exports_org_created on compliance_exports(org_id, created_at);
|
||||
create index if not exists idx_legal_holds_org_entity on legal_holds(org_id, entity_type, entity_id, active);
|
||||
create index if not exists idx_retention_freezes_org_scope on retention_freezes(org_id, scope_type, scope_id, active);
|
||||
|
||||
insert into schema_migrations(version, name, applied_at)
|
||||
values (12, 'compliance_controls', datetime('now'));
|
||||
`;
|
||||
@@ -10,6 +10,7 @@ import { providerHealthSchema } from "./migrations/008-provider-health";
|
||||
import { socialGovernanceSchema } from "./migrations/009-social-governance";
|
||||
import { fileHandoffSecuritySchema } from "./migrations/010-file-handoff-security";
|
||||
import { boardInterventionSchema } from "./migrations/011-board-interventions";
|
||||
import { complianceControlsSchema } from "./migrations/012-compliance-controls";
|
||||
|
||||
interface Migration {
|
||||
version: number;
|
||||
@@ -29,6 +30,7 @@ const MIGRATIONS: Migration[] = [
|
||||
{ version: 9, name: "social_governance", sql: socialGovernanceSchema },
|
||||
{ version: 10, name: "file_handoff_security", sql: fileHandoffSecuritySchema },
|
||||
{ version: 11, name: "board_interventions", sql: boardInterventionSchema },
|
||||
{ version: 12, name: "compliance_controls", sql: complianceControlsSchema },
|
||||
];
|
||||
|
||||
const SCHEMA_VERSION = MIGRATIONS[MIGRATIONS.length - 1].version;
|
||||
|
||||
@@ -0,0 +1,245 @@
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
import type {
|
||||
CreateComplianceExportInput,
|
||||
CreateLegalHoldInput,
|
||||
CreateRetentionFreezeInput,
|
||||
SetLegalHoldStateInput,
|
||||
SetRetentionFreezeStateInput,
|
||||
} from "../../shared/commands";
|
||||
import { COMPLIANCE_EXPORT_STATE, COMPLIANCE_EXPORT_TYPE } from "../../shared/domain";
|
||||
import { createId } from "../../shared/ids";
|
||||
import { AuditService } from "./audit-service";
|
||||
|
||||
type Row = Record<string, unknown>;
|
||||
|
||||
export class ComplianceService {
|
||||
private readonly audit: AuditService;
|
||||
|
||||
constructor(private readonly db: DatabaseSync) {
|
||||
this.audit = new AuditService(db);
|
||||
}
|
||||
|
||||
createExport(input: CreateComplianceExportInput): string {
|
||||
this.assertOrg(input.orgId);
|
||||
this.assertEmployee(input.orgId, input.requestedByEmployeeId, "Requesting employee was not found.");
|
||||
this.assertSubject(input.orgId, input.subjectType, input.subjectId);
|
||||
if (input.exportType === COMPLIANCE_EXPORT_TYPE.GDPR_DELETE) this.assertDeleteAllowed(input);
|
||||
const payload = this.subjectPayload(input.orgId, input.subjectType, input.subjectId);
|
||||
if (input.exportType === COMPLIANCE_EXPORT_TYPE.GDPR_DELETE) this.applyGdprDelete(input.orgId, input.subjectType, input.subjectId);
|
||||
const id = createId("cmp");
|
||||
const now = new Date().toISOString();
|
||||
this.db.prepare(`
|
||||
insert into compliance_exports(
|
||||
id, org_id, subject_type, subject_id, export_type, state, payload_json,
|
||||
requested_by_employee_id, created_at, completed_at
|
||||
) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
id,
|
||||
input.orgId,
|
||||
input.subjectType,
|
||||
input.subjectId,
|
||||
input.exportType,
|
||||
COMPLIANCE_EXPORT_STATE.COMPLETED,
|
||||
JSON.stringify(payload),
|
||||
input.requestedByEmployeeId,
|
||||
now,
|
||||
now,
|
||||
);
|
||||
this.audit.record({
|
||||
orgId: input.orgId,
|
||||
entityType: "compliance_export",
|
||||
entityId: id,
|
||||
actorType: "employee",
|
||||
actorId: input.requestedByEmployeeId,
|
||||
action: input.exportType === COMPLIANCE_EXPORT_TYPE.GDPR_DELETE ? "gdpr_deleted" : "created",
|
||||
payload: { subjectType: input.subjectType, subjectId: input.subjectId, exportType: input.exportType },
|
||||
});
|
||||
return id;
|
||||
}
|
||||
|
||||
createLegalHold(input: CreateLegalHoldInput): string {
|
||||
const reason = input.reason.trim();
|
||||
if (!reason) throw new Error("Legal hold reason is required.");
|
||||
this.assertOrg(input.orgId);
|
||||
this.assertEmployee(input.orgId, input.createdByEmployeeId, "Legal hold employee was not found.");
|
||||
this.assertHoldEntity(input.orgId, input.entityType, input.entityId);
|
||||
const id = createId("hold");
|
||||
const now = new Date().toISOString();
|
||||
this.db.prepare(`
|
||||
insert into legal_holds(id, org_id, entity_type, entity_id, reason, created_by_employee_id, active, created_at, released_at)
|
||||
values (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(id, input.orgId, input.entityType, input.entityId, reason, input.createdByEmployeeId, 1, now, null);
|
||||
this.audit.record({
|
||||
orgId: input.orgId,
|
||||
entityType: "legal_hold",
|
||||
entityId: id,
|
||||
actorType: "employee",
|
||||
actorId: input.createdByEmployeeId,
|
||||
action: "created",
|
||||
payload: { entityType: input.entityType, entityId: input.entityId },
|
||||
});
|
||||
return id;
|
||||
}
|
||||
|
||||
setLegalHoldState(input: SetLegalHoldStateInput): void {
|
||||
this.assertEmployee(input.orgId, input.employeeId, "Legal hold employee was not found.");
|
||||
const now = new Date().toISOString();
|
||||
const result = this.db.prepare(`
|
||||
update legal_holds
|
||||
set active = ?, released_at = ?
|
||||
where id = ? and org_id = ?
|
||||
`).run(input.active ? 1 : 0, input.active ? null : now, input.legalHoldId, input.orgId);
|
||||
if (result.changes === 0) throw new Error("Legal hold was not found.");
|
||||
this.audit.record({
|
||||
orgId: input.orgId,
|
||||
entityType: "legal_hold",
|
||||
entityId: input.legalHoldId,
|
||||
actorType: "employee",
|
||||
actorId: input.employeeId,
|
||||
action: input.active ? "activated" : "released",
|
||||
payload: { active: input.active },
|
||||
});
|
||||
}
|
||||
|
||||
createRetentionFreeze(input: CreateRetentionFreezeInput): string {
|
||||
const reason = input.reason.trim();
|
||||
if (!reason) throw new Error("Retention freeze reason is required.");
|
||||
this.assertOrg(input.orgId);
|
||||
this.assertEmployee(input.orgId, input.createdByEmployeeId, "Retention freeze employee was not found.");
|
||||
this.assertFreezeScope(input.orgId, input.scopeType, input.scopeId);
|
||||
const id = createId("frz");
|
||||
const now = new Date().toISOString();
|
||||
this.db.prepare(`
|
||||
insert into retention_freezes(id, org_id, scope_type, scope_id, reason, created_by_employee_id, active, created_at, released_at)
|
||||
values (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(id, input.orgId, input.scopeType, input.scopeId, reason, input.createdByEmployeeId, 1, now, null);
|
||||
this.audit.record({
|
||||
orgId: input.orgId,
|
||||
entityType: "retention_freeze",
|
||||
entityId: id,
|
||||
actorType: "employee",
|
||||
actorId: input.createdByEmployeeId,
|
||||
action: "created",
|
||||
payload: { scopeType: input.scopeType, scopeId: input.scopeId },
|
||||
});
|
||||
return id;
|
||||
}
|
||||
|
||||
setRetentionFreezeState(input: SetRetentionFreezeStateInput): void {
|
||||
this.assertEmployee(input.orgId, input.employeeId, "Retention freeze employee was not found.");
|
||||
const now = new Date().toISOString();
|
||||
const result = this.db.prepare(`
|
||||
update retention_freezes
|
||||
set active = ?, released_at = ?
|
||||
where id = ? and org_id = ?
|
||||
`).run(input.active ? 1 : 0, input.active ? null : now, input.retentionFreezeId, input.orgId);
|
||||
if (result.changes === 0) throw new Error("Retention freeze was not found.");
|
||||
this.audit.record({
|
||||
orgId: input.orgId,
|
||||
entityType: "retention_freeze",
|
||||
entityId: input.retentionFreezeId,
|
||||
actorType: "employee",
|
||||
actorId: input.employeeId,
|
||||
action: input.active ? "activated" : "released",
|
||||
payload: { active: input.active },
|
||||
});
|
||||
}
|
||||
|
||||
private subjectPayload(orgId: string, subjectType: string, subjectId: string): Record<string, unknown> {
|
||||
if (subjectType === "employee") {
|
||||
return {
|
||||
profile: this.one("employees", orgId, subjectId),
|
||||
chatMessages: this.db.prepare("select * from chat_messages where org_id = ? and sender_type = 'employee' and sender_id = ? order by created_at").all(orgId, subjectId),
|
||||
approvalsRequested: this.db.prepare("select * from approvals where org_id = ? and requested_by_type = 'employee' and requested_by_id = ? order by created_at").all(orgId, subjectId),
|
||||
approvalsDecided: this.db.prepare("select * from approvals where org_id = ? and decided_by_employee_id = ? order by created_at").all(orgId, subjectId),
|
||||
auditEventsAsActor: this.db.prepare("select * from audit_events where org_id = ? and actor_type = 'employee' and actor_id = ? order by created_at").all(orgId, subjectId),
|
||||
};
|
||||
}
|
||||
return {
|
||||
profile: this.one("agents", orgId, subjectId),
|
||||
tasks: this.db.prepare("select * from tasks where org_id = ? and assignee_agent_id = ? order by created_at").all(orgId, subjectId),
|
||||
taskRuns: this.db.prepare("select * from task_runs where org_id = ? and agent_id = ? order by created_at").all(orgId, subjectId),
|
||||
chatMessages: this.db.prepare("select * from chat_messages where org_id = ? and sender_type = 'agent' and sender_id = ? order by created_at").all(orgId, subjectId),
|
||||
auditEventsAsActor: this.db.prepare("select * from audit_events where org_id = ? and actor_type = 'agent' and actor_id = ? order by created_at").all(orgId, subjectId),
|
||||
};
|
||||
}
|
||||
|
||||
private applyGdprDelete(orgId: string, subjectType: string, subjectId: string): void {
|
||||
if (subjectType === "employee") {
|
||||
this.db.prepare("update employees set display_name = ?, role = ? where id = ? and org_id = ?")
|
||||
.run("Deleted employee", "Deleted", subjectId, orgId);
|
||||
this.db.prepare("update chat_messages set sender_id = null where org_id = ? and sender_type = 'employee' and sender_id = ?")
|
||||
.run(orgId, subjectId);
|
||||
this.db.prepare("update approvals set requested_by_id = null where org_id = ? and requested_by_type = 'employee' and requested_by_id = ?")
|
||||
.run(orgId, subjectId);
|
||||
return;
|
||||
}
|
||||
this.db.prepare("update agents set name = ?, role = ?, lifecycle = ?, model_provider_id = null, model_id = null where id = ? and org_id = ?")
|
||||
.run("Deleted agent", "Deleted", "archived", subjectId, orgId);
|
||||
this.db.prepare("update tasks set assignee_agent_id = null where org_id = ? and assignee_agent_id = ?").run(orgId, subjectId);
|
||||
this.db.prepare("update task_runs set agent_id = null where org_id = ? and agent_id = ?").run(orgId, subjectId);
|
||||
this.db.prepare("update chat_messages set sender_id = null where org_id = ? and sender_type = 'agent' and sender_id = ?")
|
||||
.run(orgId, subjectId);
|
||||
}
|
||||
|
||||
private assertDeleteAllowed(input: CreateComplianceExportInput): void {
|
||||
const legalHold = this.db.prepare(`
|
||||
select id from legal_holds
|
||||
where org_id = ? and active = 1
|
||||
and ((entity_type = ? and entity_id = ?) or (entity_type = 'organization' and entity_id = ?))
|
||||
limit 1
|
||||
`).get(input.orgId, input.subjectType, input.subjectId, input.orgId);
|
||||
const retentionFreeze = this.db.prepare(`
|
||||
select id from retention_freezes
|
||||
where org_id = ? and active = 1 and scope_type = 'organization' and scope_id = ?
|
||||
limit 1
|
||||
`).get(input.orgId, input.orgId);
|
||||
if (legalHold || retentionFreeze) throw new Error("Active compliance hold prevents GDPR delete.");
|
||||
}
|
||||
|
||||
private one(table: "employees" | "agents", orgId: string, id: string): Row {
|
||||
const row = this.db.prepare(`select * from ${table} where id = ? and org_id = ?`).get(id, orgId) as Row | undefined;
|
||||
if (!row) throw new Error("Compliance subject was not found.");
|
||||
return row;
|
||||
}
|
||||
|
||||
private assertSubject(orgId: string, subjectType: string, subjectId: string): void {
|
||||
if (subjectType === "employee") return void this.assertEmployee(orgId, subjectId, "Compliance subject was not found.");
|
||||
if (subjectType === "agent") return void this.assertAgent(orgId, subjectId, "Compliance subject was not found.");
|
||||
throw new Error(`Unsupported compliance subject type: ${subjectType}`);
|
||||
}
|
||||
|
||||
private assertHoldEntity(orgId: string, entityType: string, entityId: string): void {
|
||||
if (entityType === "organization" && entityId === orgId) return;
|
||||
if (entityType === "employee") return void this.assertEmployee(orgId, entityId, "Legal hold entity was not found.");
|
||||
if (entityType === "agent") return void this.assertAgent(orgId, entityId, "Legal hold entity was not found.");
|
||||
if (entityType === "chat_thread") return void this.assertOrgEntity("chat_threads", orgId, entityId, "Legal hold entity was not found.");
|
||||
if (entityType === "file_object") return void this.assertOrgEntity("file_objects", orgId, entityId, "Legal hold entity was not found.");
|
||||
throw new Error(`Unsupported legal hold entity type: ${entityType}`);
|
||||
}
|
||||
|
||||
private assertFreezeScope(orgId: string, scopeType: string, scopeId: string): void {
|
||||
if (scopeType === "organization" && scopeId === orgId) return;
|
||||
if (scopeType === "chat_space") return void this.assertOrgEntity("chat_spaces", orgId, scopeId, "Retention freeze scope was not found.");
|
||||
if (scopeType === "file_object") return void this.assertOrgEntity("file_objects", orgId, scopeId, "Retention freeze scope was not found.");
|
||||
throw new Error(`Unsupported retention freeze scope type: ${scopeType}`);
|
||||
}
|
||||
|
||||
private assertOrg(orgId: string): void {
|
||||
const row = this.db.prepare("select id from organizations where id = ?").get(orgId);
|
||||
if (!row) throw new Error("Organization was not found.");
|
||||
}
|
||||
|
||||
private assertEmployee(orgId: string, employeeId: string, message: string): void {
|
||||
this.assertOrgEntity("employees", orgId, employeeId, message);
|
||||
}
|
||||
|
||||
private assertAgent(orgId: string, agentId: string, message: string): void {
|
||||
this.assertOrgEntity("agents", orgId, agentId, message);
|
||||
}
|
||||
|
||||
private assertOrgEntity(table: "employees" | "agents" | "chat_threads" | "file_objects" | "chat_spaces", orgId: string, id: string, message: string): void {
|
||||
const row = this.db.prepare(`select id from ${table} where id = ? and org_id = ?`).get(id, orgId);
|
||||
if (!row) throw new Error(message);
|
||||
}
|
||||
}
|
||||
@@ -44,6 +44,9 @@ const LEGACY_TABLES = [
|
||||
"browser_actions",
|
||||
"scheduled_posts",
|
||||
"policy_rules",
|
||||
"compliance_exports",
|
||||
"legal_holds",
|
||||
"retention_freezes",
|
||||
"audit_events",
|
||||
];
|
||||
|
||||
@@ -159,6 +162,12 @@ const RELATION_CHECKS: RelationCheck[] = [
|
||||
{ table: "scheduled_posts", column: "approval_id", referencedTable: "approvals", referencedColumn: "id", reason: "dangling_approval_id" },
|
||||
{ table: "scheduled_posts", column: "created_by_employee_id", referencedTable: "employees", referencedColumn: "id", reason: "dangling_creator_employee_id" },
|
||||
{ table: "policy_rules", column: "org_id", referencedTable: "organizations", referencedColumn: "id", reason: "dangling_org_id" },
|
||||
{ table: "compliance_exports", column: "org_id", referencedTable: "organizations", referencedColumn: "id", reason: "dangling_org_id" },
|
||||
{ table: "compliance_exports", column: "requested_by_employee_id", referencedTable: "employees", referencedColumn: "id", reason: "dangling_requester_employee_id" },
|
||||
{ table: "legal_holds", column: "org_id", referencedTable: "organizations", referencedColumn: "id", reason: "dangling_org_id" },
|
||||
{ table: "legal_holds", column: "created_by_employee_id", referencedTable: "employees", referencedColumn: "id", reason: "dangling_creator_employee_id" },
|
||||
{ table: "retention_freezes", column: "org_id", referencedTable: "organizations", referencedColumn: "id", reason: "dangling_org_id" },
|
||||
{ table: "retention_freezes", column: "created_by_employee_id", referencedTable: "employees", referencedColumn: "id", reason: "dangling_creator_employee_id" },
|
||||
{ table: "audit_events", column: "org_id", referencedTable: "organizations", referencedColumn: "id", reason: "dangling_org_id" },
|
||||
];
|
||||
|
||||
|
||||
@@ -47,6 +47,10 @@ export class SnapshotService {
|
||||
.map((binding) => ({ ...binding, enabled: toBool(binding.enabled) }));
|
||||
const files = list<AppSnapshot["files"][number]>(this.db, "file_objects", "created_at desc")
|
||||
.map((file) => ({ ...file, pinned: toBool(file.pinned) }));
|
||||
const legalHolds = list<AppSnapshot["legalHolds"][number]>(this.db, "legal_holds", "created_at desc")
|
||||
.map((hold) => ({ ...hold, active: toBool(hold.active) }));
|
||||
const retentionFreezes = list<AppSnapshot["retentionFreezes"][number]>(this.db, "retention_freezes", "created_at desc")
|
||||
.map((freeze) => ({ ...freeze, active: toBool(freeze.active) }));
|
||||
|
||||
return {
|
||||
activeOrgId,
|
||||
@@ -83,6 +87,9 @@ export class SnapshotService {
|
||||
browserActions: list(this.db, "browser_actions", "created_at desc"),
|
||||
policyRules: list(this.db, "policy_rules", "created_at asc"),
|
||||
migrationReports: list(this.db, "migration_reports", "created_at desc"),
|
||||
complianceExports: list(this.db, "compliance_exports", "created_at desc"),
|
||||
legalHolds,
|
||||
retentionFreezes,
|
||||
auditEvents: list<AppSnapshot["auditEvents"][number]>(this.db, "audit_events", "created_at desc").slice(0, 200),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -35,6 +35,11 @@ const api: DesktopApi = {
|
||||
importFile: (input) => invoke(IPC_CHANNELS.importFile, input),
|
||||
setFilePinned: (input) => invoke(IPC_CHANNELS.setFilePinned, input),
|
||||
createMigrationReport: (input) => invoke(IPC_CHANNELS.createMigrationReport, input),
|
||||
createComplianceExport: (input) => invoke(IPC_CHANNELS.createComplianceExport, input),
|
||||
createLegalHold: (input) => invoke(IPC_CHANNELS.createLegalHold, input),
|
||||
setLegalHoldState: (input) => invoke(IPC_CHANNELS.setLegalHoldState, input),
|
||||
createRetentionFreeze: (input) => invoke(IPC_CHANNELS.createRetentionFreeze, input),
|
||||
setRetentionFreezeState: (input) => invoke(IPC_CHANNELS.setRetentionFreezeState, input),
|
||||
subscribe: (callback) => {
|
||||
const listener = (_event: Electron.IpcRendererEvent, payload: RendererEvent) => callback(payload);
|
||||
ipcRenderer.on(IPC_CHANNELS.rendererEvent, listener);
|
||||
|
||||
@@ -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)];
|
||||
}
|
||||
@@ -1,5 +1,9 @@
|
||||
import type {
|
||||
ApprovalState,
|
||||
ComplianceExportType,
|
||||
ComplianceSubjectType,
|
||||
LegalHoldEntityType,
|
||||
RetentionFreezeScopeType,
|
||||
RiskLevel,
|
||||
SocialPlatform,
|
||||
TaskPriority,
|
||||
@@ -172,6 +176,44 @@ export interface CreateMigrationReportInput {
|
||||
sourceDatabasePath: string;
|
||||
}
|
||||
|
||||
export interface CreateComplianceExportInput {
|
||||
orgId: string;
|
||||
subjectType: ComplianceSubjectType;
|
||||
subjectId: string;
|
||||
exportType: ComplianceExportType;
|
||||
requestedByEmployeeId: string;
|
||||
}
|
||||
|
||||
export interface CreateLegalHoldInput {
|
||||
orgId: string;
|
||||
entityType: LegalHoldEntityType;
|
||||
entityId: string;
|
||||
reason: string;
|
||||
createdByEmployeeId: string;
|
||||
}
|
||||
|
||||
export interface SetLegalHoldStateInput {
|
||||
orgId: string;
|
||||
legalHoldId: string;
|
||||
active: boolean;
|
||||
employeeId: string;
|
||||
}
|
||||
|
||||
export interface CreateRetentionFreezeInput {
|
||||
orgId: string;
|
||||
scopeType: RetentionFreezeScopeType;
|
||||
scopeId: string;
|
||||
reason: string;
|
||||
createdByEmployeeId: string;
|
||||
}
|
||||
|
||||
export interface SetRetentionFreezeStateInput {
|
||||
orgId: string;
|
||||
retentionFreezeId: string;
|
||||
active: boolean;
|
||||
employeeId: string;
|
||||
}
|
||||
|
||||
export interface CreateScheduledPostInput {
|
||||
orgId: string;
|
||||
socialAccountId: string;
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
export const COMPLIANCE_EXPORT_TYPE = {
|
||||
GDPR_EXPORT: "gdpr_export",
|
||||
GDPR_DELETE: "gdpr_delete",
|
||||
} as const;
|
||||
|
||||
export const COMPLIANCE_EXPORT_STATE = {
|
||||
COMPLETED: "completed",
|
||||
} as const;
|
||||
|
||||
type ValueOf<T> = T[keyof T];
|
||||
|
||||
export type ComplianceExportType = ValueOf<typeof COMPLIANCE_EXPORT_TYPE>;
|
||||
export type ComplianceExportState = ValueOf<typeof COMPLIANCE_EXPORT_STATE>;
|
||||
export type ComplianceSubjectType = "employee" | "agent";
|
||||
export type LegalHoldEntityType = "organization" | "employee" | "agent" | "chat_thread" | "file_object";
|
||||
export type RetentionFreezeScopeType = "organization" | "chat_space" | "file_object";
|
||||
|
||||
export interface ComplianceExportRecord {
|
||||
id: string;
|
||||
orgId: string;
|
||||
subjectType: ComplianceSubjectType;
|
||||
subjectId: string;
|
||||
exportType: ComplianceExportType;
|
||||
state: ComplianceExportState;
|
||||
payloadJson: string;
|
||||
requestedByEmployeeId: string | null;
|
||||
createdAt: string;
|
||||
completedAt: string | null;
|
||||
}
|
||||
|
||||
export interface LegalHoldRecord {
|
||||
id: string;
|
||||
orgId: string;
|
||||
entityType: LegalHoldEntityType;
|
||||
entityId: string;
|
||||
reason: string;
|
||||
createdByEmployeeId: string | null;
|
||||
active: boolean;
|
||||
createdAt: string;
|
||||
releasedAt: string | null;
|
||||
}
|
||||
|
||||
export interface RetentionFreezeRecord {
|
||||
id: string;
|
||||
orgId: string;
|
||||
scopeType: RetentionFreezeScopeType;
|
||||
scopeId: string;
|
||||
reason: string;
|
||||
createdByEmployeeId: string | null;
|
||||
active: boolean;
|
||||
createdAt: string;
|
||||
releasedAt: string | null;
|
||||
}
|
||||
@@ -7,11 +7,14 @@ import type {
|
||||
CreateAgentInput,
|
||||
CreateApprovalInput,
|
||||
CreateChatSpaceInput,
|
||||
CreateComplianceExportInput,
|
||||
CreateLegalHoldInput,
|
||||
CreateMigrationReportInput,
|
||||
CheckProviderHealthInput,
|
||||
MarkChatSpaceReadInput,
|
||||
CreateOrganizationInput,
|
||||
CreateProviderResponseInput,
|
||||
CreateRetentionFreezeInput,
|
||||
CreateScheduledPostInput,
|
||||
CreateSocialAccountInput,
|
||||
CreateTaskInput,
|
||||
@@ -26,6 +29,8 @@ import type {
|
||||
SendMessageInput,
|
||||
SetActiveOrganizationInput,
|
||||
SetFilePinnedInput,
|
||||
SetLegalHoldStateInput,
|
||||
SetRetentionFreezeStateInput,
|
||||
SetScheduledPostStateInput,
|
||||
UpdateTaskStatusInput,
|
||||
} from "./commands";
|
||||
@@ -63,6 +68,11 @@ export interface DesktopApi {
|
||||
importFile(input: ImportFileInput): Promise<DesktopResult<AppSnapshot>>;
|
||||
setFilePinned(input: SetFilePinnedInput): Promise<DesktopResult<AppSnapshot>>;
|
||||
createMigrationReport(input: CreateMigrationReportInput): Promise<DesktopResult<AppSnapshot>>;
|
||||
createComplianceExport(input: CreateComplianceExportInput): Promise<DesktopResult<AppSnapshot>>;
|
||||
createLegalHold(input: CreateLegalHoldInput): Promise<DesktopResult<AppSnapshot>>;
|
||||
setLegalHoldState(input: SetLegalHoldStateInput): Promise<DesktopResult<AppSnapshot>>;
|
||||
createRetentionFreeze(input: CreateRetentionFreezeInput): Promise<DesktopResult<AppSnapshot>>;
|
||||
setRetentionFreezeState(input: SetRetentionFreezeStateInput): Promise<DesktopResult<AppSnapshot>>;
|
||||
subscribe(callback: (event: RendererEvent) => void): () => void;
|
||||
platform: "win32" | "darwin" | "linux";
|
||||
windowMinimize(): Promise<void>;
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import type { BoardInterventionRecord as BoardInterventionRecordModel, ChatThreadBoardState as ChatThreadBoardStateModel } from "./board-domain";
|
||||
|
||||
export { BOARD_INTERVENTION_ACTION, BOARD_INTERVENTION_MODE, CHAT_THREAD_BOARD_STATE } from "./board-domain";
|
||||
export type { BoardInterventionAction, BoardInterventionMode, BoardInterventionRecord, ChatThreadBoardState } from "./board-domain";
|
||||
|
||||
import type { ComplianceExportRecord, LegalHoldRecord, RetentionFreezeRecord } from "./compliance-domain";
|
||||
export { BOARD_INTERVENTION_ACTION, BOARD_INTERVENTION_MODE, CHAT_THREAD_BOARD_STATE, type BoardInterventionAction, type BoardInterventionMode, type BoardInterventionRecord, type ChatThreadBoardState } from "./board-domain";
|
||||
export { COMPLIANCE_EXPORT_STATE, COMPLIANCE_EXPORT_TYPE, type ComplianceExportRecord, type ComplianceExportState, type ComplianceExportType, type ComplianceSubjectType, type LegalHoldEntityType, type LegalHoldRecord, type RetentionFreezeRecord, type RetentionFreezeScopeType } from "./compliance-domain";
|
||||
export const TASK_STATUS = {
|
||||
BACKLOG: "backlog",
|
||||
IN_PROGRESS: "in_progress",
|
||||
@@ -192,6 +191,7 @@ export interface ChatSpaceRecord {
|
||||
name: string;
|
||||
topic: string;
|
||||
visibility: string;
|
||||
retentionPolicy: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
@@ -494,5 +494,6 @@ export interface AppSnapshot {
|
||||
browserActions: BrowserActionRecord[];
|
||||
policyRules: PolicyRuleRecord[];
|
||||
migrationReports: MigrationReportRecord[];
|
||||
complianceExports: ComplianceExportRecord[]; legalHolds: LegalHoldRecord[]; retentionFreezes: RetentionFreezeRecord[];
|
||||
auditEvents: AuditEventRecord[];
|
||||
}
|
||||
@@ -27,6 +27,11 @@ export const IPC_CHANNELS = {
|
||||
importFile: "agentcompany:import-file",
|
||||
setFilePinned: "agentcompany:set-file-pinned",
|
||||
createMigrationReport: "agentcompany:create-migration-report",
|
||||
createComplianceExport: "agentcompany:create-compliance-export",
|
||||
createLegalHold: "agentcompany:create-legal-hold",
|
||||
setLegalHoldState: "agentcompany:set-legal-hold-state",
|
||||
createRetentionFreeze: "agentcompany:create-retention-freeze",
|
||||
setRetentionFreezeState: "agentcompany:set-retention-freeze-state",
|
||||
rendererEvent: "agentcompany:renderer-event",
|
||||
windowMinimize: "agentcompany:window-minimize",
|
||||
windowMaximize: "agentcompany:window-maximize",
|
||||
|
||||
@@ -92,6 +92,11 @@ test("onboards, hires, and creates work in the Electron shell", async () => {
|
||||
await page.getByRole("button", { name: "Publish" }).click();
|
||||
await expect(page.getByText("Published").first()).toBeVisible();
|
||||
|
||||
await page.getByRole("button", { name: "Settings" }).click();
|
||||
await page.getByRole("button", { name: "Compliance" }).click();
|
||||
await page.getByRole("button", { name: "Create" }).click();
|
||||
await expect(page.getByText("gdpr export · employee").first()).toBeVisible();
|
||||
|
||||
await verifyPagesDoNotOverflow(page);
|
||||
} finally {
|
||||
await app.close();
|
||||
@@ -105,7 +110,7 @@ async function verifyPagesDoNotOverflow(page: Page): Promise<void> {
|
||||
await page.getByRole("button", { name: nav }).click();
|
||||
await expectPageToFit(page);
|
||||
}
|
||||
for (const tab of ["Workspace", "Members", "Billing", "Models", "Providers", "Skills", "Policies", "Migration", "Profile", "Notifications", "Security"]) {
|
||||
for (const tab of ["Workspace", "Members", "Billing", "Models", "Providers", "Skills", "Policies", "Compliance", "Migration", "Profile", "Notifications", "Security"]) {
|
||||
await page.getByRole("button", { name: "Settings" }).click();
|
||||
await page.getByRole("button", { name: tab }).click();
|
||||
await expectPageToFit(page);
|
||||
|
||||
@@ -48,7 +48,7 @@ test("captures every page for visual review", async () => {
|
||||
await go(page, "Social", outDir, "16-social");
|
||||
await go(page, "Settings", outDir, "17-settings");
|
||||
|
||||
for (const tab of ["Workspace", "Members", "Billing", "Models", "Skills", "Policies", "Migration", "Profile", "Notifications", "Security"]) {
|
||||
for (const tab of ["Workspace", "Members", "Billing", "Models", "Skills", "Policies", "Compliance", "Migration", "Profile", "Notifications", "Security"]) {
|
||||
await page.getByRole("button", { name: "Settings" }).click();
|
||||
await page.getByRole("button", { name: tab }).first().click();
|
||||
await snap(page, outDir, `settings-${tab.toLowerCase()}`);
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { DatabaseConnection } from "../../src/main/database/connection";
|
||||
import { ChatService } from "../../src/main/services/chat-service";
|
||||
import { ComplianceService } from "../../src/main/services/compliance-service";
|
||||
import { OrganizationService } from "../../src/main/services/organization-service";
|
||||
import { SnapshotService } from "../../src/main/services/snapshot-service";
|
||||
|
||||
function createConnection(): DatabaseConnection {
|
||||
return new DatabaseConnection(join(tmpdir(), `agentcompany-new-compliance-${randomUUID()}.db`));
|
||||
}
|
||||
|
||||
describe("compliance service", () => {
|
||||
test("creates GDPR export payloads with audit coverage", () => {
|
||||
const connection = createConnection();
|
||||
const organization = new OrganizationService(connection.db);
|
||||
const chat = new ChatService(connection.db);
|
||||
const compliance = new ComplianceService(connection.db);
|
||||
const snapshot = new SnapshotService(connection.db);
|
||||
let orgId = "";
|
||||
let employeeId = "";
|
||||
let spaceId = "";
|
||||
|
||||
connection.transaction(() => {
|
||||
orgId = organization.createOrganization({
|
||||
organizationName: "Acme Operations",
|
||||
employeeName: "Dana Lee",
|
||||
mission: "Coordinate agents with auditable approvals.",
|
||||
goal: "Ship a verified control plane.",
|
||||
});
|
||||
employeeId = (connection.db.prepare("select id from employees where org_id = ?").get(orgId) as { id: string }).id;
|
||||
spaceId = chat.createSpace({ orgId, spaceType: "project", name: "release", topic: "Release coordination", visibility: "workspace" });
|
||||
chat.sendMessage({
|
||||
orgId,
|
||||
spaceId,
|
||||
threadId: null,
|
||||
senderType: "employee",
|
||||
senderId: employeeId,
|
||||
bodyMd: "Exportable message",
|
||||
attachmentFileIds: [],
|
||||
});
|
||||
compliance.createExport({
|
||||
orgId,
|
||||
subjectType: "employee",
|
||||
subjectId: employeeId,
|
||||
exportType: "gdpr_export",
|
||||
requestedByEmployeeId: employeeId,
|
||||
});
|
||||
});
|
||||
|
||||
const state = snapshot.load();
|
||||
const record = state.complianceExports[0];
|
||||
if (!record) throw new Error("Compliance export was not created.");
|
||||
const payload = JSON.parse(record.payloadJson) as { profile: { display_name: string }; chatMessages: unknown[] };
|
||||
expect(record).toMatchObject({ subjectType: "employee", subjectId: employeeId, exportType: "gdpr_export", state: "completed" });
|
||||
expect(payload.profile.display_name).toBe("Dana Lee");
|
||||
expect(payload.chatMessages).toHaveLength(1);
|
||||
expect(state.auditEvents.some((event) => event.entityType === "compliance_export" && event.action === "created")).toBe(true);
|
||||
connection.close();
|
||||
});
|
||||
|
||||
test("blocks GDPR delete under holds and redacts after release", () => {
|
||||
const connection = createConnection();
|
||||
const organization = new OrganizationService(connection.db);
|
||||
const chat = new ChatService(connection.db);
|
||||
const compliance = new ComplianceService(connection.db);
|
||||
const snapshot = new SnapshotService(connection.db);
|
||||
let orgId = "";
|
||||
let employeeId = "";
|
||||
let spaceId = "";
|
||||
let holdId = "";
|
||||
|
||||
connection.transaction(() => {
|
||||
orgId = organization.createOrganization({
|
||||
organizationName: "Acme Operations",
|
||||
employeeName: "Dana Lee",
|
||||
mission: "Coordinate agents with auditable approvals.",
|
||||
goal: "Ship a verified control plane.",
|
||||
});
|
||||
employeeId = (connection.db.prepare("select id from employees where org_id = ?").get(orgId) as { id: string }).id;
|
||||
spaceId = chat.createSpace({ orgId, spaceType: "project", name: "release", topic: "Release coordination", visibility: "workspace" });
|
||||
chat.sendMessage({ orgId, spaceId, threadId: null, senderType: "employee", senderId: employeeId, bodyMd: "Delete me", attachmentFileIds: [] });
|
||||
holdId = compliance.createLegalHold({
|
||||
orgId,
|
||||
entityType: "employee",
|
||||
entityId: employeeId,
|
||||
reason: "Discovery hold",
|
||||
createdByEmployeeId: employeeId,
|
||||
});
|
||||
});
|
||||
|
||||
expect(() => connection.transaction(() => compliance.createExport({
|
||||
orgId,
|
||||
subjectType: "employee",
|
||||
subjectId: employeeId,
|
||||
exportType: "gdpr_delete",
|
||||
requestedByEmployeeId: employeeId,
|
||||
}))).toThrow("Active compliance hold prevents GDPR delete.");
|
||||
|
||||
connection.transaction(() => {
|
||||
compliance.setLegalHoldState({ orgId, legalHoldId: holdId, active: false, employeeId });
|
||||
compliance.createExport({
|
||||
orgId,
|
||||
subjectType: "employee",
|
||||
subjectId: employeeId,
|
||||
exportType: "gdpr_delete",
|
||||
requestedByEmployeeId: employeeId,
|
||||
});
|
||||
});
|
||||
|
||||
const state = snapshot.load();
|
||||
expect(state.employees[0]?.displayName).toBe("Deleted employee");
|
||||
expect(state.chatMessages[0]?.senderId).toBeNull();
|
||||
expect(state.complianceExports[0]?.exportType).toBe("gdpr_delete");
|
||||
expect(state.auditEvents.some((event) => event.entityType === "compliance_export" && event.action === "gdpr_deleted")).toBe(true);
|
||||
connection.close();
|
||||
});
|
||||
|
||||
test("blocks GDPR delete under organization retention freeze", () => {
|
||||
const connection = createConnection();
|
||||
const organization = new OrganizationService(connection.db);
|
||||
const compliance = new ComplianceService(connection.db);
|
||||
let orgId = "";
|
||||
let employeeId = "";
|
||||
let agentId = "";
|
||||
|
||||
connection.transaction(() => {
|
||||
orgId = organization.createOrganization({
|
||||
organizationName: "Acme Operations",
|
||||
employeeName: "Dana Lee",
|
||||
mission: "Coordinate agents with auditable approvals.",
|
||||
goal: "Ship a verified control plane.",
|
||||
});
|
||||
employeeId = (connection.db.prepare("select id from employees where org_id = ?").get(orgId) as { id: string }).id;
|
||||
agentId = organization.createAgent({
|
||||
orgId,
|
||||
name: "Ada",
|
||||
role: "Release reviewer",
|
||||
department: "Engineering",
|
||||
modelProviderId: null,
|
||||
modelId: null,
|
||||
budgetCents: 10000,
|
||||
managerAgentId: null,
|
||||
});
|
||||
compliance.createRetentionFreeze({
|
||||
orgId,
|
||||
scopeType: "organization",
|
||||
scopeId: orgId,
|
||||
reason: "Retention freeze",
|
||||
createdByEmployeeId: employeeId,
|
||||
});
|
||||
});
|
||||
|
||||
expect(() => connection.transaction(() => compliance.createExport({
|
||||
orgId,
|
||||
subjectType: "agent",
|
||||
subjectId: agentId,
|
||||
exportType: "gdpr_delete",
|
||||
requestedByEmployeeId: employeeId,
|
||||
}))).toThrow("Active compliance hold prevents GDPR delete.");
|
||||
connection.close();
|
||||
});
|
||||
});
|
||||
@@ -23,7 +23,7 @@ describe("domain services", () => {
|
||||
const connection = createConnection();
|
||||
|
||||
const rows = connection.db.prepare("select version from schema_migrations order by version asc").all() as Array<{ version: number }>;
|
||||
expect(rows.map((row) => row.version)).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11]);
|
||||
expect(rows.map((row) => row.version)).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]);
|
||||
connection.close();
|
||||
});
|
||||
|
||||
|
||||
@@ -128,6 +128,9 @@ describe("migration service", () => {
|
||||
create table browser_actions(id text primary key, org_id text, social_account_id text, evidence_file_id text);
|
||||
create table scheduled_posts(id text primary key, org_id text, social_account_id text, approval_id text, created_by_employee_id text);
|
||||
create table policy_rules(id text primary key, org_id text);
|
||||
create table compliance_exports(id text primary key, org_id text, requested_by_employee_id text);
|
||||
create table legal_holds(id text primary key, org_id text, created_by_employee_id text);
|
||||
create table retention_freezes(id text primary key, org_id text, created_by_employee_id text);
|
||||
create table audit_events(id text primary key, org_id text);
|
||||
insert into organizations(id) values ('org-1');
|
||||
insert into employees(id, org_id) values ('emp-1', 'org-1');
|
||||
@@ -166,6 +169,9 @@ describe("migration service", () => {
|
||||
insert into browser_actions(id, org_id, social_account_id, evidence_file_id) values ('browser-1', 'org-1', 'social-1', 'file-1');
|
||||
insert into scheduled_posts(id, org_id, social_account_id, approval_id, created_by_employee_id) values ('post-1', 'org-1', 'social-1', 'approval-1', 'emp-1');
|
||||
insert into policy_rules(id, org_id) values ('policy-1', 'org-1');
|
||||
insert into compliance_exports(id, org_id, requested_by_employee_id) values ('cmp-1', 'org-1', 'emp-1');
|
||||
insert into legal_holds(id, org_id, created_by_employee_id) values ('hold-1', 'org-1', 'emp-1');
|
||||
insert into retention_freezes(id, org_id, created_by_employee_id) values ('freeze-1', 'org-1', 'emp-1');
|
||||
insert into audit_events(id, org_id) values ('audit-1', 'org-1');
|
||||
`);
|
||||
let orgId = "";
|
||||
@@ -183,9 +189,11 @@ describe("migration service", () => {
|
||||
expect(body.tableCounts.board_interventions).toBe(1);
|
||||
expect(body.tableCounts.file_access_grants).toBe(1);
|
||||
expect(body.tableCounts.provider_requests).toBe(1);
|
||||
expect(body.tableCounts.compliance_exports).toBe(1);
|
||||
expect(body.tableChecksums.audit_events).toHaveLength(64);
|
||||
expect(body.idMaps.skill_runtime_snapshots).toEqual(["snapshot-1"]);
|
||||
expect(body.idMaps.board_interventions).toEqual(["bdi-1"]);
|
||||
expect(body.idMaps.retention_freezes).toEqual(["freeze-1"]);
|
||||
connection.close();
|
||||
});
|
||||
|
||||
|
||||
Reference in new issue
Block a user