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
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,10 @@ That runtime-config flow is intentional. The dashboard is designed to be reconfi
- enable or disable login methods such as passkeys, magic links, OTP, and OAuth
- configure OAuth providers without entering provider client secrets in the browser

Role management supports scoped role names such as `admin:read` and `admin:write`. Dashboard access
accepts the legacy `admin` role, `admin:read`, or `admin:write`; mutating API requests still depend
on the Seamless Auth API enforcing write scopes.

#### OAuth Provider Configuration

The dashboard edits the Seamless Auth API `oauth_providers` system config. OAuth is enabled by
Expand Down
28 changes: 27 additions & 1 deletion src/components/RequireAuth.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,7 @@ describe("RequireAuth", () => {
vi.useFakeTimers();
authState.value = {
isAuthenticated: true,
user: { id: "1" },
user: { id: "1", roles: ["admin:read"] },
hasRole: (role: string) => role === "admin",
loading: false,
};
Expand Down Expand Up @@ -140,4 +140,30 @@ describe("RequireAuth", () => {

expect(container.querySelector(".opacity-100")).toBeInTheDocument();
});

it("allows admin write users into the dashboard", () => {
authState.value = {
isAuthenticated: true,
user: { id: "1", roles: ["admin:write"] },
hasRole: () => false,
loading: false,
};

render(
<MemoryRouter initialEntries={["/events"]}>
<Routes>
<Route
path="/events"
element={
<RequireAuth>
<div>Secret</div>
</RequireAuth>
}
/>
</Routes>
</MemoryRouter>,
);

expect(screen.getByText("Secret")).toBeInTheDocument();
});
});
5 changes: 3 additions & 2 deletions src/components/RequireAuth.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,14 @@ import { Navigate, useLocation } from "react-router-dom";
import AuthLoading from "./AuthLoading";
import { useState, useEffect } from "react";
import { saveLastProtectedRoute } from "../lib/lastRoute";
import { hasScopedRole } from "../lib/scopedRoles";

export default function RequireAuth({
children,
}: {
children: React.ReactNode;
}) {
const { isAuthenticated, user, hasRole, loading } = useAuth();
const { isAuthenticated, user, loading } = useAuth();
const [ready, setReady] = useState(false);
const location = useLocation();

Expand All @@ -35,7 +36,7 @@ export default function RequireAuth({
return <AuthLoading />;
}

if (!isAuthenticated || !hasRole("admin")) {
if (!isAuthenticated || !hasScopedRole(user?.roles, "admin:read")) {
return (
<Navigate to="/unauthenticated" replace state={{ from: currentPath }} />
);
Expand Down
32 changes: 32 additions & 0 deletions src/lib/scopedRoles.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
/*
* Copyright © 2026 Fells Code, LLC
* Licensed under the GNU Affero General Public License v3.0
* See LICENSE file in the project root for full license information
*/

import { describe, expect, it } from "vitest";
import { hasScopedRole, roleGrantsAccess } from "./scopedRoles";

describe("scoped roles", () => {
it("matches exact roles", () => {
expect(roleGrantsAccess("admin", "admin")).toBe(true);
expect(roleGrantsAccess("admin:read", "admin:read")).toBe(true);
});

it("lets broad and write roles satisfy scoped read checks", () => {
expect(roleGrantsAccess("admin", "admin:read")).toBe(true);
expect(roleGrantsAccess("admin:write", "admin:read")).toBe(true);
});

it("does not let read satisfy write or broad checks", () => {
expect(roleGrantsAccess("admin:read", "admin:write")).toBe(false);
expect(roleGrantsAccess("admin:read", "admin")).toBe(false);
});

it("checks any granted role against any required role", () => {
expect(
hasScopedRole(["user", "admin:read"], ["admin:write", "admin:read"]),
).toBe(true);
expect(hasScopedRole(["user"], ["admin:write", "admin:read"])).toBe(false);
});
});
68 changes: 68 additions & 0 deletions src/lib/scopedRoles.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
/*
* Copyright © 2026 Fells Code, LLC
* Licensed under the GNU Affero General Public License v3.0
* See LICENSE file in the project root for full license information
*/

export function roleGrantsAccess(
grantedRole: string,
requiredRole: string,
): boolean {
const granted = grantedRole.trim();
const required = requiredRole.trim();

if (!granted || !required) {
return false;
}

if (granted === required) {
return true;
}

if (granted.endsWith(":*")) {
const prefix = granted.slice(0, -2);
return required === prefix || required.startsWith(`${prefix}:`);
}

if (!required.includes(":")) {
return false;
}

if (!granted.includes(":")) {
return required.startsWith(`${granted}:`);
}

const grantedParts = granted.split(":");
const requiredParts = required.split(":");
const grantedAction = grantedParts.at(-1);
const requiredAction = requiredParts.at(-1);
const grantedPrefix = grantedParts.slice(0, -1);
const requiredPrefix = requiredParts.slice(0, -1);

return (
grantedAction === "write" &&
requiredAction === "read" &&
grantedPrefix.length === requiredPrefix.length &&
grantedPrefix.every((part, index) => part === requiredPrefix[index])
);
}

export function hasScopedRole(
grantedRoles: unknown,
requiredRoles: string | string[],
): boolean {
if (!Array.isArray(grantedRoles)) {
return false;
}

const granted = grantedRoles.filter(
(role): role is string => typeof role === "string",
);
const required = Array.isArray(requiredRoles)
? requiredRoles
: [requiredRoles];

return required.some((requiredRole) =>
granted.some((grantedRole) => roleGrantsAccess(grantedRole, requiredRole)),
);
}
18 changes: 18 additions & 0 deletions src/pages/SystemConfig.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,24 @@ describe("SystemConfigPage", () => {
);
});

it("adds scoped roles to the available role set", () => {
render(<SystemConfigPage />);

fireEvent.change(screen.getByPlaceholderText(/admin:read/i), {
target: { value: "admin:write" },
});
const [addRoleButton] = screen.getAllByRole("button", { name: /^add$/i });
expect(addRoleButton).toBeDefined();
fireEvent.click(addRoleButton!);
fireEvent.click(screen.getByRole("button", { name: /save changes/i }));

expect(mocks.mutate).toHaveBeenCalledWith(
expect.objectContaining({
available_roles: ["user", "admin", "admin:write"],
}),
);
});

it("adds OAuth provider configuration without a secret value", () => {
render(<SystemConfigPage />);

Expand Down
2 changes: 1 addition & 1 deletion src/pages/SystemConfig.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -869,7 +869,7 @@ function AddRoleInput({
<input
value={value}
onChange={(e) => setValue(e.target.value)}
placeholder="Add role (e.g. admin)"
placeholder="Add role (e.g. admin:read)"
className="flex-1 rounded-md border border-subtle bg-surface-alt px-3 py-2 text-sm outline-none transition focus:border-[var(--primary)] focus:ring-1 focus:ring-[var(--primary)]"
onKeyDown={(e) => {
if (e.key === "Enter") {
Expand Down
9 changes: 6 additions & 3 deletions src/pages/Unauthenticated.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,10 @@ import { useAuth } from "@seamless-auth/react";
import { Navigate, useLocation } from "react-router-dom";
import LayoutSkeleton from "../components/LayoutSkeleton";
import { getLastProtectedRoute } from "../lib/lastRoute";
import { hasScopedRole } from "../lib/scopedRoles";

export default function Unauthenticated() {
const { isAuthenticated, hasRole, user, loading } = useAuth();
const { isAuthenticated, user, loading } = useAuth();
const location = useLocation();

const redirectTo =
Expand All @@ -21,7 +22,9 @@ export default function Unauthenticated() {
return <LayoutSkeleton />;
}

if (isAuthenticated && hasRole("admin")) {
const hasAdminReadAccess = hasScopedRole(user?.roles, "admin:read");

if (isAuthenticated && hasAdminReadAccess) {
return <Navigate to={redirectTo} replace />;
}

Expand All @@ -41,7 +44,7 @@ export default function Unauthenticated() {
<p className="text-muted text-sm">Seamless Auth Dashboard</p>
</div>

{isAuthenticated && !hasRole("admin") ? (
{isAuthenticated && !hasAdminReadAccess ? (
<div className="text-sm text-[var(--highlight)]">
Your account does not have admin access.
</div>
Expand Down
3 changes: 2 additions & 1 deletion src/pages/Users.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import SearchInput from "../components/SearchInput";
import CreateUserModal from "../components/CreateUserModal";
import StatCard from "../components/StatCard";
import { Section } from "../components/Section";
import { hasScopedRole } from "../lib/scopedRoles";

function formatTimeAgo(date?: string | null, now?: number) {
if (!date) return "No recent activity";
Expand Down Expand Up @@ -69,7 +70,7 @@ export default function Users() {
const total = data?.total ?? 0;
const verifiedCount = users.filter((user) => user.verified).length;
const adminCount = users.filter((user) =>
user.roles.includes("admin"),
hasScopedRole(user.roles, "admin:read"),
).length;
const recentlyActiveCount = users.filter((user) => {
if (!user.lastLogin) return false;
Expand Down
Loading