mirror of
https://github.com/tale/headplane.git
synced 2026-08-31 01:09:25 +00:00
feat: completely overhaul the auth model
* Cookies are now encrypted JWTs (GHSA-wrqq-v7qw-r5w7) * Authentication is stored in the SQLite database (auto-migrated) * Session logic is much cleaner
This commit is contained in:
@@ -1,16 +1,23 @@
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { LoaderFunctionArgs, redirect } from 'react-router';
|
||||
import { LoadContext } from '~/server';
|
||||
import { users } from '~/server/db/schema';
|
||||
|
||||
export async function loader({
|
||||
request,
|
||||
context,
|
||||
}: LoaderFunctionArgs<LoadContext>) {
|
||||
const session = await context.sessions.auth(request);
|
||||
const user = session.get('user');
|
||||
if (!user) {
|
||||
try {
|
||||
const { user } = await context.sessions.auth(request);
|
||||
await context.db
|
||||
.update(users)
|
||||
.set({
|
||||
onboarded: true,
|
||||
})
|
||||
.where(eq(users.sub, user.subject));
|
||||
|
||||
return redirect('/machines');
|
||||
} catch {
|
||||
return redirect('/login');
|
||||
}
|
||||
|
||||
context.sessions.overrideOnboarding(user.subject, true);
|
||||
return redirect('/machines');
|
||||
}
|
||||
|
||||
@@ -4,12 +4,7 @@ import { GrApple } from 'react-icons/gr';
|
||||
import { ImFinder } from 'react-icons/im';
|
||||
import { MdAndroid } from 'react-icons/md';
|
||||
import { PiTerminalFill, PiWindowsLogoFill } from 'react-icons/pi';
|
||||
import {
|
||||
LoaderFunctionArgs,
|
||||
NavLink,
|
||||
redirect,
|
||||
useLoaderData,
|
||||
} from 'react-router';
|
||||
import { LoaderFunctionArgs, NavLink, useLoaderData } from 'react-router';
|
||||
import Button from '~/components/Button';
|
||||
import Card from '~/components/Card';
|
||||
import Link from '~/components/Link';
|
||||
@@ -27,10 +22,6 @@ export async function loader({
|
||||
context,
|
||||
}: LoaderFunctionArgs<LoadContext>) {
|
||||
const session = await context.sessions.auth(request);
|
||||
const user = session.get('user');
|
||||
if (!user) {
|
||||
return redirect('/login');
|
||||
}
|
||||
|
||||
// Try to determine the OS split between Linux, Windows, macOS, iOS, and Android
|
||||
// We need to convert this to a known value to return it to the client so we can
|
||||
@@ -60,11 +51,11 @@ export async function loader({
|
||||
break;
|
||||
}
|
||||
|
||||
let firstMachine: Machine | undefined = undefined;
|
||||
let firstMachine: Machine | undefined;
|
||||
try {
|
||||
const { nodes } = await context.client.get<{ nodes: Machine[] }>(
|
||||
'v1/node',
|
||||
session.get('api_key')!,
|
||||
session.api_key,
|
||||
);
|
||||
|
||||
const node = nodes.find((n) => {
|
||||
@@ -79,12 +70,7 @@ export async function loader({
|
||||
return false;
|
||||
}
|
||||
|
||||
const sessionUser = session.get('user');
|
||||
if (!sessionUser) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (subject !== sessionUser.subject) {
|
||||
if (subject !== session.user.subject) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -98,7 +84,7 @@ export async function loader({
|
||||
}
|
||||
|
||||
return {
|
||||
user,
|
||||
user: session.user,
|
||||
osValue,
|
||||
firstMachine,
|
||||
};
|
||||
@@ -126,7 +112,7 @@ export default function Page() {
|
||||
return (
|
||||
<div className="fixed w-full h-screen flex items-center px-4">
|
||||
<div className="w-fit mx-auto grid grid-cols-1 md:grid-cols-2 gap-4 mb-24">
|
||||
<Card variant="flat" className="max-w-lg">
|
||||
<Card className="max-w-lg" variant="flat">
|
||||
<Card.Title className="mb-8">
|
||||
Welcome!
|
||||
<br />
|
||||
@@ -138,9 +124,9 @@ export default function Page() {
|
||||
</Card.Text>
|
||||
|
||||
<Options
|
||||
className="my-4"
|
||||
defaultSelectedKey={osValue}
|
||||
label="Download Selector"
|
||||
className="my-4"
|
||||
>
|
||||
<Options.Item
|
||||
key="linux"
|
||||
@@ -183,12 +169,12 @@ export default function Page() {
|
||||
}
|
||||
>
|
||||
<a
|
||||
href="https://pkgs.tailscale.com/stable/tailscale-setup-latest.exe"
|
||||
aria-label="Download for Windows"
|
||||
target="_blank"
|
||||
href="https://pkgs.tailscale.com/stable/tailscale-setup-latest.exe"
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
<Button variant="heavy" className="my-4 w-full">
|
||||
<Button className="my-4 w-full" variant="heavy">
|
||||
Download for Windows
|
||||
</Button>
|
||||
</a>
|
||||
@@ -206,12 +192,12 @@ export default function Page() {
|
||||
}
|
||||
>
|
||||
<a
|
||||
href="https://pkgs.tailscale.com/stable/Tailscale-latest-macos.pkg"
|
||||
aria-label="Download for macOS"
|
||||
target="_blank"
|
||||
href="https://pkgs.tailscale.com/stable/Tailscale-latest-macos.pkg"
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
<Button variant="heavy" className="my-4 w-full">
|
||||
<Button className="my-4 w-full" variant="heavy">
|
||||
Download for macOS
|
||||
</Button>
|
||||
</a>
|
||||
@@ -238,12 +224,12 @@ export default function Page() {
|
||||
}
|
||||
>
|
||||
<a
|
||||
href="https://apps.apple.com/us/app/tailscale/id1470499037"
|
||||
aria-label="Download for iOS"
|
||||
target="_blank"
|
||||
href="https://apps.apple.com/us/app/tailscale/id1470499037"
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
<Button variant="heavy" className="my-4 w-full">
|
||||
<Button className="my-4 w-full" variant="heavy">
|
||||
Download for iOS
|
||||
</Button>
|
||||
</a>
|
||||
@@ -261,12 +247,12 @@ export default function Page() {
|
||||
}
|
||||
>
|
||||
<a
|
||||
href="https://play.google.com/store/apps/details?id=com.tailscale.ipn"
|
||||
aria-label="Download for Android"
|
||||
target="_blank"
|
||||
href="https://play.google.com/store/apps/details?id=com.tailscale.ipn"
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
<Button variant="heavy" className="my-4 w-full">
|
||||
<Button className="my-4 w-full" variant="heavy">
|
||||
Download for Android
|
||||
</Button>
|
||||
</a>
|
||||
@@ -287,8 +273,8 @@ export default function Page() {
|
||||
<div className="border border-headplane-100 dark:border-headplane-800 rounded-xl p-4">
|
||||
<div className="flex items-start gap-4">
|
||||
<StatusCircle
|
||||
isOnline={firstMachine.online}
|
||||
className="size-6 mt-3"
|
||||
isOnline={firstMachine.online}
|
||||
/>
|
||||
<div>
|
||||
<p className="font-semibold leading-snug">
|
||||
@@ -300,7 +286,7 @@ export default function Page() {
|
||||
<div className="mt-6">
|
||||
<p className="text-sm font-semibold">IP Addresses</p>
|
||||
{firstMachine.ipAddresses.map((ip) => (
|
||||
<p key={ip} className="text-xs font-mono opacity-50">
|
||||
<p className="text-xs font-mono opacity-50" key={ip}>
|
||||
{ip}
|
||||
</p>
|
||||
))}
|
||||
@@ -308,8 +294,8 @@ export default function Page() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<NavLink to="/">
|
||||
<Button variant="heavy" className="w-full">
|
||||
<NavLink to="/onboarding/skip">
|
||||
<Button className="w-full" variant="heavy">
|
||||
Continue
|
||||
</Button>
|
||||
</NavLink>
|
||||
@@ -335,7 +321,7 @@ export default function Page() {
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
<NavLink to="/onboarding/skip" className="col-span-2 w-max mx-auto">
|
||||
<NavLink className="col-span-2 w-max mx-auto" to="/onboarding/skip">
|
||||
<Button className="flex items-center gap-1">
|
||||
I already know what I'm doing
|
||||
<ArrowRight className="p-1" />
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import type { ActionFunctionArgs, LoaderFunctionArgs } from 'react-router';
|
||||
import { useLoaderData, useSubmit } from 'react-router';
|
||||
import { useLoaderData } from 'react-router';
|
||||
import type { LoadContext } from '~/server';
|
||||
import { Capabilities } from '~/server/web/roles';
|
||||
import { Machine, User } from '~/types';
|
||||
@@ -32,11 +32,8 @@ export async function loader({
|
||||
);
|
||||
|
||||
const [machines, apiUsers] = await Promise.all([
|
||||
context.client.get<{ nodes: Machine[] }>(
|
||||
'v1/node',
|
||||
session.get('api_key')!,
|
||||
),
|
||||
context.client.get<{ users: User[] }>('v1/user', session.get('api_key')!),
|
||||
context.client.get<{ nodes: Machine[] }>('v1/node', session.api_key),
|
||||
context.client.get<{ users: User[] }>('v1/user', session.api_key),
|
||||
]);
|
||||
|
||||
const users = apiUsers.users.map((user) => ({
|
||||
@@ -44,30 +41,32 @@ export async function loader({
|
||||
machines: machines.nodes.filter((machine) => machine.user.id === user.id),
|
||||
}));
|
||||
|
||||
const roles = users
|
||||
.sort((a, b) => a.name.localeCompare(b.name))
|
||||
.map((user) => {
|
||||
if (user.provider !== 'oidc') {
|
||||
return 'no-oidc';
|
||||
}
|
||||
|
||||
if (user.provider === 'oidc' && user.providerId) {
|
||||
// For some reason, headscale makes providerID a url where the
|
||||
// last component is the subject, so we need to strip that out
|
||||
const subject = user.providerId.split('/').pop();
|
||||
if (!subject) {
|
||||
return 'invalid-oidc';
|
||||
const roles = await Promise.all(
|
||||
users
|
||||
.sort((a, b) => a.name.localeCompare(b.name))
|
||||
.map(async (user) => {
|
||||
if (user.provider !== 'oidc') {
|
||||
return 'no-oidc';
|
||||
}
|
||||
|
||||
const role = context.sessions.roleForSubject(subject);
|
||||
return role ?? 'no-role';
|
||||
}
|
||||
if (user.provider === 'oidc' && user.providerId) {
|
||||
// For some reason, headscale makes providerID a url where the
|
||||
// last component is the subject, so we need to strip that out
|
||||
const subject = user.providerId.split('/').pop();
|
||||
if (!subject) {
|
||||
return 'invalid-oidc';
|
||||
}
|
||||
|
||||
// No role means the user is not registered in Headplane, but they
|
||||
// are in Headscale. We also need to handle what happens if someone
|
||||
// logs into the UI and they don't have a Headscale setup.
|
||||
return 'no-role';
|
||||
});
|
||||
const role = await context.sessions.roleForSubject(subject);
|
||||
return role ?? 'no-role';
|
||||
}
|
||||
|
||||
// No role means the user is not registered in Headplane, but they
|
||||
// are in Headscale. We also need to handle what happens if someone
|
||||
// logs into the UI and they don't have a Headscale setup.
|
||||
return 'no-role';
|
||||
}),
|
||||
);
|
||||
|
||||
let magic: string | undefined;
|
||||
if (context.hs.readable()) {
|
||||
@@ -107,7 +106,7 @@ export default function Page() {
|
||||
<p className="mb-8 text-md">
|
||||
Manage the users in your network and their permissions.
|
||||
</p>
|
||||
<ManageBanner oidc={data.oidc} isDisabled={!data.writable} />
|
||||
<ManageBanner isDisabled={!data.writable} oidc={data.oidc} />
|
||||
<table className="table-auto w-full rounded-lg">
|
||||
<thead className="text-headplane-600 dark:text-headplane-300">
|
||||
<tr className="text-left px-0.5">
|
||||
@@ -128,8 +127,8 @@ export default function Page() {
|
||||
.map((user) => (
|
||||
<UserRow
|
||||
key={user.id}
|
||||
user={user}
|
||||
role={data.roles[users.indexOf(user)]}
|
||||
user={user}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { ActionFunctionArgs, Session, data } from 'react-router';
|
||||
import { ActionFunctionArgs, data } from 'react-router';
|
||||
import type { LoadContext } from '~/server';
|
||||
import { Capabilities, Roles } from '~/server/web/roles';
|
||||
import { AuthSession } from '~/server/web/sessions';
|
||||
import { User } from '~/types';
|
||||
import { data400, data403 } from '~/utils/res';
|
||||
|
||||
@@ -15,7 +14,7 @@ export async function userAction({
|
||||
throw data403('You do not have permission to update users');
|
||||
}
|
||||
|
||||
const apiKey = session.get('api_key')!;
|
||||
const apiKey = session.api_key;
|
||||
const formData = await request.formData();
|
||||
const action = formData.get('action_id')?.toString();
|
||||
if (!action) {
|
||||
|
||||
Reference in New Issue
Block a user