add pending approval page for OIDC users without ui_access

This commit is contained in:
drifterza
2026-02-24 15:22:50 +02:00
parent 9183ec2942
commit a6b25b3ba3
5 changed files with 387 additions and 220 deletions
+69 -100
View File
@@ -1,110 +1,79 @@
import { eq } from 'drizzle-orm'; import { eq } from "drizzle-orm";
import { CircleCheckIcon } from 'lucide-react'; import { Outlet, redirect } from "react-router";
import { Outlet, redirect } from 'react-router';
import Button from '~/components/Button'; import Footer from "~/components/Footer";
import Card from '~/components/Card'; import Header from "~/components/Header";
import Footer from '~/components/Footer'; import { users } from "~/server/db/schema";
import Header from '~/components/Header'; import { Capabilities } from "~/server/web/roles";
import { users } from '~/server/db/schema';
import { Capabilities } from '~/server/web/roles'; import { Route } from "./+types/shell";
import toast from '~/utils/toast';
import { Route } from './+types/shell';
// This loads the bare minimum for the application to function // This loads the bare minimum for the application to function
// So we know that if context fails to load then well, oops? // So we know that if context fails to load then well, oops?
export async function loader({ request, context }: Route.LoaderArgs) { export async function loader({ request, context }: Route.LoaderArgs) {
try { try {
const session = await context.sessions.auth(request); const session = await context.sessions.auth(request);
if ( if (
typeof context.oidc === 'object' && typeof context.oidc === "object" &&
session.user.subject !== 'unknown-non-oauth' && session.user.subject !== "unknown-non-oauth" &&
!request.url.endsWith('/onboarding') !request.url.endsWith("/onboarding")
) { ) {
const [user] = await context.db const [user] = await context.db
.select() .select()
.from(users) .from(users)
.where(eq(users.sub, session.user.subject)) .where(eq(users.sub, session.user.subject))
.limit(1); .limit(1);
if (!user?.onboarded) { if (!user?.onboarded) {
return redirect('/onboarding'); return redirect("/onboarding");
} }
} }
const api = context.hsApi.getRuntimeClient(session.api_key); const api = context.hsApi.getRuntimeClient(session.api_key);
const check = await context.sessions.check(request, Capabilities.ui_access); const check = await context.sessions.check(request, Capabilities.ui_access);
return {
config: context.hs.c, // OIDC users without ui_access go to pending approval
url: context.config.headscale.public_url ?? context.config.headscale.url, if (
configAvailable: context.hs.readable(), !check &&
debug: context.config.debug, session.user.subject !== "unknown-non-oauth" &&
user: session.user, !request.url.endsWith("/onboarding")
uiAccess: check, ) {
access: { return redirect("/pending-approval");
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), return {
policy: await context.sessions.check(request, Capabilities.read_policy), config: context.hs.c,
machines: await context.sessions.check( url: context.config.headscale.public_url ?? context.config.headscale.url,
request, configAvailable: context.hs.readable(),
Capabilities.read_machines, debug: context.config.debug,
), user: session.user,
settings: await context.sessions.check( uiAccess: check,
request, access: {
Capabilities.read_feature, 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),
onboarding: request.url.endsWith('/onboarding'), policy: await context.sessions.check(request, Capabilities.read_policy),
healthy: await api.isHealthy(), machines: await context.sessions.check(request, Capabilities.read_machines),
}; settings: await context.sessions.check(request, Capabilities.read_feature),
} catch { },
return redirect('/login', { onboarding: request.url.endsWith("/onboarding"),
headers: { healthy: await api.isHealthy(),
'Set-Cookie': await context.sessions.destroySession(), };
}, } catch {
}); return redirect("/login", {
} headers: {
"Set-Cookie": await context.sessions.destroySession(),
},
});
}
} }
export default function Shell({ loaderData }: Route.ComponentProps) { export default function Shell({ loaderData }: Route.ComponentProps) {
return ( return (
<> <>
<Header {...loaderData} /> <Header {...loaderData} />
{/* Always show the outlet if we are onboarding */} <Outlet />
{(loaderData.onboarding ? true : loaderData.uiAccess) ? ( <Footer {...loaderData} />
<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} />
</>
);
} }
+34 -33
View File
@@ -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 [ export default [
// Utility Routes // Utility Routes
index('routes/util/redirect.ts'), index("routes/util/redirect.ts"),
route('/healthz', 'routes/util/healthz.ts'), route("/healthz", "routes/util/healthz.ts"),
// API Routes // API Routes
...prefix('/api', [route('/info', 'routes/util/info.ts')]), ...prefix("/api", [route("/info", "routes/util/info.ts")]),
// Authentication Routes // Authentication Routes
route('/login', 'routes/auth/login/page.tsx'), route("/login", "routes/auth/login/page.tsx"),
route('/logout', 'routes/auth/logout.ts'), route("/logout", "routes/auth/logout.ts"),
route('/oidc/callback', 'routes/auth/oidc-callback.ts'), route("/oidc/callback", "routes/auth/oidc-callback.ts"),
route('/oidc/start', 'routes/auth/oidc-start.ts'), route("/oidc/start", "routes/auth/oidc-start.ts"),
route('/ssh', 'routes/ssh/console.tsx'), route("/pending-approval", "routes/auth/pending-approval.tsx"),
route("/ssh", "routes/ssh/console.tsx"),
// All the main logged-in dashboard routes // All the main logged-in dashboard routes
// Double nested to separate error propagations // Double nested to separate error propagations
layout('layouts/shell.tsx', [ layout("layouts/shell.tsx", [
route('/onboarding', 'routes/users/onboarding.tsx'), route("/onboarding", "routes/users/onboarding.tsx"),
route('/onboarding/skip', 'routes/users/onboarding-skip.tsx'), route("/onboarding/skip", "routes/users/onboarding-skip.tsx"),
layout('layouts/dashboard.tsx', [ layout("layouts/dashboard.tsx", [
...prefix('/machines', [ ...prefix("/machines", [
index('routes/machines/overview.tsx'), index("routes/machines/overview.tsx"),
route('/:id', 'routes/machines/machine.tsx'), route("/:id", "routes/machines/machine.tsx"),
]), ]),
route('/users', 'routes/users/overview.tsx'), route("/users", "routes/users/overview.tsx"),
route('/acls', 'routes/acls/overview.tsx'), route("/acls", "routes/acls/overview.tsx"),
route('/dns', 'routes/dns/overview.tsx'), route("/dns", "routes/dns/overview.tsx"),
...prefix('/settings', [ ...prefix("/settings", [
index('routes/settings/overview.tsx'), index("routes/settings/overview.tsx"),
route('/auth-keys', 'routes/settings/auth-keys/overview.tsx'), route("/auth-keys", "routes/settings/auth-keys/overview.tsx"),
route('/restrictions', 'routes/settings/restrictions/overview.tsx'), route("/restrictions", "routes/settings/restrictions/overview.tsx"),
// route('/local-agent', 'routes/settings/local-agent.tsx'), // route('/local-agent', 'routes/settings/local-agent.tsx'),
]), ]),
]), ]),
]), ]),
]; ];
+117
View File
@@ -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>
);
}
+86 -87
View File
@@ -1,97 +1,96 @@
import { CircleUser } from 'lucide-react'; import { CircleUser } from "lucide-react";
import StatusCircle from '~/components/StatusCircle';
import { Machine, User } from '~/types'; import StatusCircle from "~/components/StatusCircle";
import cn from '~/utils/cn'; import { Machine, User } from "~/types";
import MenuOptions from './menu'; import cn from "~/utils/cn";
import MenuOptions from "./menu";
interface UserRowProps { interface UserRowProps {
role: string; role: string;
user: User & { machines: Machine[] }; user: User & { machines: Machine[] };
} }
export default function UserRow({ user, role }: UserRowProps) { export default function UserRow({ user, role }: UserRowProps) {
const isOnline = user.machines.some((machine) => machine.online); const isOnline = user.machines.some((machine) => machine.online);
const lastSeen = user.machines.reduce( const lastSeen = user.machines.reduce(
(acc, machine) => Math.max(acc, new Date(machine.lastSeen).getTime()), (acc, machine) => Math.max(acc, new Date(machine.lastSeen).getTime()),
0, 0,
); );
return ( return (
<tr <tr className="group hover:bg-headplane-50 dark:hover:bg-headplane-950" key={user.id}>
className="group hover:bg-headplane-50 dark:hover:bg-headplane-950" <td className="py-2 pl-0.5">
key={user.id} <div className="flex items-center">
> {user.profilePicUrl ? (
<td className="pl-0.5 py-2"> <img
<div className="flex items-center"> alt={user.name || user.displayName}
{user.profilePicUrl ? ( className="h-10 w-10 rounded-full"
<img src={user.profilePicUrl}
alt={user.name || user.displayName} />
className="w-10 h-10 rounded-full" ) : (
src={user.profilePicUrl} <CircleUser className="h-10 w-10" />
/> )}
) : ( <div className="ml-4">
<CircleUser className="w-10 h-10" /> <p className={cn("font-semibold leading-snug")}>{user.name || user.displayName}</p>
)} <p className="text-sm opacity-50">{user.email}</p>
<div className="ml-4"> </div>
<p className={cn('font-semibold leading-snug')}> </div>
{user.name || user.displayName} </td>
</p> <td className="py-2 pl-0.5">
<p className="text-sm opacity-50">{user.email}</p> <p>{mapRoleToName(role)}</p>
</div> </td>
</div> <td className="py-2 pl-0.5">
</td> <p className="text-headplane-600 dark:text-headplane-300 text-sm" suppressHydrationWarning>
<td className="pl-0.5 py-2"> {new Date(user.createdAt).toLocaleDateString()}
<p>{mapRoleToName(role)}</p> </p>
</td> </td>
<td className="pl-0.5 py-2"> <td className="py-2 pl-0.5">
<p <span
className="text-sm text-headplane-600 dark:text-headplane-300" className={cn(
suppressHydrationWarning "flex items-center gap-x-1 text-sm",
> "text-headplane-600 dark:text-headplane-300",
{new Date(user.createdAt).toLocaleDateString()} )}
</p> >
</td> <StatusCircle className="h-4 w-4" isOnline={isOnline} />
<td className="pl-0.5 py-2"> <p suppressHydrationWarning>
<span {isOnline ? "Connected" : new Date(lastSeen).toLocaleString()}
className={cn( </p>
'flex items-center gap-x-1 text-sm', </span>
'text-headplane-600 dark:text-headplane-300', </td>
)} <td className="py-2 pr-0.5">
> <MenuOptions user={{ ...user, headplaneRole: role }} />
<StatusCircle className="w-4 h-4" isOnline={isOnline} /> </td>
<p suppressHydrationWarning> </tr>
{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) { function mapRoleToName(role: string) {
switch (role) { switch (role) {
case 'no-oidc': case "no-oidc":
return <p className="opacity-50">Unmanaged</p>; return <p className="opacity-50">Unmanaged</p>;
case 'invalid-oidc': case "invalid-oidc":
return <p className="opacity-50">Invalid</p>; return <p className="opacity-50">Invalid</p>;
case 'no-role': case "no-role":
return <p className="opacity-50">Unregistered</p>; return <p className="opacity-50">Unregistered</p>;
case 'owner': case "owner":
return 'Owner'; return "Owner";
case 'admin': case "admin":
return 'Admin'; return "Admin";
case 'network_admin': case "network_admin":
return 'Network Admin'; return "Network Admin";
case 'it_admin': case "it_admin":
return 'IT Admin'; return "IT Admin";
case 'auditor': case "auditor":
return 'Auditor'; return "Auditor";
case 'member': case "member":
return 'Member'; return (
default: <span className="inline-flex items-center gap-1.5">
return 'Unknown'; <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";
}
} }
+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);
}
});
});
});