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
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,8 @@ Existing tools each cover a fragment: scanners find problems in one repo, bots b

**✅ Verified-closed** — a campaign doesn't just open PRs, it **proves the fix landed**. At create it snapshots the findings it targets; after the PRs merge, re-auditing the repos confirms which are actually resolved — the campaign reports *"resolved 41/47"* with a per-repo breakdown, so you can see fleet posture move, not just PR counts. **Export evidence** downloads a tamper-evident compliance bundle (printable HTML report + machine-readable JSON + SHA-256 manifest) for any campaign.

**🔑 Roles & access** — role-based access control with named API tokens: **viewer** (read-only), **operator** (run campaigns, apply fixes), **admin** (manage tokens). Admins mint scoped, revocable tokens from the UI (the secret is shown once; only its hash is stored), and every mutation records *which* token performed it. The legacy env tokens still work (`ACTIONSPLANE_API_TOKEN` = admin, `ACTIONSPLANE_API_READ_TOKEN` = viewer); OIDC/SSO maps onto the same roles later.

**No GitHub App? Offline mode** pulls workflows/runs for any list of public repos over the public API — full dashboard, no webhooks. And `actionsplane audit local .` scans a local checkout as a CI gate (non-zero exit on findings).

## Quickstart
Expand Down
9 changes: 9 additions & 0 deletions frontend/src/api.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,16 @@
import { getOperateToken } from "./lib/auth";
import type {
AdvisoryReport,
ApiToken,
ApiTokenCreated,
AuditLogEntry,
Binding,
Campaign,
CampaignSummary,
DriftDetail,
Finding,
FleetCost,
Identity,
Job,
Metrics,
Mode,
Expand Down Expand Up @@ -163,6 +166,12 @@ export const api = {
}) => post<SimulationReport>("/policy/simulate", body),
radar: () => get<RadarReport>("/deprecations/radar"),
advisories: () => get<AdvisoryReport>("/advisories/watch"),
me: () => get<Identity>("/access/me"),
apiTokens: () => get<ApiToken[]>("/access/tokens"),
createApiToken: (body: { name: string; role: string }) =>
post<ApiTokenCreated>("/access/tokens", body),
deleteApiToken: (id: number) =>
del<{ status: string; token_id: number }>(`/access/tokens/${id}`),
requireCheck: (body: { check: string; repo_ids: number[] }) =>
post<RequireCheckOut>("/governance/require-check", body),
notificationEvents: () => get<NotificationEvent[]>("/notifications/events"),
Expand Down
30 changes: 10 additions & 20 deletions frontend/src/components/SettingsTab.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,16 @@
import { useState } from "react";
import { ReposTab } from "./ReposTab";
import { NotificationsPanel } from "./NotificationsPanel";
import { IconKey, IconShield } from "./ui";
import { TokensPanel } from "./TokensPanel";
import { IconShield } from "./ui";

type Section = "notifications" | "repositories" | "access" | "users";

const SECTIONS: { id: Section; label: string; hint: string }[] = [
{ id: "notifications", label: "Notifications", hint: "Slack, webhook & email alerts" },
{ id: "repositories", label: "Repositories", hint: "Repos ActionsPlane watches" },
{ id: "access", label: "Access (OIDC)", hint: "Single sign-on" },
{ id: "users", label: "Users", hint: "Team members & roles" },
{ id: "users", label: "API access", hint: "Tokens & roles" },
{ id: "access", label: "Single sign-on", hint: "OIDC (coming soon)" },
];

/** A not-yet-wired settings section — honest about what it will configure once built. */
Expand Down Expand Up @@ -63,29 +64,18 @@ export function SettingsTab({ initialSection = "notifications" }: { initialSecti
<div className="settings-body">
{section === "notifications" && <NotificationsPanel />}
{section === "repositories" && <ReposTab />}
{section === "users" && <TokensPanel />}
{section === "access" && (
<Placeholder icon={<IconShield size={30} />} title="Single sign-on (OIDC)">
<p>
Today ActionsPlane authenticates writes with a single <strong>operate token</strong>{" "}
(set via the key icon, top-right). OIDC will let your team sign in with your identity
provider (Okta, Google Workspace, Entra ID, GitHub) instead of sharing a token.
Access is managed today with <strong>role-based API tokens</strong> (see{" "}
<strong>API access</strong>). OIDC will additionally let your team sign in with your
identity provider (Okta, Google Workspace, Entra ID, GitHub) instead of holding a
token.
</p>
<p className="subtle">
Planned configuration: issuer URL, client ID/secret, allowed domains, and a
group→role mapping. Track this in the roadmap under Access.
</p>
</Placeholder>
)}
{section === "users" && (
<Placeholder icon={<IconKey size={30} />} title="Users & roles">
<p>
Once single sign-on is enabled, invited members will appear here with roles —{" "}
<strong>viewer</strong> (read the dashboard), <strong>operator</strong> (open fix
PRs, run campaigns), and <strong>admin</strong> (manage settings and access).
</p>
<p className="subtle">
Until then, anyone with the operate token has operator access, and the read token (if
configured) grants view-only.
group→role mapping onto the same viewer/operator/admin roles.
</p>
</Placeholder>
)}
Expand Down
187 changes: 187 additions & 0 deletions frontend/src/components/TokensPanel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
import { useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { api } from "../api";
import { promptForToken } from "../lib/auth";
import { useIdentity } from "../hooks/useIdentity";
import { EmptyState, ErrorBanner, IconCheck, IconKey, IconX, TableSkeleton } from "./ui";
import type { ApiToken } from "../types";

const ROLES: { id: string; label: string; hint: string }[] = [
{ id: "viewer", label: "Viewer", hint: "read-only — dashboards & audit, no writes" },
{ id: "operator", label: "Operator", hint: "run campaigns, apply fixes, manage notifications" },
{ id: "admin", label: "Admin", hint: "everything, incl. managing these tokens" },
];
const ROLE_BADGE: Record<string, string> = {
admin: "running",
operator: "ok",
viewer: "neutral",
};

function TokenRow({ token }: { token: ApiToken }) {
const qc = useQueryClient();
const revoke = useMutation({
mutationFn: () => api.deleteApiToken(token.id),
onSuccess: () => qc.invalidateQueries({ queryKey: ["api-tokens"] }),
});
return (
<div className="card channel-row">
<div className="channel-main">
<div className="channel-head">
<IconKey size={15} />
<span className="channel-name">{token.name}</span>
<span className={`badge ${ROLE_BADGE[token.role] ?? "neutral"}`}>{token.role}</span>
</div>
<div className="channel-dest mono subtle">
{token.prefix}…{token.created_by ? ` · created by ${token.created_by}` : ""}
</div>
{revoke.isError && (
<div className="toast err" style={{ marginTop: 8 }}>
<IconX /> {(revoke.error as Error).message}
</div>
)}
</div>
<div className="channel-actions">
<button
className="btn sm danger"
onClick={() => revoke.mutate()}
disabled={revoke.isPending}
title="Revoke this token"
>
{revoke.isPending ? "Revoking…" : "Revoke"}
</button>
</div>
</div>
);
}

export function TokensPanel() {
const { role, isAdmin } = useIdentity();
const qc = useQueryClient();
const tokens = useQuery({
queryKey: ["api-tokens"],
queryFn: api.apiTokens,
enabled: isAdmin,
});

const [name, setName] = useState("");
const [newRole, setNewRole] = useState("operator");
const [secret, setSecret] = useState<string | null>(null);
const [copied, setCopied] = useState(false);

const create = useMutation({
mutationFn: () => api.createApiToken({ name: name.trim(), role: newRole }),
onSuccess: (res) => {
qc.invalidateQueries({ queryKey: ["api-tokens"] });
setSecret(res.secret);
setCopied(false);
setName("");
},
});

if (!isAdmin) {
return (
<div className="card settings-placeholder">
<div className="settings-placeholder-icon">
<IconKey size={30} />
</div>
<h3>API access &amp; roles</h3>
<div className="settings-placeholder-body">
<p>
Named API tokens carry a role — <strong>viewer</strong> (read-only),{" "}
<strong>operator</strong> (run campaigns &amp; apply fixes), and{" "}
<strong>admin</strong> (manage tokens). Managing tokens requires the{" "}
<strong>admin</strong> role.
</p>
<p className="subtle">
You are currently <strong>{role ?? "unauthenticated"}</strong>.{" "}
<button className="link-cell" onClick={promptForToken}>
Set an admin token
</button>{" "}
to manage access.
</p>
</div>
</div>
);
}

const list = tokens.data ?? [];

return (
<div>
<p className="settings-hint" style={{ marginBottom: 16 }}>
Issue named API tokens, each with a role. The legacy env tokens still work
(<span className="mono">ACTIONSPLANE_API_TOKEN</span> = admin,{" "}
<span className="mono">ACTIONSPLANE_API_READ_TOKEN</span> = viewer). A token's secret is
shown once, on creation — only its hash is stored.
</p>

<div className="card token-create">
<div className="token-create-row">
<input
className="input"
placeholder="Token name (e.g. deploy-bot)"
value={name}
onChange={(e) => setName(e.target.value)}
/>
<select className="input" value={newRole} onChange={(e) => setNewRole(e.target.value)}>
{ROLES.map((r) => (
<option key={r.id} value={r.id}>
{r.label}
</option>
))}
</select>
<button
className="btn sm primary"
disabled={!name.trim() || create.isPending}
onClick={() => create.mutate()}
>
{create.isPending ? "Creating…" : "Create token"}
</button>
</div>
<div className="subtle" style={{ fontSize: 11.5, marginTop: 6 }}>
{ROLES.find((r) => r.id === newRole)?.hint}
</div>
{create.isError && (
<div className="toast err" style={{ marginTop: 10 }}>
<IconX /> {(create.error as Error).message}
</div>
)}
{secret && (
<div className="token-secret" style={{ marginTop: 10 }}>
<div className="token-secret-head">
<IconCheck /> Copy this token now — it won't be shown again.
</div>
<div className="token-secret-value">
<code className="mono">{secret}</code>
<button
className="btn sm"
onClick={() => {
navigator.clipboard?.writeText(secret);
setCopied(true);
}}
>
{copied ? "Copied" : "Copy"}
</button>
</div>
</div>
)}
</div>

{tokens.isError ? (
<ErrorBanner error={tokens.error} />
) : tokens.isLoading ? (
<TableSkeleton rows={3} />
) : list.length === 0 ? (
<EmptyState icon={<IconKey size={30} />} title="No API tokens yet">
Create one above to grant scoped, revocable access without sharing the env token.
</EmptyState>
) : (
<div className="token-list">
{list.map((t) => (
<TokenRow key={t.id} token={t} />
))}
</div>
)}
</div>
);
}
26 changes: 26 additions & 0 deletions frontend/src/hooks/useIdentity.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { useQuery } from "@tanstack/react-query";
import { api } from "../api";
import { useOperateToken } from "../lib/auth";

/**
* The caller's resolved identity + role (RBAC). Re-fetches when the operate token changes so the
* UI reflects the new role immediately after pasting a token. In tokenless "open" mode the server
* answers as a viewer; when a token is configured but none is set here, `/access/me` 401s and the
* role is simply undefined (write controls stay gated by the backend regardless).
*/
export function useIdentity() {
const token = useOperateToken();
const q = useQuery({
queryKey: ["me", token],
queryFn: api.me,
retry: false,
staleTime: 30_000,
});
const role = q.data?.role;
return {
identity: q.data,
role,
isAdmin: role === "admin",
canWrite: role === "operator" || role === "admin",
};
}
22 changes: 22 additions & 0 deletions frontend/src/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -350,6 +350,28 @@ select.input {
text-transform: none; letter-spacing: 0; transition: border-color 0.15s;
}
select.input:hover { border-color: var(--fg-subtle); }
input.input {
background: var(--surface-2); color: var(--fg); border: 1px solid var(--border-strong);
border-radius: var(--r-sm); padding: 7px 10px; font-size: 13px; font-family: var(--mono);
transition: border-color 0.15s;
}
input.input:focus { outline: none; border-color: var(--accent); }

/* ---------- API tokens (RBAC) ---------- */
.token-create { padding: 14px 16px; margin-bottom: 16px; }
.token-create-row { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; }
.token-create-row input.input { flex: 1 1 220px; min-width: 0; }
.token-list { display: flex; flex-direction: column; gap: 10px; }
.token-secret {
border: 1px solid var(--ok); border-radius: var(--r-sm); padding: 10px 12px;
background: color-mix(in srgb, var(--ok) 10%, transparent);
}
.token-secret-head { display: flex; align-items: center; gap: 6px; font-size: 12px; font-weight: 600; color: var(--ok); }
.token-secret-value { display: flex; align-items: center; gap: 10px; margin-top: 8px; }
.token-secret-value code {
flex: 1; overflow-x: auto; white-space: nowrap; padding: 6px 8px; font-size: 12px;
background: var(--surface-3); border-radius: var(--r-sm); color: var(--fg);
}

.filter-chip {
display: inline-flex; align-items: center; gap: 7px; margin-top: 6px;
Expand Down
19 changes: 19 additions & 0 deletions frontend/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -270,6 +270,25 @@ export interface AdvisoryReport {
entries: AdvisoryImpact[];
}

export interface Identity {
actor: string;
role: string;
}

export interface ApiToken {
id: number;
name: string;
role: string;
prefix: string;
created_by: string | null;
created_at: string | null;
}

export interface ApiTokenCreated {
token: ApiToken;
secret: string;
}

export interface RequireCheckResult {
repo_id: number;
repo: string | null;
Expand Down
Loading