mirror of
https://github.com/tale/headplane.git
synced 2026-10-25 22:20:30 +00:00
feat(ui): move settings dialogs to action dialog
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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."
|
||||
|
||||
@@ -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'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't linked to a Headscale user yet, so you can'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({
|
||||
|
||||
@@ -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.")),
|
||||
};
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user