Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions .changeset/olive-bats-jam.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
"seamless-auth-admin-dashboard": minor
---

Show who performed an administrative action in the events table.

The API now records the acting administrator separately from the subject of an
action. An administrative event names two people, and showing only the subject
reads as though they did it to themselves, so the User column now shows the
target with the administrator beneath it, each linking to their own detail page.

Administrative events are also labelled as such rather than as ordinary
user-linked events.

Also upgrades `@seamless-auth/types` to 0.10.0.
8 changes: 4 additions & 4 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@
},
"dependencies": {
"@seamless-auth/react": "^0.7.0",
"@seamless-auth/types": "^0.9.0",
"@seamless-auth/types": "^0.10.0",
"@tailwindcss/vite": "^4.3.3",
"@tanstack/react-query": "^5.101.4",
"clsx": "^2.1.1",
Expand Down
54 changes: 54 additions & 0 deletions src/pages/Events.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -158,3 +158,57 @@ describe("Events time range", () => {
expect(screen.queryByText("Suspicious Signals")).not.toBeInTheDocument();
});
});

describe("Events actor attribution", () => {
function withEvents(events: unknown[]) {
mocks.useEvents.mockReset();
mocks.useEvents.mockReturnValue({
data: { events, total: events.length },
isLoading: false,
isError: false,
error: null,
refetch: vi.fn(),
});
}

const base = {
id: "evt-1",
type: "admin_device_replacement_recovery",
ip_address: "127.0.0.1",
user_agent: "agent",
metadata: null,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
};

it("names the administrator alongside the target", () => {
withEvents([
{
...base,
user_id: "11111111-aaaa-bbbb-cccc-222222222222",
actor_user_id: "99999999-dddd-eeee-ffff-333333333333",
},
]);

renderPage();

expect(screen.getByText("11111111...")).toBeInTheDocument();
expect(screen.getByText("99999999...")).toBeInTheDocument();
expect(screen.getByText("Administrative action")).toBeInTheDocument();
});

it("leaves an ordinary user event unattributed", () => {
withEvents([
{
...base,
type: "login_success",
user_id: "11111111-aaaa-bbbb-cccc-222222222222",
},
]);

renderPage();

expect(screen.getByText("User-linked event")).toBeInTheDocument();
expect(screen.queryByText("by")).not.toBeInTheDocument();
});
});
52 changes: 37 additions & 15 deletions src/pages/Events.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -345,7 +345,11 @@ export default function Events() {
{value as string}
</span>
<span className="text-xs text-muted">
{row.user_id ? "User-linked event" : "System-level event"}
{row.actor_user_id
? "Administrative action"
: row.user_id
? "User-linked event"
: "System-level event"}
</span>
</div>
),
Expand All @@ -354,20 +358,38 @@ export default function Events() {
key: "user_id",
label: "User",
width: "small",
render: (value) =>
value ? (
<button
onClick={() => navigate(`/users/${value}`)}
className="inline-flex items-center gap-2 text-sm text-muted transition hover:text-primary"
>
<span className="font-mono">
{(value as string).slice(0, 8)}...
</span>
<ArrowRight size={12} />
</button>
) : (
<span className="text-sm text-subtle">System</span>
),
// An administrative event names two people. Showing only the
// subject reads as though they did it to themselves.
render: (value, row) => (
<div className="flex flex-col gap-0.5">
{value ? (
<button
onClick={() => navigate(`/users/${value}`)}
className="inline-flex items-center gap-2 text-sm text-muted transition hover:text-primary"
>
<span className="font-mono">
{(value as string).slice(0, 8)}...
</span>
<ArrowRight size={12} />
</button>
) : (
<span className="text-sm text-subtle">System</span>
)}

{row.actor_user_id && (
<button
onClick={() => navigate(`/users/${row.actor_user_id}`)}
className="inline-flex items-center gap-1 text-xs text-muted transition hover:text-primary"
title="The administrator who performed this action"
>
<span>by</span>
<span className="font-mono">
{row.actor_user_id.slice(0, 8)}...
</span>
</button>
)}
</div>
),
},
{
key: "ip_address",
Expand Down