From a6b25b3ba30609547401414296903aaed6a01b4e Mon Sep 17 00:00:00 2001 From: drifterza Date: Tue, 24 Feb 2026 15:22:50 +0200 Subject: [PATCH] add pending approval page for OIDC users without ui_access --- app/layouts/shell.tsx | 169 +++++++++------------- app/routes.ts | 67 ++++----- app/routes/auth/pending-approval.tsx | 117 +++++++++++++++ app/routes/users/components/user-row.tsx | 173 +++++++++++------------ tests/unit/roles/roles.test.ts | 81 +++++++++++ 5 files changed, 387 insertions(+), 220 deletions(-) create mode 100644 app/routes/auth/pending-approval.tsx create mode 100644 tests/unit/roles/roles.test.ts 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. -

-
- )} -