diff --git a/app/layouts/shell.tsx b/app/layouts/shell.tsx
index ae5b376..90f4264 100644
--- a/app/layouts/shell.tsx
+++ b/app/layouts/shell.tsx
@@ -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 (
- <>
-
- {/* Always show the outlet if we are onboarding */}
- {(loaderData.onboarding ? true : loaderData.uiAccess) ? (
-
- ) : (
-
-
- Connected
-
-
-
- Connect to Tailscale with your devices to access this Tailnet. Use
- this command to help you get started:
-
-
-
- Click this button to copy the command.
-
-
- Your account does not have access to the UI. Please contact your
- administrator if you believe this is a mistake.
-
-
- )}
-
- >
- );
+ return (
+ <>
+
+
+
+ >
+ );
}
diff --git a/app/routes.ts b/app/routes.ts
index d27e9b6..cd4bfff 100644
--- a/app/routes.ts
+++ b/app/routes.ts
@@ -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'),
+ ]),
+ ]),
+ ]),
];
diff --git a/app/routes/auth/pending-approval.tsx b/app/routes/auth/pending-approval.tsx
new file mode 100644
index 0000000..9fd76d2
--- /dev/null
+++ b/app/routes/auth/pending-approval.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 (
+
+
+
+
+
+
+
+
Approval Required
+
+ {loaderData.user.email ?? loaderData.user.name}
+
+
+
+
+
+ Your account has been created but requires approval from an administrator before you can
+ access the management console.
+
+
+
+
+
+ - An administrator will review your account
+ - Once approved, you will receive the appropriate access level
+ - Refresh this page after receiving approval
+
+
+
+
+ In the meantime, you can still connect your devices to the Tailnet using the command
+ below:
+
+
+
+ Click to copy the command
+
+
+
+
+
+
+
+ );
+}
diff --git a/app/routes/users/components/user-row.tsx b/app/routes/users/components/user-row.tsx
index 9429ee4..2e5586c 100644
--- a/app/routes/users/components/user-row.tsx
+++ b/app/routes/users/components/user-row.tsx
@@ -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 (
-
-
-
- {user.profilePicUrl ? (
- 
- ) : (
-
- )}
-
-
- {user.name || user.displayName}
-
- {user.email}
-
-
- |
-
- {mapRoleToName(role)}
- |
-
-
- {new Date(user.createdAt).toLocaleDateString()}
-
- |
-
-
-
-
- {isOnline ? 'Connected' : new Date(lastSeen).toLocaleString()}
-
-
- |
-
-
- |
-
- );
+ return (
+
+
+
+ {user.profilePicUrl ? (
+ 
+ ) : (
+
+ )}
+
+ {user.name || user.displayName}
+ {user.email}
+
+
+ |
+
+ {mapRoleToName(role)}
+ |
+
+
+ {new Date(user.createdAt).toLocaleDateString()}
+
+ |
+
+
+
+
+ {isOnline ? "Connected" : new Date(lastSeen).toLocaleString()}
+
+
+ |
+
+
+ |
+
+ );
}
function mapRoleToName(role: string) {
- switch (role) {
- case 'no-oidc':
- return Unmanaged
;
- case 'invalid-oidc':
- return Invalid
;
- case 'no-role':
- return Unregistered
;
- 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 Unmanaged
;
+ case "invalid-oidc":
+ return Invalid
;
+ case "no-role":
+ return Unregistered
;
+ 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 (
+
+
+ Pending Approval
+
+ );
+ default:
+ return "Unknown";
+ }
}
diff --git a/tests/unit/roles/roles.test.ts b/tests/unit/roles/roles.test.ts
new file mode 100644
index 0000000..cb65ff3
--- /dev/null
+++ b/tests/unit/roles/roles.test.ts
@@ -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);
+ }
+ });
+ });
+});