Compare commits

...

5 Commits

Author SHA1 Message Date
Aarnav Tale 66c7d9a327 chore: v0.5.9 2025-04-03 23:24:13 -04:00
Aarnav Tale f2747ada94 fix: suppress date and button hydration warnings 2025-04-03 16:54:12 -04:00
Aarnav Tale 0ad578e651 fix: prevent user rename if they are an oidc user 2025-04-03 16:25:30 -04:00
Aarnav Tale 8d1132606a fix: add aria-label to radio groups 2025-04-03 16:22:52 -04:00
Aarnav Tale 5e332c4a5c fix: filter out empty users in auth-keys, potential headscale bug 2025-04-03 16:10:39 -04:00
17 changed files with 140 additions and 57 deletions
+5
View File
@@ -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.
+9 -1
View File
@@ -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
View File
@@ -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
+19 -3
View File
@@ -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
View File
@@ -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()}
+3
View File
@@ -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'}
/>
+10 -8
View File
@@ -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 {
+7 -3
View File
@@ -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} />
+1 -1
View File
@@ -48,7 +48,7 @@ export default function UserMenu({ user }: MenuProps) {
/>
)}
<Menu>
<Menu disabledKeys={user.provider === 'oidc' ? ['rename'] : []}>
<Menu.IconButton
label="Machine Options"
className={cn(
+7 -2
View File
@@ -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">
+1 -1
View File
@@ -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 -13
View File
@@ -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"
+26 -9
View File
@@ -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(
+2 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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",