mirror of
https://github.com/tale/headplane.git
synced 2026-08-26 04:16:49 +00:00
feat(users): validate usernames before creating or renaming (#610)
This commit is contained in:
@@ -3,6 +3,7 @@ import Dialog, { DialogPanel } from "~/components/dialog";
|
|||||||
import Input from "~/components/input";
|
import Input from "~/components/input";
|
||||||
import Text from "~/components/text";
|
import Text from "~/components/text";
|
||||||
import Title from "~/components/title";
|
import Title from "~/components/title";
|
||||||
|
import { USERNAME_PATTERN, USERNAME_RULE } from "~/utils/user";
|
||||||
|
|
||||||
interface CreateUserProps {
|
interface CreateUserProps {
|
||||||
isOidc?: boolean;
|
isOidc?: boolean;
|
||||||
@@ -23,7 +24,17 @@ export default function CreateUser({ isOidc, isDisabled }: CreateUserProps) {
|
|||||||
</Text>
|
</Text>
|
||||||
<input name="action_id" type="hidden" value="create_user" />
|
<input name="action_id" type="hidden" value="create_user" />
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<Input required label="Username" name="username" placeholder="my-new-user" type="text" />
|
<Input
|
||||||
|
description={USERNAME_RULE}
|
||||||
|
minLength={2}
|
||||||
|
pattern={USERNAME_PATTERN}
|
||||||
|
required
|
||||||
|
title={USERNAME_RULE}
|
||||||
|
label="Username"
|
||||||
|
name="username"
|
||||||
|
placeholder="my-new-user"
|
||||||
|
type="text"
|
||||||
|
/>
|
||||||
<Input label="Display Name" name="display_name" placeholder="John Doe" type="text" />
|
<Input label="Display Name" name="display_name" placeholder="John Doe" type="text" />
|
||||||
<Input label="Email" name="email" placeholder="name@example.com" type="email" />
|
<Input label="Email" name="email" placeholder="name@example.com" type="email" />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import Input from "~/components/input";
|
|||||||
import Text from "~/components/text";
|
import Text from "~/components/text";
|
||||||
import Title from "~/components/title";
|
import Title from "~/components/title";
|
||||||
import { User } from "~/types";
|
import { User } from "~/types";
|
||||||
|
import { USERNAME_PATTERN, USERNAME_RULE } from "~/utils/user";
|
||||||
|
|
||||||
interface RenameProps {
|
interface RenameProps {
|
||||||
user: User;
|
user: User;
|
||||||
@@ -10,7 +11,6 @@ interface RenameProps {
|
|||||||
setIsOpen: (isOpen: boolean) => void;
|
setIsOpen: (isOpen: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: Server side validation before submitting
|
|
||||||
export default function RenameUser({ user, isOpen, setIsOpen }: RenameProps) {
|
export default function RenameUser({ user, isOpen, setIsOpen }: RenameProps) {
|
||||||
return (
|
return (
|
||||||
<Dialog isOpen={isOpen} onOpenChange={setIsOpen}>
|
<Dialog isOpen={isOpen} onOpenChange={setIsOpen}>
|
||||||
@@ -24,7 +24,11 @@ export default function RenameUser({ user, isOpen, setIsOpen }: RenameProps) {
|
|||||||
<input name="headscale_user_id" type="hidden" value={user.id} />
|
<input name="headscale_user_id" type="hidden" value={user.id} />
|
||||||
<Input
|
<Input
|
||||||
defaultValue={user.name}
|
defaultValue={user.name}
|
||||||
|
description={USERNAME_RULE}
|
||||||
|
minLength={2}
|
||||||
|
pattern={USERNAME_PATTERN}
|
||||||
required
|
required
|
||||||
|
title={USERNAME_RULE}
|
||||||
label="Username"
|
label="Username"
|
||||||
name="new_name"
|
name="new_name"
|
||||||
placeholder="my-new-name"
|
placeholder="my-new-name"
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { usersResource } from "~/server/headscale/live-store";
|
|||||||
import { isUserPrincipal } from "~/server/web/auth";
|
import { isUserPrincipal } from "~/server/web/auth";
|
||||||
import { Capabilities } from "~/server/web/roles";
|
import { Capabilities } from "~/server/web/roles";
|
||||||
import type { Role } from "~/server/web/roles";
|
import type { Role } from "~/server/web/roles";
|
||||||
|
import { validateUsername } from "~/utils/user";
|
||||||
|
|
||||||
import type { Route } from "./+types/overview";
|
import type { Route } from "./+types/overview";
|
||||||
|
|
||||||
@@ -42,6 +43,11 @@ export async function userAction({ request, context }: Route.ActionArgs) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const nameError = validateUsername(name);
|
||||||
|
if (nameError) {
|
||||||
|
throw data(nameError, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
await api.users.create({ name, email, displayName });
|
await api.users.create({ name, email, displayName });
|
||||||
await headscaleLiveStore.refresh(usersResource, api);
|
await headscaleLiveStore.refresh(usersResource, api);
|
||||||
return { message: "User created successfully" };
|
return { message: "User created successfully" };
|
||||||
@@ -65,6 +71,11 @@ export async function userAction({ request, context }: Route.ActionArgs) {
|
|||||||
return data({ success: false }, 400);
|
return data({ success: false }, 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const newNameError = validateUsername(newName);
|
||||||
|
if (newNameError) {
|
||||||
|
throw data(newNameError, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
const users = await api.users.list({ id: headscaleUserId });
|
const users = await api.users.list({ id: headscaleUserId });
|
||||||
const user = users.find((user) => user.id === headscaleUserId);
|
const user = users.find((user) => user.id === headscaleUserId);
|
||||||
if (!user) {
|
if (!user) {
|
||||||
|
|||||||
@@ -7,3 +7,25 @@ export function getUserDisplayName(user: User): string {
|
|||||||
|
|
||||||
return user.name || user.displayName || user.email || user.id;
|
return user.name || user.displayName || user.email || user.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Mirrors Headscale's `util.ValidateUsername` (hscontrol/util/dns.go): a name has
|
||||||
|
// to start with a letter and may only contain letters, numbers, `-`, `.`, `_` and
|
||||||
|
// at most one `@` (`\p{L}`/`\p{Nd}` match Go's `unicode.IsLetter`/`IsDigit`).
|
||||||
|
// The trailing `@` is ours: Headscale strips it when resolving a
|
||||||
|
// username in an ACL policy, so a user whose name ends in `@` is created fine but
|
||||||
|
// can never be matched by a rule.
|
||||||
|
export const USERNAME_PATTERN = String.raw`\p{L}[\p{L}\p{Nd}._\-]*(@[\p{L}\p{Nd}._\-]+)?`;
|
||||||
|
|
||||||
|
export const USERNAME_RULE =
|
||||||
|
"Usernames must be at least 2 characters, start with a letter, and contain only " +
|
||||||
|
"letters, numbers, dots, dashes and underscores, with at most one @ that cannot " +
|
||||||
|
"be the last character.";
|
||||||
|
|
||||||
|
const usernameRegex = new RegExp(`^${USERNAME_PATTERN}$`, "u");
|
||||||
|
|
||||||
|
// Returns an error message when the username is not usable in Headscale.
|
||||||
|
export function validateUsername(name: string): string | undefined {
|
||||||
|
if (name.length < 2 || !usernameRegex.test(name)) {
|
||||||
|
return USERNAME_RULE;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { describe, expect, test } from "vitest";
|
import { describe, expect, test } from "vitest";
|
||||||
|
|
||||||
import type { User } from "~/types/User";
|
import type { User } from "~/types/User";
|
||||||
import { getUserDisplayName } from "~/utils/user";
|
import { getUserDisplayName, USERNAME_PATTERN, validateUsername } from "~/utils/user";
|
||||||
|
|
||||||
const makeUser = (overrides: Partial<User>): User => ({
|
const makeUser = (overrides: Partial<User>): User => ({
|
||||||
id: "default-id",
|
id: "default-id",
|
||||||
@@ -56,3 +56,30 @@ describe("getUserDisplayName", () => {
|
|||||||
expect(getUserDisplayName(user)).toBe("John Doe");
|
expect(getUserDisplayName(user)).toBe("John Doe");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("validateUsername", () => {
|
||||||
|
test.each(["ab", "john_doe", "user-1.name", "alice@example.com", "josé"])(
|
||||||
|
"accepts %s",
|
||||||
|
(name) => {
|
||||||
|
expect(validateUsername(name)).toBeUndefined();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// "lm@" is the case from #502: Headscale creates it, but policies strip the
|
||||||
|
// trailing "@" so no rule can ever match the user.
|
||||||
|
// `ab²`/`abⅫ` are Nl/No, which Go's unicode.IsDigit rejects.
|
||||||
|
test.each(["", "a", "1abc", "@abc", "lm@", "a@b@c", "bad name", "bad/name", "ab²", "abⅫ"])(
|
||||||
|
"rejects %s",
|
||||||
|
(name) => {
|
||||||
|
expect(validateUsername(name)).toBeTypeOf("string");
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("USERNAME_PATTERN", () => {
|
||||||
|
test("is a valid HTML pattern (unicode sets mode)", () => {
|
||||||
|
const regex = new RegExp(`^${USERNAME_PATTERN}$`, "v");
|
||||||
|
expect(regex.test("alice@example.com")).toBe(true);
|
||||||
|
expect(regex.test("lm@")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user