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:
1 parent
db679cfeb9
commit
ea1af95536
2 files changed
+150
No files matched your search
@@ -12,6 +12,7 @@ import {
|
|||||||
} from "../../../shared/domain";
|
} from "../../../shared/domain";
|
||||||
import { APPROVAL_STATE } from "../../../shared/domain";
|
import { APPROVAL_STATE } from "../../../shared/domain";
|
||||||
import { SocialCalendar } from "./social/SocialCalendar";
|
import { SocialCalendar } from "./social/SocialCalendar";
|
||||||
|
import { SocialActivity } from "./social/SocialActivity";
|
||||||
import { PlatformIcon } from "./social/PlatformIcon";
|
import { PlatformIcon } from "./social/PlatformIcon";
|
||||||
import type { PageProps } from "./types";
|
import type { PageProps } from "./types";
|
||||||
import { activeOrgId, requireActiveEmployee } from "./types";
|
import { activeOrgId, requireActiveEmployee } from "./types";
|
||||||
@@ -37,6 +38,9 @@ export function SocialPage({ snapshot, actions }: PageProps) {
|
|||||||
return states;
|
return states;
|
||||||
}, [snapshot.approvals, orgId]);
|
}, [snapshot.approvals, orgId]);
|
||||||
const employees = snapshot.employees.filter((item) => item.orgId === 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 [connectOpen, setConnectOpen] = useState(false);
|
||||||
const [draftOpen, setDraftOpen] = useState(false);
|
const [draftOpen, setDraftOpen] = useState(false);
|
||||||
@@ -149,6 +153,14 @@ export function SocialPage({ snapshot, actions }: PageProps) {
|
|||||||
|
|
||||||
<SocialCalendar posts={scheduledPosts} accounts={accounts} />
|
<SocialCalendar posts={scheduledPosts} accounts={accounts} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<SocialActivity
|
||||||
|
accounts={accounts}
|
||||||
|
grants={socialGrants}
|
||||||
|
auditRecords={socialAudit}
|
||||||
|
browserActions={browserActions}
|
||||||
|
employees={employees}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Dialog title="Connect social account" open={connectOpen} submitLabel="Connect" onClose={() => setConnectOpen(false)} onSubmit={connect}>
|
<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 & 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";
|
||||||
|
}
|
||||||
Reference in new issue
Block a user