diff --git a/app/components/action-dialog.tsx b/app/components/action-dialog.tsx index c4864a9a..f5dd0b25 100644 --- a/app/components/action-dialog.tsx +++ b/app/components/action-dialog.tsx @@ -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) => React.ReactNode; } export default function ActionDialog({ @@ -42,6 +44,7 @@ export default function ActionDialog({ onOpenChange, trigger, children, + success, }: ActionDialogProps) { const fetcher = useFetcher(); 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} > - {title} - {description && {description}} -
} - validationMode="onBlur" - > - - {Object.entries(fields ?? {}).map(([name, value]) => ( - - ))} - {children} - {errors?.form && {errors.form}} - - Cancel} /> - } /> + + + ) : ( + <> + {title} + {description && {description}} + } + validationMode="onBlur" > - {submitLabel} - - -
+ + {Object.entries(fields ?? {}).map(([name, value]) => ( + + ))} + {children} + {errors?.form && {errors.form}} + + Cancel} /> + + + + + )} ); } diff --git a/app/components/switch.tsx b/app/components/switch.tsx index 41bd5d59..040e4ec6 100644 --- a/app/components/switch.tsx +++ b/app/components/switch.tsx @@ -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) { ); } + +interface SwitchFieldProps extends SwitchProps { + description?: React.ReactNode; +} + +export function SwitchField({ label, description, ...props }: SwitchFieldProps) { + return ( + + ); +} diff --git a/app/hooks/use-form.ts b/app/hooks/use-form.ts deleted file mode 100644 index d8e57db3..00000000 --- a/app/hooks/use-form.ts +++ /dev/null @@ -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; - errors: Record; - modified: Record; -} - -export interface FormFieldProps { - name: string; - value: string; - onChange: (value: string) => void; - onBlur: () => void; - invalid: boolean; - errorMessage?: string; -} - -interface UseFormOptions> { - schema: Type; - defaultValues?: Partial>; - actionData?: unknown; - validate?: (values: Record) => Record | undefined; -} - -// arktype's Type 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>(options: UseFormOptions) { - const { schema, actionData, validate } = options; - const keys = schemaKeys(schema); - const validateRef = useRef(validate); - validateRef.current = validate; - - const initialValues = useMemo(() => { - const values: Record = {}; - 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({ - values: initialValues, - errors: {}, - modified: {}, - }); - - useEffect(() => { - if (actionData && typeof actionData === "object" && "errors" in actionData) { - const errors = actionData.errors as Record; - 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 = {}; - - // 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 = - | { success: true; data: T } - | { success: false; errors: Record }; - -export async function validateFormData>( - request: Request, - schema: Type, -): Promise> { - const formData = await request.formData(); - const parsed: Record = {}; - - 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 = {}; - for (const [path, problems] of Object.entries(result.flatProblemsByPath)) { - errors[path] = problems[0]; - } - return { success: false, errors }; - } - - return { success: true, data: result as T }; -} diff --git a/app/routes/dns/dialogs/add-ns.tsx b/app/routes/dns/dialogs/add-ns.tsx index beabe0d6..aa1cd6f3 100644 --- a/app/routes/dns/dialogs/add-ns.tsx +++ b/app/routes/dns/dialogs/add-ns.tsx @@ -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} /> - + {isSplit && ( 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"); + }, + }, + }, +}); diff --git a/app/routes/settings/auth-keys/dialogs/add-auth-key.tsx b/app/routes/settings/auth-keys/dialogs/add-auth-key.tsx index 4f0e3453..45e1d771 100644 --- a/app/routes/settings/auth-keys/dialogs/add-auth-key.tsx +++ b/app/routes/settings/auth-keys/dialogs/add-auth-key.tsx @@ -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(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 ( - { - if (!open && submittingRef.current) { - return; - } - setIsOpen(open); - }} - > - - {createdKey ? ( - - Pre-auth key created - Copy this key now. You will not be able to see the full key again. - {createdKey} - To register a device with this key: - - {`tailscale up --login-server=${url} --authkey ${createdKey}`} - - - ) : ( - { - 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} - > - Generate auth key - - {!selfServiceOnly && ( -
-
- Tag-only key - Create a key owned by ACL tags instead of a user. -
- setTagOnly(!tagOnly)} - /> -
- )} - - {!tagOnly && ( - setTags(value)} - placeholder="server, prod" - value={tags} - /> - -
-
- Reusable - Use this key to authenticate more than one device. -
- setReusable(!reusable)} - /> -
-
-
- Ephemeral - - Devices authenticated with this key will be automatically removed once they go - offline.{" "} - - Learn more - - -
- setEphemeral(!ephemeral)} - /> -
-
+ ( + <> + Pre-auth key created + Copy the key now. It won't be shown again. + {key} +

Register a device with it:

+ {`tailscale up --login-server=${url} --authkey ${key}`} + )} -
+ title="Create pre-auth key" + trigger={} + > + + + ); +} + +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([]); + + if (selfServiceOnly && !currentUser) { + return ( + + Your account isn't linked to a Headscale user yet, so you can't create a key. + + ); + } + + return ( + <> + {!selfServiceOnly && ( + + )} + {!isTagOnly && + (currentUser ? ( + <> + + ({ + value: user.id, + label: getUserDisplayName(user), + }))} + label="User" + name="user_id" + placeholder="Select a user" + required + /> + ))} + {tags.map((tag) => ( + + ))} + (value.startsWith("tag:") ? value : `tag:${value}`)} + onChange={setTags} + placeholder="tag:server" + validate={(value) => /^tag:\S+$/.test(value)} + values={tags} + /> + + + + Devices are removed once they go offline.{" "} + + Learn more + + + } + label="Ephemeral" + name="ephemeral" + /> + ); } diff --git a/app/routes/settings/auth-keys/dialogs/expire-auth-key.tsx b/app/routes/settings/auth-keys/dialogs/expire-auth-key.tsx index 6197bc6a..1eb66b81 100644 --- a/app/routes/settings/auth-keys/dialogs/expire-auth-key.tsx +++ b/app/routes/settings/auth-keys/dialogs/expire-auth-key.tsx @@ -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 ( - - - - Expire auth key? - - - - - - Expiring this authentication key will immediately prevent it from being used to - authenticate new devices. This action cannot be undone. - - - + Expire key} + /> ); } diff --git a/app/routes/settings/auth-keys/overview.tsx b/app/routes/settings/auth-keys/overview.tsx index 40111d1c..2ec038c3 100644 --- a/app/routes/settings/auth-keys/overview.tsx +++ b/app/routes/settings/auth-keys/overview.tsx @@ -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({ diff --git a/app/routes/settings/restrictions/actions.ts b/app/routes/settings/restrictions/actions.ts index 5d2d1b67..ccff73ec 100644 --- a/app/routes/settings/restrictions/actions.ts +++ b/app/routes/settings/restrictions/actions.ts @@ -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`); + }, + }, + }, +}); diff --git a/app/routes/settings/restrictions/dialogs/add-domain.tsx b/app/routes/settings/restrictions/dialogs/add-domain.tsx deleted file mode 100644 index 30ff72c1..00000000 --- a/app/routes/settings/restrictions/dialogs/add-domain.tsx +++ /dev/null @@ -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 ( - - - - Add domain - - Add this domain to a list of allowed email domains that can authenticate with Headscale - via OIDC. - - - 0 - ? `Matches users with @${domain}` - : "Enter a domain to match users with their email addresses." - } - required - label="Domain" - placeholder="example.com" - /> - - - ); -} diff --git a/app/routes/settings/restrictions/dialogs/add-group.tsx b/app/routes/settings/restrictions/dialogs/add-group.tsx deleted file mode 100644 index 8d9d596d..00000000 --- a/app/routes/settings/restrictions/dialogs/add-group.tsx +++ /dev/null @@ -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 ( - - - - Add group - - Add this group to a list of allowed groups that can authenticate with Headscale via OIDC. - - - - - - ); -} diff --git a/app/routes/settings/restrictions/dialogs/add-restriction.tsx b/app/routes/settings/restrictions/dialogs/add-restriction.tsx new file mode 100644 index 00000000..15c700dd --- /dev/null +++ b/app/routes/settings/restrictions/dialogs/add-restriction.tsx @@ -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 ( + {title}} + > + 0 ? `Matches @${domain}` : undefined + } + label={label} + name="value" + onChange={setValue} + placeholder={placeholder} + required + validate={validators[type]} + /> + + ); +} diff --git a/app/routes/settings/restrictions/dialogs/add-user.tsx b/app/routes/settings/restrictions/dialogs/add-user.tsx deleted file mode 100644 index 137e73c3..00000000 --- a/app/routes/settings/restrictions/dialogs/add-user.tsx +++ /dev/null @@ -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 ( - - - - Add user - - Add this user to a list of allowed users that can authenticate with Headscale via OIDC. - - - - - - ); -} diff --git a/app/routes/settings/restrictions/overview.tsx b/app/routes/settings/restrictions/overview.tsx index 7c06cde4..84910b57 100644 --- a/app/routes/settings/restrictions/overview.tsx +++ b/app/routes/settings/restrictions/overview.tsx @@ -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} - + - + - + ); diff --git a/app/routes/settings/restrictions/schemas.ts b/app/routes/settings/restrictions/schemas.ts new file mode 100644 index 00000000..1f729924 --- /dev/null +++ b/app/routes/settings/restrictions/schemas.ts @@ -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.")), +}; diff --git a/app/routes/settings/restrictions/table.tsx b/app/routes/settings/restrictions/table.tsx index 80f4ea66..1787baad 100644 --- a/app/routes/settings/restrictions/table.tsx +++ b/app/routes/settings/restrictions/table.tsx @@ -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 }: ) : (

{value}

)} -
- - - -
+ + Remove + + } + /> )) ) : ( @@ -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 ; } diff --git a/app/utils/form.ts b/app/utils/form.ts index 37c1e344..4f5da1b1 100644 --- a/app/utils/form.ts +++ b/app/utils/form.ts @@ -3,7 +3,9 @@ import * as v from "valibot"; // Errors not tied to a field are keyed as `form`. export type FormErrors = Record; -export type ActionResult = { ok: true; message?: string } | { ok: false; errors: FormErrors }; +export type ActionResult = + | { ok: true; message?: string; data?: Record } + | { ok: false; errors: FormErrors }; type ParseResult = { ok: true; data: T } | { ok: false; errors: FormErrors }; export function formValues(formData: FormData) { @@ -45,8 +47,8 @@ export function list(item: v.GenericSchema = v.string()) { ); } -export function ok(message?: string): ActionResult { - return { ok: true, message }; +export function ok(message?: string, data?: Record): ActionResult { + return { ok: true, message, data }; } export function invalid(errors: FormErrors, status = 400) { diff --git a/tests/unit/settings/auth-keys-action.test.ts b/tests/unit/settings/auth-keys-action.test.ts index 66a7b4bd..895862c8 100644 --- a/tests/unit/settings/auth-keys-action.test.ts +++ b/tests/unit/settings/auth-keys-action.test.ts @@ -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; }