mirror of
https://github.com/tale/headplane.git
synced 2026-07-27 16:18:57 +00:00
add pending approval page for OIDC users without ui_access
This commit is contained in:
+69
-100
@@ -1,110 +1,79 @@
|
||||
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?
|
||||
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')
|
||||
) {
|
||||
const [user] = await context.db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.sub, session.user.subject))
|
||||
.limit(1);
|
||||
try {
|
||||
const session = await context.sessions.auth(request);
|
||||
if (
|
||||
typeof context.oidc === "object" &&
|
||||
session.user.subject !== "unknown-non-oauth" &&
|
||||
!request.url.endsWith("/onboarding")
|
||||
) {
|
||||
const [user] = await context.db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.sub, session.user.subject))
|
||||
.limit(1);
|
||||
|
||||
if (!user?.onboarded) {
|
||||
return redirect('/onboarding');
|
||||
}
|
||||
}
|
||||
if (!user?.onboarded) {
|
||||
return redirect("/onboarding");
|
||||
}
|
||||
}
|
||||
|
||||
const api = context.hsApi.getRuntimeClient(session.api_key);
|
||||
const check = await context.sessions.check(request, Capabilities.ui_access);
|
||||
return {
|
||||
config: context.hs.c,
|
||||
url: context.config.headscale.public_url ?? context.config.headscale.url,
|
||||
configAvailable: context.hs.readable(),
|
||||
debug: context.config.debug,
|
||||
user: session.user,
|
||||
uiAccess: check,
|
||||
access: {
|
||||
ui: await context.sessions.check(request, Capabilities.ui_access),
|
||||
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,
|
||||
),
|
||||
},
|
||||
onboarding: request.url.endsWith('/onboarding'),
|
||||
healthy: await api.isHealthy(),
|
||||
};
|
||||
} catch {
|
||||
return redirect('/login', {
|
||||
headers: {
|
||||
'Set-Cookie': await context.sessions.destroySession(),
|
||||
},
|
||||
});
|
||||
}
|
||||
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,
|
||||
configAvailable: context.hs.readable(),
|
||||
debug: context.config.debug,
|
||||
user: session.user,
|
||||
uiAccess: check,
|
||||
access: {
|
||||
ui: await context.sessions.check(request, Capabilities.ui_access),
|
||||
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),
|
||||
},
|
||||
onboarding: request.url.endsWith("/onboarding"),
|
||||
healthy: await api.isHealthy(),
|
||||
};
|
||||
} catch {
|
||||
return redirect("/login", {
|
||||
headers: {
|
||||
"Set-Cookie": await context.sessions.destroySession(),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
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} />
|
||||
</>
|
||||
);
|
||||
return (
|
||||
<>
|
||||
<Header {...loaderData} />
|
||||
<Outlet />
|
||||
<Footer {...loaderData} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
+34
-33
@@ -1,41 +1,42 @@
|
||||
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'),
|
||||
// Utility Routes
|
||||
index("routes/util/redirect.ts"),
|
||||
route("/healthz", "routes/util/healthz.ts"),
|
||||
|
||||
// API Routes
|
||||
...prefix('/api', [route('/info', 'routes/util/info.ts')]),
|
||||
// API Routes
|
||||
...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'),
|
||||
// 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("/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'),
|
||||
]),
|
||||
// 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"),
|
||||
]),
|
||||
|
||||
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'),
|
||||
// route('/local-agent', 'routes/settings/local-agent.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'),
|
||||
]),
|
||||
]),
|
||||
]),
|
||||
];
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
import { eq } from "drizzle-orm";
|
||||
import { ClockIcon, LogOut, 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 (
|
||||
<main className="flex min-h-screen flex-col items-center justify-center p-4">
|
||||
<Card className="w-full max-w-md">
|
||||
<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>Refresh this page after receiving approval</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="mt-6 flex gap-2">
|
||||
<Button className="flex-1" variant="light" onPress={() => window.location.reload()}>
|
||||
Check Status
|
||||
</Button>
|
||||
<Form action="/logout" method="post" className="flex-1">
|
||||
<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>
|
||||
</div>
|
||||
</Card>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -1,97 +1,96 @@
|
||||
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;
|
||||
user: User & { machines: Machine[] };
|
||||
role: string;
|
||||
user: User & { machines: Machine[] };
|
||||
}
|
||||
|
||||
export default function UserRow({ user, role }: UserRowProps) {
|
||||
const isOnline = user.machines.some((machine) => machine.online);
|
||||
const lastSeen = user.machines.reduce(
|
||||
(acc, machine) => Math.max(acc, new Date(machine.lastSeen).getTime()),
|
||||
0,
|
||||
);
|
||||
const isOnline = user.machines.some((machine) => machine.online);
|
||||
const lastSeen = user.machines.reduce(
|
||||
(acc, machine) => Math.max(acc, new Date(machine.lastSeen).getTime()),
|
||||
0,
|
||||
);
|
||||
|
||||
return (
|
||||
<tr
|
||||
className="group hover:bg-headplane-50 dark:hover:bg-headplane-950"
|
||||
key={user.id}
|
||||
>
|
||||
<td className="pl-0.5 py-2">
|
||||
<div className="flex items-center">
|
||||
{user.profilePicUrl ? (
|
||||
<img
|
||||
alt={user.name || user.displayName}
|
||||
className="w-10 h-10 rounded-full"
|
||||
src={user.profilePicUrl}
|
||||
/>
|
||||
) : (
|
||||
<CircleUser className="w-10 h-10" />
|
||||
)}
|
||||
<div className="ml-4">
|
||||
<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">
|
||||
<p>{mapRoleToName(role)}</p>
|
||||
</td>
|
||||
<td className="pl-0.5 py-2">
|
||||
<p
|
||||
className="text-sm text-headplane-600 dark:text-headplane-300"
|
||||
suppressHydrationWarning
|
||||
>
|
||||
{new Date(user.createdAt).toLocaleDateString()}
|
||||
</p>
|
||||
</td>
|
||||
<td className="pl-0.5 py-2">
|
||||
<span
|
||||
className={cn(
|
||||
'flex items-center gap-x-1 text-sm',
|
||||
'text-headplane-600 dark:text-headplane-300',
|
||||
)}
|
||||
>
|
||||
<StatusCircle className="w-4 h-4" isOnline={isOnline} />
|
||||
<p suppressHydrationWarning>
|
||||
{isOnline ? 'Connected' : new Date(lastSeen).toLocaleString()}
|
||||
</p>
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-2 pr-0.5">
|
||||
<MenuOptions user={{ ...user, headplaneRole: role }} />
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
return (
|
||||
<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="h-10 w-10 rounded-full"
|
||||
src={user.profilePicUrl}
|
||||
/>
|
||||
) : (
|
||||
<CircleUser className="h-10 w-10" />
|
||||
)}
|
||||
<div className="ml-4">
|
||||
<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="py-2 pl-0.5">
|
||||
<p>{mapRoleToName(role)}</p>
|
||||
</td>
|
||||
<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="py-2 pl-0.5">
|
||||
<span
|
||||
className={cn(
|
||||
"flex items-center gap-x-1 text-sm",
|
||||
"text-headplane-600 dark:text-headplane-300",
|
||||
)}
|
||||
>
|
||||
<StatusCircle className="h-4 w-4" isOnline={isOnline} />
|
||||
<p suppressHydrationWarning>
|
||||
{isOnline ? "Connected" : new Date(lastSeen).toLocaleString()}
|
||||
</p>
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-2 pr-0.5">
|
||||
<MenuOptions user={{ ...user, headplaneRole: role }} />
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
function mapRoleToName(role: string) {
|
||||
switch (role) {
|
||||
case 'no-oidc':
|
||||
return <p className="opacity-50">Unmanaged</p>;
|
||||
case 'invalid-oidc':
|
||||
return <p className="opacity-50">Invalid</p>;
|
||||
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';
|
||||
default:
|
||||
return 'Unknown';
|
||||
}
|
||||
switch (role) {
|
||||
case "no-oidc":
|
||||
return <p className="opacity-50">Unmanaged</p>;
|
||||
case "invalid-oidc":
|
||||
return <p className="opacity-50">Invalid</p>;
|
||||
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 (
|
||||
<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";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user