From b2692839f15ba2e4aa499770a7e348aab364f883 Mon Sep 17 00:00:00 2001
From: Matt
Date: Thu, 25 Jun 2026 22:57:24 +0200
Subject: [PATCH] feat(admin): set a sign-in username when creating a user
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The New User form had no username field, so users created through it
could only sign in by email. Add an optional username to the create
flow (form + createUserSchema + createUser service), validated up front
(shape via USERNAME_REGEX, reserved-list, case-insensitive uniqueness)
before the auth account is minted. Fix the stale schema comment (2–30,
not 3–30; enforced in-app, no DB CHECK constraint exists).
Co-Authored-By: Claude Opus 4.8 (1M context)
Claude-Session: https://claude.ai/code/session_01L2qc3xZTfif7N4Wq3QDa8X
---
src/components/admin/users/user-form.tsx | 24 ++++++++++++++++++++
src/lib/db/schema/users.ts | 8 +++----
src/lib/services/users.service.ts | 29 +++++++++++++++++++++++-
src/lib/validators/users.ts | 3 +++
4 files changed, 59 insertions(+), 5 deletions(-)
diff --git a/src/components/admin/users/user-form.tsx b/src/components/admin/users/user-form.tsx
index ccb19d44..80bc79aa 100644
--- a/src/components/admin/users/user-form.tsx
+++ b/src/components/admin/users/user-form.tsx
@@ -93,6 +93,9 @@ function UserFormBody({ open, onOpenChange, user, onSuccess }: UserFormProps) {
// password here. Toggle off to set one manually.
const [sendSetupEmail, setSendSetupEmail] = useState(true);
const [displayName, setDisplayName] = useState(user?.displayName ?? '');
+ // New users: optional sign-in username (they can also sign in with their
+ // email). Lowercased on the way out; the API validates shape + uniqueness.
+ const [username, setUsername] = useState('');
const [phoneValue, setPhoneValue] = useState(
user?.phone ? { e164: user.phone, country: 'US' } : null,
);
@@ -153,6 +156,7 @@ function UserFormBody({ open, onOpenChange, user, onSuccess }: UserFormProps) {
password: sendSetupEmail ? undefined : password,
sendSetupEmail,
displayName,
+ username: username.trim() ? username.trim().toLowerCase() : undefined,
phone: phoneE164 ?? undefined,
roleId,
residentialAccess,
@@ -236,6 +240,26 @@ function UserFormBody({ open, onOpenChange, user, onSuccess }: UserFormProps) {
+ Lets them sign in with a short username instead of their email. 2–30 lowercase
+ letters, digits, dot, underscore, or hyphen. Leave blank to sign in by email
+ only.
+
+
+ )}
+
0) {
+ throw new ConflictError('That username is already taken.');
+ }
+ username = candidate;
+ }
+
// Two onboarding modes:
// - setup-email (default when no password is supplied): provision the
// account with a throwaway random password the admin never sees, then
@@ -179,6 +205,7 @@ export async function createUser(portId: string, data: CreateUserInput, meta: Au
await db.insert(userProfiles).values({
userId: newUserId,
displayName: data.displayName,
+ username,
firstName: data.firstName ?? null,
lastName: data.lastName ?? null,
phone: data.phone ?? null,
diff --git a/src/lib/validators/users.ts b/src/lib/validators/users.ts
index 27a2fe60..059af9d1 100644
--- a/src/lib/validators/users.ts
+++ b/src/lib/validators/users.ts
@@ -13,6 +13,9 @@ export const createUserSchema = z
* password. When false, `password` must be supplied inline. */
sendSetupEmail: z.boolean().optional(),
displayName: z.string().min(1).max(200),
+ /** Optional sign-in username. Shape/uniqueness/reserved checks run in the
+ * service (mirrors the self-service /api/v1/me path). Omit for email-only. */
+ username: z.string().optional(),
firstName: z.string().min(1).max(200).nullable().optional(),
lastName: z.string().min(1).max(200).nullable().optional(),
phone: z.string().optional(),