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