From dda7b9939f8f93d01fd8c2043164a31b777684ae Mon Sep 17 00:00:00 2001 From: Nico <47644445+nicotsx@users.noreply.github.com> Date: Mon, 16 Feb 2026 21:37:15 +0100 Subject: [PATCH] refactor: allow more characters in usernames (#529) --- app/client/modules/auth/routes/login.tsx | 3 ++- app/client/modules/auth/routes/onboarding.tsx | 5 +++-- app/lib/username.ts | 5 +++++ app/server/lib/__tests__/username.test.ts | 21 +++++++++++++++++++ .../auth-middlewares/convert-legacy-user.ts | 3 ++- app/server/lib/auth.ts | 6 +++++- 6 files changed, 38 insertions(+), 5 deletions(-) create mode 100644 app/lib/username.ts create mode 100644 app/server/lib/__tests__/username.test.ts diff --git a/app/client/modules/auth/routes/login.tsx b/app/client/modules/auth/routes/login.tsx index 20ee6187..c4085632 100644 --- a/app/client/modules/auth/routes/login.tsx +++ b/app/client/modules/auth/routes/login.tsx @@ -12,6 +12,7 @@ import { Label } from "~/client/components/ui/label"; import { authClient } from "~/client/lib/auth-client"; import { ResetPasswordDialog } from "../components/reset-password-dialog"; import { useNavigate } from "@tanstack/react-router"; +import { normalizeUsername } from "~/lib/username"; const loginSchema = type({ username: "2<=string<=50", @@ -39,7 +40,7 @@ export function LoginPage() { const onSubmit = async (values: LoginFormValues) => { const { data, error } = await authClient.signIn.username({ - username: values.username.toLowerCase().trim(), + username: normalizeUsername(values.username), password: values.password, fetchOptions: { onRequest: () => { diff --git a/app/client/modules/auth/routes/onboarding.tsx b/app/client/modules/auth/routes/onboarding.tsx index 39909562..8eddace4 100644 --- a/app/client/modules/auth/routes/onboarding.tsx +++ b/app/client/modules/auth/routes/onboarding.tsx @@ -18,11 +18,12 @@ import { Button } from "~/client/components/ui/button"; import { authClient } from "~/client/lib/auth-client"; import { useState } from "react"; import { useNavigate } from "@tanstack/react-router"; +import { normalizeUsername } from "~/lib/username"; export const clientMiddleware = [authMiddleware]; const onboardingSchema = type({ - username: type("2<=string<=30").pipe((str) => str.trim().toLowerCase()), + username: type("2<=string<=30").pipe(normalizeUsername), email: type("string.email").pipe((str) => str.trim().toLowerCase()), password: "string>=8", confirmPassword: "string>=1", @@ -54,7 +55,7 @@ export function OnboardingPage() { } const { data, error } = await authClient.signUp.email({ - username: values.username.toLowerCase().trim(), + username: normalizeUsername(values.username), password: values.password, email: values.email.toLowerCase().trim(), name: values.username, diff --git a/app/lib/username.ts b/app/lib/username.ts new file mode 100644 index 00000000..a8456c9e --- /dev/null +++ b/app/lib/username.ts @@ -0,0 +1,5 @@ +const USERNAME_ALLOWED_CHARACTERS_REGEX = /^[a-z0-9_.-]+$/i; + +export const normalizeUsername = (username: string): string => username.trim().toLowerCase(); + +export const isValidUsername = (username: string): boolean => USERNAME_ALLOWED_CHARACTERS_REGEX.test(username); diff --git a/app/server/lib/__tests__/username.test.ts b/app/server/lib/__tests__/username.test.ts new file mode 100644 index 00000000..bffacd7f --- /dev/null +++ b/app/server/lib/__tests__/username.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, test } from "bun:test"; +import { isValidUsername, normalizeUsername } from "~/lib/username"; + +describe("username helpers", () => { + test("normalizes usernames by trimming and lowercasing", () => { + expect(normalizeUsername(" Admin-User ")).toBe("admin-user"); + }); + + test("accepts usernames containing a hyphen", () => { + expect(isValidUsername(normalizeUsername("Admin-User"))).toBe(true); + }); + + test("accepts letters, numbers, dots, and underscores", () => { + expect(isValidUsername("admin.user_01")).toBe(true); + }); + + test("rejects usernames with unsupported characters", () => { + expect(isValidUsername("admin user")).toBe(false); + expect(isValidUsername("admin@user")).toBe(false); + }); +}); diff --git a/app/server/lib/auth-middlewares/convert-legacy-user.ts b/app/server/lib/auth-middlewares/convert-legacy-user.ts index 5be364d4..73de8227 100644 --- a/app/server/lib/auth-middlewares/convert-legacy-user.ts +++ b/app/server/lib/auth-middlewares/convert-legacy-user.ts @@ -5,6 +5,7 @@ import { account, usersTable, member, organization } from "~/server/db/schema"; import type { AuthMiddlewareContext } from "../auth"; import { UnauthorizedError } from "http-errors-enhanced"; import { cryptoUtils } from "~/server/utils/crypto"; +import { normalizeUsername } from "~/lib/username"; export const convertLegacyUserOnFirstLogin = async (ctx: AuthMiddlewareContext) => { const { path, body } = ctx; @@ -16,7 +17,7 @@ export const convertLegacyUserOnFirstLogin = async (ctx: AuthMiddlewareContext) const legacyUser = await db.query.usersTable.findFirst({ where: { AND: [ - { username: body.username.trim().toLowerCase() }, + { username: normalizeUsername(body.username) }, { passwordHash: { NOT: "" } }, { passwordHash: { isNotNull: true } }, ], diff --git a/app/server/lib/auth.ts b/app/server/lib/auth.ts index 58f0bd6e..ee8f63db 100644 --- a/app/server/lib/auth.ts +++ b/app/server/lib/auth.ts @@ -17,6 +17,7 @@ import { organization as organizationTable, member, usersTable } from "../db/sch import { ensureOnlyOneUser } from "./auth-middlewares/only-one-user"; import { authService } from "../modules/auth/auth.service"; import { tanstackStartCookies } from "better-auth/tanstack-start"; +import { isValidUsername, normalizeUsername } from "~/lib/username"; export type AuthMiddlewareContext = MiddlewareContext>; @@ -140,7 +141,10 @@ export const auth = betterAuth({ modelName: "sessionsTable", }, plugins: [ - username(), + username({ + usernameValidator: isValidUsername, + usernameNormalization: normalizeUsername, + }), admin({ defaultRole: "user", }),