Surface social access grants and activity history

The browser-platform governance data (account grants, social audit records,
browser actions with policy decisions) was recorded but never shown. Add a
read-only Access & activity section to the Social page that lists who is
authorized on each account with what scope, and a merged, policy-tagged
timeline of recent social and agent browser actions.
This commit is contained in:
ZacharyZhang-NY committed 2026-05-28 20:47:43 -04:00
1 parent db679cfeb9
commit ea1af95536
2 files changed
+150

No files matched your search

@@ -12,6 +12,7 @@ import {
} from "../../../shared/domain";
import { APPROVAL_STATE } from "../../../shared/domain";
import { SocialCalendar } from "./social/SocialCalendar";
import { SocialActivity } from "./social/SocialActivity";
import { PlatformIcon } from "./social/PlatformIcon";
import type { PageProps } from "./types";
import { activeOrgId, requireActiveEmployee } from "./types";
@@ -37,6 +38,9 @@ export function SocialPage({ snapshot, actions }: PageProps) {
return states;
}, [snapshot.approvals, orgId]);
const employees = snapshot.employees.filter((item) => item.orgId === orgId);
const socialGrants = useMemo(() => snapshot.socialAccountGrants.filter((grant) => grant.orgId === orgId), [snapshot.socialAccountGrants, orgId]);
const socialAudit = useMemo(() => snapshot.socialAuditRecords.filter((record) => record.orgId === orgId), [snapshot.socialAuditRecords, orgId]);
const browserActions = useMemo(() => snapshot.browserActions.filter((action) => action.orgId === orgId), [snapshot.browserActions, orgId]);
const [connectOpen, setConnectOpen] = useState(false);
const [draftOpen, setDraftOpen] = useState(false);
@@ -149,6 +153,14 @@ export function SocialPage({ snapshot, actions }: PageProps) {
<SocialCalendar posts={scheduledPosts} accounts={accounts} />
</div>
<SocialActivity
accounts={accounts}
grants={socialGrants}
auditRecords={socialAudit}
browserActions={browserActions}
employees={employees}
/>
</div>
<Dialog title="Connect social account" open={connectOpen} submitLabel="Connect" onClose={() => setConnectOpen(false)} onSubmit={connect}>
@@ -0,0 +1,138 @@
import { Pill } from "../../shared/ui";
import type {
BrowserActionRecord,
EmployeeRecord,
SocialAccountGrantRecord,
SocialAccountRecord,
SocialAuditRecord,
} from "../../../../shared/domain";
import { formatRelative } from "../types";
interface ActivityEntry {
id: string;
accountId: string | null;
label: string;
policyDecision: string;
actorId: string | null;
actorType: string;
createdAt: string;
}
export function SocialActivity({
accounts,
grants,
auditRecords,
browserActions,
employees,
}: {
accounts: SocialAccountRecord[];
grants: SocialAccountGrantRecord[];
auditRecords: SocialAuditRecord[];
browserActions: BrowserActionRecord[];
employees: EmployeeRecord[];
}) {
const accountHandle = (id: string | null): string => {
if (!id) return "—";
const account = accounts.find((item) => item.id === id);
return account ? account.handle : "—";
};
const employeeName = (id: string | null): string => {
if (!id) return "System";
return employees.find((item) => item.id === id)?.displayName ?? id;
};
const entries: ActivityEntry[] = [
...auditRecords.map((record) => ({
id: record.id,
accountId: record.socialAccountId,
label: humanize(record.eventType),
policyDecision: record.policyDecision,
actorId: record.actorId,
actorType: record.actorType,
createdAt: record.createdAt,
})),
...browserActions.map((action) => ({
id: action.id,
accountId: action.socialAccountId,
label: humanize(action.actionType),
policyDecision: action.policyDecision,
actorId: null,
actorType: "agent",
createdAt: action.createdAt,
})),
]
.sort((a, b) => b.createdAt.localeCompare(a.createdAt))
.slice(0, 12);
if (grants.length === 0 && entries.length === 0) return null;
return (
<div style={{ marginTop: 28 }}>
<div className="section-label" style={{ marginBottom: 10 }}>Access &amp; activity</div>
{grants.length > 0 && (
<div className="card" style={{ padding: 0, overflow: "hidden", marginBottom: 12 }}>
{grants.map((grant, index) => (
<div
key={grant.id}
className="row"
style={{ gap: 10, padding: "10px 16px", borderBottom: index === grants.length - 1 ? "none" : "1px solid var(--border-soft)" }}
>
<span style={{ fontWeight: 500, minWidth: 0, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
{employeeName(grant.employeeId)}
</span>
<span style={{ color: "var(--text-3)", fontSize: 12 }}>{accountHandle(grant.socialAccountId)}</span>
<Pill tone="violet">{humanize(grant.role)}</Pill>
<span style={{ marginLeft: "auto", fontSize: 11, color: "var(--text-3)" }}>{scopeSummary(grant.scopeJson)}</span>
</div>
))}
</div>
)}
{entries.length > 0 && (
<div className="card" style={{ padding: 0, overflow: "hidden" }}>
{entries.map((entry, index) => (
<div
key={entry.id}
className="row"
style={{ gap: 10, padding: "10px 16px", borderBottom: index === entries.length - 1 ? "none" : "1px solid var(--border-soft)" }}
>
<span style={{ fontSize: 12.5, fontWeight: 500 }}>{entry.label}</span>
<span style={{ fontSize: 12, color: "var(--text-3)" }}>{accountHandle(entry.accountId)}</span>
<Pill tone={policyTone(entry.policyDecision)}>{humanize(entry.policyDecision)}</Pill>
<span style={{ marginLeft: "auto", fontSize: 11, color: "var(--text-3)" }}>
{entry.actorType === "agent" ? "agent" : employeeName(entry.actorId)} · {formatRelative(entry.createdAt)}
</span>
</div>
))}
</div>
)}
</div>
);
}
function humanize(value: string): string {
if (!value) return value;
const spaced = value.replace(/_/g, " ");
return spaced.charAt(0).toUpperCase() + spaced.slice(1);
}
function policyTone(decision: string): "green" | "amber" | "red" | undefined {
if (decision.includes("denied") || decision.includes("blocked")) return "red";
if (decision.includes("approval")) return "amber";
if (decision.includes("allowed")) return "green";
return undefined;
}
function scopeSummary(scopeJson: string): string {
try {
const parsed: unknown = JSON.parse(scopeJson);
if (parsed && typeof parsed === "object" && Array.isArray((parsed as { actions?: unknown }).actions)) {
const actions = (parsed as { actions: unknown[] }).actions.filter((item): item is string => typeof item === "string");
const ceiling = (parsed as { riskCeiling?: unknown }).riskCeiling;
const risk = typeof ceiling === "string" ? ` · ≤${ceiling}` : "";
return `${actions.length} action${actions.length === 1 ? "" : "s"}${risk}`;
}
} catch {
return "scoped";
}
return "scoped";
}