mirror of
https://github.com/tale/headplane.git
synced 2026-07-27 08:08:56 +00:00
Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 66c7d9a327 | |||
| f2747ada94 | |||
| 0ad578e651 | |||
| 8d1132606a | |||
| 5e332c4a5c |
@@ -1,3 +1,8 @@
|
||||
### 0.5.9 (April 3, 2025)
|
||||
- Filter out empty users from the pre-auth keys page which could possibly cause a crash with unmigrated users.
|
||||
- OIDC users cannot be renamed, so that functionality has been disabled in the menu options.
|
||||
- Suppress hydration errors for any fields with a date in it.
|
||||
|
||||
### 0.5.8 (April 3, 2025)
|
||||
- You can now skip the onboarding page if desired.
|
||||
- Added the UI to change user roles in the dashboard.
|
||||
|
||||
@@ -7,6 +7,7 @@ export interface AttributeProps {
|
||||
value: string;
|
||||
isCopyable?: boolean;
|
||||
link?: string;
|
||||
suppressHydrationWarning?: boolean;
|
||||
}
|
||||
|
||||
export default function Attribute({
|
||||
@@ -14,6 +15,7 @@ export default function Attribute({
|
||||
value,
|
||||
link,
|
||||
isCopyable,
|
||||
suppressHydrationWarning,
|
||||
}: AttributeProps) {
|
||||
return (
|
||||
<dl className="flex items-center w-full gap-x-1">
|
||||
@@ -27,6 +29,7 @@ export default function Attribute({
|
||||
)}
|
||||
</dt>
|
||||
<dd
|
||||
suppressHydrationWarning={suppressHydrationWarning}
|
||||
className={cn(
|
||||
'rounded-lg truncate w-full px-2.5 py-1 text-sm',
|
||||
'flex items-center gap-x-1',
|
||||
@@ -54,7 +57,12 @@ export default function Attribute({
|
||||
}, 1000);
|
||||
}}
|
||||
>
|
||||
<p className="truncate">{value}</p>
|
||||
<p
|
||||
suppressHydrationWarning={suppressHydrationWarning}
|
||||
className="truncate"
|
||||
>
|
||||
{value}
|
||||
</p>
|
||||
<Check className="h-4.5 w-4.5 p-1 hidden data-[copied]:block" />
|
||||
<Copy className="h-4.5 w-4.5 p-1 block data-[copied]:hidden" />
|
||||
</button>
|
||||
|
||||
+20
-6
@@ -17,6 +17,7 @@ import cn from '~/utils/cn';
|
||||
interface MenuProps extends MenuTriggerProps {
|
||||
placement?: Placement;
|
||||
isDisabled?: boolean;
|
||||
disabledKeys?: Key[];
|
||||
children: [
|
||||
React.ReactElement<ButtonProps> | React.ReactElement<IconButtonProps>,
|
||||
React.ReactElement<MenuPanelProps>,
|
||||
@@ -26,7 +27,7 @@ interface MenuProps extends MenuTriggerProps {
|
||||
// TODO: onAction is called twice for some reason?
|
||||
// TODO: isDisabled per-prop
|
||||
function Menu(props: MenuProps) {
|
||||
const { placement = 'bottom', isDisabled } = props;
|
||||
const { placement = 'bottom', isDisabled, disabledKeys = [] } = props;
|
||||
const state = useMenuTriggerState(props);
|
||||
const ref = useRef<HTMLButtonElement | null>(null);
|
||||
const { menuTriggerProps, menuProps } = useMenuTrigger<object>(
|
||||
@@ -51,6 +52,7 @@ function Menu(props: MenuProps) {
|
||||
...menuProps,
|
||||
autoFocus: state.focusStrategy ?? true,
|
||||
onClose: () => state.close(),
|
||||
disabledKeys,
|
||||
})}
|
||||
</Popover>
|
||||
)}
|
||||
@@ -60,6 +62,7 @@ function Menu(props: MenuProps) {
|
||||
|
||||
interface MenuPanelProps extends AriaMenuProps<object> {
|
||||
onClose?: () => void;
|
||||
disabledKeys?: Key[];
|
||||
}
|
||||
|
||||
function Panel(props: MenuPanelProps) {
|
||||
@@ -74,7 +77,12 @@ function Panel(props: MenuPanelProps) {
|
||||
className="pt-1 pb-1 shadow-xs rounded-md min-w-[200px] focus:outline-none"
|
||||
>
|
||||
{[...state.collection].map((item) => (
|
||||
<MenuSection key={item.key} section={item} state={state} />
|
||||
<MenuSection
|
||||
key={item.key}
|
||||
section={item}
|
||||
state={state}
|
||||
disabledKeys={props.disabledKeys}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
@@ -83,9 +91,10 @@ function Panel(props: MenuPanelProps) {
|
||||
interface MenuSectionProps<T> {
|
||||
section: Node<T>;
|
||||
state: TreeState<T>;
|
||||
disabledKeys?: Key[];
|
||||
}
|
||||
|
||||
function MenuSection<T>({ section, state }: MenuSectionProps<T>) {
|
||||
function MenuSection<T>({ section, state, disabledKeys }: MenuSectionProps<T>) {
|
||||
const { itemProps, groupProps } = useMenuSection({
|
||||
heading: section.rendered,
|
||||
'aria-label': section['aria-label'],
|
||||
@@ -109,7 +118,12 @@ function MenuSection<T>({ section, state }: MenuSectionProps<T>) {
|
||||
<li {...itemProps}>
|
||||
<ul {...groupProps}>
|
||||
{[...section.childNodes].map((item) => (
|
||||
<MenuItem key={item.key} item={item} state={state} />
|
||||
<MenuItem
|
||||
key={item.key}
|
||||
item={item}
|
||||
state={state}
|
||||
isDisabled={disabledKeys?.includes(item.key)}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
</li>
|
||||
@@ -120,14 +134,14 @@ function MenuSection<T>({ section, state }: MenuSectionProps<T>) {
|
||||
interface MenuItemProps<T> {
|
||||
item: Node<T>;
|
||||
state: TreeState<T>;
|
||||
isDisabled?: boolean;
|
||||
}
|
||||
|
||||
function MenuItem<T>({ item, state }: MenuItemProps<T>) {
|
||||
function MenuItem<T>({ item, state, isDisabled }: MenuItemProps<T>) {
|
||||
const ref = useRef<HTMLLIElement | null>(null);
|
||||
const { menuItemProps } = useMenuItem({ key: item.key }, state, ref);
|
||||
|
||||
const isFocused = state.selectionManager.focusedKey === item.key;
|
||||
const isDisabled = state.selectionManager.isDisabled(item.key);
|
||||
|
||||
return (
|
||||
<li
|
||||
|
||||
@@ -13,6 +13,7 @@ import { useRadioGroupState } from 'react-stately';
|
||||
|
||||
interface RadioGroupProps extends AriaRadioGroupProps {
|
||||
children: React.ReactElement<RadioProps>[];
|
||||
label: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
@@ -20,7 +21,13 @@ const RadioContext = createContext<RadioGroupState | null>(null);
|
||||
|
||||
function RadioGroup({ children, label, className, ...props }: RadioGroupProps) {
|
||||
const state = useRadioGroupState(props);
|
||||
const { radioGroupProps, labelProps } = useRadioGroup(props, state);
|
||||
const { radioGroupProps, labelProps } = useRadioGroup(
|
||||
{
|
||||
...props,
|
||||
'aria-label': label,
|
||||
},
|
||||
state,
|
||||
);
|
||||
|
||||
return (
|
||||
<div {...radioGroupProps} className={cn('flex flex-col gap-2', className)}>
|
||||
@@ -33,13 +40,21 @@ function RadioGroup({ children, label, className, ...props }: RadioGroupProps) {
|
||||
}
|
||||
|
||||
interface RadioProps extends AriaRadioProps {
|
||||
label: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function Radio({ children, className, ...props }: RadioProps) {
|
||||
function Radio({ children, label, className, ...props }: RadioProps) {
|
||||
const state = useContext(RadioContext);
|
||||
const ref = useRef(null);
|
||||
const { inputProps, isSelected, isDisabled } = useRadio(props, state!, ref);
|
||||
const { inputProps, isSelected, isDisabled } = useRadio(
|
||||
{
|
||||
...props,
|
||||
'aria-label': label,
|
||||
},
|
||||
state!,
|
||||
ref,
|
||||
);
|
||||
const { isFocusVisible, focusProps } = useFocusRing();
|
||||
|
||||
return (
|
||||
@@ -48,6 +63,7 @@ function Radio({ children, className, ...props }: RadioProps) {
|
||||
<input {...inputProps} {...focusProps} ref={ref} className="peer" />
|
||||
</VisuallyHidden>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
'w-5 h-5 aspect-square rounded-full p-1 border-2',
|
||||
'border border-headplane-600 dark:border-headplane-300',
|
||||
|
||||
+15
-5
@@ -7,13 +7,13 @@ import {
|
||||
} from 'react-router';
|
||||
import Button from '~/components/Button';
|
||||
import Card from '~/components/Card';
|
||||
import Code from '~/components/Code';
|
||||
import Footer from '~/components/Footer';
|
||||
import Header from '~/components/Header';
|
||||
import type { LoadContext } from '~/server';
|
||||
import { Capabilities } from '~/server/web/roles';
|
||||
import { User } from '~/types';
|
||||
import log from '~/utils/log';
|
||||
import toast from '~/utils/toast';
|
||||
|
||||
// This loads the bare minimum for the application to function
|
||||
// So we know that if context fails to load then well, oops?
|
||||
@@ -134,11 +134,21 @@ export default function Shell() {
|
||||
Connect to Tailscale with your devices to access this Tailnet. Use
|
||||
this command to help you get started:
|
||||
</Card.Text>
|
||||
<Button className="pointer-events-none text-md hover:bg-initial focus:ring-0">
|
||||
<Code className="pointer-events-auto bg-transparent" isCopyable>
|
||||
tailscale up --login-server={data.url}
|
||||
</Code>
|
||||
<Button
|
||||
className="flex text-md font-mono"
|
||||
onPress={async () => {
|
||||
await navigator.clipboard.writeText(
|
||||
`tailscale up --login-server=${data.url}`,
|
||||
);
|
||||
|
||||
toast('Copied to clipboard');
|
||||
}}
|
||||
>
|
||||
tailscale up --login-server={data.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.
|
||||
|
||||
@@ -180,7 +180,7 @@ export default function MachineRow({
|
||||
isOnline={machine.online && !expired}
|
||||
className="w-4 h-4"
|
||||
/>
|
||||
<p>
|
||||
<p suppressHydrationWarning>
|
||||
{machine.online && !expired
|
||||
? 'Connected'
|
||||
: new Date(machine.lastSeen).toLocaleString()}
|
||||
|
||||
@@ -310,14 +310,17 @@ export default function Page() {
|
||||
<Attribute name="Hostname" value={machine.name} />
|
||||
<Attribute isCopyable name="Node Key" value={machine.nodeKey} />
|
||||
<Attribute
|
||||
suppressHydrationWarning
|
||||
name="Created"
|
||||
value={new Date(machine.createdAt).toLocaleString()}
|
||||
/>
|
||||
<Attribute
|
||||
suppressHydrationWarning
|
||||
name="Last Seen"
|
||||
value={new Date(machine.lastSeen).toLocaleString()}
|
||||
/>
|
||||
<Attribute
|
||||
suppressHydrationWarning
|
||||
name="Expiry"
|
||||
value={expired ? new Date(machine.expiry).toLocaleString() : 'Never'}
|
||||
/>
|
||||
|
||||
@@ -22,15 +22,17 @@ export async function loader({
|
||||
);
|
||||
|
||||
const preAuthKeys = await Promise.all(
|
||||
users.users.map((user) => {
|
||||
const qp = new URLSearchParams();
|
||||
qp.set('user', user.name);
|
||||
users.users
|
||||
.filter((user) => user.name?.length > 0) // Filter out any invalid users
|
||||
.map((user) => {
|
||||
const qp = new URLSearchParams();
|
||||
qp.set('user', user.name);
|
||||
|
||||
return context.client.get<{ preAuthKeys: PreAuthKey[] }>(
|
||||
`v1/preauthkey?${qp.toString()}`,
|
||||
session.get('api_key')!,
|
||||
);
|
||||
}),
|
||||
return context.client.get<{ preAuthKeys: PreAuthKey[] }>(
|
||||
`v1/preauthkey?${qp.toString()}`,
|
||||
session.get('api_key')!,
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
return {
|
||||
|
||||
@@ -22,15 +22,19 @@ export default function AuthKeyRow({ authKey, server }: Props) {
|
||||
<Attribute name="Reusable" value={authKey.reusable ? 'Yes' : 'No'} />
|
||||
<Attribute name="Ephemeral" value={authKey.ephemeral ? 'Yes' : 'No'} />
|
||||
<Attribute name="Used" value={authKey.used ? 'Yes' : 'No'} />
|
||||
<Attribute name="Created" value={createdAt} />
|
||||
<Attribute name="Expiration" value={expiration} />
|
||||
<Attribute suppressHydrationWarning name="Created" value={createdAt} />
|
||||
<Attribute
|
||||
suppressHydrationWarning
|
||||
name="Expiration"
|
||||
value={expiration}
|
||||
/>
|
||||
<p className="mb-1 mt-4">
|
||||
To use this key, run the following command on your device:
|
||||
</p>
|
||||
<Code className="text-sm">
|
||||
tailscale up --login-server {server} --authkey {authKey.key}
|
||||
</Code>
|
||||
<div className="flex gap-4 items-center">
|
||||
<div suppressHydrationWarning className="flex gap-4 items-center">
|
||||
{(authKey.used && !authKey.reusable) ||
|
||||
new Date(authKey.expiration) < new Date() ? undefined : (
|
||||
<ExpireKey authKey={authKey} />
|
||||
|
||||
@@ -48,7 +48,7 @@ export default function UserMenu({ user }: MenuProps) {
|
||||
/>
|
||||
)}
|
||||
|
||||
<Menu>
|
||||
<Menu disabledKeys={user.provider === 'oidc' ? ['rename'] : []}>
|
||||
<Menu.IconButton
|
||||
label="Machine Options"
|
||||
className={cn(
|
||||
|
||||
@@ -42,7 +42,10 @@ export default function UserRow({ user, role }: UserRowProps) {
|
||||
<p>{mapRoleToName(role)}</p>
|
||||
</td>
|
||||
<td className="pl-0.5 py-2">
|
||||
<p className="text-sm text-headplane-600 dark:text-headplane-300">
|
||||
<p
|
||||
suppressHydrationWarning
|
||||
className="text-sm text-headplane-600 dark:text-headplane-300"
|
||||
>
|
||||
{new Date(user.createdAt).toLocaleDateString()}
|
||||
</p>
|
||||
</td>
|
||||
@@ -54,7 +57,9 @@ export default function UserRow({ user, role }: UserRowProps) {
|
||||
)}
|
||||
>
|
||||
<StatusCircle isOnline={isOnline} className="w-4 h-4" />
|
||||
<p>{isOnline ? 'Connected' : new Date(lastSeen).toLocaleString()}</p>
|
||||
<p suppressHydrationWarning>
|
||||
{isOnline ? 'Connected' : new Date(lastSeen).toLocaleString()}
|
||||
</p>
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-2 pr-0.5">
|
||||
|
||||
@@ -51,7 +51,7 @@ export default function ReassignUser({
|
||||
.map((role) => {
|
||||
const { name, desc } = mapRoleToName(role);
|
||||
return (
|
||||
<RadioGroup.Radio key={role} value={role}>
|
||||
<RadioGroup.Radio key={role} value={role} label={name}>
|
||||
<div className="block">
|
||||
<p className="font-bold">{name}</p>
|
||||
<p className="opacity-70">{desc}</p>
|
||||
|
||||
@@ -12,7 +12,6 @@ import {
|
||||
} from 'react-router';
|
||||
import Button from '~/components/Button';
|
||||
import Card from '~/components/Card';
|
||||
import Code from '~/components/Code';
|
||||
import Link from '~/components/Link';
|
||||
import Options from '~/components/Options';
|
||||
import StatusCircle from '~/components/StatusCircle';
|
||||
@@ -21,6 +20,7 @@ import { Machine } from '~/types';
|
||||
import cn from '~/utils/cn';
|
||||
import { useLiveData } from '~/utils/live-data';
|
||||
import log from '~/utils/log';
|
||||
import toast from '~/utils/toast';
|
||||
|
||||
export async function loader({
|
||||
request,
|
||||
@@ -152,20 +152,19 @@ export default function Page() {
|
||||
}
|
||||
>
|
||||
<Button
|
||||
variant="heavy"
|
||||
className={cn(
|
||||
'my-4 px-0 w-full pointer-events-none',
|
||||
'hover:bg-initial focus:ring-0',
|
||||
)}
|
||||
className="flex text-md font-mono"
|
||||
onPress={async () => {
|
||||
await navigator.clipboard.writeText(
|
||||
'curl -fsSL https://tailscale.com/install.sh | sh',
|
||||
);
|
||||
|
||||
toast('Copied to clipboard');
|
||||
}}
|
||||
>
|
||||
<Code
|
||||
isCopyable
|
||||
className="bg-transparent pointer-events-auto mx-0"
|
||||
>
|
||||
curl -fsSL https://tailscale.com/install.sh | sh
|
||||
</Code>
|
||||
curl -fsSL https://tailscale.com/install.sh | sh
|
||||
</Button>
|
||||
<p className="text-end text-sm">
|
||||
<p className="text-xs mt-1 text-headplane-600 dark:text-headplane-300 text-center">
|
||||
Click this button to copy the command.{' '}
|
||||
<Link
|
||||
name="Linux installation script"
|
||||
to="https://github.com/tailscale/tailscale/blob/main/scripts/installer.sh"
|
||||
|
||||
@@ -3,6 +3,7 @@ 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';
|
||||
|
||||
export async function userAction({
|
||||
request,
|
||||
@@ -11,14 +12,14 @@ export async function userAction({
|
||||
const session = await context.sessions.auth(request);
|
||||
const check = await context.sessions.check(request, Capabilities.write_users);
|
||||
if (!check) {
|
||||
return data({ success: false }, 403);
|
||||
throw data403('You do not have permission to update users');
|
||||
}
|
||||
|
||||
const apiKey = session.get('api_key')!;
|
||||
const formData = await request.formData();
|
||||
const action = formData.get('action_id')?.toString();
|
||||
if (!action) {
|
||||
return data({ success: false }, 400);
|
||||
throw data400('Missing `action_id` in the form data.');
|
||||
}
|
||||
|
||||
switch (action) {
|
||||
@@ -31,7 +32,7 @@ export async function userAction({
|
||||
case 'reassign_user':
|
||||
return reassignUser(formData, apiKey, context, session);
|
||||
default:
|
||||
return data({ success: false }, 400);
|
||||
throw data400('Invalid `action_id` provided.');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,7 +46,7 @@ async function createUser(
|
||||
const email = formData.get('email')?.toString();
|
||||
|
||||
if (!name) {
|
||||
return data({ success: false }, 400);
|
||||
throw data400('Missing `username` in the form data.');
|
||||
}
|
||||
|
||||
await context.client.post('v1/user', apiKey, {
|
||||
@@ -62,7 +63,7 @@ async function deleteUser(
|
||||
) {
|
||||
const userId = formData.get('user_id')?.toString();
|
||||
if (!userId) {
|
||||
return data({ success: false }, 400);
|
||||
throw data400('Missing `user_id` in the form data.');
|
||||
}
|
||||
|
||||
await context.client.delete(`v1/user/${userId}`, apiKey);
|
||||
@@ -79,6 +80,21 @@ async function renameUser(
|
||||
return data({ success: false }, 400);
|
||||
}
|
||||
|
||||
const { users } = await context.client.get<{ users: User[] }>(
|
||||
'v1/user',
|
||||
apiKey,
|
||||
);
|
||||
|
||||
const user = users.find((user) => user.id === userId);
|
||||
if (!user) {
|
||||
throw data400(`No user found with id: ${userId}`);
|
||||
}
|
||||
|
||||
if (user.provider === 'oidc') {
|
||||
// OIDC users cannot be renamed via this endpoint, return an error
|
||||
throw data403('Users managed by OIDC cannot be renamed');
|
||||
}
|
||||
|
||||
await context.client.post(`v1/user/${userId}/rename/${newName}`, apiKey);
|
||||
}
|
||||
|
||||
@@ -86,12 +102,11 @@ async function reassignUser(
|
||||
formData: FormData,
|
||||
apiKey: string,
|
||||
context: LoadContext,
|
||||
session: Session<AuthSession, unknown>,
|
||||
) {
|
||||
const userId = formData.get('user_id')?.toString();
|
||||
const newRole = formData.get('new_role')?.toString();
|
||||
if (!userId || !newRole) {
|
||||
return data({ success: false }, 400);
|
||||
throw data400('Missing `user_id` or `new_role` in the form data.');
|
||||
}
|
||||
|
||||
const { users } = await context.client.get<{ users: User[] }>(
|
||||
@@ -101,14 +116,16 @@ async function reassignUser(
|
||||
|
||||
const user = users.find((user) => user.id === userId);
|
||||
if (!user?.providerId) {
|
||||
return data({ success: false }, 400);
|
||||
throw data400('Specified user is not an OIDC user');
|
||||
}
|
||||
|
||||
// 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 data({ success: false }, 400);
|
||||
throw data400(
|
||||
'Malformed `providerId` for the specified user. Cannot find subject.',
|
||||
);
|
||||
}
|
||||
|
||||
const result = await context.sessions.reassignSubject(
|
||||
|
||||
@@ -34,7 +34,7 @@ Here is what a sample Docker Compose deployment would look like:
|
||||
services:
|
||||
headplane:
|
||||
# I recommend you pin the version to a specific release
|
||||
image: ghcr.io/tale/headplane:0.5.8
|
||||
image: ghcr.io/tale/headplane:0.5.9
|
||||
container_name: headplane
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
@@ -151,7 +151,7 @@ spec:
|
||||
serviceAccountName: default
|
||||
containers:
|
||||
- name: headplane
|
||||
image: ghcr.io/tale/headplane:0.5.8
|
||||
image: ghcr.io/tale/headplane:0.5.9
|
||||
env:
|
||||
# Set these if the pod name for Headscale is not static
|
||||
# We will use the downward API to get the pod name instead
|
||||
|
||||
+1
-1
@@ -19,7 +19,7 @@ Here is what a sample Docker Compose deployment would look like:
|
||||
services:
|
||||
headplane:
|
||||
# I recommend you pin the version to a specific release
|
||||
image: ghcr.io/tale/headplane:0.5.8
|
||||
image: ghcr.io/tale/headplane:0.5.9
|
||||
container_name: headplane
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
"name": "headplane",
|
||||
"private": true,
|
||||
"sideEffects": false,
|
||||
"version": "0.5.8",
|
||||
"version": "0.5.9",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "react-router build",
|
||||
|
||||
Reference in New Issue
Block a user