feat(audit): append-only audit log for spaces
OpenSSF Scorecard / Scorecard analysis (push) Waiting to run
CI / Build & test (Node 20) (push) Canceled after 0s
CI / Build & test (Node 24) (push) Canceled after 0s
CI / Build & test (push) Canceled after 0s
CodeQL / Analyze (javascript-typescript) (push) Canceled after 0s
Security / Secret scan (gitleaks) (push) Canceled after 0s
Security / Dependency scan (OSV-Scanner) (push) Canceled after 0s
Security / IaC/config scan (Trivy) (push) Canceled after 0s
Security / License compliance scan (Trivy) (push) Canceled after 0s
OpenSSF Scorecard / Scorecard analysis (push) Waiting to run
CI / Build & test (Node 20) (push) Canceled after 0s
CI / Build & test (Node 24) (push) Canceled after 0s
CI / Build & test (push) Canceled after 0s
CodeQL / Analyze (javascript-typescript) (push) Canceled after 0s
Security / Secret scan (gitleaks) (push) Canceled after 0s
Security / Dependency scan (OSV-Scanner) (push) Canceled after 0s
Security / IaC/config scan (Trivy) (push) Canceled after 0s
Security / License compliance scan (Trivy) (push) Canceled after 0s
Records who changed what, and is the mechanism statistics will read — one event table rather than two logs that drift apart. The table is deliberately generic (action + target + JSON metadata) so a new action needs no migration. Writes never throw: a kick must not fail because its log entry could not be written, since the kick already happened. Leaving is recorded as a different action from being removed. The same route serves both, and a log that conflates them misleads exactly when it matters. Actor is nullable with ON DELETE SET NULL: the event outlives the account, and a log that vanished with its actor would be worthless. Reads are gated on MANAGE_SPACE rather than a new permission bit, which would default to nobody until every role was re-edited. Paging uses the snowflake id, stable even for two events in the same millisecond, and an action this build does not know still renders a row.
This commit is contained in:
@@ -7,6 +7,8 @@ import { Avatar } from '../ui/Avatar';
|
||||
import { api } from '../../api/client';
|
||||
import { hasPermissionBit, PermissionBits } from '../../utils/permissions';
|
||||
import { OverviewPanel } from './spaceSettingsPanels/OverviewPanel';
|
||||
import { AuditLogPanel } from './spaceSettingsPanels/AuditLogPanel';
|
||||
import { useT } from '../../i18n';
|
||||
import { MembersPanel } from './spaceSettingsPanels/MembersPanel';
|
||||
import { RolesPanel } from './spaceSettingsPanels/RolesPanel';
|
||||
import { BansPanel } from './spaceSettingsPanels/BansPanel';
|
||||
@@ -266,7 +268,8 @@ export function SpaceSettingsModal() {
|
||||
const spaces = useSpaceStore((s) => s.spaces);
|
||||
const spacePermissions = useSpaceStore((s) => s.spacePermissions);
|
||||
|
||||
const [tab, setTab] = useState<'overview' | 'discovery' | 'members' | 'roles' | 'bans'>('overview');
|
||||
const t = useT();
|
||||
const [tab, setTab] = useState<'overview' | 'discovery' | 'members' | 'roles' | 'bans' | 'audit'>('overview');
|
||||
const [mobileView, setMobileView] = useState<'tabs' | 'content'>('tabs');
|
||||
|
||||
const isOpen = activeModal === 'spaceSettings';
|
||||
@@ -331,6 +334,9 @@ export function SpaceSettingsModal() {
|
||||
{canBanMembers && (
|
||||
<button onClick={() => handleTabClick('bans')} className={tabClass('bans')}>Bans</button>
|
||||
)}
|
||||
{canManageSpace && (
|
||||
<button onClick={() => handleTabClick('audit')} className={tabClass('audit')}>{t('audit.title')}</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -366,6 +372,9 @@ export function SpaceSettingsModal() {
|
||||
{canBanMembers && (
|
||||
<button onClick={() => handleTabClick('bans')} className={tabClass('bans')}>Bans</button>
|
||||
)}
|
||||
{canManageSpace && (
|
||||
<button onClick={() => handleTabClick('audit')} className={tabClass('audit')}>{t('audit.title')}</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -392,6 +401,7 @@ export function SpaceSettingsModal() {
|
||||
{tab === 'members' && <MembersPanel spaceId={currentSpaceId} />}
|
||||
{tab === 'roles' && canManageRoles && <RolesPanel spaceId={currentSpaceId} />}
|
||||
{tab === 'bans' && canBanMembers && <BansPanel spaceId={currentSpaceId} />}
|
||||
{tab === 'audit' && canManageSpace && <AuditLogPanel spaceId={currentSpaceId} />}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import type { AuditEvent } from '@backspace/shared/src/audit.js';
|
||||
import { api } from '../../../api/client';
|
||||
import { Avatar } from '../../ui/Avatar';
|
||||
import { useT, type TranslationKey } from '../../../i18n';
|
||||
|
||||
interface AuditLogPanelProps {
|
||||
spaceId: string;
|
||||
}
|
||||
|
||||
/** Actions carry a `{name}` only when the metadata supplies one. */
|
||||
function actionKey(action: string): TranslationKey {
|
||||
const key = `audit.action.${action}` as TranslationKey;
|
||||
return key;
|
||||
}
|
||||
|
||||
const KNOWN_ACTIONS = new Set([
|
||||
'space.update', 'space.transfer_ownership',
|
||||
'channel.create', 'channel.update', 'channel.delete',
|
||||
'member.kick', 'member.leave', 'member.ban', 'member.unban',
|
||||
'role.create', 'role.update', 'role.delete',
|
||||
'invite.create', 'message.delete',
|
||||
]);
|
||||
|
||||
function formatTimestamp(ms: number, locale: string): string {
|
||||
return new Date(ms).toLocaleString(locale, {
|
||||
day: '2-digit', month: 'short', hour: '2-digit', minute: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
export function AuditLogPanel({ spaceId }: AuditLogPanelProps) {
|
||||
const t = useT();
|
||||
const [events, setEvents] = useState<AuditEvent[]>([]);
|
||||
const [hasMore, setHasMore] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [loadingMore, setLoadingMore] = useState(false);
|
||||
|
||||
const load = useCallback(async (before?: string) => {
|
||||
const page = await api.audit.log(spaceId, before);
|
||||
setEvents((prev) => (before ? [...prev, ...page.events] : page.events));
|
||||
setHasMore(page.hasMore);
|
||||
}, [spaceId]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
load()
|
||||
.catch(() => { /* an empty log reads the same as an unreachable one here */ })
|
||||
.finally(() => { if (!cancelled) setLoading(false); });
|
||||
return () => { cancelled = true; };
|
||||
}, [load]);
|
||||
|
||||
const handleLoadMore = async () => {
|
||||
const last = events[events.length - 1];
|
||||
if (!last) return;
|
||||
setLoadingMore(true);
|
||||
try {
|
||||
await load(last.id);
|
||||
} finally {
|
||||
setLoadingMore(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="max-w-2xl">
|
||||
<h2 className="text-lg font-semibold text-txt-primary mb-6">{t('audit.title')}</h2>
|
||||
<div className="space-y-2">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<div key={i} className="h-12 rounded-lg bg-surface-elevated animate-pulse" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl">
|
||||
<h2 className="text-lg font-semibold text-txt-primary mb-6">{t('audit.title')}</h2>
|
||||
|
||||
{events.length === 0 ? (
|
||||
<p className="text-[13px] text-txt-tertiary">{t('audit.empty')}</p>
|
||||
) : (
|
||||
<>
|
||||
<ul className="space-y-1">
|
||||
{events.map((event) => {
|
||||
const actorName = event.actor
|
||||
? (event.actor.displayName ?? event.actor.username)
|
||||
: t('audit.unknownActor');
|
||||
const name = typeof event.metadata?.name === 'string' ? event.metadata.name : '';
|
||||
// An action this build does not know about still gets a row: the
|
||||
// log is a record, and hiding entries would defeat its purpose.
|
||||
const key = KNOWN_ACTIONS.has(event.action) ? actionKey(event.action) : 'audit.action.unknown';
|
||||
return (
|
||||
<li key={event.id} className="flex items-center gap-3 py-2 px-2 rounded-lg hover:bg-interactive-hover">
|
||||
<Avatar
|
||||
src={event.actor?.avatar ?? null}
|
||||
name={actorName}
|
||||
size={28}
|
||||
userId={event.actor?.id}
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-[13px] text-txt-secondary truncate">
|
||||
{t(key, { actor: actorName, name })}
|
||||
</div>
|
||||
</div>
|
||||
<time
|
||||
dateTime={new Date(event.createdAt).toISOString()}
|
||||
className="text-[11px] text-txt-tertiary flex-shrink-0 tabular-nums"
|
||||
>
|
||||
{formatTimestamp(event.createdAt, document.documentElement.lang || 'en')}
|
||||
</time>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
|
||||
{hasMore && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleLoadMore()}
|
||||
disabled={loadingMore}
|
||||
className="mt-4 px-3 py-1.5 rounded-md text-[13px] font-medium bg-surface-elevated text-txt-secondary hover:text-txt-primary transition-colors disabled:opacity-50"
|
||||
>
|
||||
{t('audit.loadMore')}
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user