diff --git a/backend/internal/middleware/error_handler.go b/backend/internal/middleware/error_handler.go index 7efca70a..c565f403 100644 --- a/backend/internal/middleware/error_handler.go +++ b/backend/internal/middleware/error_handler.go @@ -77,7 +77,7 @@ func handleValidationError(validationErrors validator.ValidationErrors) string { case "email": errorMessage = fmt.Sprintf("%s must be a valid email address", fieldName) 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": errorMessage = fmt.Sprintf("%s must be a valid URL", fieldName) case "min": diff --git a/backend/resources/migrations/postgres/20250829120000_user_display_name.down.sql b/backend/resources/migrations/postgres/20250829120000_user_display_name.down.sql deleted file mode 100644 index bf03c636..00000000 --- a/backend/resources/migrations/postgres/20250829120000_user_display_name.down.sql +++ /dev/null @@ -1 +0,0 @@ -ALTER TABLE users DROP COLUMN display_name; \ No newline at end of file diff --git a/backend/resources/migrations/postgres/20250829120000_user_display_name.up.sql b/backend/resources/migrations/postgres/20250829120000_user_display_name.up.sql deleted file mode 100644 index 8b2fcf52..00000000 --- a/backend/resources/migrations/postgres/20250829120000_user_display_name.up.sql +++ /dev/null @@ -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; \ No newline at end of file diff --git a/backend/resources/migrations/postgres/20250917170000_user_display_name_and_username.down.sql b/backend/resources/migrations/postgres/20250917170000_user_display_name_and_username.down.sql new file mode 100644 index 00000000..b68a8083 --- /dev/null +++ b/backend/resources/migrations/postgres/20250917170000_user_display_name_and_username.down.sql @@ -0,0 +1,3 @@ +ALTER TABLE users DROP COLUMN display_name; + +ALTER TABLE users ALTER COLUMN username TYPE TEXT; \ No newline at end of file diff --git a/backend/resources/migrations/postgres/20250917170000_user_display_name_and_username.up.sql b/backend/resources/migrations/postgres/20250917170000_user_display_name_and_username.up.sql new file mode 100644 index 00000000..a7c10e02 --- /dev/null +++ b/backend/resources/migrations/postgres/20250917170000_user_display_name_and_username.up.sql @@ -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"; \ No newline at end of file diff --git a/backend/resources/migrations/sqlite/20250829120000_user_display_name.down.sql b/backend/resources/migrations/sqlite/20250917170000_user_display_name_and_username.down.sql similarity index 100% rename from backend/resources/migrations/sqlite/20250829120000_user_display_name.down.sql rename to backend/resources/migrations/sqlite/20250917170000_user_display_name_and_username.down.sql diff --git a/backend/resources/migrations/sqlite/20250829120000_user_display_name.up.sql b/backend/resources/migrations/sqlite/20250917170000_user_display_name_and_username.up.sql similarity index 94% rename from backend/resources/migrations/sqlite/20250829120000_user_display_name.up.sql rename to backend/resources/migrations/sqlite/20250917170000_user_display_name_and_username.up.sql index f89b67e3..04a3012f 100644 --- a/backend/resources/migrations/sqlite/20250829120000_user_display_name.up.sql +++ b/backend/resources/migrations/sqlite/20250917170000_user_display_name_and_username.up.sql @@ -5,7 +5,7 @@ CREATE TABLE users_new ( id TEXT NOT NULL PRIMARY KEY, created_at DATETIME, - username TEXT NOT NULL UNIQUE, + username TEXT NOT NULL COLLATE NOCASE UNIQUE, email TEXT NOT NULL UNIQUE, first_name TEXT, last_name TEXT NOT NULL, diff --git a/frontend/messages/en.json b/frontend/messages/en.json index 41accd76..8dec4a83 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -120,6 +120,8 @@ "username": "Username", "save": "Save", "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.", "or_visit": "or visit", "added_on": "Added on", diff --git a/frontend/src/lib/components/signup/signup-form.svelte b/frontend/src/lib/components/signup/signup-form.svelte index 448bc8d6..00d5f6b9 100644 --- a/frontend/src/lib/components/signup/signup-form.svelte +++ b/frontend/src/lib/components/signup/signup-form.svelte @@ -5,7 +5,7 @@ import { preventDefault } from '$lib/utils/event-util'; import { createForm } from '$lib/utils/form-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'; let { @@ -26,11 +26,7 @@ const formSchema = z.object({ firstName: z.string().min(1).max(50), lastName: emptyToUndefined(z.string().max(50).optional()), - username: z - .string() - .min(2) - .max(30) - .regex(/^[a-z0-9_@.-]+$/, m.username_can_only_contain()), + username: usernameSchema, email: z.email() }); type FormSchema = typeof formSchema; diff --git a/frontend/src/lib/utils/zod-util.ts b/frontend/src/lib/utils/zod-util.ts index 079e4310..a0813bb2 100644 --- a/frontend/src/lib/utils/zod-util.ts +++ b/frontend/src/lib/utils/zod-util.ts @@ -26,3 +26,11 @@ export const callbackUrlSchema = z 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()); diff --git a/frontend/src/routes/settings/account/account-form.svelte b/frontend/src/routes/settings/account/account-form.svelte index 83abf81b..15cc3379 100644 --- a/frontend/src/routes/settings/account/account-form.svelte +++ b/frontend/src/routes/settings/account/account-form.svelte @@ -8,7 +8,7 @@ import { axiosErrorToast } from '$lib/utils/error-util'; import { preventDefault } from '$lib/utils/event-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 { z } from 'zod/v4'; @@ -35,11 +35,7 @@ firstName: z.string().min(1).max(50), lastName: emptyToUndefined(z.string().max(50).optional()), displayName: z.string().max(100), - username: z - .string() - .min(2) - .max(30) - .regex(/^[a-z0-9_@.-]+$/, m.username_can_only_contain()), + username: usernameSchema, email: z.email(), isAdmin: z.boolean() }); diff --git a/frontend/src/routes/settings/admin/users/user-form.svelte b/frontend/src/routes/settings/admin/users/user-form.svelte index f9424f23..0edc28eb 100644 --- a/frontend/src/routes/settings/admin/users/user-form.svelte +++ b/frontend/src/routes/settings/admin/users/user-form.svelte @@ -7,7 +7,7 @@ import type { User, UserCreate } from '$lib/types/user.type'; import { preventDefault } from '$lib/utils/event-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'; let { @@ -36,11 +36,7 @@ firstName: z.string().min(1).max(50), lastName: emptyToUndefined(z.string().max(50).optional()), displayName: z.string().max(100), - username: z - .string() - .min(2) - .max(30) - .regex(/^[a-z0-9_@.-]+$/, m.username_can_only_contain()), + username: usernameSchema, email: z.email(), isAdmin: z.boolean(), disabled: z.boolean() diff --git a/tests/specs/account-settings.spec.ts b/tests/specs/account-settings.spec.ts index 0499ac25..d97e9092 100644 --- a/tests/specs/account-settings.spec.ts +++ b/tests/specs/account-settings.spec.ts @@ -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'); }); +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 }) => { await page.goto('/settings/account'); diff --git a/tests/specs/user-settings.spec.ts b/tests/specs/user-settings.spec.ts index 22db892d..42cedd0c 100644 --- a/tests/specs/user-settings.spec.ts +++ b/tests/specs/user-settings.spec.ts @@ -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'); }); +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 }) => { 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'); }); +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 }) => { await page.goto(`/settings/admin/users/${users.craig.id}`);