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
72 changes: 72 additions & 0 deletions apps/web/src/settings/grant-preview.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { describe, expect, test } from "bun:test";

import { expiryIsoFromPreset, expiryLabelFromPreset, grantPreviewSentence } from "./grant-preview";

describe("grantPreviewSentence", () => {
test("an allow reads as permission for the named target", () => {
expect(
grantPreviewSentence({
targetLabel: "Billing",
resource: "credential",
action: "use",
effect: "allow",
expiresLabel: null,
}),
).toBe("Billing may use on credential.");
});

test("a deny reads as refusal, and an ask as a checkpoint", () => {
expect(
grantPreviewSentence({
targetLabel: "Deploy bot",
resource: "workflow-run",
action: "create",
effect: "deny",
expiresLabel: null,
}),
).toBe("Deploy bot must not create on workflow-run.");
expect(
grantPreviewSentence({
targetLabel: "Deploy bot",
resource: "workflow-run",
action: "create",
effect: "ask",
expiresLabel: null,
}),
).toBe("Deploy bot must ask before they can create on workflow-run.");
});

test("a missing target falls back to Someone, and an expiry extends the sentence", () => {
expect(
grantPreviewSentence({
targetLabel: null,
resource: "credential",
action: "use",
effect: "allow",
expiresLabel: null,
}),
).toBe("Someone may use on credential.");
expect(
grantPreviewSentence({
targetLabel: " ",
resource: "credential",
action: "use",
effect: "allow",
expiresLabel: "in 7 days",
}),
).toBe("Someone may use on credential, until in 7 days.");
});
});

describe("expiry presets", () => {
test("never means no expiry in both projections", () => {
expect(expiryIsoFromPreset("never")).toBeNull();
expect(expiryLabelFromPreset("never")).toBeNull();
});

test("a 7d preset lands exactly seven days after now", () => {
const now = new Date("2026-02-10T00:00:00.000Z");
expect(expiryIsoFromPreset("7d", now)).toBe("2026-02-17T00:00:00.000Z");
expect(expiryLabelFromPreset("7d")).toBe("in 7 days");
});
});
11 changes: 9 additions & 2 deletions apps/web/src/settings/grants-section.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import { useMemo, useState } from "react";

import { QueryView, toAPIQuery } from "@/lib/api-query";
import { tenantKeys } from "@/query-client";
import { reportError } from "@corbits/error-sink";
import { PRINCIPAL_KIND_LABEL, PRINCIPAL_KIND_ORDER, principalLabel } from "./identity";
import { expiryIsoFromPreset, expiryLabelFromPreset, grantPreviewSentence } from "./grant-preview";
import { KindCards } from "./kind-cards";
Expand Down Expand Up @@ -157,7 +158,10 @@ export function GrantsSection({ tenantId }: { readonly tenantId: string | null }
reload();
toast(SETTINGS_STRINGS.grantCreatedToast);
})
.catch(() => setCreateError(SETTINGS_STRINGS.grantsCreateError))
.catch((cause: unknown) => {
reportError(cause, { operation: "settings.grants.create", tenantId });
setCreateError(SETTINGS_STRINGS.grantsCreateError);
})
.finally(() => setCreating(false));
}

Expand All @@ -169,7 +173,10 @@ export function GrantsSection({ tenantId }: { readonly tenantId: string | null }
reload();
toast(SETTINGS_STRINGS.grantRevokedToast);
})
.catch(() => setRowError(SETTINGS_STRINGS.grantsRevokeError));
.catch((cause: unknown) => {
reportError(cause, { operation: "settings.grants.revoke", tenantId });
setRowError(SETTINGS_STRINGS.grantsRevokeError);
});
}

return (
Expand Down
77 changes: 77 additions & 0 deletions apps/web/src/settings/role-assignment.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import { describe, expect, test } from "bun:test";

import {
assignFailureMessage,
assignmentPrincipalLabel,
compareAssignmentPrincipals,
} from "./role-assignment";
import { SETTINGS_STRINGS } from "./strings";
import { TenancyApiError, type Principal } from "./tenancy-api";

function principal(overrides: Partial<Principal> & Pick<Principal, "id" | "kind">): Principal {
return {
tenantId: "tenant-1",
refId: overrides.id,
displayName: "Alice Anderson",
status: "active",
roles: [],
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
...overrides,
};
}

describe("assignFailureMessage", () => {
test("a missing principal earns first-run guidance, not the transient error", () => {
expect(assignFailureMessage(new TenancyApiError("The hub answered 404 for /roles.", 404))).toBe(
SETTINGS_STRINGS.rolesAssignMissingPrincipalError,
);
expect(SETTINGS_STRINGS.rolesAssignMissingPrincipalError).not.toBe(
SETTINGS_STRINGS.rolesAssignError,
);
});

test("server and network failures stay on the transient error", () => {
expect(assignFailureMessage(new TenancyApiError("The hub answered 500 for /roles.", 500))).toBe(
SETTINGS_STRINGS.rolesAssignError,
);
expect(assignFailureMessage(new TenancyApiError("fetch failed"))).toBe(
SETTINGS_STRINGS.rolesAssignError,
);
expect(assignFailureMessage(new Error("boom"))).toBe(SETTINGS_STRINGS.rolesAssignError);
});
});

describe("assignmentPrincipalLabel", () => {
test("a person keeps their bare name", () => {
expect(assignmentPrincipalLabel(principal({ id: "prn-1", kind: "user" }))).toBe(
"Alice Anderson",
);
});

test("agent and workflow principals carry their kind", () => {
expect(
assignmentPrincipalLabel(
principal({ id: "prn-2", kind: "agent", displayName: "Deploy Bot" }),
),
).toBe(`Deploy Bot — ${SETTINGS_STRINGS.peopleKindAgent}`);
expect(
assignmentPrincipalLabel(
principal({ id: "prn-3", kind: "workflow", displayName: "Nightly" }),
),
).toBe(`Nightly — ${SETTINGS_STRINGS.peopleKindWorkflow}`);
});
});

describe("compareAssignmentPrincipals", () => {
test("people sort before agents before workflows", () => {
const workflow = principal({ id: "prn-3", kind: "workflow", displayName: "Aaron" });
const agent = principal({ id: "prn-2", kind: "agent", displayName: "Zed" });
const person = principal({ id: "prn-1", kind: "user", displayName: "Zoe" });
expect([workflow, agent, person].sort(compareAssignmentPrincipals).map((p) => p.id)).toEqual([
"prn-1",
"prn-2",
"prn-3",
]);
});
});
35 changes: 35 additions & 0 deletions apps/web/src/settings/role-assignment.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
// Pure logic behind the Roles assignment UI: kind-qualified labels so
// machine principals never read as people, users-first ordering for the
// picker, and the failure message that distinguishes a missing (never
// minted) principal from a transient error.

import { PRINCIPAL_KIND_LABEL, PRINCIPAL_KIND_ORDER, principalLabel } from "./identity";
import { SETTINGS_STRINGS } from "./strings";
import { isNotFoundError, type Principal } from "./tenancy-api";

/**
* A 404 from the assign endpoint means the hub has no such principal (or
* role) in this tenant -- for machine principals that usually means the
* account has not been minted yet, because agent and workflow principals are
* created on their first run rather than at deploy time. Any other failure
* is reported as transient. Still fail-closed: both branches only choose the
* message shown next to the untouched assignment table.
*/
export function assignFailureMessage(cause: unknown): string {
if (isNotFoundError(cause)) return SETTINGS_STRINGS.rolesAssignMissingPrincipalError;
return SETTINGS_STRINGS.rolesAssignError;
}

/** "Deploy Bot — Agent": machine principals are never shown as bare people,
* matching the grants picker's kind-qualified labels. */
export function assignmentPrincipalLabel(principal: Principal): string {
const { label } = principalLabel(principal.displayName);
if (principal.kind === "user") return label;
return `${label} — ${PRINCIPAL_KIND_LABEL[principal.kind]}`;
}

export function compareAssignmentPrincipals(a: Principal, b: Principal): number {
const kindOrder = PRINCIPAL_KIND_ORDER.indexOf(a.kind) - PRINCIPAL_KIND_ORDER.indexOf(b.kind);
if (kindOrder !== 0) return kindOrder;
return assignmentPrincipalLabel(a).localeCompare(assignmentPrincipalLabel(b));
}
52 changes: 37 additions & 15 deletions apps/web/src/settings/roles-section.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,12 @@ import { useState } from "react";

import { QueryView, toAPIQuery } from "@/lib/api-query";
import { tenantKeys } from "@/query-client";
import { principalLabel } from "./identity";
import { reportError } from "@corbits/error-sink";
import {
assignFailureMessage,
assignmentPrincipalLabel,
compareAssignmentPrincipals,
} from "./role-assignment";
import { SETTINGS_STRINGS } from "./strings";
import {
assignRole,
Expand Down Expand Up @@ -94,7 +99,10 @@ export function RolesSection({ tenantId }: { readonly tenantId: string | null })
setCreateOpen(false);
reload();
})
.catch(() => setCreateError(SETTINGS_STRINGS.rolesCreateError))
.catch((cause: unknown) => {
reportError(cause, { operation: "settings.roles.create", tenantId });
setCreateError(SETTINGS_STRINGS.rolesCreateError);
})
.finally(() => setCreating(false));
}

Expand All @@ -103,31 +111,43 @@ export function RolesSection({ tenantId }: { readonly tenantId: string | null })
setRowError(null);
deleteRole(tenantId, role.id)
.then(reload)
.catch(() => setRowError(SETTINGS_STRINGS.rolesDeleteError));
.catch((cause: unknown) => {
reportError(cause, { operation: "settings.roles.delete", tenantId });
setRowError(SETTINGS_STRINGS.rolesDeleteError);
});
}

function handleRename(role: Role, name: string) {
if (tenantId === null) return;
setRowError(null);
renameRole(tenantId, role.id, { name })
.then(reload)
.catch(() => setRowError(SETTINGS_STRINGS.rolesRenameError));
.catch((cause: unknown) => {
reportError(cause, { operation: "settings.roles.rename", tenantId });
setRowError(SETTINGS_STRINGS.rolesRenameError);
});
}

function handleAssign(principalId: string, roleId: string) {
if (tenantId === null) return;
setRowError(null);
assignRole(tenantId, principalId, roleId)
.then(reload)
.catch(() => setRowError(SETTINGS_STRINGS.rolesAssignError));
.catch((cause: unknown) => {
reportError(cause, { operation: "settings.roles.assign", tenantId });
setRowError(assignFailureMessage(cause));
});
}

function handleUnassign(principalId: string, roleId: string) {
if (tenantId === null) return;
setRowError(null);
unassignRole(tenantId, principalId, roleId)
.then(reload)
.catch(() => setRowError(SETTINGS_STRINGS.rolesUnassignError));
.catch((cause: unknown) => {
reportError(cause, { operation: "settings.roles.unassign", tenantId });
setRowError(SETTINGS_STRINGS.rolesUnassignError);
});
}

return (
Expand Down Expand Up @@ -281,11 +301,13 @@ export function RoleAssignments({
const [principalId, setPrincipalId] = useState("");
const [roleId, setRoleId] = useState("");

// The "Person" picker should match People's member roster, not the
// full tenant-wide principal list.
const people = principals.filter((p) => p.kind === "user");
// `assignRole` accepts any principal id, so the picker lists every kind:
// agents and workflows hold roles exactly like people do, and their
// principals only exist after their first run, which is also why a 404
// here earns its own guidance (see `assignFailureMessage`).
const assignable = [...principals].sort(compareAssignmentPrincipals);

const assignments = people.flatMap((principal) =>
const assignments = assignable.flatMap((principal) =>
principal.roles.map((role) => ({ principal, role })),
);

Expand All @@ -294,16 +316,16 @@ export function RoleAssignments({
<h4>{SETTINGS_STRINGS.rolesAssignSectionTitle}</h4>
<div className="settings-row-actions">
<label className="settings-form-field">
<span>{SETTINGS_STRINGS.rolesAssignPersonLabel}</span>
<span>{SETTINGS_STRINGS.rolesAssignPrincipalLabel}</span>
<select
className="settings-select"
value={principalId}
onChange={(event) => setPrincipalId(event.target.value)}
>
<option value="">—</option>
{people.map((principal) => (
{assignable.map((principal) => (
<option key={principal.id} value={principal.id}>
{principalLabel(principal.displayName).label}
{assignmentPrincipalLabel(principal)}
</option>
))}
</select>
Expand Down Expand Up @@ -338,15 +360,15 @@ export function RoleAssignments({
<Table>
<TableHeader>
<TableRow>
<TableHead>Person</TableHead>
<TableHead>Principal</TableHead>
<TableHead>Role</TableHead>
<TableHead>Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{assignments.map(({ principal, role }) => (
<TableRow key={`${principal.id}-${role.id}`}>
<TableCell>{principalLabel(principal.displayName).label}</TableCell>
<TableCell>{assignmentPrincipalLabel(principal)}</TableCell>
<TableCell>{role.name}</TableCell>
<TableCell>
<Button
Expand Down
6 changes: 4 additions & 2 deletions apps/web/src/settings/strings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,13 +94,15 @@ export const SETTINGS_STRINGS = {
rolesDeleteAction: "Delete",
rolesDeleteConfirm: "Delete for good?",
rolesAssignSectionTitle: "Assign a role",
rolesAssignPersonLabel: "Person",
rolesAssignPrincipalLabel: "Principal",
rolesAssignRoleLabel: "Role",
rolesAssignSubmit: "Assign",
rolesAssignError: "Couldn't assign that role — try again.",
rolesAssignMissingPrincipalError:
"That account isn't on this workbench yet — agent and workflow accounts are created on their first run, not when they're deployed. Have it run once, then assign the role again.",
rolesUnassignError: "Couldn't remove that role — try again.",
rolesAssignmentsTitle: "Assignments",
rolesAssignmentsEmpty: "No one has been assigned a role yet.",
rolesAssignmentsEmpty: "No assignments yet.",
rolesUnassign: "Remove",

grantsSectionTitle: "What agents can do",
Expand Down
11 changes: 11 additions & 0 deletions apps/web/src/settings/tenancy-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,17 @@ export class TenancyApiError extends Error {
}
}

/**
* The hub answers 404 when the principal or role on an assignment path does
* not exist in the tenant -- most often a machine principal the picker listed
* from a stale snapshot, or one never minted because its first run has not
* happened yet. Callers use this to explain that case instead of reporting a
* generic transient failure.
*/
export function isNotFoundError(cause: unknown): boolean {
return cause instanceof TenancyApiError && cause.status === 404;
}

type Validator<T> = (data: unknown) => T | ArkErrors;

async function request<T>(path: string, schema: Validator<T>, init?: RequestInit): Promise<T> {
Expand Down
Loading
Loading