feat: allow uppercase usernames (#958)

This commit is contained in:
Elias Schneider
2025-09-17 21:43:12 +02:00
committed by GitHub
parent cf0892922b
commit 02249491f8
14 changed files with 71 additions and 27 deletions

View File

@@ -77,7 +77,7 @@ func handleValidationError(validationErrors validator.ValidationErrors) string {
case "email": case "email":
errorMessage = fmt.Sprintf("%s must be a valid email address", fieldName) errorMessage = fmt.Sprintf("%s must be a valid email address", fieldName)
case "username": case "username":
errorMessage = fmt.Sprintf("%s must only contain lowercase letters, numbers, underscores, dots, hyphens, and '@' symbols and not start or end with a special character", fieldName) errorMessage = fmt.Sprintf("%s must only contain letters, numbers, underscores, dots, hyphens, and '@' symbols and not start or end with a special character", fieldName)
case "url": case "url":
errorMessage = fmt.Sprintf("%s must be a valid URL", fieldName) errorMessage = fmt.Sprintf("%s must be a valid URL", fieldName)
case "min": case "min":

View File

@@ -1 +0,0 @@
ALTER TABLE users DROP COLUMN display_name;

View File

@@ -1,6 +0,0 @@
ALTER TABLE users ADD COLUMN display_name TEXT;
UPDATE users
SET display_name = trim(coalesce(first_name,'') || ' ' || coalesce(last_name,''));
ALTER TABLE users ALTER COLUMN display_name SET NOT NULL;

View File

@@ -0,0 +1,3 @@
ALTER TABLE users DROP COLUMN display_name;
ALTER TABLE users ALTER COLUMN username TYPE TEXT;

View File

@@ -0,0 +1,6 @@
ALTER TABLE users ADD COLUMN display_name TEXT;
UPDATE users SET display_name = trim(coalesce(first_name,'') || ' ' || coalesce(last_name,''));
ALTER TABLE users ALTER COLUMN display_name SET NOT NULL;
CREATE EXTENSION IF NOT EXISTS citext;
ALTER TABLE users ALTER COLUMN username TYPE CITEXT COLLATE "C";

View File

@@ -5,7 +5,7 @@ CREATE TABLE users_new
( (
id TEXT NOT NULL PRIMARY KEY, id TEXT NOT NULL PRIMARY KEY,
created_at DATETIME, created_at DATETIME,
username TEXT NOT NULL UNIQUE, username TEXT NOT NULL COLLATE NOCASE UNIQUE,
email TEXT NOT NULL UNIQUE, email TEXT NOT NULL UNIQUE,
first_name TEXT, first_name TEXT,
last_name TEXT NOT NULL, last_name TEXT NOT NULL,

View File

@@ -120,6 +120,8 @@
"username": "Username", "username": "Username",
"save": "Save", "save": "Save",
"username_can_only_contain": "Username can only contain lowercase letters, numbers, underscores, dots, hyphens, and '@' symbols", "username_can_only_contain": "Username can only contain lowercase letters, numbers, underscores, dots, hyphens, and '@' symbols",
"username_must_start_with": "Username must start with an alphanumeric character",
"username_must_end_with": "Username must end with an alphanumeric character",
"sign_in_using_the_following_code_the_code_will_expire_in_minutes": "Sign in using the following code. The code will expire in 15 minutes.", "sign_in_using_the_following_code_the_code_will_expire_in_minutes": "Sign in using the following code. The code will expire in 15 minutes.",
"or_visit": "or visit", "or_visit": "or visit",
"added_on": "Added on", "added_on": "Added on",

View File

@@ -5,7 +5,7 @@
import { preventDefault } from '$lib/utils/event-util'; import { preventDefault } from '$lib/utils/event-util';
import { createForm } from '$lib/utils/form-util'; import { createForm } from '$lib/utils/form-util';
import { tryCatch } from '$lib/utils/try-catch-util'; import { tryCatch } from '$lib/utils/try-catch-util';
import { emptyToUndefined } from '$lib/utils/zod-util'; import { emptyToUndefined, usernameSchema } from '$lib/utils/zod-util';
import { z } from 'zod/v4'; import { z } from 'zod/v4';
let { let {
@@ -26,11 +26,7 @@
const formSchema = z.object({ const formSchema = z.object({
firstName: z.string().min(1).max(50), firstName: z.string().min(1).max(50),
lastName: emptyToUndefined(z.string().max(50).optional()), lastName: emptyToUndefined(z.string().max(50).optional()),
username: z username: usernameSchema,
.string()
.min(2)
.max(30)
.regex(/^[a-z0-9_@.-]+$/, m.username_can_only_contain()),
email: z.email() email: z.email()
}); });
type FormSchema = typeof formSchema; type FormSchema = typeof formSchema;

View File

@@ -26,3 +26,11 @@ export const callbackUrlSchema = z
message: m.invalid_redirect_url() message: m.invalid_redirect_url()
} }
); );
export const usernameSchema = z
.string()
.min(2)
.max(30)
.regex(/^[a-zA-Z0-9]/, m.username_must_start_with())
.regex(/[a-zA-Z0-9]$/, m.username_must_end_with())
.regex(/^[a-zA-Z0-9_.@-]+$/, m.username_can_only_contain());

View File

@@ -8,7 +8,7 @@
import { axiosErrorToast } from '$lib/utils/error-util'; import { axiosErrorToast } from '$lib/utils/error-util';
import { preventDefault } from '$lib/utils/event-util'; import { preventDefault } from '$lib/utils/event-util';
import { createForm } from '$lib/utils/form-util'; import { createForm } from '$lib/utils/form-util';
import { emptyToUndefined } from '$lib/utils/zod-util'; import { emptyToUndefined, usernameSchema } from '$lib/utils/zod-util';
import { toast } from 'svelte-sonner'; import { toast } from 'svelte-sonner';
import { z } from 'zod/v4'; import { z } from 'zod/v4';
@@ -35,11 +35,7 @@
firstName: z.string().min(1).max(50), firstName: z.string().min(1).max(50),
lastName: emptyToUndefined(z.string().max(50).optional()), lastName: emptyToUndefined(z.string().max(50).optional()),
displayName: z.string().max(100), displayName: z.string().max(100),
username: z username: usernameSchema,
.string()
.min(2)
.max(30)
.regex(/^[a-z0-9_@.-]+$/, m.username_can_only_contain()),
email: z.email(), email: z.email(),
isAdmin: z.boolean() isAdmin: z.boolean()
}); });

View File

@@ -7,7 +7,7 @@
import type { User, UserCreate } from '$lib/types/user.type'; import type { User, UserCreate } from '$lib/types/user.type';
import { preventDefault } from '$lib/utils/event-util'; import { preventDefault } from '$lib/utils/event-util';
import { createForm } from '$lib/utils/form-util'; import { createForm } from '$lib/utils/form-util';
import { emptyToUndefined } from '$lib/utils/zod-util'; import { emptyToUndefined, usernameSchema } from '$lib/utils/zod-util';
import { z } from 'zod/v4'; import { z } from 'zod/v4';
let { let {
@@ -36,11 +36,7 @@
firstName: z.string().min(1).max(50), firstName: z.string().min(1).max(50),
lastName: emptyToUndefined(z.string().max(50).optional()), lastName: emptyToUndefined(z.string().max(50).optional()),
displayName: z.string().max(100), displayName: z.string().max(100),
username: z username: usernameSchema,
.string()
.min(2)
.max(30)
.regex(/^[a-z0-9_@.-]+$/, m.username_can_only_contain()),
email: z.email(), email: z.email(),
isAdmin: z.boolean(), isAdmin: z.boolean(),
disabled: z.boolean() disabled: z.boolean()

View File

@@ -42,6 +42,18 @@ test('Update account details fails with already taken username', async ({ page }
await expect(page.locator('[data-type="error"]')).toHaveText('Username is already in use'); await expect(page.locator('[data-type="error"]')).toHaveText('Username is already in use');
}); });
test('Update account details fails with already taken username in different casing', async ({
page
}) => {
await page.goto('/settings/account');
await page.getByLabel('Username').fill(users.craig.username.toUpperCase());
await page.getByRole('button', { name: 'Save' }).click();
await expect(page.locator('[data-type="error"]')).toHaveText('Username is already in use');
});
test('Change Locale', async ({ page }) => { test('Change Locale', async ({ page }) => {
await page.goto('/settings/account'); await page.goto('/settings/account');

View File

@@ -53,6 +53,21 @@ test('Create user fails with already taken username', async ({ page }) => {
await expect(page.locator('[data-type="error"]')).toHaveText('Username is already in use'); await expect(page.locator('[data-type="error"]')).toHaveText('Username is already in use');
}); });
test('Create user fails with already taken username in different casing', async ({ page }) => {
const user = users.steve;
await page.goto('/settings/admin/users');
await page.getByRole('button', { name: 'Add User' }).click();
await page.getByLabel('First name').fill(user.firstname);
await page.getByLabel('Last name').fill(user.lastname);
await page.getByLabel('Email').fill(user.email);
await page.getByLabel('Username').fill(users.tim.username.toUpperCase());
await page.getByRole('button', { name: 'Save' }).click();
await expect(page.locator('[data-type="error"]')).toHaveText('Username is already in use');
});
test('Create one time access token', async ({ page, context }) => { test('Create one time access token', async ({ page, context }) => {
await page.goto('/settings/admin/users'); await page.goto('/settings/admin/users');
@@ -151,6 +166,23 @@ test('Update user fails with already taken username', async ({ page }) => {
await expect(page.locator('[data-type="error"]')).toHaveText('Username is already in use'); await expect(page.locator('[data-type="error"]')).toHaveText('Username is already in use');
}); });
test('Update user fails with already taken username in different casing', async ({ page }) => {
const user = users.craig;
await page.goto('/settings/admin/users');
await page
.getByRole('row', { name: `${user.firstname} ${user.lastname}` })
.getByRole('button')
.click();
await page.getByRole('menuitem', { name: 'Edit' }).click();
await page.getByLabel('Username').fill(users.tim.username.toUpperCase());
await page.getByRole('button', { name: 'Save' }).first().click();
await expect(page.locator('[data-type="error"]')).toHaveText('Username is already in use');
});
test('Update user custom claims', async ({ page }) => { test('Update user custom claims', async ({ page }) => {
await page.goto(`/settings/admin/users/${users.craig.id}`); await page.goto(`/settings/admin/users/${users.craig.id}`);