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) {

+ {!isEdit && ( +
+ + setUsername(e.target.value)} + placeholder="e.g. abbie" + autoCapitalize="none" + autoCorrect="off" + spellCheck={false} + /> +

+ 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(),