Merge pull request #469 from drifterza/feature/oidc-pending-approval

This commit is contained in:
Aarnav Tale
2026-02-26 18:03:26 -05:00
committed by GitHub
8 changed files with 905 additions and 473 deletions
+28 -59
View File
@@ -1,14 +1,12 @@
import { eq } from 'drizzle-orm';
import { CircleCheckIcon } from 'lucide-react';
import { Outlet, redirect } from 'react-router';
import Button from '~/components/Button';
import Card from '~/components/Card';
import Footer from '~/components/Footer';
import Header from '~/components/Header';
import { users } from '~/server/db/schema';
import { Capabilities } from '~/server/web/roles';
import toast from '~/utils/toast';
import { Route } from './+types/shell';
import { eq } from "drizzle-orm";
import { Outlet, redirect } from "react-router";
import Footer from "~/components/Footer";
import Header from "~/components/Header";
import { users } from "~/server/db/schema";
import { Capabilities } from "~/server/web/roles";
import { Route } from "./+types/shell";
// This loads the bare minimum for the application to function
// So we know that if context fails to load then well, oops?
@@ -16,9 +14,9 @@ export async function loader({ request, context }: Route.LoaderArgs) {
try {
const session = await context.sessions.auth(request);
if (
typeof context.oidc === 'object' &&
session.user.subject !== 'unknown-non-oauth' &&
!request.url.endsWith('/onboarding')
typeof context.oidc === "object" &&
session.user.subject !== "unknown-non-oauth" &&
!request.url.endsWith("/onboarding")
) {
const [user] = await context.db
.select()
@@ -27,12 +25,22 @@ export async function loader({ request, context }: Route.LoaderArgs) {
.limit(1);
if (!user?.onboarded) {
return redirect('/onboarding');
return redirect("/onboarding");
}
}
const api = context.hsApi.getRuntimeClient(session.api_key);
const check = await context.sessions.check(request, Capabilities.ui_access);
// OIDC users without ui_access go to pending approval
if (
!check &&
session.user.subject !== "unknown-non-oauth" &&
!request.url.endsWith("/onboarding")
) {
return redirect("/pending-approval");
}
return {
config: context.hs.c,
url: context.config.headscale.public_url ?? context.config.headscale.url,
@@ -45,22 +53,16 @@ export async function loader({ request, context }: Route.LoaderArgs) {
dns: await context.sessions.check(request, Capabilities.read_network),
users: await context.sessions.check(request, Capabilities.read_users),
policy: await context.sessions.check(request, Capabilities.read_policy),
machines: await context.sessions.check(
request,
Capabilities.read_machines,
),
settings: await context.sessions.check(
request,
Capabilities.read_feature,
),
machines: await context.sessions.check(request, Capabilities.read_machines),
settings: await context.sessions.check(request, Capabilities.read_feature),
},
onboarding: request.url.endsWith('/onboarding'),
onboarding: request.url.endsWith("/onboarding"),
healthy: await api.isHealthy(),
};
} catch {
return redirect('/login', {
return redirect("/login", {
headers: {
'Set-Cookie': await context.sessions.destroySession(),
"Set-Cookie": await context.sessions.destroySession(),
},
});
}
@@ -70,40 +72,7 @@ export default function Shell({ loaderData }: Route.ComponentProps) {
return (
<>
<Header {...loaderData} />
{/* Always show the outlet if we are onboarding */}
{(loaderData.onboarding ? true : loaderData.uiAccess) ? (
<Outlet />
) : (
<Card className="mx-auto w-fit mt-24">
<div className="flex items-center justify-between">
<Card.Title className="text-3xl mb-0">Connected</Card.Title>
<CircleCheckIcon className="w-10 h-10" />
</div>
<Card.Text className="my-4 text-lg">
Connect to Tailscale with your devices to access this Tailnet. Use
this command to help you get started:
</Card.Text>
<Button
className="flex text-md font-mono"
onPress={async () => {
await navigator.clipboard.writeText(
`tailscale up --login-server=${loaderData.url}`,
);
toast('Copied to clipboard');
}}
>
tailscale up --login-server={loaderData.url}
</Button>
<p className="text-xs mt-1 opacity-50 text-center">
Click this button to copy the command.
</p>
<p className="mt-4 text-sm opacity-50">
Your account does not have access to the UI. Please contact your
administrator if you believe this is a mistake.
</p>
</Card>
)}
<Footer {...loaderData} />
</>
);
+24 -23
View File
@@ -1,39 +1,40 @@
import { index, layout, prefix, route } from '@react-router/dev/routes';
import { index, layout, prefix, route } from "@react-router/dev/routes";
export default [
// Utility Routes
index('routes/util/redirect.ts'),
route('/healthz', 'routes/util/healthz.ts'),
index("routes/util/redirect.ts"),
route("/healthz", "routes/util/healthz.ts"),
// API Routes
...prefix('/api', [route('/info', 'routes/util/info.ts')]),
...prefix("/api", [route("/info", "routes/util/info.ts")]),
// Authentication Routes
route('/login', 'routes/auth/login/page.tsx'),
route('/logout', 'routes/auth/logout.ts'),
route('/oidc/callback', 'routes/auth/oidc-callback.ts'),
route('/oidc/start', 'routes/auth/oidc-start.ts'),
route('/ssh', 'routes/ssh/console.tsx'),
route("/login", "routes/auth/login/page.tsx"),
route("/logout", "routes/auth/logout.ts"),
route("/oidc/callback", "routes/auth/oidc-callback.ts"),
route("/oidc/start", "routes/auth/oidc-start.ts"),
route("/pending-approval", "routes/auth/pending-approval.tsx"),
route("/ssh", "routes/ssh/console.tsx"),
// All the main logged-in dashboard routes
// Double nested to separate error propagations
layout('layouts/shell.tsx', [
route('/onboarding', 'routes/users/onboarding.tsx'),
route('/onboarding/skip', 'routes/users/onboarding-skip.tsx'),
layout('layouts/dashboard.tsx', [
...prefix('/machines', [
index('routes/machines/overview.tsx'),
route('/:id', 'routes/machines/machine.tsx'),
layout("layouts/shell.tsx", [
route("/onboarding", "routes/users/onboarding.tsx"),
route("/onboarding/skip", "routes/users/onboarding-skip.tsx"),
layout("layouts/dashboard.tsx", [
...prefix("/machines", [
index("routes/machines/overview.tsx"),
route("/:id", "routes/machines/machine.tsx"),
]),
route('/users', 'routes/users/overview.tsx'),
route('/acls', 'routes/acls/overview.tsx'),
route('/dns', 'routes/dns/overview.tsx'),
route("/users", "routes/users/overview.tsx"),
route("/acls", "routes/acls/overview.tsx"),
route("/dns", "routes/dns/overview.tsx"),
...prefix('/settings', [
index('routes/settings/overview.tsx'),
route('/auth-keys', 'routes/settings/auth-keys/overview.tsx'),
route('/restrictions', 'routes/settings/restrictions/overview.tsx'),
...prefix("/settings", [
index("routes/settings/overview.tsx"),
route("/auth-keys", "routes/settings/auth-keys/overview.tsx"),
route("/restrictions", "routes/settings/restrictions/overview.tsx"),
// route('/local-agent', 'routes/settings/local-agent.tsx'),
]),
]),
+119
View File
@@ -0,0 +1,119 @@
import { eq } from "drizzle-orm";
import { ClockIcon, LogOut, RefreshCw, UserCheck } from "lucide-react";
import { Form, redirect } from "react-router";
import Button from "~/components/Button";
import Card from "~/components/Card";
import { users } from "~/server/db/schema";
import { Capabilities } from "~/server/web/roles";
import toast from "~/utils/toast";
import type { Route } from "./+types/pending-approval";
export async function loader({ request, context }: Route.LoaderArgs) {
try {
const session = await context.sessions.auth(request);
// API key users skip this page
if (session.user.subject === "unknown-non-oauth") {
return redirect("/machines");
}
const hasAccess = await context.sessions.check(request, Capabilities.ui_access);
if (hasAccess) {
return redirect("/machines");
}
const [user] = await context.db
.select()
.from(users)
.where(eq(users.sub, session.user.subject))
.limit(1);
const url = context.config.headscale.public_url ?? context.config.headscale.url;
return {
user: session.user,
url,
exists: !!user,
};
} catch {
return redirect("/login", {
headers: {
"Set-Cookie": await context.sessions.destroySession(),
},
});
}
}
export default function PendingApproval({ loaderData }: Route.ComponentProps) {
return (
<div className="flex h-screen w-screen items-center justify-center">
<Card className="m-4 max-w-md sm:m-0">
<div className="mb-4 flex items-center gap-3">
<div className="rounded-full bg-amber-100 p-3 dark:bg-amber-900">
<ClockIcon className="h-8 w-8 text-amber-600 dark:text-amber-400" />
</div>
<div>
<Card.Title className="mb-0 text-xl">Approval Required</Card.Title>
<p className="text-headplane-500 text-sm">
{loaderData.user.email ?? loaderData.user.name}
</p>
</div>
</div>
<Card.Text className="mb-4">
Your account has been created but requires approval from an administrator before you can
access the management console.
</Card.Text>
<div className="bg-headplane-50 dark:bg-headplane-900 mb-4 rounded-lg p-4">
<div className="mb-2 flex items-center gap-2">
<UserCheck className="text-headplane-500 h-5 w-5" />
<p className="font-medium">What happens next?</p>
</div>
<ul className="text-headplane-600 dark:text-headplane-400 list-inside list-disc space-y-1 text-sm">
<li>An administrator will review your account</li>
<li>Once approved, you will receive the appropriate access level</li>
<li>This page will automatically redirect you once approved</li>
</ul>
</div>
<Card.Text className="mb-4 text-sm">
In the meantime, you can still connect your devices to the Tailnet using the command
below:
</Card.Text>
<Button
className="w-full font-mono text-sm"
variant="light"
onPress={async () => {
await navigator.clipboard.writeText(`tailscale up --login-server=${loaderData.url}`);
toast("Copied to clipboard");
}}
>
tailscale up --login-server={loaderData.url}
</Button>
<p className="mt-1 text-center text-xs opacity-50">Click to copy the command</p>
<div className="bg-headplane-100 dark:bg-headplane-800 mb-4 flex items-center justify-center gap-2 rounded-lg p-3 text-sm">
<RefreshCw className="text-headplane-500 h-4 w-4 animate-spin" />
<span className="text-headplane-600 dark:text-headplane-400">
Checking for approval automatically...
</span>
</div>
<Form action="/logout" method="post">
<Button
type="submit"
variant="heavy"
className="flex w-full items-center justify-center gap-2"
>
<LogOut className="h-4 w-4" />
Sign Out
</Button>
</Form>
</Card>
</div>
);
}
+41 -42
View File
@@ -1,8 +1,10 @@
import { CircleUser } from 'lucide-react';
import StatusCircle from '~/components/StatusCircle';
import { Machine, User } from '~/types';
import cn from '~/utils/cn';
import MenuOptions from './menu';
import { CircleUser } from "lucide-react";
import StatusCircle from "~/components/StatusCircle";
import { Machine, User } from "~/types";
import cn from "~/utils/cn";
import MenuOptions from "./menu";
interface UserRowProps {
role: string;
@@ -17,50 +19,42 @@ export default function UserRow({ user, role }: UserRowProps) {
);
return (
<tr
className="group hover:bg-headplane-50 dark:hover:bg-headplane-950"
key={user.id}
>
<td className="pl-0.5 py-2">
<tr className="group hover:bg-headplane-50 dark:hover:bg-headplane-950" key={user.id}>
<td className="py-2 pl-0.5">
<div className="flex items-center">
{user.profilePicUrl ? (
<img
alt={user.name || user.displayName}
className="w-10 h-10 rounded-full"
className="h-10 w-10 rounded-full"
src={user.profilePicUrl}
/>
) : (
<CircleUser className="w-10 h-10" />
<CircleUser className="h-10 w-10" />
)}
<div className="ml-4">
<p className={cn('font-semibold leading-snug')}>
{user.name || user.displayName}
</p>
<p className={cn("font-semibold leading-snug")}>{user.name || user.displayName}</p>
<p className="text-sm opacity-50">{user.email}</p>
</div>
</div>
</td>
<td className="pl-0.5 py-2">
<td className="py-2 pl-0.5">
<p>{mapRoleToName(role)}</p>
</td>
<td className="pl-0.5 py-2">
<p
className="text-sm text-headplane-600 dark:text-headplane-300"
suppressHydrationWarning
>
<td className="py-2 pl-0.5">
<p className="text-headplane-600 dark:text-headplane-300 text-sm" suppressHydrationWarning>
{new Date(user.createdAt).toLocaleDateString()}
</p>
</td>
<td className="pl-0.5 py-2">
<td className="py-2 pl-0.5">
<span
className={cn(
'flex items-center gap-x-1 text-sm',
'text-headplane-600 dark:text-headplane-300',
"flex items-center gap-x-1 text-sm",
"text-headplane-600 dark:text-headplane-300",
)}
>
<StatusCircle className="w-4 h-4" isOnline={isOnline} />
<StatusCircle className="h-4 w-4" isOnline={isOnline} />
<p suppressHydrationWarning>
{isOnline ? 'Connected' : new Date(lastSeen).toLocaleString()}
{isOnline ? "Connected" : new Date(lastSeen).toLocaleString()}
</p>
</span>
</td>
@@ -73,25 +67,30 @@ export default function UserRow({ user, role }: UserRowProps) {
function mapRoleToName(role: string) {
switch (role) {
case 'no-oidc':
case "no-oidc":
return <p className="opacity-50">Unmanaged</p>;
case 'invalid-oidc':
case "invalid-oidc":
return <p className="opacity-50">Invalid</p>;
case 'no-role':
case "no-role":
return <p className="opacity-50">Unregistered</p>;
case 'owner':
return 'Owner';
case 'admin':
return 'Admin';
case 'network_admin':
return 'Network Admin';
case 'it_admin':
return 'IT Admin';
case 'auditor':
return 'Auditor';
case 'member':
return 'Member';
case "owner":
return "Owner";
case "admin":
return "Admin";
case "network_admin":
return "Network Admin";
case "it_admin":
return "IT Admin";
case "auditor":
return "Auditor";
case "member":
return (
<span className="inline-flex items-center gap-1.5">
<span className="h-2 w-2 animate-pulse rounded-full bg-amber-500" />
<span className="text-amber-600 dark:text-amber-400">Pending Approval</span>
</span>
);
default:
return 'Unknown';
return "Unknown";
}
}
+61 -72
View File
@@ -1,17 +1,19 @@
import { createHash } from 'node:crypto';
import { open, readFile, rm } from 'node:fs/promises';
import { resolve } from 'node:path';
import { eq } from 'drizzle-orm';
import { LibSQLDatabase } from 'drizzle-orm/libsql/driver';
import { EncryptJWT, jwtDecrypt } from 'jose';
import { createCookie } from 'react-router';
import { ulid } from 'ulidx';
import log from '~/utils/log';
import { users } from '../db/schema';
import { Capabilities, Roles } from './roles';
import { eq } from "drizzle-orm";
import { LibSQLDatabase } from "drizzle-orm/libsql/driver";
import { EncryptJWT, jwtDecrypt } from "jose";
import { createHash } from "node:crypto";
import { open, readFile, rm } from "node:fs/promises";
import { resolve } from "node:path";
import { createCookie } from "react-router";
import { ulid } from "ulidx";
import log from "~/utils/log";
import { users } from "../db/schema";
import { Capabilities, Roles } from "./roles";
export interface AuthSession {
state: 'auth';
state: "auth";
api_key: string;
user: {
subject: string;
@@ -34,7 +36,7 @@ interface JWTSession {
}
export interface OidcFlowSession {
state: 'flow';
state: "flow";
oidc: {
state: string;
nonce: string;
@@ -69,10 +71,7 @@ class Sessionizer {
return decodeSession(request, this.options);
}
async createSession(
payload: JWTSession,
maxAge = this.options.cookie.maxAge,
) {
async createSession(payload: JWTSession, maxAge = this.options.cookie.maxAge) {
// TODO: What the hell is this garbage
return createSession(payload, {
...this.options,
@@ -87,9 +86,7 @@ class Sessionizer {
return destroySession(this.options);
}
async roleForSubject(
subject: string,
): Promise<keyof typeof Roles | undefined> {
async roleForSubject(subject: string): Promise<keyof typeof Roles | undefined> {
const [user] = await this.options.db
.select()
.from(users)
@@ -115,7 +112,7 @@ class Sessionizer {
// This is the subject we set on API key based sessions. API keys
// inherently imply admin access so we return true for all checks.
if (session.user.subject === 'unknown-non-oauth') {
if (session.user.subject === "unknown-non-oauth") {
return true;
}
@@ -133,19 +130,28 @@ class Sessionizer {
}
// Updates the capabilities and roles of a subject
// Creates the user record if it doesn't exist yet
async reassignSubject(subject: string, role: keyof typeof Roles) {
// Check if we are owner
const subjectRole = await this.roleForSubject(subject);
if (subjectRole === 'owner') {
if (subjectRole === "owner") {
return false;
}
// Use upsert to handle users who exist in Headscale but haven't
// logged into Headplane yet (no DB record)
await this.options.db
.update(users)
.set({
.insert(users)
.values({
id: ulid(),
sub: subject,
caps: Roles[role],
onboarded: false,
})
.where(eq(users.sub, subject));
.onConflictDoUpdate({
target: users.sub,
set: { caps: Roles[role] },
});
return true;
}
@@ -153,15 +159,15 @@ class Sessionizer {
async function createSession(payload: JWTSession, options: AuthSessionOptions) {
const now = Math.floor(Date.now() / 1000);
const secret = createHash('sha256').update(options.secret, 'utf8').digest();
const secret = createHash("sha256").update(options.secret, "utf8").digest();
const jwt = await new EncryptJWT({
...payload,
})
.setProtectedHeader({ alg: 'dir', enc: 'A256GCM', typ: 'JWT' })
.setProtectedHeader({ alg: "dir", enc: "A256GCM", typ: "JWT" })
.setIssuedAt()
.setExpirationTime(now + options.cookie.maxAge)
.setIssuer('urn:tale:headplane')
.setAudience('urn:tale:headplane')
.setIssuer("urn:tale:headplane")
.setAudience("urn:tale:headplane")
.setJti(ulid())
.encrypt(secret);
@@ -174,9 +180,9 @@ async function createSession(payload: JWTSession, options: AuthSessionOptions) {
}
async function decodeSession(request: Request, options: AuthSessionOptions) {
const cookieHeader = request.headers.get('cookie');
const cookieHeader = request.headers.get("cookie");
if (cookieHeader === null) {
throw new Error('No session cookie found');
throw new Error("No session cookie found");
}
const cookie = createCookie(options.cookie.name, {
@@ -186,13 +192,13 @@ async function decodeSession(request: Request, options: AuthSessionOptions) {
const cookieValue = (await cookie.parse(cookieHeader)) as string | null;
if (cookieValue === null) {
throw new Error('Session cookie is empty');
throw new Error("Session cookie is empty");
}
const secret = createHash('sha256').update(options.secret, 'utf8').digest();
const secret = createHash("sha256").update(options.secret, "utf8").digest();
const { payload } = await jwtDecrypt(cookieValue, secret, {
issuer: 'urn:tale:headplane',
audience: 'urn:tale:headplane',
issuer: "urn:tale:headplane",
audience: "urn:tale:headplane",
});
// Safe since we encode the session directly into the JWT
@@ -205,7 +211,7 @@ async function destroySession(options: AuthSessionOptions) {
path: __PREFIX__,
});
return cookie.serialize('', {
return cookie.serialize("", {
expires: new Date(0),
});
}
@@ -222,31 +228,23 @@ async function migrateUserDatabase(path: string, db: LibSQLDatabase) {
const realPath = resolve(path);
try {
const handle = await open(realPath, 'a+');
const handle = await open(realPath, "a+");
await handle.close();
} catch (error) {
if (
error != null &&
typeof error === 'object' &&
'code' in error &&
error.code === 'ENOENT'
) {
log.debug('config', 'No old user database file found at %s', realPath);
if (error != null && typeof error === "object" && "code" in error && error.code === "ENOENT") {
log.debug("config", "No old user database file found at %s", realPath);
return;
}
log.warn('config', 'Failed to migrate old user database at %s', realPath);
log.warn(
'config',
'This is not an error, but existing users will not be migrated',
);
log.warn('config', 'Unable to open user database file: %s', String(error));
log.debug('config', 'Error details: %s', error);
log.warn("config", "Failed to migrate old user database at %s", realPath);
log.warn("config", "This is not an error, but existing users will not be migrated");
log.warn("config", "Unable to open user database file: %s", String(error));
log.debug("config", "Error details: %s", error);
return;
}
log.info('config', 'Found old user database file at %s', realPath);
log.info('config', 'Migrating user database to the new SQL database');
log.info("config", "Found old user database file at %s", realPath);
log.info("config", "Migrating user database to the new SQL database");
let migratableUsers: {
u: string;
@@ -255,13 +253,10 @@ async function migrateUserDatabase(path: string, db: LibSQLDatabase) {
}[];
try {
const data = await readFile(realPath, 'utf8');
const data = await readFile(realPath, "utf8");
if (data.trim().length === 0) {
log.info('config', 'Old user database file is empty, nothing to migrate');
log.info(
'config',
'You SHOULD remove oidc.user_storage_file from your config!',
);
log.info("config", "Old user database file is empty, nothing to migrate");
log.info("config", "You SHOULD remove oidc.user_storage_file from your config!");
await rm(realPath, { force: true });
return;
}
@@ -272,29 +267,23 @@ async function migrateUserDatabase(path: string, db: LibSQLDatabase) {
oo?: boolean;
}[];
migratableUsers = users.filter(
(user) => user.u !== undefined && user.c !== undefined,
) as {
migratableUsers = users.filter((user) => user.u !== undefined && user.c !== undefined) as {
u: string;
c: number;
oo?: boolean;
}[];
} catch (error) {
log.warn('config', 'Error reading old user database file: %s', error);
log.warn('config', 'Not migrating any users');
log.warn("config", "Error reading old user database file: %s", error);
log.warn("config", "Not migrating any users");
return;
}
if (migratableUsers.length === 0) {
log.info('config', 'No users found in the old database to migrate');
log.info("config", "No users found in the old database to migrate");
return;
}
log.info(
'config',
'Migrating %d users from the old database',
migratableUsers.length,
);
log.info("config", "Migrating %d users from the old database", migratableUsers.length);
const updated = await db
.insert(users)
@@ -311,7 +300,7 @@ async function migrateUserDatabase(path: string, db: LibSQLDatabase) {
})
.returning();
log.info('config', 'Migrated %d users successfully', updated.length);
log.info('config', 'Removed old user database file %s', realPath);
log.info("config", "Migrated %d users successfully", updated.length);
log.info("config", "Removed old user database file %s", realPath);
await rm(realPath, { force: true });
}
+94
View File
@@ -0,0 +1,94 @@
import { describe, expect, test, vi, beforeEach, afterEach } from "vitest";
vi.mock("react-router", () => ({
useRevalidator: vi.fn(),
}));
vi.mock("usehooks-ts", () => ({
useInterval: vi.fn(),
}));
vi.mock("react", async () => {
const actual = await vi.importActual("react");
return {
...actual,
createContext: vi.fn(() => ({ Provider: vi.fn() })),
useContext: vi.fn(),
useEffect: vi.fn(),
useState: vi.fn(),
};
});
describe("LiveDataProvider", () => {
beforeEach(() => vi.useFakeTimers());
afterEach(() => {
vi.useRealTimers();
vi.clearAllMocks();
});
describe("refresh interval", () => {
test("uses 3 second interval", async () => {
const { useInterval } = await import("usehooks-ts");
expect(useInterval).toBeDefined();
expect(3000).toBe(3000);
});
test("disables interval when paused", () => {
const visible = true;
const paused = true;
const interval = visible && !paused ? 3000 : null;
expect(interval).toBeNull();
});
test("disables interval when hidden", () => {
const visible = false;
const paused = false;
const interval = visible && !paused ? 3000 : null;
expect(interval).toBeNull();
});
test("only revalidates when idle", () => {
const mockRevalidate = vi.fn();
const revalidateIfIdle = (state: string) => {
if (state === "idle") mockRevalidate();
};
revalidateIfIdle("idle");
expect(mockRevalidate).toHaveBeenCalledTimes(1);
mockRevalidate.mockClear();
revalidateIfIdle("loading");
expect(mockRevalidate).not.toHaveBeenCalled();
});
});
describe("useLiveData hook", () => {
test("returns pause and resume functions", () => {
const mockSetPaused = vi.fn();
const hook = {
pause: () => mockSetPaused(true),
resume: () => mockSetPaused(false),
};
hook.pause();
expect(mockSetPaused).toHaveBeenCalledWith(true);
hook.resume();
expect(mockSetPaused).toHaveBeenCalledWith(false);
});
});
});
describe("pending approval page", () => {
test("redirects when user has access", () => {
const hasAccess = true;
const redirect = hasAccess ? "/machines" : null;
expect(redirect).toBe("/machines");
});
test("stays on page when user lacks access", () => {
const hasAccess = false;
const redirect = hasAccess ? "/machines" : null;
expect(redirect).toBeNull();
});
});
+81
View File
@@ -0,0 +1,81 @@
import { describe, expect, test } from "vitest";
import { Capabilities, hasCapability, Roles, getRoleFromCapabilities } from "~/server/web/roles";
describe("Roles and Capabilities", () => {
describe("Roles definitions", () => {
test("owner has all capabilities including ui_access", () => {
expect(Roles.owner & Capabilities.ui_access).toBe(Capabilities.ui_access);
expect(Roles.owner & Capabilities.owner).toBe(Capabilities.owner);
expect(Roles.owner & Capabilities.write_users).toBe(Capabilities.write_users);
});
test("admin has ui_access but not owner flag", () => {
expect(Roles.admin & Capabilities.ui_access).toBe(Capabilities.ui_access);
expect(Roles.admin & Capabilities.owner).toBe(0);
expect(Roles.admin & Capabilities.write_users).toBe(Capabilities.write_users);
});
test("auditor has ui_access but limited write permissions", () => {
expect(Roles.auditor & Capabilities.ui_access).toBe(Capabilities.ui_access);
expect(Roles.auditor & Capabilities.write_users).toBe(0);
expect(Roles.auditor & Capabilities.read_users).toBe(Capabilities.read_users);
});
test("member has NO capabilities (including no ui_access)", () => {
expect(Roles.member).toBe(0);
expect(Roles.member & Capabilities.ui_access).toBe(0);
expect(Roles.member & Capabilities.read_machines).toBe(0);
});
});
describe("hasCapability function", () => {
test("returns true when role has the capability", () => {
expect(hasCapability("owner", "ui_access")).toBe(true);
expect(hasCapability("admin", "ui_access")).toBe(true);
expect(hasCapability("auditor", "ui_access")).toBe(true);
});
test("returns false when role lacks the capability", () => {
expect(hasCapability("member", "ui_access")).toBe(false);
expect(hasCapability("auditor", "write_users")).toBe(false);
});
test("only owner has owner capability", () => {
expect(hasCapability("owner", "owner")).toBe(true);
expect(hasCapability("admin", "owner")).toBe(false);
expect(hasCapability("member", "owner")).toBe(false);
});
});
describe("getRoleFromCapabilities function", () => {
test("returns correct role for exact capability match", () => {
expect(getRoleFromCapabilities(Roles.owner)).toBe("owner");
expect(getRoleFromCapabilities(Roles.admin)).toBe("admin");
expect(getRoleFromCapabilities(Roles.auditor)).toBe("auditor");
expect(getRoleFromCapabilities(Roles.member)).toBe("member");
});
test("returns member for unrecognized capability values", () => {
expect(getRoleFromCapabilities(999999 as any)).toBe("member");
});
});
describe("member role", () => {
test("blocks UI access", () => {
const memberCaps = Roles.member;
const hasUIAccess = (memberCaps & Capabilities.ui_access) === Capabilities.ui_access;
expect(hasUIAccess).toBe(false);
expect(memberCaps).toBe(0);
});
test("other roles have UI access", () => {
const rolesWithUIAccess = ["owner", "admin", "network_admin", "it_admin", "auditor"] as const;
for (const role of rolesWithUIAccess) {
expect(hasCapability(role, "ui_access")).toBe(true);
}
});
});
});
+180
View File
@@ -0,0 +1,180 @@
import { createClient } from "@libsql/client";
import { eq } from "drizzle-orm";
import { drizzle } from "drizzle-orm/libsql";
import { ulid } from "ulidx";
import { beforeEach, describe, expect, test } from "vitest";
import { users } from "~/server/db/schema";
import { Roles } from "~/server/web/roles";
// Create in-memory database for testing
function createTestDb() {
const client = createClient({ url: ":memory:" });
const db = drizzle(client);
return { client, db };
}
// Create the users table schema
async function setupSchema(client: ReturnType<typeof createClient>) {
await client.execute(`
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
sub TEXT NOT NULL UNIQUE,
caps INTEGER NOT NULL DEFAULT 0,
onboarded INTEGER NOT NULL DEFAULT 0
)
`);
}
describe("Session role assignment", () => {
let db: ReturnType<typeof drizzle>;
let client: ReturnType<typeof createClient>;
beforeEach(async () => {
const testDb = createTestDb();
db = testDb.db;
client = testDb.client;
await setupSchema(client);
});
describe("reassignSubject upsert behavior", () => {
test("creates user record when subject does not exist", async () => {
const subject = "new-user-subject";
const role = "admin";
// Verify user doesn't exist
const beforeInsert = await db.select().from(users).where(eq(users.sub, subject));
expect(beforeInsert.length).toBe(0);
// Perform upsert (simulating reassignSubject)
await db
.insert(users)
.values({
id: ulid(),
sub: subject,
caps: Roles[role],
onboarded: false,
})
.onConflictDoUpdate({
target: users.sub,
set: { caps: Roles[role] },
});
// Verify user was created with correct role
const afterInsert = await db.select().from(users).where(eq(users.sub, subject));
expect(afterInsert.length).toBe(1);
expect(afterInsert[0].caps).toBe(Roles.admin);
});
test("updates existing user role without creating duplicate", async () => {
const subject = "existing-user";
const initialRole = "member";
const newRole = "admin";
// Create initial user
await db.insert(users).values({
id: ulid(),
sub: subject,
caps: Roles[initialRole],
onboarded: true,
});
// Verify initial state
const beforeUpdate = await db.select().from(users).where(eq(users.sub, subject));
expect(beforeUpdate.length).toBe(1);
expect(beforeUpdate[0].caps).toBe(Roles.member);
expect(beforeUpdate[0].onboarded).toBe(true);
// Perform upsert (simulating reassignSubject)
await db
.insert(users)
.values({
id: ulid(),
sub: subject,
caps: Roles[newRole],
onboarded: false,
})
.onConflictDoUpdate({
target: users.sub,
set: { caps: Roles[newRole] },
});
// Verify role was updated, no duplicate created
const afterUpdate = await db.select().from(users).where(eq(users.sub, subject));
expect(afterUpdate.length).toBe(1);
expect(afterUpdate[0].caps).toBe(Roles.admin);
// onboarded should remain true (not overwritten)
expect(afterUpdate[0].onboarded).toBe(true);
});
test("can assign all role types", async () => {
const roles = ["admin", "network_admin", "it_admin", "auditor", "member"] as const;
for (const role of roles) {
const subject = `user-${role}`;
await db
.insert(users)
.values({
id: ulid(),
sub: subject,
caps: Roles[role],
onboarded: false,
})
.onConflictDoUpdate({
target: users.sub,
set: { caps: Roles[role] },
});
const [user] = await db.select().from(users).where(eq(users.sub, subject));
expect(user.caps).toBe(Roles[role]);
}
});
});
describe("member role handling", () => {
test("member role has zero capabilities", async () => {
const subject = "member-user";
await db.insert(users).values({
id: ulid(),
sub: subject,
caps: Roles.member,
onboarded: false,
});
const [user] = await db.select().from(users).where(eq(users.sub, subject));
expect(user.caps).toBe(0);
});
test("upgrading from member to admin grants ui_access", async () => {
const subject = "upgrading-user";
// Start as member
await db.insert(users).values({
id: ulid(),
sub: subject,
caps: Roles.member,
onboarded: false,
});
// Upgrade to admin
await db
.insert(users)
.values({
id: ulid(),
sub: subject,
caps: Roles.admin,
onboarded: false,
})
.onConflictDoUpdate({
target: users.sub,
set: { caps: Roles.admin },
});
const [user] = await db.select().from(users).where(eq(users.sub, subject));
expect(user.caps).toBe(Roles.admin);
expect(user.caps).not.toBe(0);
});
});
});