Harden PRD page coverage and migration audit
This commit is contained in:
1 parent
83a51c6881
commit
f107ecba31
12 files changed
+235
-25
No files matched your search
@@ -1,4 +1,5 @@
|
|||||||
import { existsSync } from "node:fs";
|
import { existsSync } from "node:fs";
|
||||||
|
import { createHash } from "node:crypto";
|
||||||
import { DatabaseSync, type StatementSync } from "node:sqlite";
|
import { DatabaseSync, type StatementSync } from "node:sqlite";
|
||||||
import type { CreateMigrationReportInput } from "../../shared/commands";
|
import type { CreateMigrationReportInput } from "../../shared/commands";
|
||||||
import { createId } from "../../shared/ids";
|
import { createId } from "../../shared/ids";
|
||||||
@@ -37,13 +38,35 @@ export class MigrationService {
|
|||||||
.filter((table) => availableTables.has(table))
|
.filter((table) => availableTables.has(table))
|
||||||
.map((table) => [table, countRows(source.prepare(`select count(*) as count from ${table}`))]),
|
.map((table) => [table, countRows(source.prepare(`select count(*) as count from ${table}`))]),
|
||||||
);
|
);
|
||||||
|
const tableChecksums = Object.fromEntries(
|
||||||
|
LEGACY_TABLES
|
||||||
|
.filter((table) => availableTables.has(table))
|
||||||
|
.map((table) => [table, tableChecksum(source, table)]),
|
||||||
|
);
|
||||||
|
const idMaps = Object.fromEntries(
|
||||||
|
LEGACY_TABLES
|
||||||
|
.filter((table) => availableTables.has(table))
|
||||||
|
.map((table) => [table, tableIds(source, table)]),
|
||||||
|
);
|
||||||
const missingTables = LEGACY_TABLES.filter((table) => !availableTables.has(table));
|
const missingTables = LEGACY_TABLES.filter((table) => !availableTables.has(table));
|
||||||
|
const failures = missingTables.map((table) => ({ table, reason: "missing_table" }));
|
||||||
const reportId = createId("mig");
|
const reportId = createId("mig");
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
this.db.prepare(`
|
this.db.prepare(`
|
||||||
insert into migration_reports(id, org_id, source_ref, report_json, created_at)
|
insert into migration_reports(id, org_id, source_ref, report_json, created_at)
|
||||||
values (?, ?, ?, ?, ?)
|
values (?, ?, ?, ?, ?)
|
||||||
`).run(reportId, input.orgId, input.sourceDatabasePath, JSON.stringify({ tableCounts, missingTables }), now);
|
`).run(reportId, input.orgId, input.sourceDatabasePath, JSON.stringify({
|
||||||
|
tableCounts,
|
||||||
|
tableChecksums,
|
||||||
|
idMaps,
|
||||||
|
missingTables,
|
||||||
|
failures,
|
||||||
|
rollbackPack: {
|
||||||
|
sourceDatabasePath: input.sourceDatabasePath,
|
||||||
|
capturedAt: now,
|
||||||
|
restoreStrategy: "legacy_snapshot_restore",
|
||||||
|
},
|
||||||
|
}), now);
|
||||||
this.audit.record({
|
this.audit.record({
|
||||||
orgId: input.orgId,
|
orgId: input.orgId,
|
||||||
entityType: "migration_report",
|
entityType: "migration_report",
|
||||||
@@ -63,3 +86,16 @@ export class MigrationService {
|
|||||||
function countRows(statement: StatementSync): number {
|
function countRows(statement: StatementSync): number {
|
||||||
return (statement.get() as { count: number }).count;
|
return (statement.get() as { count: number }).count;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function tableChecksum(source: DatabaseSync, table: string): string {
|
||||||
|
const hash = createHash("sha256");
|
||||||
|
const rows = source.prepare(`select * from ${table} order by rowid`).all();
|
||||||
|
for (const row of rows) hash.update(JSON.stringify(row));
|
||||||
|
return hash.digest("hex");
|
||||||
|
}
|
||||||
|
|
||||||
|
function tableIds(source: DatabaseSync, table: string): string[] {
|
||||||
|
const columns = source.prepare(`pragma table_info(${table})`).all() as Array<{ name: string }>;
|
||||||
|
if (!columns.some((column) => column.name === "id")) return [];
|
||||||
|
return source.prepare(`select id from ${table} order by id`).all().map((row) => String((row as { id: unknown }).id));
|
||||||
|
}
|
||||||
@@ -71,6 +71,8 @@ export class SnapshotService {
|
|||||||
skillBindings,
|
skillBindings,
|
||||||
skillRuntimeSnapshots: list(this.db, "skill_runtime_snapshots", "created_at desc"),
|
skillRuntimeSnapshots: list(this.db, "skill_runtime_snapshots", "created_at desc"),
|
||||||
socialAccounts: list(this.db, "social_accounts", "created_at asc"),
|
socialAccounts: list(this.db, "social_accounts", "created_at asc"),
|
||||||
|
socialAccountGrants: list(this.db, "social_account_grants", "created_at asc"),
|
||||||
|
browserActions: list(this.db, "browser_actions", "created_at desc"),
|
||||||
policyRules: list(this.db, "policy_rules", "created_at asc"),
|
policyRules: list(this.db, "policy_rules", "created_at asc"),
|
||||||
migrationReports: list(this.db, "migration_reports", "created_at desc"),
|
migrationReports: list(this.db, "migration_reports", "created_at desc"),
|
||||||
auditEvents: list<AppSnapshot["auditEvents"][number]>(this.db, "audit_events", "created_at desc").slice(0, 200),
|
auditEvents: list<AppSnapshot["auditEvents"][number]>(this.db, "audit_events", "created_at desc").slice(0, 200),
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useState, type FormEvent, type ReactNode } from "react";
|
import { useState, type FormEvent, type ReactNode } from "react";
|
||||||
import { Avatar, Button, Field, Icon, IconButton } from "../shared/ui";
|
import { Avatar, Button, Field, Icon, IconButton } from "../shared/ui";
|
||||||
import { Dialog } from "../shared/Dialog";
|
import { Dialog } from "../shared/Dialog";
|
||||||
import type { ChatSpaceRecord } from "../../../shared/domain";
|
import type { ChatAttachmentRecord, ChatSpaceRecord, FileObjectRecord } from "../../../shared/domain";
|
||||||
import type { PageProps } from "./types";
|
import type { PageProps } from "./types";
|
||||||
import { activeOrgId } from "./types";
|
import { activeOrgId } from "./types";
|
||||||
|
|
||||||
@@ -18,10 +18,12 @@ export function ChatPage({ snapshot, actions }: PageProps) {
|
|||||||
const spaces = snapshot.chatSpaces.filter((item) => item.orgId === orgId);
|
const spaces = snapshot.chatSpaces.filter((item) => item.orgId === orgId);
|
||||||
const employees = snapshot.employees.filter((item) => item.orgId === orgId);
|
const employees = snapshot.employees.filter((item) => item.orgId === orgId);
|
||||||
const agents = snapshot.agents.filter((item) => item.orgId === orgId);
|
const agents = snapshot.agents.filter((item) => item.orgId === orgId);
|
||||||
|
const files = snapshot.files.filter((item) => item.orgId === orgId);
|
||||||
const [spaceId, setSpaceId] = useState(spaces[0]?.id ?? "");
|
const [spaceId, setSpaceId] = useState(spaces[0]?.id ?? "");
|
||||||
const [spaceOpen, setSpaceOpen] = useState(false);
|
const [spaceOpen, setSpaceOpen] = useState(false);
|
||||||
const activeSpace = spaces.find((space) => space.id === spaceId) ?? spaces[0];
|
const activeSpace = spaces.find((space) => space.id === spaceId) ?? spaces[0];
|
||||||
const messages = snapshot.chatMessages.filter((item) => item.orgId === orgId && item.spaceId === activeSpace?.id);
|
if (!activeSpace) throw new Error("At least one chat space is required.");
|
||||||
|
const messages = snapshot.chatMessages.filter((item) => item.orgId === orgId && item.spaceId === activeSpace.id);
|
||||||
const sender = employees[0];
|
const sender = employees[0];
|
||||||
|
|
||||||
const submit = (event: FormEvent<HTMLFormElement>) => {
|
const submit = (event: FormEvent<HTMLFormElement>) => {
|
||||||
@@ -31,6 +33,7 @@ export function ChatPage({ snapshot, actions }: PageProps) {
|
|||||||
const bodyMd = messageBody(form);
|
const bodyMd = messageBody(form);
|
||||||
if (!bodyMd) return;
|
if (!bodyMd) return;
|
||||||
event.currentTarget.reset();
|
event.currentTarget.reset();
|
||||||
|
const attachmentFileId = value(form, "attachmentFileId");
|
||||||
void actions.run(() => window.agentCompany.sendMessage({
|
void actions.run(() => window.agentCompany.sendMessage({
|
||||||
orgId,
|
orgId,
|
||||||
spaceId: activeSpace.id,
|
spaceId: activeSpace.id,
|
||||||
@@ -38,6 +41,7 @@ export function ChatPage({ snapshot, actions }: PageProps) {
|
|||||||
senderType: "employee",
|
senderType: "employee",
|
||||||
senderId: sender.id,
|
senderId: sender.id,
|
||||||
bodyMd,
|
bodyMd,
|
||||||
|
attachmentFileIds: attachmentFileId ? [attachmentFileId] : [],
|
||||||
}));
|
}));
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -82,10 +86,10 @@ export function ChatPage({ snapshot, actions }: PageProps) {
|
|||||||
</aside>
|
</aside>
|
||||||
<section style={{ flex: 1, minWidth: 0, display: "flex", flexDirection: "column", background: "var(--panel)" }}>
|
<section style={{ flex: 1, minWidth: 0, display: "flex", flexDirection: "column", background: "var(--panel)" }}>
|
||||||
<header className="row" style={{ height: 52, padding: "0 24px", borderBottom: "1px solid var(--border-soft)" }}>
|
<header className="row" style={{ height: 52, padding: "0 24px", borderBottom: "1px solid var(--border-soft)" }}>
|
||||||
<Icon name={activeSpace?.visibility === "restricted" ? "lock" : "hash"} />
|
<Icon name={activeSpace.visibility === "restricted" ? "lock" : "hash"} />
|
||||||
<div style={{ fontSize: 14, fontWeight: 600 }}>{activeSpace?.name ?? "Messages"}</div>
|
<div style={{ fontSize: 14, fontWeight: 600 }}>{activeSpace.name}</div>
|
||||||
<div style={{ color: "var(--text-3)", borderLeft: "1px solid var(--border)", paddingLeft: 10 }}>
|
<div style={{ color: "var(--text-3)", borderLeft: "1px solid var(--border)", paddingLeft: 10 }}>
|
||||||
{activeSpace?.topic ?? ""}
|
{activeSpace.topic}
|
||||||
</div>
|
</div>
|
||||||
<div style={{ marginLeft: "auto" }}><Icon name="search" /></div>
|
<div style={{ marginLeft: "auto" }}><Icon name="search" /></div>
|
||||||
</header>
|
</header>
|
||||||
@@ -99,6 +103,7 @@ export function ChatPage({ snapshot, actions }: PageProps) {
|
|||||||
time={new Date(message.createdAt).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}
|
time={new Date(message.createdAt).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}
|
||||||
name={displayName(message.senderType, message.senderId, employees, agents)}
|
name={displayName(message.senderType, message.senderId, employees, agents)}
|
||||||
bot={message.senderType === "agent"}
|
bot={message.senderType === "agent"}
|
||||||
|
files={messageFiles(message.id, snapshot.chatAttachments, files)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -107,7 +112,10 @@ export function ChatPage({ snapshot, actions }: PageProps) {
|
|||||||
<textarea className="input" name="message" rows={2} aria-label="Message" />
|
<textarea className="input" name="message" rows={2} aria-label="Message" />
|
||||||
<div className="row" style={{ paddingTop: 8, borderTop: "1px solid var(--border-soft)" }}>
|
<div className="row" style={{ paddingTop: 8, borderTop: "1px solid var(--border-soft)" }}>
|
||||||
<Icon name="attach" />
|
<Icon name="attach" />
|
||||||
<Icon name="social" />
|
<select className="input" name="attachmentFileId" aria-label="File attachment" style={{ maxWidth: 220 }}>
|
||||||
|
<option value="">File</option>
|
||||||
|
{files.map((file) => <option key={file.id} value={file.id}>{file.name}</option>)}
|
||||||
|
</select>
|
||||||
<div style={{ marginLeft: "auto" }}>
|
<div style={{ marginLeft: "auto" }}>
|
||||||
<Button tone="primary" size="small" type="submit" disabled={!activeSpace || !sender}><Icon name="send" /> Send</Button>
|
<Button tone="primary" size="small" type="submit" disabled={!activeSpace || !sender}><Icon name="send" /> Send</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -158,7 +166,7 @@ function SpaceItem({
|
|||||||
onClick: () => void;
|
onClick: () => void;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<button type="button" className={`nav-item ${active ? "active" : ""}`} onClick={onClick}>
|
<button type="button" aria-label={space.name} className={`nav-item ${active ? "active" : ""}`} onClick={onClick}>
|
||||||
<Icon name={space.visibility === "restricted" ? "lock" : "hash"} />
|
<Icon name={space.visibility === "restricted" ? "lock" : "hash"} />
|
||||||
<span>{space.name}</span>
|
<span>{space.name}</span>
|
||||||
{count > 0 && <span className="nav-badge">{count}</span>}
|
{count > 0 && <span className="nav-badge">{count}</span>}
|
||||||
@@ -166,7 +174,19 @@ function SpaceItem({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function MessageRow({ name, body, time, bot }: { name: string; body: string; time: string; bot: boolean }) {
|
function MessageRow({
|
||||||
|
name,
|
||||||
|
body,
|
||||||
|
time,
|
||||||
|
bot,
|
||||||
|
files,
|
||||||
|
}: {
|
||||||
|
name: string;
|
||||||
|
body: string;
|
||||||
|
time: string;
|
||||||
|
bot: boolean;
|
||||||
|
files: FileObjectRecord[];
|
||||||
|
}) {
|
||||||
return (
|
return (
|
||||||
<div style={{ display: "flex", gap: 12, marginBottom: 18 }}>
|
<div style={{ display: "flex", gap: 12, marginBottom: 18 }}>
|
||||||
<Avatar name={name} bot={bot} />
|
<Avatar name={name} bot={bot} />
|
||||||
@@ -176,11 +196,34 @@ function MessageRow({ name, body, time, bot }: { name: string; body: string; tim
|
|||||||
<span className="num" style={{ color: "var(--text-3)", fontSize: 11 }}>{time}</span>
|
<span className="num" style={{ color: "var(--text-3)", fontSize: 11 }}>{time}</span>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ lineHeight: 1.5, whiteSpace: "pre-wrap" }}>{body}</div>
|
<div style={{ lineHeight: 1.5, whiteSpace: "pre-wrap" }}>{body}</div>
|
||||||
|
{files.length > 0 && (
|
||||||
|
<div className="row" style={{ marginTop: 8, flexWrap: "wrap" }}>
|
||||||
|
{files.map((file) => (
|
||||||
|
<span key={file.id} className="attachment-pill">
|
||||||
|
<Icon name="file" size={13} /> {file.name}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function messageFiles(
|
||||||
|
messageId: string,
|
||||||
|
attachments: ChatAttachmentRecord[],
|
||||||
|
files: FileObjectRecord[],
|
||||||
|
): FileObjectRecord[] {
|
||||||
|
return attachments
|
||||||
|
.filter((attachment) => attachment.messageId === messageId)
|
||||||
|
.map((attachment) => {
|
||||||
|
const file = files.find((item) => item.id === attachment.fileId);
|
||||||
|
if (!file) throw new Error("Chat attachment file is missing.");
|
||||||
|
return file;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function displayName(
|
function displayName(
|
||||||
senderType: string,
|
senderType: string,
|
||||||
senderId: string | null,
|
senderId: string | null,
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ function TreeItem({
|
|||||||
active?: boolean;
|
active?: boolean;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<button type="button" className={`nav-item ${active ? "active" : ""}`} onClick={onClick}>
|
<button type="button" aria-label={label} className={`nav-item ${active ? "active" : ""}`} onClick={onClick}>
|
||||||
<Icon name={icon} />
|
<Icon name={icon} />
|
||||||
<span>{label}</span>
|
<span>{label}</span>
|
||||||
{typeof count === "number" && <span className="nav-badge">{count}</span>}
|
{typeof count === "number" && <span className="nav-badge">{count}</span>}
|
||||||
|
|||||||
@@ -4,7 +4,8 @@ import { activeOrgId, money } from "./types";
|
|||||||
|
|
||||||
export function HomePage({ snapshot }: PageProps) {
|
export function HomePage({ snapshot }: PageProps) {
|
||||||
const orgId = activeOrgId(snapshot);
|
const orgId = activeOrgId(snapshot);
|
||||||
const organization = snapshot.organizations.find((item) => item.id === orgId)!;
|
const organization = snapshot.organizations.find((item) => item.id === orgId);
|
||||||
|
if (!organization) throw new Error("Active organization is missing.");
|
||||||
const tasks = snapshot.tasks.filter((item) => item.orgId === orgId);
|
const tasks = snapshot.tasks.filter((item) => item.orgId === orgId);
|
||||||
const approvals = snapshot.approvals.filter((item) => item.orgId === orgId);
|
const approvals = snapshot.approvals.filter((item) => item.orgId === orgId);
|
||||||
const agents = snapshot.agents.filter((item) => item.orgId === orgId);
|
const agents = snapshot.agents.filter((item) => item.orgId === orgId);
|
||||||
|
|||||||
@@ -102,7 +102,7 @@ export function SettingsPage({ snapshot, actions }: PageProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function SettingsNav({ label, active, onClick }: { label: string; active: boolean; onClick: () => void }) {
|
function SettingsNav({ label, active, onClick }: { label: string; active: boolean; onClick: () => void }) {
|
||||||
return <button type="button" className={`nav-item ${active ? "active" : ""}`} onClick={onClick}>{label}</button>;
|
return <button type="button" aria-label={label} className={`nav-item ${active ? "active" : ""}`} onClick={onClick}>{label}</button>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function ProvidersPanel({ providers, onManage }: { providers: ProviderRecord[]; onManage: (provider: ProviderRecord) => void }) {
|
function ProvidersPanel({ providers, onManage }: { providers: ProviderRecord[]; onManage: (provider: ProviderRecord) => void }) {
|
||||||
@@ -231,8 +231,9 @@ function MigrationPanel({ reports, onCreate }: { reports: MigrationReportRecord[
|
|||||||
|
|
||||||
function MigrationReportRow({ report }: { report: MigrationReportRecord }) {
|
function MigrationReportRow({ report }: { report: MigrationReportRecord }) {
|
||||||
const body = parseMigrationReport(report.reportJson);
|
const body = parseMigrationReport(report.reportJson);
|
||||||
|
const idCount = Object.values(body.idMaps).reduce((sum, ids) => sum + ids.length, 0);
|
||||||
return (
|
return (
|
||||||
<div className="table-row" style={{ gridTemplateColumns: "1.4fr 1fr 1fr" }}>
|
<div className="table-row" style={{ gridTemplateColumns: "1.4fr 1fr 1fr 1fr" }}>
|
||||||
<div style={{ minWidth: 0 }}>
|
<div style={{ minWidth: 0 }}>
|
||||||
<div style={{ fontWeight: 540, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }} title={report.sourceRef}>{report.sourceRef}</div>
|
<div style={{ fontWeight: 540, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }} title={report.sourceRef}>{report.sourceRef}</div>
|
||||||
<div style={{ color: "var(--text-3)", fontSize: 11 }}>{new Date(report.createdAt).toLocaleString()}</div>
|
<div style={{ color: "var(--text-3)", fontSize: 11 }}>{new Date(report.createdAt).toLocaleString()}</div>
|
||||||
@@ -240,15 +241,36 @@ function MigrationReportRow({ report }: { report: MigrationReportRecord }) {
|
|||||||
<div className="row" style={{ flexWrap: "wrap" }}>
|
<div className="row" style={{ flexWrap: "wrap" }}>
|
||||||
{Object.entries(body.tableCounts).map(([table, count]) => <Pill key={table}>{table}: {count}</Pill>)}
|
{Object.entries(body.tableCounts).map(([table, count]) => <Pill key={table}>{table}: {count}</Pill>)}
|
||||||
</div>
|
</div>
|
||||||
<Pill>{body.missingTables.length} missing</Pill>
|
<Pill>{Object.keys(body.tableChecksums).length} checksums</Pill>
|
||||||
|
<div className="row" style={{ flexWrap: "wrap" }}>
|
||||||
|
<Pill>{idCount} ids</Pill>
|
||||||
|
<Pill>{body.failures.length} failures</Pill>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseMigrationReport(reportJson: string): { tableCounts: Record<string, number>; missingTables: string[] } {
|
function parseMigrationReport(reportJson: string): {
|
||||||
const parsed = JSON.parse(reportJson) as { tableCounts?: Record<string, number>; missingTables?: string[] };
|
tableCounts: Record<string, number>;
|
||||||
if (!parsed.tableCounts || !Array.isArray(parsed.missingTables)) throw new Error("Migration report is invalid.");
|
tableChecksums: Record<string, string>;
|
||||||
return { tableCounts: parsed.tableCounts, missingTables: parsed.missingTables };
|
idMaps: Record<string, string[]>;
|
||||||
|
failures: Array<{ table: string; reason: string }>;
|
||||||
|
} {
|
||||||
|
const parsed = JSON.parse(reportJson) as {
|
||||||
|
tableCounts?: Record<string, number>;
|
||||||
|
tableChecksums?: Record<string, string>;
|
||||||
|
idMaps?: Record<string, string[]>;
|
||||||
|
failures?: Array<{ table: string; reason: string }>;
|
||||||
|
};
|
||||||
|
if (!parsed.tableCounts || !parsed.tableChecksums || !parsed.idMaps || !Array.isArray(parsed.failures)) {
|
||||||
|
throw new Error("Migration report is invalid.");
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
tableCounts: parsed.tableCounts,
|
||||||
|
tableChecksums: parsed.tableChecksums,
|
||||||
|
idMaps: parsed.idMaps,
|
||||||
|
failures: parsed.failures,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function requireRevision(
|
function requireRevision(
|
||||||
|
|||||||
@@ -1,7 +1,12 @@
|
|||||||
import { useState, type FormEvent } from "react";
|
import { useState, type FormEvent } from "react";
|
||||||
import { Avatar, Button, Field, Icon } from "../shared/ui";
|
import { Avatar, Button, Field, Icon } from "../shared/ui";
|
||||||
import { Dialog } from "../shared/Dialog";
|
import { Dialog } from "../shared/Dialog";
|
||||||
import { SOCIAL_PLATFORM, type SocialAccountRecord, type SocialPlatform } from "../../../shared/domain";
|
import {
|
||||||
|
SOCIAL_PLATFORM,
|
||||||
|
type SocialAccountGrantRecord,
|
||||||
|
type SocialAccountRecord,
|
||||||
|
type SocialPlatform,
|
||||||
|
} from "../../../shared/domain";
|
||||||
import type { PageProps } from "./types";
|
import type { PageProps } from "./types";
|
||||||
import { activeOrgId } from "./types";
|
import { activeOrgId } from "./types";
|
||||||
|
|
||||||
@@ -13,6 +18,8 @@ export function SocialPage({ snapshot, actions }: PageProps) {
|
|||||||
const orgId = activeOrgId(snapshot);
|
const orgId = activeOrgId(snapshot);
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const accounts = snapshot.socialAccounts.filter((item) => item.orgId === orgId);
|
const accounts = snapshot.socialAccounts.filter((item) => item.orgId === orgId);
|
||||||
|
const grants = snapshot.socialAccountGrants.filter((item) => item.orgId === orgId);
|
||||||
|
const actionsByAccount = snapshot.browserActions.filter((item) => item.orgId === orgId);
|
||||||
const employees = snapshot.employees.filter((item) => item.orgId === orgId);
|
const employees = snapshot.employees.filter((item) => item.orgId === orgId);
|
||||||
|
|
||||||
const submit = (event: FormEvent<HTMLFormElement>) => {
|
const submit = (event: FormEvent<HTMLFormElement>) => {
|
||||||
@@ -47,6 +54,8 @@ export function SocialPage({ snapshot, actions }: PageProps) {
|
|||||||
key={account.id}
|
key={account.id}
|
||||||
account={account}
|
account={account}
|
||||||
employeeName={authorizedEmployeeName(account.authorizedEmployeeId, employees)}
|
employeeName={authorizedEmployeeName(account.authorizedEmployeeId, employees)}
|
||||||
|
grants={grants.filter((grant) => grant.socialAccountId === account.id)}
|
||||||
|
actionCount={actionsByAccount.filter((action) => action.socialAccountId === account.id).length}
|
||||||
onLogin={() => void actions.run(() => window.agentCompany.launchSocialLogin({ socialAccountId: account.id }))}
|
onLogin={() => void actions.run(() => window.agentCompany.launchSocialLogin({ socialAccountId: account.id }))}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
@@ -76,10 +85,14 @@ export function SocialPage({ snapshot, actions }: PageProps) {
|
|||||||
function AccountCard({
|
function AccountCard({
|
||||||
account,
|
account,
|
||||||
employeeName,
|
employeeName,
|
||||||
|
grants,
|
||||||
|
actionCount,
|
||||||
onLogin,
|
onLogin,
|
||||||
}: {
|
}: {
|
||||||
account: SocialAccountRecord;
|
account: SocialAccountRecord;
|
||||||
employeeName: string | null;
|
employeeName: string | null;
|
||||||
|
grants: SocialAccountGrantRecord[];
|
||||||
|
actionCount: number;
|
||||||
onLogin: () => void;
|
onLogin: () => void;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
@@ -97,6 +110,10 @@ function AccountCard({
|
|||||||
<span>{employeeName}</span>
|
<span>{employeeName}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
<div className="row" style={{ color: "var(--text-3)", fontSize: 11 }}>
|
||||||
|
<span>{grants.length} grants</span>
|
||||||
|
<span>{actionCount} actions</span>
|
||||||
|
</div>
|
||||||
<Button size="small" onClick={onLogin}>Sign in</Button>
|
<Button size="small" onClick={onLogin}>Sign in</Button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -30,6 +30,10 @@ export function Shell({
|
|||||||
}) {
|
}) {
|
||||||
const organization = snapshot.organizations.find((item) => item.id === snapshot.activeOrgId);
|
const organization = snapshot.organizations.find((item) => item.id === snapshot.activeOrgId);
|
||||||
const employee = snapshot.employees.find((item) => item.orgId === snapshot.activeOrgId);
|
const employee = snapshot.employees.find((item) => item.orgId === snapshot.activeOrgId);
|
||||||
|
const activeRoute = navItems.find((item) => item.id === route);
|
||||||
|
if (!organization) throw new Error("Active organization is missing.");
|
||||||
|
if (!employee) throw new Error("Active employee is missing.");
|
||||||
|
if (!activeRoute) throw new Error("Active route is missing.");
|
||||||
const badges = {
|
const badges = {
|
||||||
work: snapshot.tasks.filter((item) => item.orgId === snapshot.activeOrgId && item.status !== "done").length,
|
work: snapshot.tasks.filter((item) => item.orgId === snapshot.activeOrgId && item.status !== "done").length,
|
||||||
chat: snapshot.chatMessages.filter((item) => item.orgId === snapshot.activeOrgId).length,
|
chat: snapshot.chatMessages.filter((item) => item.orgId === snapshot.activeOrgId).length,
|
||||||
@@ -41,7 +45,7 @@ export function Shell({
|
|||||||
<aside className="sidebar">
|
<aside className="sidebar">
|
||||||
<div className="brand">
|
<div className="brand">
|
||||||
<div className="brand-mark">A</div>
|
<div className="brand-mark">A</div>
|
||||||
<div className="brand-name">{organization?.name ?? "AgentCompany"}</div>
|
<div className="brand-name">{organization.name}</div>
|
||||||
<Icon name="chevronDown" size={12} />
|
<Icon name="chevronDown" size={12} />
|
||||||
</div>
|
</div>
|
||||||
<nav className="nav">
|
<nav className="nav">
|
||||||
@@ -49,6 +53,7 @@ export function Shell({
|
|||||||
<button
|
<button
|
||||||
key={item.id}
|
key={item.id}
|
||||||
type="button"
|
type="button"
|
||||||
|
aria-label={item.label}
|
||||||
className={`nav-item ${route === item.id ? "active" : ""}`}
|
className={`nav-item ${route === item.id ? "active" : ""}`}
|
||||||
onClick={() => setRoute(item.id)}
|
onClick={() => setRoute(item.id)}
|
||||||
>
|
>
|
||||||
@@ -59,16 +64,16 @@ export function Shell({
|
|||||||
))}
|
))}
|
||||||
</nav>
|
</nav>
|
||||||
<div className="sidebar-foot">
|
<div className="sidebar-foot">
|
||||||
<Avatar name={employee?.displayName ?? "User"} size="sm" />
|
<Avatar name={employee.displayName} size="sm" />
|
||||||
<div style={{ flex: 1, minWidth: 0 }}>{employee?.displayName ?? "User"}</div>
|
<div style={{ flex: 1, minWidth: 0 }}>{employee.displayName}</div>
|
||||||
<Icon name="bell" />
|
<Icon name="bell" />
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
<main className="main">
|
<main className="main">
|
||||||
<header className="topbar">
|
<header className="topbar">
|
||||||
<span className="crumb-sub">{organization?.name ?? "Workspace"}</span>
|
<span className="crumb-sub">{organization.name}</span>
|
||||||
<span style={{ color: "var(--text-4)" }}>/</span>
|
<span style={{ color: "var(--text-4)" }}>/</span>
|
||||||
<span className="crumb">{navItems.find((item) => item.id === route)?.label}</span>
|
<span className="crumb">{activeRoute.label}</span>
|
||||||
<div className="topbar-actions">
|
<div className="topbar-actions">
|
||||||
{actions}
|
{actions}
|
||||||
{window.agentCompany.platform !== "darwin" && (
|
{window.agentCompany.platform !== "darwin" && (
|
||||||
|
|||||||
@@ -177,6 +177,20 @@ button { cursor: pointer; }
|
|||||||
.pill.amber { background: var(--amber-soft); color: var(--amber); border-color: transparent; }
|
.pill.amber { background: var(--amber-soft); color: var(--amber); border-color: transparent; }
|
||||||
.pill.green { background: var(--green-soft); color: var(--green); border-color: transparent; }
|
.pill.green { background: var(--green-soft); color: var(--green); border-color: transparent; }
|
||||||
.pill.violet { background: var(--accent-soft); color: var(--accent); border-color: transparent; }
|
.pill.violet { background: var(--accent-soft); color: var(--accent); border-color: transparent; }
|
||||||
|
.attachment-pill {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
max-width: 260px;
|
||||||
|
padding: 6px 8px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--r-md);
|
||||||
|
background: var(--panel-2);
|
||||||
|
color: var(--text-2);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
.dot { width: 6px; height: 6px; border-radius: 50%; background: currentColor; }
|
.dot { width: 6px; height: 6px; border-radius: 50%; background: currentColor; }
|
||||||
.avatar {
|
.avatar {
|
||||||
width: 28px;
|
width: 28px;
|
||||||
|
|||||||
@@ -324,6 +324,26 @@ export interface SocialAccountRecord {
|
|||||||
createdAt: string;
|
createdAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface SocialAccountGrantRecord {
|
||||||
|
id: string;
|
||||||
|
orgId: string;
|
||||||
|
socialAccountId: string;
|
||||||
|
employeeId: string;
|
||||||
|
role: string;
|
||||||
|
scopeJson: string;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BrowserActionRecord {
|
||||||
|
id: string;
|
||||||
|
orgId: string;
|
||||||
|
socialAccountId: string;
|
||||||
|
actionType: string;
|
||||||
|
policyDecision: string;
|
||||||
|
evidenceFileId: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface AuditEventRecord {
|
export interface AuditEventRecord {
|
||||||
id: string;
|
id: string;
|
||||||
orgId: string;
|
orgId: string;
|
||||||
@@ -380,6 +400,8 @@ export interface AppSnapshot {
|
|||||||
skillBindings: SkillBindingRecord[];
|
skillBindings: SkillBindingRecord[];
|
||||||
skillRuntimeSnapshots: SkillRuntimeSnapshotRecord[];
|
skillRuntimeSnapshots: SkillRuntimeSnapshotRecord[];
|
||||||
socialAccounts: SocialAccountRecord[];
|
socialAccounts: SocialAccountRecord[];
|
||||||
|
socialAccountGrants: SocialAccountGrantRecord[];
|
||||||
|
browserActions: BrowserActionRecord[];
|
||||||
policyRules: PolicyRuleRecord[];
|
policyRules: PolicyRuleRecord[];
|
||||||
migrationReports: MigrationReportRecord[];
|
migrationReports: MigrationReportRecord[];
|
||||||
auditEvents: AuditEventRecord[];
|
auditEvents: AuditEventRecord[];
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { _electron as electron, expect, test } from "@playwright/test";
|
import { _electron as electron, expect, test, type Page } from "@playwright/test";
|
||||||
import { mkdirSync } from "node:fs";
|
import { mkdirSync } from "node:fs";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import { join, resolve } from "node:path";
|
import { join, resolve } from "node:path";
|
||||||
@@ -50,7 +50,45 @@ test("onboards, hires, and creates work in the Electron shell", async () => {
|
|||||||
|
|
||||||
await page.getByRole("button", { name: "Approvals" }).click();
|
await page.getByRole("button", { name: "Approvals" }).click();
|
||||||
await expect(page.getByText("Production deploy").first()).toBeVisible();
|
await expect(page.getByText("Production deploy").first()).toBeVisible();
|
||||||
|
|
||||||
|
await page.getByRole("button", { name: "Messages" }).click();
|
||||||
|
await page.getByRole("button", { name: "New space" }).click();
|
||||||
|
await page.getByLabel("Name").fill("release");
|
||||||
|
await page.getByLabel("Topic").fill("Release coordination");
|
||||||
|
await page.locator(".modal").getByRole("button", { name: "Create" }).click();
|
||||||
|
await expect(page.getByText("release").first()).toBeVisible();
|
||||||
|
await page.getByRole("textbox", { name: "Message" }).fill("Release channel opened.");
|
||||||
|
await page.getByRole("button", { name: "Send" }).click();
|
||||||
|
await expect(page.getByText("Release channel opened.")).toBeVisible();
|
||||||
|
|
||||||
|
await verifyPagesDoNotOverflow(page);
|
||||||
} finally {
|
} finally {
|
||||||
await app.close();
|
await app.close();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
async function verifyPagesDoNotOverflow(page: Page): Promise<void> {
|
||||||
|
for (const size of [{ width: 1280, height: 860 }, { width: 980, height: 700 }]) {
|
||||||
|
await page.setViewportSize(size);
|
||||||
|
for (const nav of ["Home", "Team", "Work", "Messages", "Files", "Approvals", "Social", "Settings"]) {
|
||||||
|
await page.getByRole("button", { name: nav }).click();
|
||||||
|
await expectPageToFit(page);
|
||||||
|
}
|
||||||
|
for (const tab of ["Providers", "Skills", "Policies", "Migration"]) {
|
||||||
|
await page.getByRole("button", { name: "Settings" }).click();
|
||||||
|
await page.getByRole("button", { name: tab }).click();
|
||||||
|
await expectPageToFit(page);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function expectPageToFit(page: Page): Promise<void> {
|
||||||
|
const dimensions = await page.evaluate(() => ({
|
||||||
|
documentScrollWidth: document.documentElement.scrollWidth,
|
||||||
|
documentClientWidth: document.documentElement.clientWidth,
|
||||||
|
bodyScrollWidth: document.body.scrollWidth,
|
||||||
|
bodyClientWidth: document.body.clientWidth,
|
||||||
|
}));
|
||||||
|
expect(dimensions.documentScrollWidth).toBeLessThanOrEqual(dimensions.documentClientWidth + 1);
|
||||||
|
expect(dimensions.bodyScrollWidth).toBeLessThanOrEqual(dimensions.bodyClientWidth + 1);
|
||||||
|
}
|
||||||
@@ -198,6 +198,16 @@ describe("domain services", () => {
|
|||||||
const state = snapshot.load();
|
const state = snapshot.load();
|
||||||
expect(state.migrationReports).toHaveLength(1);
|
expect(state.migrationReports).toHaveLength(1);
|
||||||
expect(state.migrationReports[0]?.reportJson).toContain("\"agents\":1");
|
expect(state.migrationReports[0]?.reportJson).toContain("\"agents\":1");
|
||||||
|
const report = JSON.parse(state.migrationReports[0]!.reportJson) as {
|
||||||
|
tableChecksums: Record<string, string>;
|
||||||
|
idMaps: Record<string, string[]>;
|
||||||
|
failures: Array<{ table: string; reason: string }>;
|
||||||
|
rollbackPack: { sourceDatabasePath: string };
|
||||||
|
};
|
||||||
|
expect(report.tableChecksums.agents).toHaveLength(64);
|
||||||
|
expect(report.idMaps.agents).toEqual(["agent-1"]);
|
||||||
|
expect(report.failures.some((failure) => failure.reason === "missing_table")).toBe(true);
|
||||||
|
expect(report.rollbackPack.sourceDatabasePath).toBe(legacyPath);
|
||||||
connection.close();
|
connection.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user