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..b7031bf
--- /dev/null
+++ b/app/routes/auth/pending-approval.tsx
@@ -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 (
+
+
+
+
+
+
+
+
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
+ - This page will automatically redirect you once approved
+
+
+
+
+ In the meantime, you can still connect your devices to the Tailnet using the command
+ below:
+
+
+
+ Click to copy the command
+
+
+
+
+ Checking for approval automatically...
+
+
+
+
+
+
+ );
+}
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/app/server/web/sessions.ts b/app/server/web/sessions.ts
index 900c41a..4dbc2b7 100644
--- a/app/server/web/sessions.ts
+++ b/app/server/web/sessions.ts
@@ -1,317 +1,306 @@
-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';
- api_key: string;
- user: {
- subject: string;
- name: string;
- email?: string;
- username?: string;
- picture?: string;
- };
+ state: "auth";
+ api_key: string;
+ user: {
+ subject: string;
+ name: string;
+ email?: string;
+ username?: string;
+ picture?: string;
+ };
}
interface JWTSession {
- api_key: string;
- user: {
- subject: string;
- name: string;
- email?: string;
- username?: string;
- picture?: string;
- };
+ api_key: string;
+ user: {
+ subject: string;
+ name: string;
+ email?: string;
+ username?: string;
+ picture?: string;
+ };
}
export interface OidcFlowSession {
- state: 'flow';
- oidc: {
- state: string;
- nonce: string;
- code_verifier: string;
- redirect_uri: string;
- };
+ state: "flow";
+ oidc: {
+ state: string;
+ nonce: string;
+ code_verifier: string;
+ redirect_uri: string;
+ };
}
interface AuthSessionOptions {
- secret: string;
- db: LibSQLDatabase;
- oidcUsersFile?: string;
- cookie: {
- name: string;
- secure: boolean;
- maxAge: number;
- domain?: string;
- };
+ secret: string;
+ db: LibSQLDatabase;
+ oidcUsersFile?: string;
+ cookie: {
+ name: string;
+ secure: boolean;
+ maxAge: number;
+ domain?: string;
+ };
}
class Sessionizer {
- private options: AuthSessionOptions;
+ private options: AuthSessionOptions;
- constructor(options: AuthSessionOptions) {
- this.options = options;
- }
+ constructor(options: AuthSessionOptions) {
+ this.options = options;
+ }
- // This throws on the assumption that auth is already checked correctly
- // on something that wraps the route calling auth. The top-level routes
- // that call this are wrapped with try/catch to handle the error.
- async auth(request: Request) {
- return decodeSession(request, this.options);
- }
+ // This throws on the assumption that auth is already checked correctly
+ // on something that wraps the route calling auth. The top-level routes
+ // that call this are wrapped with try/catch to handle the error.
+ async auth(request: Request) {
+ return decodeSession(request, this.options);
+ }
- async createSession(
- payload: JWTSession,
- maxAge = this.options.cookie.maxAge,
- ) {
- // TODO: What the hell is this garbage
- return createSession(payload, {
- ...this.options,
- cookie: {
- ...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,
+ cookie: {
+ ...this.options.cookie,
+ maxAge,
+ },
+ });
+ }
- async destroySession() {
- return destroySession(this.options);
- }
+ async destroySession() {
+ return destroySession(this.options);
+ }
- async roleForSubject(
- subject: string,
- ): Promise {
- const [user] = await this.options.db
- .select()
- .from(users)
- .where(eq(users.sub, subject))
- .limit(1);
+ async roleForSubject(subject: string): Promise {
+ const [user] = await this.options.db
+ .select()
+ .from(users)
+ .where(eq(users.sub, subject))
+ .limit(1);
- if (!user) {
- return;
- }
+ if (!user) {
+ return;
+ }
- // We need this in string form based on Object.keys of the roles
- for (const [key, value] of Object.entries(Roles)) {
- if (value === user.caps) {
- return key as keyof typeof Roles;
- }
- }
- }
+ // We need this in string form based on Object.keys of the roles
+ for (const [key, value] of Object.entries(Roles)) {
+ if (value === user.caps) {
+ return key as keyof typeof Roles;
+ }
+ }
+ }
- // Given an OR of capabilities, check if the session has the required
- // capabilities. If not, return false. Can throw since it calls auth()
- async check(request: Request, capabilities: Capabilities) {
- const session = await this.auth(request);
+ // Given an OR of capabilities, check if the session has the required
+ // capabilities. If not, return false. Can throw since it calls auth()
+ async check(request: Request, capabilities: Capabilities) {
+ const session = await this.auth(request);
- // 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') {
- return true;
- }
+ // 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") {
+ return true;
+ }
- const [user] = await this.options.db
- .select()
- .from(users)
- .where(eq(users.sub, session.user.subject))
- .limit(1);
+ const [user] = await this.options.db
+ .select()
+ .from(users)
+ .where(eq(users.sub, session.user.subject))
+ .limit(1);
- if (!user) {
- return false;
- }
+ if (!user) {
+ return false;
+ }
- return (capabilities & user.caps) === capabilities;
- }
+ return (capabilities & user.caps) === capabilities;
+ }
- // Updates the capabilities and roles of a subject
- async reassignSubject(subject: string, role: keyof typeof Roles) {
- // Check if we are owner
- const subjectRole = await this.roleForSubject(subject);
- if (subjectRole === 'owner') {
- return false;
- }
+ // 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") {
+ return false;
+ }
- await this.options.db
- .update(users)
- .set({
- caps: Roles[role],
- })
- .where(eq(users.sub, subject));
+ // Use upsert to handle users who exist in Headscale but haven't
+ // logged into Headplane yet (no DB record)
+ await this.options.db
+ .insert(users)
+ .values({
+ id: ulid(),
+ sub: subject,
+ caps: Roles[role],
+ onboarded: false,
+ })
+ .onConflictDoUpdate({
+ target: users.sub,
+ set: { caps: Roles[role] },
+ });
- return true;
- }
+ return true;
+ }
}
async function createSession(payload: JWTSession, options: AuthSessionOptions) {
- const now = Math.floor(Date.now() / 1000);
- const secret = createHash('sha256').update(options.secret, 'utf8').digest();
- const jwt = await new EncryptJWT({
- ...payload,
- })
- .setProtectedHeader({ alg: 'dir', enc: 'A256GCM', typ: 'JWT' })
- .setIssuedAt()
- .setExpirationTime(now + options.cookie.maxAge)
- .setIssuer('urn:tale:headplane')
- .setAudience('urn:tale:headplane')
- .setJti(ulid())
- .encrypt(secret);
+ const now = Math.floor(Date.now() / 1000);
+ const secret = createHash("sha256").update(options.secret, "utf8").digest();
+ const jwt = await new EncryptJWT({
+ ...payload,
+ })
+ .setProtectedHeader({ alg: "dir", enc: "A256GCM", typ: "JWT" })
+ .setIssuedAt()
+ .setExpirationTime(now + options.cookie.maxAge)
+ .setIssuer("urn:tale:headplane")
+ .setAudience("urn:tale:headplane")
+ .setJti(ulid())
+ .encrypt(secret);
- const cookie = createCookie(options.cookie.name, {
- ...options.cookie,
- path: __PREFIX__,
- });
+ const cookie = createCookie(options.cookie.name, {
+ ...options.cookie,
+ path: __PREFIX__,
+ });
- return cookie.serialize(jwt);
+ return cookie.serialize(jwt);
}
async function decodeSession(request: Request, options: AuthSessionOptions) {
- const cookieHeader = request.headers.get('cookie');
- if (cookieHeader === null) {
- throw new Error('No session cookie found');
- }
+ const cookieHeader = request.headers.get("cookie");
+ if (cookieHeader === null) {
+ throw new Error("No session cookie found");
+ }
- const cookie = createCookie(options.cookie.name, {
- ...options.cookie,
- path: __PREFIX__,
- });
+ const cookie = createCookie(options.cookie.name, {
+ ...options.cookie,
+ path: __PREFIX__,
+ });
- const cookieValue = (await cookie.parse(cookieHeader)) as string | null;
- if (cookieValue === null) {
- throw new Error('Session cookie is empty');
- }
+ const cookieValue = (await cookie.parse(cookieHeader)) as string | null;
+ if (cookieValue === null) {
+ throw new Error("Session cookie is empty");
+ }
- const secret = createHash('sha256').update(options.secret, 'utf8').digest();
- const { payload } = await jwtDecrypt(cookieValue, secret, {
- issuer: 'urn:tale:headplane',
- audience: 'urn:tale:headplane',
- });
+ const secret = createHash("sha256").update(options.secret, "utf8").digest();
+ const { payload } = await jwtDecrypt(cookieValue, secret, {
+ issuer: "urn:tale:headplane",
+ audience: "urn:tale:headplane",
+ });
- // Safe since we encode the session directly into the JWT
- return payload as unknown as JWTSession;
+ // Safe since we encode the session directly into the JWT
+ return payload as unknown as JWTSession;
}
async function destroySession(options: AuthSessionOptions) {
- const cookie = createCookie(options.cookie.name, {
- ...options.cookie,
- path: __PREFIX__,
- });
+ const cookie = createCookie(options.cookie.name, {
+ ...options.cookie,
+ path: __PREFIX__,
+ });
- return cookie.serialize('', {
- expires: new Date(0),
- });
+ return cookie.serialize("", {
+ expires: new Date(0),
+ });
}
export async function createSessionStorage(options: AuthSessionOptions) {
- if (options.oidcUsersFile) {
- await migrateUserDatabase(options.oidcUsersFile, options.db);
- }
+ if (options.oidcUsersFile) {
+ await migrateUserDatabase(options.oidcUsersFile, options.db);
+ }
- return new Sessionizer(options);
+ return new Sessionizer(options);
}
async function migrateUserDatabase(path: string, db: LibSQLDatabase) {
- const realPath = resolve(path);
+ const realPath = resolve(path);
- try {
- 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);
- return;
- }
+ try {
+ 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);
+ 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);
- 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);
+ 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;
- c: number;
- oo?: boolean;
- }[];
+ let migratableUsers: {
+ u: string;
+ c: number;
+ oo?: boolean;
+ }[];
- try {
- 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!',
- );
- await rm(realPath, { force: true });
- return;
- }
+ try {
+ 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!");
+ await rm(realPath, { force: true });
+ return;
+ }
- const users = JSON.parse(data.trim()) as {
- u?: string;
- c?: number;
- oo?: boolean;
- }[];
+ const users = JSON.parse(data.trim()) as {
+ u?: string;
+ c?: number;
+ oo?: boolean;
+ }[];
- 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');
- return;
- }
+ 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");
+ return;
+ }
- if (migratableUsers.length === 0) {
- log.info('config', 'No users found in the old database to migrate');
- return;
- }
+ if (migratableUsers.length === 0) {
+ 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)
- .values(
- migratableUsers.map((user) => ({
- id: ulid(),
- sub: user.u,
- caps: user.c,
- onboarded: user.oo ?? false,
- })),
- )
- .onConflictDoNothing({
- target: users.sub,
- })
- .returning();
+ const updated = await db
+ .insert(users)
+ .values(
+ migratableUsers.map((user) => ({
+ id: ulid(),
+ sub: user.u,
+ caps: user.c,
+ onboarded: user.oo ?? false,
+ })),
+ )
+ .onConflictDoNothing({
+ target: users.sub,
+ })
+ .returning();
- log.info('config', 'Migrated %d users successfully', updated.length);
- log.info('config', 'Removed old user database file %s', realPath);
- await rm(realPath, { force: true });
+ log.info("config", "Migrated %d users successfully", updated.length);
+ log.info("config", "Removed old user database file %s", realPath);
+ await rm(realPath, { force: true });
}
diff --git a/tests/unit/live-data/live-data.test.ts b/tests/unit/live-data/live-data.test.ts
new file mode 100644
index 0000000..bc2a529
--- /dev/null
+++ b/tests/unit/live-data/live-data.test.ts
@@ -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();
+ });
+});
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);
+ }
+ });
+ });
+});
diff --git a/tests/unit/sessions/sessions.test.ts b/tests/unit/sessions/sessions.test.ts
new file mode 100644
index 0000000..90700db
--- /dev/null
+++ b/tests/unit/sessions/sessions.test.ts
@@ -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) {
+ 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;
+ let client: ReturnType;
+
+ 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);
+ });
+ });
+});