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..4dc83c7
--- /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..7fd858b
--- /dev/null
+++ b/src/lib/scopedRoles.ts
@@ -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)),
+ );
+}
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;