refactor: allow more characters in usernames (#529)
Some checks failed
Release Workflow / determine-release-type (push) Has been cancelled
Release Workflow / checks (push) Has been cancelled
Release Workflow / e2e-tests (push) Has been cancelled
Release Workflow / build-images (push) Has been cancelled
Release Workflow / publish-release (push) Has been cancelled

This commit is contained in:
Nico 2026-02-16 21:37:15 +01:00 committed by GitHub
parent 99bb296866
commit dda7b9939f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 38 additions and 5 deletions

View file

@ -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: () => {

View file

@ -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,

5
app/lib/username.ts Normal file
View file

@ -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);

View file

@ -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);
});
});

View file

@ -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 } },
],

View file

@ -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<MiddlewareOptions, AuthContext<BetterAuthOptions>>;
@ -140,7 +141,10 @@ export const auth = betterAuth({
modelName: "sessionsTable",
},
plugins: [
username(),
username({
usernameValidator: isValidUsername,
usernameNormalization: normalizeUsername,
}),
admin({
defaultRole: "user",
}),