diff --git a/app/routes/users/dialogs/create-user.tsx b/app/routes/users/dialogs/create-user.tsx index 9794df7..e546ea7 100644 --- a/app/routes/users/dialogs/create-user.tsx +++ b/app/routes/users/dialogs/create-user.tsx @@ -3,6 +3,7 @@ import Dialog, { DialogPanel } from "~/components/dialog"; import Input from "~/components/input"; import Text from "~/components/text"; import Title from "~/components/title"; +import { USERNAME_PATTERN, USERNAME_RULE } from "~/utils/user"; interface CreateUserProps { isOidc?: boolean; @@ -23,7 +24,17 @@ export default function CreateUser({ isOidc, isDisabled }: CreateUserProps) {
- +
diff --git a/app/routes/users/dialogs/rename-user.tsx b/app/routes/users/dialogs/rename-user.tsx index 96f8976..bc0560c 100644 --- a/app/routes/users/dialogs/rename-user.tsx +++ b/app/routes/users/dialogs/rename-user.tsx @@ -3,6 +3,7 @@ import Input from "~/components/input"; import Text from "~/components/text"; import Title from "~/components/title"; import { User } from "~/types"; +import { USERNAME_PATTERN, USERNAME_RULE } from "~/utils/user"; interface RenameProps { user: User; @@ -10,7 +11,6 @@ interface RenameProps { setIsOpen: (isOpen: boolean) => void; } -// TODO: Server side validation before submitting export default function RenameUser({ user, isOpen, setIsOpen }: RenameProps) { return ( @@ -24,7 +24,11 @@ export default function RenameUser({ user, isOpen, setIsOpen }: RenameProps) { user.id === headscaleUserId); if (!user) { diff --git a/app/utils/user.ts b/app/utils/user.ts index 1c7a3ad..aa19dff 100644 --- a/app/utils/user.ts +++ b/app/utils/user.ts @@ -7,3 +7,25 @@ export function getUserDisplayName(user: User): string { 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; + } +} diff --git a/tests/unit/utils/user.test.ts b/tests/unit/utils/user.test.ts index a11dbd0..52d7ba8 100644 --- a/tests/unit/utils/user.test.ts +++ b/tests/unit/utils/user.test.ts @@ -1,7 +1,7 @@ import { describe, expect, test } from "vitest"; import type { User } from "~/types/User"; -import { getUserDisplayName } from "~/utils/user"; +import { getUserDisplayName, USERNAME_PATTERN, validateUsername } from "~/utils/user"; const makeUser = (overrides: Partial): User => ({ id: "default-id", @@ -56,3 +56,30 @@ describe("getUserDisplayName", () => { 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); + }); +});