feat(ui): move settings dialogs to action dialog

This commit is contained in:
Aarnav Tale
2026-10-14 14:27:28 -07:00
parent 35f592c945
commit d6bcc8dfd6
18 changed files with 471 additions and 920 deletions
+40 -26
View File
@@ -27,6 +27,8 @@ export interface ActionDialogProps {
onOpenChange?: (open: boolean) => void;
trigger?: React.ReactElement;
children?: React.ReactNode;
// Replaces the form after a successful submit instead of closing.
success?: (data: Record<string, string>) => React.ReactNode;
}
export default function ActionDialog({
@@ -42,6 +44,7 @@ export default function ActionDialog({
onOpenChange,
trigger,
children,
success,
}: ActionDialogProps) {
const fetcher = useFetcher<ActionResult>();
const [internalOpen, setInternalOpen] = useState(false);
@@ -53,7 +56,7 @@ export default function ActionDialog({
}
const result = hasSubmitted && !isPending ? fetcher.data : undefined;
const isDone = result?.ok === true;
const isDone = result?.ok === true && !success;
const errors = result?.ok === false ? result.errors : undefined;
function handleOpenChange(next: boolean) {
@@ -88,32 +91,43 @@ export default function ActionDialog({
open={(open ?? internalOpen) && !isDone}
trigger={trigger}
>
<DialogTitle>{title}</DialogTitle>
{description && <DialogDescription>{description}</DialogDescription>}
<Form
className="flex flex-col gap-4"
errors={errors}
render={<fetcher.Form action={action} method="post" />}
validationMode="onBlur"
>
<input name="intent" type="hidden" value={intent} />
{Object.entries(fields ?? {}).map(([name, value]) => (
<input key={name} name={name} type="hidden" value={value} />
))}
{children}
{errors?.form && <Callout variant="error">{errors.form}</Callout>}
<DialogActions>
<DialogClose disabled={isPending} render={<Button>Cancel</Button>} />
<Button
disabled={isDisabled}
pending={isPending}
type="submit"
variant={isDestructive ? "danger" : "primary"}
{result?.ok && success ? (
<>
{success(result.data ?? {})}
<DialogActions>
<DialogClose render={<Button>Done</Button>} />
</DialogActions>
</>
) : (
<>
<DialogTitle>{title}</DialogTitle>
{description && <DialogDescription>{description}</DialogDescription>}
<Form
className="flex flex-col gap-4"
errors={errors}
render={<fetcher.Form action={action} method="post" />}
validationMode="onBlur"
>
{submitLabel}
</Button>
</DialogActions>
</Form>
<input name="intent" type="hidden" value={intent} />
{Object.entries(fields ?? {}).map(([name, value]) => (
<input key={name} name={name} type="hidden" value={value} />
))}
{children}
{errors?.form && <Callout variant="error">{errors.form}</Callout>}
<DialogActions>
<DialogClose disabled={isPending} render={<Button>Cancel</Button>} />
<Button
disabled={isDisabled}
pending={isPending}
type="submit"
variant={isDestructive ? "danger" : "primary"}
>
{submitLabel}
</Button>
</DialogActions>
</Form>
</>
)}
</Root>
);
}
+17
View File
@@ -1,4 +1,5 @@
import { Switch as BaseSwitch } from "@base-ui/react/switch";
import type React from "react";
import cn from "~/utils/cn";
@@ -39,3 +40,19 @@ export default function Switch(props: SwitchProps) {
</BaseSwitch.Root>
);
}
interface SwitchFieldProps extends SwitchProps {
description?: React.ReactNode;
}
export function SwitchField({ label, description, ...props }: SwitchFieldProps) {
return (
<label className="flex items-center justify-between gap-4">
<span>
<span className="block font-medium">{label}</span>
{description && <span className="text-muted block">{description}</span>}
</span>
<Switch label={label} {...props} />
</label>
);
}
-181
View File
@@ -1,181 +0,0 @@
import { type, type Type } from "arktype";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
type FormValue = string | number | boolean | null;
interface FormState {
values: Record<string, FormValue>;
errors: Record<string, string>;
modified: Record<string, boolean>;
}
export interface FormFieldProps {
name: string;
value: string;
onChange: (value: string) => void;
onBlur: () => void;
invalid: boolean;
errorMessage?: string;
}
interface UseFormOptions<T extends Record<string, unknown>> {
schema: Type<T>;
defaultValues?: Partial<Record<keyof T & string, FormValue>>;
actionData?: unknown;
validate?: (values: Record<string, FormValue>) => Record<string, string> | undefined;
}
// arktype's Type<T> doesn't expose `.props` in the type system,
// but it exists at runtime. This extracts schema keys safely.
function schemaKeys(schema: Type): string[] {
const props = (schema as unknown as { props?: { key: string }[] }).props;
return props?.map((p) => p.key) ?? [];
}
export function useForm<T extends Record<string, unknown>>(options: UseFormOptions<T>) {
const { schema, actionData, validate } = options;
const keys = schemaKeys(schema);
const validateRef = useRef(validate);
validateRef.current = validate;
const initialValues = useMemo(() => {
const values: Record<string, FormValue> = {};
for (const key of keys) {
values[key] =
options.defaultValues && key in options.defaultValues
? (options.defaultValues[key as keyof T & string] as FormValue)
: "";
}
return values;
}, []);
const [state, setState] = useState<FormState>({
values: initialValues,
errors: {},
modified: {},
});
useEffect(() => {
if (actionData && typeof actionData === "object" && "errors" in actionData) {
const errors = actionData.errors as Record<string, string>;
setState((prev) => ({
...prev,
errors: { ...prev.errors, ...errors },
}));
}
}, [actionData]);
const setValue = useCallback((name: keyof T & string, raw: FormValue) => {
setState((prev) => ({
...prev,
values: { ...prev.values, [name]: raw },
modified: { ...prev.modified, [name]: true },
}));
}, []);
function validateField(name: string) {
if (!state.modified[name]) {
return;
}
const errors: Record<string, string> = {};
// Schema validation
const result = schema({ ...state.values } as never);
if (result instanceof type.errors) {
const fieldProblems = result.flatProblemsByPath[name];
if (fieldProblems) {
errors[name] = fieldProblems[0];
}
}
// Cross-field validation
if (!errors[name]) {
const customErrors = validateRef.current?.(state.values);
if (customErrors?.[name]) {
errors[name] = customErrors[name];
}
}
setState((prev) => ({
...prev,
errors: {
...prev.errors,
[name]: errors[name] ?? "",
},
}));
}
function field(name: keyof T & string): FormFieldProps {
const error = state.errors[name];
return {
name,
value: String(state.values[name] ?? ""),
onChange: (value: string) => setValue(name, value),
onBlur: () => validateField(name),
invalid: !!error,
errorMessage: error || undefined,
};
}
function computeCanSubmit(): boolean {
const result = schema({ ...state.values } as never);
if (result instanceof type.errors) {
return false;
}
const customErrors = validateRef.current?.(state.values);
if (customErrors && Object.values(customErrors).some(Boolean)) {
return false;
}
return true;
}
const reset = useCallback(() => {
setState({
values: { ...initialValues },
errors: {},
modified: {},
});
}, [initialValues]);
return {
values: state.values,
errors: state.errors,
field,
canSubmit: computeCanSubmit(),
setValue,
reset,
};
}
type ServerValidationResult<T> =
| { success: true; data: T }
| { success: false; errors: Record<string, string> };
export async function validateFormData<T extends Record<string, unknown>>(
request: Request,
schema: Type<T>,
): Promise<ServerValidationResult<T>> {
const formData = await request.formData();
const parsed: Record<string, unknown> = {};
for (const key of schemaKeys(schema)) {
const raw = formData.get(key);
if (raw !== null) {
parsed[key] = raw.toString();
}
}
const result = schema(parsed as never);
if (result instanceof type.errors) {
const errors: Record<string, string> = {};
for (const [path, problems] of Object.entries(result.flatProblemsByPath)) {
errors[path] = problems[0];
}
return { success: false, errors };
}
return { success: true, data: result as T };
}
+7 -10
View File
@@ -3,7 +3,7 @@ import { useState } from "react";
import ActionDialog from "~/components/action-dialog";
import Button from "~/components/button";
import Input from "~/components/input";
import Switch from "~/components/switch";
import { SwitchField } from "~/components/switch";
import { fieldValidator } from "~/utils/form";
import { nameserver } from "../schemas";
@@ -28,15 +28,12 @@ export default function AddNameserver() {
required
validate={validateNameserver}
/>
<label className="flex items-center justify-between gap-4">
<span>
<span className="block font-medium">Restrict to domain</span>
<span className="text-muted block">
Split DNS. Only Tailscale 1.8 and later use this nameserver.
</span>
</span>
<Switch checked={isSplit} label="Restrict to domain" onCheckedChange={setIsSplit} />
</label>
<SwitchField
checked={isSplit}
description="Split DNS. Only Tailscale 1.8 and later use this nameserver."
label="Restrict to domain"
onCheckedChange={setIsSplit}
/>
{isSplit && (
<Input
description="Only queries for this domain and its subdomains use the nameserver."
+92 -135
View File
@@ -1,151 +1,108 @@
import { data } from "react-router";
import * as v from "valibot";
import { authContext, requestApiContext } from "~/server/context";
import { ActionError, createAction } from "~/server/web/actions";
import { isUserPrincipal } from "~/server/web/auth";
import { getOidcSubject } from "~/server/web/headscale-identity";
import { Capabilities } from "~/server/web/roles";
import type { PreAuthKey } from "~/types";
import { list, ok } from "~/utils/form";
import type { Route } from "./+types/overview";
const id = v.pipe(v.string(), v.minLength(1));
const switchValue = v.pipe(
v.optional(v.string(), ""),
v.transform((value) => value === "on"),
);
export async function authKeysAction({ request, context }: Route.ActionArgs) {
const auth = context.get(authContext);
const getRequestApi = context.get(requestApiContext);
// The form submits the raw value of the number input, so anything that isn't
// a plain integer (locale grouping, unit suffixes) is malformed. Parsing it
// leniently gives an Invalid Date or, for dot-grouping locales, a silently
// truncated expiry.
const expiryDays = v.pipe(
v.string(),
v.trim(),
v.regex(/^\d+$/, "Enter a whole number of days."),
v.transform(Number),
v.minValue(1, "Enter at least 1 day."),
v.maxValue(365_000, "Enter at most 365,000 days."),
);
const { principal, api } = await getRequestApi(request);
const canGenerateAny = auth.can(principal, Capabilities.generate_authkeys);
const canGenerateOwn = auth.can(principal, Capabilities.generate_own_authkeys);
if (!canGenerateAny && !canGenerateOwn) {
throw data("You do not have permission to manage pre-auth keys", {
status: 403,
});
}
async function checkSelfServiceOwnership(userId: string) {
if (canGenerateAny || !canGenerateOwn) return;
const [targetUser] = await api.users.list({ id: userId });
if (!targetUser) {
throw data("User not found.", { status: 404 });
}
const targetSubject = getOidcSubject(targetUser);
const ownsTarget =
isUserPrincipal(principal) &&
(principal.user.headscaleUserId === userId || targetSubject === principal.user.subject);
if (!ownsTarget) {
throw data("You do not have permission to manage this user's pre-auth keys", {
status: 403,
});
}
}
const formData = await request.formData();
const action = formData.get("action_id")?.toString();
if (!action) {
throw data("Missing `action_id` in the form data.", {
status: 400,
});
}
switch (action) {
case "add_preauthkey": {
const user = formData.get("user_id")?.toString() || null;
const aclTagsRaw = formData.get("acl_tags")?.toString() || "";
const aclTags = aclTagsRaw
.split(",")
.map((t) => t.trim())
.filter((t) => t.length > 0);
if (!user && aclTags.length === 0) {
return data("Must specify either a user or ACL tags.", {
status: 400,
});
}
if (user) {
await checkSelfServiceOwnership(user);
}
const expiry = formData.get("expiry")?.toString();
if (!expiry) {
return data("Missing `expiry` in the form data.", {
status: 400,
});
}
const reusable = formData.get("reusable")?.toString();
if (!reusable) {
return data("Missing `reusable` in the form data.", {
status: 400,
});
}
const ephemeral = formData.get("ephemeral")?.toString();
if (!ephemeral) {
return data("Missing `ephemeral` in the form data.", {
status: 400,
});
}
// The form submits the raw, unformatted value of the number input, so
// anything that is not a plain integer (grouping separators from
// `Intl.NumberFormat`, unit suffixes, ...) is malformed input. Parsing it
// leniently either produces an Invalid Date (500) or, for dot-grouping
// locales, a silently truncated expiry.
const day = /^\d+$/.test(expiry.trim()) ? Number(expiry.trim()) : Number.NaN;
const date = new Date();
date.setDate(date.getDate() + day);
if (day < 1 || Number.isNaN(date.getTime())) {
return data("`expiry` must be a whole number of days.", {
status: 400,
});
}
const key = await api.preAuthKeys.create({
user,
ephemeral: ephemeral === "on",
reusable: reusable === "on",
expiration: date,
aclTags: aclTags.length > 0 ? aclTags : null,
});
return data({ success: true as const, key: key.key });
export const authKeysAction = createAction({
async setup({ request, context }) {
const auth = context.get(authContext);
const { principal, api } = await context.get(requestApiContext)(request);
const canGenerateAny = auth.can(principal, Capabilities.generate_authkeys);
const canGenerateOwn = auth.can(principal, Capabilities.generate_own_authkeys);
if (!canGenerateAny && !canGenerateOwn) {
throw new ActionError("You don't have permission to manage pre-auth keys.", 403);
}
case "expire_preauthkey": {
const keyId = formData.get("key_id")?.toString();
const key = formData.get("key")?.toString();
if (!keyId || !key) {
return data("Missing `key_id` or `key` in the form data.", {
status: 400,
});
async function requireOwnUser(userId: string) {
if (canGenerateAny) {
return;
}
const user = formData.get("user_id")?.toString();
if (!user) {
return data("Missing `user_id` in the form data.", {
status: 400,
});
const [user] = await api.users.list({ id: userId });
const ownsUser =
user &&
isUserPrincipal(principal) &&
(principal.user.headscaleUserId === userId ||
getOidcSubject(user) === principal.user.subject);
if (!ownsUser) {
throw new ActionError("You can only manage your own pre-auth keys.", 403);
}
await checkSelfServiceOwnership(user);
// `user` here is the Headscale numeric user id (form field is wired
// from User.id). Pre-0.28 expire posts a uint64 `user` field, which
// the API layer reads from `key.user?.id`. Headscale 0.28+ only
// looks at `key.id` (the stable preauthkey id).
await api.preAuthKeys.expire({
id: keyId,
key,
user: { id: user },
} as unknown as PreAuthKey);
return data("Pre-auth key expired");
}
default:
return data("Invalid action", {
status: 400,
});
}
}
return { principal, api, requireOwnUser };
},
intents: {
create: {
fields: {
user_id: v.optional(v.string()),
tags: list(),
expiry: expiryDays,
reusable: switchValue,
ephemeral: switchValue,
},
async handler(input, { api, requireOwnUser }) {
const tags = input.tags.map((tag) => tag.trim()).filter((tag) => tag !== "");
const user = input.user_id || null;
if (!user && tags.length === 0) {
throw new ActionError("Choose a user or add at least one tag.");
}
if (user) {
await requireOwnUser(user);
}
const expiration = new Date();
expiration.setDate(expiration.getDate() + input.expiry);
const key = await api.preAuthKeys.create({
user,
ephemeral: input.ephemeral,
reusable: input.reusable,
expiration,
aclTags: tags.length > 0 ? tags : null,
});
return ok(undefined, { key: key.key });
},
},
expire: {
fields: { key_id: id, key: id, user_id: id },
async handler(input, { api, requireOwnUser }) {
await requireOwnUser(input.user_id);
// `user` is the Headscale user id. Before 0.28, expiring posts it as a
// uint64 read from `key.user?.id`. 0.28 and later only use `key.id`.
await api.preAuthKeys.expire({
id: input.key_id,
key: input.key,
user: { id: input.user_id },
} as unknown as PreAuthKey);
return ok("Pre-auth key expired");
},
},
},
});
@@ -1,16 +1,15 @@
import { useEffect, useRef, useState } from "react";
import { useFetcher } from "react-router";
import { useState } from "react";
import ActionDialog from "~/components/action-dialog";
import Button from "~/components/button";
import Callout from "~/components/callout";
import CodeBlock from "~/components/code-block";
import Input from "~/components/input";
import Dialog, { DialogPanel } from "~/components/legacy-dialog";
import { DialogDescription, DialogTitle } from "~/components/dialog";
import Link from "~/components/link";
import NumberInput from "~/components/number-input";
import Select from "~/components/select";
import Switch from "~/components/switch";
import Text from "~/components/text";
import Title from "~/components/title";
import { SwitchField } from "~/components/switch";
import TokenList from "~/components/token-list";
import type { User } from "~/types";
import { getUserDisplayName, sortUsersByDisplayName } from "~/utils/user";
@@ -53,168 +52,125 @@ export default function AddAuthKey({
currentHeadscaleUserId,
currentSubject,
}: AddAuthKeyProps) {
const fetcher = useFetcher();
const submittingRef = useRef(false);
const [isOpen, setIsOpen] = useState(false);
const [reusable, setReusable] = useState(false);
const [ephemeral, setEphemeral] = useState(false);
const [tagOnly, setTagOnly] = useState(false);
const currentUser = selfServiceOnly
? findCurrentUser(users, currentHeadscaleUserId, currentSubject)
: null;
const availableUsers =
selfServiceOnly && currentUser ? [currentUser] : sortUsersByDisplayName(users);
const [userId, setUserId] = useState<string | null>(availableUsers[0]?.id);
const [tags, setTags] = useState("");
const createdKey = fetcher.data?.success ? fetcher.data.key : null;
useEffect(() => {
if (fetcher.state === "idle" && fetcher.data) {
submittingRef.current = false;
}
}, [fetcher.data, fetcher.state]);
useEffect(() => {
if (!isOpen) {
setReusable(false);
setEphemeral(false);
setTagOnly(false);
setUserId(availableUsers[0]?.id);
setTags("");
fetcher.data = undefined;
}
}, [isOpen]);
const parsedTags = tags
.split(",")
.map((t) => t.trim())
.filter((t) => t.length > 0)
.map((t) => (t.startsWith("tag:") ? t : `tag:${t}`));
const canSubmit = tagOnly ? parsedTags.length > 0 : userId != null;
: undefined;
return (
<Dialog
isOpen={isOpen}
onOpenChange={(open) => {
if (!open && submittingRef.current) {
return;
}
setIsOpen(open);
}}
>
<Button onClick={() => setIsOpen(true)}>Create pre-auth key</Button>
{createdKey ? (
<DialogPanel variant="unactionable">
<Title>Pre-auth key created</Title>
<Text>Copy this key now. You will not be able to see the full key again.</Text>
<CodeBlock className="mt-4">{createdKey}</CodeBlock>
<Text className="mt-4 text-sm">To register a device with this key:</Text>
<CodeBlock className="mt-1">
{`tailscale up --login-server=${url} --authkey ${createdKey}`}
</CodeBlock>
</DialogPanel>
) : (
<DialogPanel
onSubmit={(event) => {
event.preventDefault();
submittingRef.current = true;
const form = new FormData(event.currentTarget as HTMLFormElement);
form.set("action_id", "add_preauthkey");
form.set("user_id", tagOnly ? "" : (userId?.toString() ?? ""));
form.set("reusable", reusable ? "on" : "off");
form.set("ephemeral", ephemeral ? "on" : "off");
form.set("acl_tags", parsedTags.join(","));
fetcher.submit(form, { method: "POST" });
}}
isDisabled={fetcher.state !== "idle" || !canSubmit}
>
<Title>Generate auth key</Title>
{!selfServiceOnly && (
<div className="mb-4 flex items-center justify-between gap-2">
<div>
<Text className="font-semibold">Tag-only key</Text>
<Text className="text-sm">Create a key owned by ACL tags instead of a user.</Text>
</div>
<Switch
defaultChecked={tagOnly}
label="Tag-only"
onCheckedChange={() => setTagOnly(!tagOnly)}
/>
</div>
)}
{!tagOnly && (
<Select
className="mb-2"
description={
selfServiceOnly
? "You can only create keys for your own user."
: "Machines will belong to this user when they authenticate."
}
disabled={selfServiceOnly}
required
label="User"
onValueChange={(value) => setUserId(value)}
placeholder="Select a user"
value={userId}
items={availableUsers.map((user) => ({
value: user.id,
label: getUserDisplayName(user),
}))}
/>
)}
<Input
className="mb-2"
description="Comma-separated tags (e.g. server, prod). The tag: prefix is added automatically."
required={tagOnly}
label="ACL Tags"
onChange={(value) => setTags(value)}
placeholder="server, prod"
value={tags}
/>
<NumberInput
defaultValue={90}
description="Set this key to expire after a certain number of days."
required
label="Key Expiration"
max={365_000}
min={1}
name="expiry"
/>
<div className="mt-6 flex items-center justify-between gap-2">
<div>
<Text className="font-semibold">Reusable</Text>
<Text className="text-sm">Use this key to authenticate more than one device.</Text>
</div>
<Switch
defaultChecked={reusable}
label="Reusable"
onCheckedChange={() => setReusable(!reusable)}
/>
</div>
<div className="mt-6 flex items-center justify-between gap-2">
<div>
<Text className="font-semibold">Ephemeral</Text>
<Text className="text-sm">
Devices authenticated with this key will be automatically removed once they go
offline.{" "}
<Link external styled to="https://tailscale.com/kb/1111/ephemeral-nodes">
Learn more
</Link>
</Text>
</div>
<Switch
defaultChecked={ephemeral}
label="Ephemeral"
onCheckedChange={() => setEphemeral(!ephemeral)}
/>
</div>
</DialogPanel>
<ActionDialog
description="Devices sign in with the key instead of an interactive login."
intent="create"
isDisabled={selfServiceOnly && !currentUser}
submitLabel="Create key"
success={({ key }) => (
<>
<DialogTitle>Pre-auth key created</DialogTitle>
<DialogDescription>Copy the key now. It won&apos;t be shown again.</DialogDescription>
<CodeBlock>{key}</CodeBlock>
<p className="text-muted">Register a device with it:</p>
<CodeBlock>{`tailscale up --login-server=${url} --authkey ${key}`}</CodeBlock>
</>
)}
</Dialog>
title="Create pre-auth key"
trigger={<Button>Create pre-auth key</Button>}
>
<AuthKeyFields currentUser={currentUser} selfServiceOnly={selfServiceOnly} users={users} />
</ActionDialog>
);
}
interface AuthKeyFieldsProps {
users: User[];
selfServiceOnly: boolean;
currentUser?: User;
}
// Kept separate so its state resets each time the dialog opens.
function AuthKeyFields({ users, selfServiceOnly, currentUser }: AuthKeyFieldsProps) {
const [isTagOnly, setIsTagOnly] = useState(false);
const [tags, setTags] = useState<string[]>([]);
if (selfServiceOnly && !currentUser) {
return (
<Callout variant="warning">
Your account isn&apos;t linked to a Headscale user yet, so you can&apos;t create a key.
</Callout>
);
}
return (
<>
{!selfServiceOnly && (
<SwitchField
checked={isTagOnly}
description="The key belongs to its tags instead of a user."
label="Tag-only key"
onCheckedChange={setIsTagOnly}
/>
)}
{!isTagOnly &&
(currentUser ? (
<>
<input name="user_id" type="hidden" value={currentUser.id} />
<Select
description="You can only create keys for your own user."
disabled
items={[{ value: currentUser.id, label: getUserDisplayName(currentUser) }]}
label="User"
value={currentUser.id}
/>
</>
) : (
<Select
defaultValue={sortUsersByDisplayName(users)[0]?.id ?? null}
description="Devices that sign in with the key belong to this user."
items={sortUsersByDisplayName(users).map((user) => ({
value: user.id,
label: getUserDisplayName(user),
}))}
label="User"
name="user_id"
placeholder="Select a user"
required
/>
))}
{tags.map((tag) => (
<input key={tag} name="tags" type="hidden" value={tag} />
))}
<TokenList
emptyText={isTagOnly ? "Add at least one tag" : "No tags"}
label="Tags"
normalize={(value) => (value.startsWith("tag:") ? value : `tag:${value}`)}
onChange={setTags}
placeholder="tag:server"
validate={(value) => /^tag:\S+$/.test(value)}
values={tags}
/>
<NumberInput
defaultValue={90}
label="Expires after (days)"
max={365_000}
min={1}
name="expiry"
required
/>
<SwitchField
description="The key can sign in more than one device."
label="Reusable"
name="reusable"
/>
<SwitchField
description={
<>
Devices are removed once they go offline.{" "}
<Link external styled to="https://tailscale.com/kb/1111/ephemeral-nodes">
Learn more
</Link>
</>
}
label="Ephemeral"
name="ephemeral"
/>
</>
);
}
@@ -1,7 +1,5 @@
import ActionDialog from "~/components/action-dialog";
import Button from "~/components/button";
import Dialog, { DialogPanel } from "~/components/legacy-dialog";
import Text from "~/components/text";
import Title from "~/components/title";
import type { PreAuthKey, User } from "~/types";
interface ExpireAuthKeyProps {
@@ -11,19 +9,14 @@ interface ExpireAuthKeyProps {
export default function ExpireAuthKey({ authKey, user }: ExpireAuthKeyProps) {
return (
<Dialog>
<Button variant="primary">Expire Key</Button>
<DialogPanel variant="destructive">
<Title>Expire auth key?</Title>
<input name="action_id" type="hidden" value="expire_preauthkey" />
<input name="user_id" type="hidden" value={user.id} />
<input name="key_id" type="hidden" value={authKey.id} />
<input name="key" type="hidden" value={authKey.key} />
<Text>
Expiring this authentication key will immediately prevent it from being used to
authenticate new devices. This action cannot be undone.
</Text>
</DialogPanel>
</Dialog>
<ActionDialog
description="The key stops working for new devices right away."
fields={{ key_id: authKey.id, key: authKey.key, user_id: user.id }}
intent="expire"
isDestructive
submitLabel="Expire key"
title="Expire this pre-auth key?"
trigger={<Button size="sm">Expire key</Button>}
/>
);
}
@@ -19,6 +19,7 @@ import { Capabilities } from "~/server/web/roles";
import type { PreAuthKey } from "~/types";
import type { User } from "~/types/User";
import log from "~/utils/log";
import { toastClientAction } from "~/utils/toast";
import { getUserDisplayName } from "~/utils/user";
import type { Route } from "./+types/overview";
@@ -114,6 +115,7 @@ export async function loader({ request, context }: Route.LoaderArgs) {
}
export const action = authKeysAction;
export const clientAction = toastClientAction;
type Status = "all" | "active" | "expired" | "reusable" | "ephemeral";
export default function Page({
+54 -182
View File
@@ -1,4 +1,4 @@
import { data } from "react-router";
import * as v from "valibot";
import {
authContext,
@@ -6,197 +6,69 @@ import {
headscaleContext,
integrationContext,
} from "~/server/context";
import { ActionError, createAction } from "~/server/web/actions";
import { Capabilities } from "~/server/web/roles";
import { invalid, ok, parseValues } from "~/utils/form";
import type { Route } from "./+types/overview";
import { restrictionTypes, restrictionValue, type RestrictionType } from "./schemas";
export async function restrictionAction({ request, context }: Route.ActionArgs) {
const auth = context.get(authContext);
const headscale = context.get(headscaleContext);
const headscaleConfig = context.get(headscaleConfigContext);
const integration = context.get(integrationContext);
const configPaths = {
domain: { path: "oidc.allowed_domains", key: "allowedDomains" },
group: { path: "oidc.allowed_groups", key: "allowedGroups" },
user: { path: "oidc.allowed_users", key: "allowedUsers" },
} as const;
const principal = await auth.require(request);
const check = auth.can(principal, Capabilities.configure_iam);
const labels = { domain: "Domain", group: "Group", user: "User" };
if (!check) {
throw data("You do not have permission to modify IAM settings.", {
status: 403,
});
}
if (!headscaleConfig.writable()) {
throw data("The Headscale configuration file is not editable.", {
status: 403,
});
}
const formData = await request.formData();
const action = formData.get("action_id")?.toString();
if (!action) {
throw data("No action provided.", {
status: 400,
});
}
switch (action) {
case "add_domain": {
const domain = formData.get("domain")?.toString()?.trim();
if (!domain) {
throw data("No domain provided.", {
status: 400,
});
}
const domains = [
...new Set([...(headscaleConfig.getOIDCConfig()?.allowedDomains ?? []), domain]),
];
await headscaleConfig.patch([
{
path: "oidc.allowed_domains",
value: domains,
},
]);
integration?.onConfigChange(headscale);
return data("Domain added successfully.");
export const restrictionAction = createAction({
capability: Capabilities.configure_iam,
async setup({ request, context }) {
const principal = await context.get(authContext).require(request);
const headscale = context.get(headscaleContext);
const config = context.get(headscaleConfigContext);
const integration = context.get(integrationContext);
if (!config.writable()) {
throw new ActionError("The Headscale configuration is read-only.", 403);
}
case "remove_domain": {
const domain = formData.get("domain")?.toString()?.trim();
if (!domain) {
throw data("No domain provided.", {
status: 400,
});
}
const storedDomains = headscaleConfig.getOIDCConfig()?.allowedDomains ?? [];
if (!storedDomains.includes(domain)) {
// Domain not found in the list
throw data(`Domain "${domain}" not found in allowed domains.`, {
status: 400,
});
}
// Filter out the domain to remove it from the list
const domains = storedDomains.filter((d: string) => d !== domain);
await headscaleConfig.patch([
{
path: "oidc.allowed_domains",
value: domains,
},
]);
integration?.onConfigChange(headscale);
return data("Domain removed successfully.");
function current(type: RestrictionType) {
return config.getOIDCConfig()?.[configPaths[type].key] ?? [];
}
case "add_group": {
const group = formData.get("group")?.toString()?.trim();
if (!group) {
throw data("No group provided.", {
status: 400,
});
}
const groups = [
...new Set([...(headscaleConfig.getOIDCConfig()?.allowedGroups ?? []), group]),
];
await headscaleConfig.patch([
{
path: "oidc.allowed_groups",
value: groups,
},
]);
integration?.onConfigChange(headscale);
return data("Group added successfully.");
async function save(type: RestrictionType, values: string[]) {
await config.patch([{ path: configPaths[type].path, value: values }]);
await integration?.onConfigChange(headscale);
}
case "remove_group": {
const group = formData.get("group")?.toString()?.trim();
if (!group) {
throw data("No group provided.", {
status: 400,
});
}
return { principal, current, save };
},
intents: {
add: {
fields: { type: v.picklist(restrictionTypes), value: v.string() },
async handler(input, { current, save }) {
const value = parseValues(restrictionValue[input.type], input.value);
if (!value.ok) {
return invalid({ value: value.errors.form });
}
const storedGroups = headscaleConfig.getOIDCConfig()?.allowedGroups ?? [];
if (!storedGroups.includes(group)) {
// Group not found in the list
throw data(`Group "${group}" not found in allowed groups.`, {
status: 400,
});
}
const values = current(input.type);
if (values.includes(value.data)) {
return invalid({ value: `This ${input.type} is already in the list.` });
}
// Filter out the group to remove it from the list
const groups = storedGroups.filter((d: string) => d !== group);
await headscaleConfig.patch([
{
path: "oidc.allowed_groups",
value: groups,
},
]);
integration?.onConfigChange(headscale);
return data("Group removed successfully.");
}
case "add_user": {
const user = formData.get("user")?.toString()?.trim();
if (!user) {
throw data("No user provided.", {
status: 400,
});
}
const users = [...new Set([...(headscaleConfig.getOIDCConfig()?.allowedUsers ?? []), user])];
await headscaleConfig.patch([
{
path: "oidc.allowed_users",
value: users,
},
]);
integration?.onConfigChange(headscale);
return data("User added successfully.");
}
case "remove_user": {
const user = formData.get("user")?.toString()?.trim();
if (!user) {
throw data("No user provided.", {
status: 400,
});
}
const storedUsers = headscaleConfig.getOIDCConfig()?.allowedUsers ?? [];
if (!storedUsers.includes(user)) {
// User not found in the list
throw data(`User "${user}" not found in allowed users.`, {
status: 400,
});
}
// Filter out the user to remove it from the list
const users = storedUsers.filter((d: string) => d !== user);
await headscaleConfig.patch([
{
path: "oidc.allowed_users",
value: users,
},
]);
integration?.onConfigChange(headscale);
return data("User removed successfully.");
}
default: {
throw data("Invalid action provided.", {
status: 400,
});
}
}
}
await save(input.type, [...values, value.data]);
return ok(`${labels[input.type]} added`);
},
},
remove: {
fields: { type: v.picklist(restrictionTypes), value: v.string() },
async handler(input, { current, save }) {
await save(
input.type,
current(input.type).filter((value) => value !== input.value),
);
return ok(`${labels[input.type]} removed`);
},
},
},
});
@@ -1,68 +0,0 @@
import { type } from "arktype";
import Button from "~/components/button";
import Input from "~/components/input";
import Dialog, { DialogPanel } from "~/components/legacy-dialog";
import Text from "~/components/text";
import Title from "~/components/title";
import { useForm } from "~/hooks/use-form";
const domainSchema = type({
domain: "string > 0",
});
interface AddDomainProps {
domains: string[];
isDisabled?: boolean;
}
export default function AddDomain({ domains, isDisabled }: AddDomainProps) {
const form = useForm({
schema: domainSchema,
validate: (values) => {
const domain = (values.domain as string).trim();
if (domain.length === 0) return undefined;
if (domains.includes(domain)) {
return { domain: "This domain already exists in the list." };
}
try {
const url = new URL(`http://${domain}`);
if (url.hostname !== domain) {
return { domain: "This is not a valid domain." };
}
} catch {
return { domain: "This is not a valid domain." };
}
return undefined;
},
});
const domain = (form.values.domain as string).trim();
return (
<Dialog>
<Button disabled={isDisabled}>Add domain</Button>
<DialogPanel>
<Title>Add domain</Title>
<Text className="mb-4">
Add this domain to a list of allowed email domains that can authenticate with Headscale
via OIDC.
</Text>
<input name="action_id" type="hidden" value="add_domain" />
<Input
{...form.field("domain")}
description={
domain.length > 0
? `Matches users with <user>@${domain}`
: "Enter a domain to match users with their email addresses."
}
required
label="Domain"
placeholder="example.com"
/>
</DialogPanel>
</Dialog>
);
}
@@ -1,53 +0,0 @@
import { type } from "arktype";
import Button from "~/components/button";
import Input from "~/components/input";
import Dialog, { DialogPanel } from "~/components/legacy-dialog";
import Text from "~/components/text";
import Title from "~/components/title";
import { useForm } from "~/hooks/use-form";
const groupSchema = type({
group: "string > 0",
});
interface AddGroupProps {
groups: string[];
isDisabled?: boolean;
}
export default function AddGroup({ groups, isDisabled }: AddGroupProps) {
const form = useForm({
schema: groupSchema,
validate: (values) => {
const group = (values.group as string).trim();
if (group.length === 0) return undefined;
if (groups.includes(group)) {
return { group: "This group already exists in the list." };
}
return undefined;
},
});
return (
<Dialog>
<Button disabled={isDisabled}>Add group</Button>
<DialogPanel>
<Title>Add group</Title>
<Text className="mb-4">
Add this group to a list of allowed groups that can authenticate with Headscale via OIDC.
</Text>
<input name="action_id" type="hidden" value="add_group" />
<Input
{...form.field("group")}
description="The group to allow for OIDC authentication."
required
label="Group"
placeholder="admin"
/>
</DialogPanel>
</Dialog>
);
}
@@ -0,0 +1,69 @@
import { useState } from "react";
import ActionDialog from "~/components/action-dialog";
import Button from "~/components/button";
import Input from "~/components/input";
import { fieldValidator } from "~/utils/form";
import { type RestrictionType, restrictionValue } from "../schemas";
const copy = {
domain: {
title: "Add domain",
label: "Domain",
description: "Only users with an email address at this domain can sign in through OIDC.",
placeholder: "example.com",
},
group: {
title: "Add group",
label: "Group",
description: "Only members of this OIDC group can sign in.",
placeholder: "admin",
},
user: {
title: "Add user",
label: "User",
description: "Only this OIDC user can sign in.",
placeholder: "john_doe",
},
};
const validators = {
domain: fieldValidator(restrictionValue.domain),
group: fieldValidator(restrictionValue.group),
user: fieldValidator(restrictionValue.user),
};
interface AddRestrictionProps {
type: RestrictionType;
isDisabled?: boolean;
}
export default function AddRestriction({ type, isDisabled }: AddRestrictionProps) {
const [value, setValue] = useState("");
const { title, label, description, placeholder } = copy[type];
const domain = value.trim();
return (
<ActionDialog
description={description}
fields={{ type }}
intent="add"
submitLabel={title}
title={title}
trigger={<Button disabled={isDisabled}>{title}</Button>}
>
<Input
description={
type === "domain" && domain.length > 0 ? `Matches <user>@${domain}` : undefined
}
label={label}
name="value"
onChange={setValue}
placeholder={placeholder}
required
validate={validators[type]}
/>
</ActionDialog>
);
}
@@ -1,53 +0,0 @@
import { type } from "arktype";
import Button from "~/components/button";
import Input from "~/components/input";
import Dialog, { DialogPanel } from "~/components/legacy-dialog";
import Text from "~/components/text";
import Title from "~/components/title";
import { useForm } from "~/hooks/use-form";
const userSchema = type({
user: "string > 0",
});
interface AddUserProps {
users: string[];
isDisabled?: boolean;
}
export default function AddUser({ users, isDisabled }: AddUserProps) {
const form = useForm({
schema: userSchema,
validate: (values) => {
const user = (values.user as string).trim();
if (user.length === 0) return undefined;
if (users.includes(user)) {
return { user: "This user already exists in the list." };
}
return undefined;
},
});
return (
<Dialog>
<Button disabled={isDisabled}>Add user</Button>
<DialogPanel>
<Title>Add user</Title>
<Text className="mb-4">
Add this user to a list of allowed users that can authenticate with Headscale via OIDC.
</Text>
<input name="action_id" type="hidden" value="add_user" />
<Input
{...form.field("user")}
description="The user to allow for OIDC authentication."
required
label="User"
placeholder="john_doe"
/>
</DialogPanel>
</Dialog>
);
}
@@ -5,12 +5,11 @@ import Link from "~/components/link";
import PageHeader from "~/components/page-header";
import { authContext, headscaleConfigContext } from "~/server/context";
import { Capabilities } from "~/server/web/roles";
import { toastClientAction } from "~/utils/toast";
import type { Route } from "./+types/overview";
import { restrictionAction } from "./actions";
import AddDomain from "./dialogs/add-domain";
import AddGroup from "./dialogs/add-group";
import AddUser from "./dialogs/add-user";
import AddRestriction from "./dialogs/add-restriction";
import RestrictionTable from "./table";
export async function loader({ request, context }: Route.LoaderArgs) {
@@ -44,6 +43,7 @@ export async function loader({ request, context }: Route.LoaderArgs) {
}
export const action = restrictionAction;
export const clientAction = toastClientAction;
export default function Page({ loaderData: { access, writable, settings } }: Route.ComponentProps) {
const isDisabled = writable ? !access : true;
@@ -90,13 +90,13 @@ export default function Page({ loaderData: { access, writable, settings } }: Rou
) : undefined}
</div>
<RestrictionTable isDisabled={isDisabled} type="domain" values={settings.domains}>
<AddDomain domains={settings.domains} isDisabled={isDisabled} />
<AddRestriction isDisabled={isDisabled} type="domain" />
</RestrictionTable>
<RestrictionTable isDisabled={isDisabled} type="group" values={settings.groups}>
<AddGroup groups={settings.groups} isDisabled={isDisabled} />
<AddRestriction isDisabled={isDisabled} type="group" />
</RestrictionTable>
<RestrictionTable isDisabled={isDisabled} type="user" values={settings.users}>
<AddUser isDisabled={isDisabled} users={settings.users} />
<AddRestriction isDisabled={isDisabled} type="user" />
</RestrictionTable>
</div>
);
@@ -0,0 +1,23 @@
import * as v from "valibot";
export const restrictionTypes = ["domain", "group", "user"] as const;
export type RestrictionType = (typeof restrictionTypes)[number];
function isHostname(value: string) {
try {
return new URL(`http://${value}`).hostname === value;
} catch {
return false;
}
}
export const restrictionValue = {
domain: v.pipe(
v.string(),
v.trim(),
v.nonEmpty("Enter a domain."),
v.check(isHostname, "Enter a domain like example.com."),
),
group: v.pipe(v.string(), v.trim(), v.nonEmpty("Enter a group.")),
user: v.pipe(v.string(), v.trim(), v.nonEmpty("Enter a user.")),
};
+24 -17
View File
@@ -1,13 +1,15 @@
import { GlobeLock, Group, User2 } from "lucide-react";
import React from "react";
import { Form } from "react-router";
import type React from "react";
import ActionDialog from "~/components/action-dialog";
import Button from "~/components/button";
import List from "~/components/list";
import type { RestrictionType } from "./schemas";
interface RestrictionProps {
children: React.ReactNode;
type: "domain" | "group" | "user";
type: RestrictionType;
values: string[];
isDisabled?: boolean;
}
@@ -31,19 +33,24 @@ export default function RestrictionTable({ children, type, values, isDisabled }:
) : (
<p>{value}</p>
)}
<Form method="POST">
<input name="action_id" type="hidden" value={`remove_${type}`} />
<input name={type} type="hidden" value={value} />
<Button
className="text-bad hover:bg-bad-subtle hover:text-bad"
size="sm"
variant="ghost"
disabled={isDisabled}
type="submit"
>
Remove
</Button>
</Form>
<ActionDialog
description={`Only the remaining ${type}s can sign in. If none are left, every ${type} can.`}
fields={{ type, value }}
intent="remove"
isDestructive
submitLabel="Remove"
title={`Remove ${value}?`}
trigger={
<Button
className="text-bad hover:bg-bad-subtle hover:text-bad"
disabled={isDisabled}
size="sm"
variant="ghost"
>
Remove
</Button>
}
/>
</List.Item>
))
) : (
@@ -58,7 +65,7 @@ export default function RestrictionTable({ children, type, values, isDisabled }:
);
}
function iconForType(type: "domain" | "group" | "user") {
function iconForType(type: RestrictionType) {
if (type === "domain") {
return <GlobeLock />;
}
+5 -3
View File
@@ -3,7 +3,9 @@ import * as v from "valibot";
// Errors not tied to a field are keyed as `form`.
export type FormErrors = Record<string, string>;
export type ActionResult = { ok: true; message?: string } | { ok: false; errors: FormErrors };
export type ActionResult =
| { ok: true; message?: string; data?: Record<string, string> }
| { ok: false; errors: FormErrors };
type ParseResult<T> = { ok: true; data: T } | { ok: false; errors: FormErrors };
export function formValues(formData: FormData) {
@@ -45,8 +47,8 @@ export function list(item: v.GenericSchema<string> = v.string()) {
);
}
export function ok(message?: string): ActionResult {
return { ok: true, message };
export function ok(message?: string, data?: Record<string, string>): ActionResult {
return { ok: true, message, data };
}
export function invalid(errors: FormErrors, status = 400) {
+1 -4
View File
@@ -13,11 +13,8 @@ vi.mock("~/utils/log", () => ({
function mockFormData(expiry: string): FormData {
const formData = new FormData();
formData.set("action_id", "add_preauthkey");
formData.set("intent", "create");
formData.set("user_id", "1");
formData.set("acl_tags", "");
formData.set("reusable", "off");
formData.set("ephemeral", "off");
formData.set("expiry", expiry);
return formData;
}