From 2031f912ed5b1ef020e7834d4084cb55c21f47e5 Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Sat, 23 May 2026 21:52:56 -0400 Subject: [PATCH 1/2] feat scoped role support --- README.md | 4 ++ src/components/RequireAuth.test.tsx | 28 ++++++++++++- src/components/RequireAuth.tsx | 5 ++- src/lib/scopedRoles.test.ts | 32 +++++++++++++++ src/lib/scopedRoles.ts | 63 +++++++++++++++++++++++++++++ src/pages/SystemConfig.test.tsx | 18 +++++++++ src/pages/SystemConfig.tsx | 2 +- src/pages/Unauthenticated.tsx | 9 +++-- src/pages/Users.tsx | 3 +- 9 files changed, 156 insertions(+), 8 deletions(-) create mode 100644 src/lib/scopedRoles.test.ts create mode 100644 src/lib/scopedRoles.ts diff --git a/README.md b/README.md index e09dbf5..ead006b 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/src/components/RequireAuth.test.tsx b/src/components/RequireAuth.test.tsx index 372821b..42ff118 100644 --- a/src/components/RequireAuth.test.tsx +++ b/src/components/RequireAuth.test.tsx @@ -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, }; @@ -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( + + + +
Secret
+ + } + /> +
+
, + ); + + expect(screen.getByText("Secret")).toBeInTheDocument(); + }); }); diff --git a/src/components/RequireAuth.tsx b/src/components/RequireAuth.tsx index e4613aa..4fd2129 100644 --- a/src/components/RequireAuth.tsx +++ b/src/components/RequireAuth.tsx @@ -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(); @@ -35,7 +36,7 @@ export default function RequireAuth({ return ; } - if (!isAuthenticated || !hasRole("admin")) { + if (!isAuthenticated || !hasScopedRole(user?.roles, "admin:read")) { return ( ); diff --git a/src/lib/scopedRoles.test.ts b/src/lib/scopedRoles.test.ts new file mode 100644 index 0000000..233d37f --- /dev/null +++ b/src/lib/scopedRoles.test.ts @@ -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); + }); +}); diff --git a/src/lib/scopedRoles.ts b/src/lib/scopedRoles.ts new file mode 100644 index 0000000..57cdc25 --- /dev/null +++ b/src/lib/scopedRoles.ts @@ -0,0 +1,63 @@ +/* + * 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)), + ); +} diff --git a/src/pages/SystemConfig.test.tsx b/src/pages/SystemConfig.test.tsx index 81e6748..4ef780f 100644 --- a/src/pages/SystemConfig.test.tsx +++ b/src/pages/SystemConfig.test.tsx @@ -67,6 +67,24 @@ describe("SystemConfigPage", () => { ); }); + it("adds scoped roles to the available role set", () => { + render(); + + 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(); diff --git a/src/pages/SystemConfig.tsx b/src/pages/SystemConfig.tsx index b4dd717..3e963ed 100644 --- a/src/pages/SystemConfig.tsx +++ b/src/pages/SystemConfig.tsx @@ -869,7 +869,7 @@ function AddRoleInput({ 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") { diff --git a/src/pages/Unauthenticated.tsx b/src/pages/Unauthenticated.tsx index 8130333..d3e909f 100644 --- a/src/pages/Unauthenticated.tsx +++ b/src/pages/Unauthenticated.tsx @@ -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 = @@ -21,7 +22,9 @@ export default function Unauthenticated() { return ; } - if (isAuthenticated && hasRole("admin")) { + const hasAdminReadAccess = hasScopedRole(user?.roles, "admin:read"); + + if (isAuthenticated && hasAdminReadAccess) { return ; } @@ -41,7 +44,7 @@ export default function Unauthenticated() {

Seamless Auth Dashboard

- {isAuthenticated && !hasRole("admin") ? ( + {isAuthenticated && !hasAdminReadAccess ? (
Your account does not have admin access.
diff --git a/src/pages/Users.tsx b/src/pages/Users.tsx index d13350b..e77c34d 100644 --- a/src/pages/Users.tsx +++ b/src/pages/Users.tsx @@ -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"; @@ -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; From 939484d2548c9b32d0f2b0ca7ee1e2c4f687fa4b Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Tue, 26 May 2026 16:24:07 -0400 Subject: [PATCH 2/2] ci: linting --- src/lib/scopedRoles.test.ts | 6 +++--- src/lib/scopedRoles.ts | 9 +++++++-- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/src/lib/scopedRoles.test.ts b/src/lib/scopedRoles.test.ts index 233d37f..4dc83c7 100644 --- a/src/lib/scopedRoles.test.ts +++ b/src/lib/scopedRoles.test.ts @@ -24,9 +24,9 @@ describe("scoped roles", () => { }); it("checks any granted role against any required role", () => { - expect(hasScopedRole(["user", "admin:read"], ["admin:write", "admin:read"])).toBe( - true, - ); + expect( + hasScopedRole(["user", "admin:read"], ["admin:write", "admin:read"]), + ).toBe(true); expect(hasScopedRole(["user"], ["admin:write", "admin:read"])).toBe(false); }); }); diff --git a/src/lib/scopedRoles.ts b/src/lib/scopedRoles.ts index 57cdc25..7fd858b 100644 --- a/src/lib/scopedRoles.ts +++ b/src/lib/scopedRoles.ts @@ -4,7 +4,10 @@ * See LICENSE file in the project root for full license information */ -export function roleGrantsAccess(grantedRole: string, requiredRole: string): boolean { +export function roleGrantsAccess( + grantedRole: string, + requiredRole: string, +): boolean { const granted = grantedRole.trim(); const required = requiredRole.trim(); @@ -55,7 +58,9 @@ export function hasScopedRole( const granted = grantedRoles.filter( (role): role is string => typeof role === "string", ); - const required = Array.isArray(requiredRoles) ? requiredRoles : [requiredRoles]; + const required = Array.isArray(requiredRoles) + ? requiredRoles + : [requiredRoles]; return required.some((requiredRole) => granted.some((grantedRole) => roleGrantsAccess(grantedRole, requiredRole)),