feat(admin): set a sign-in username when creating a user
All checks were successful
Build & Push Docker Images / lint (push) Successful in 3m4s
Build & Push Docker Images / build-and-push (push) Successful in 9m11s

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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L2qc3xZTfif7N4Wq3QDa8X
This commit is contained in:
2026-06-25 22:57:24 +02:00
parent caaebd77fa
commit b2692839f1
4 changed files with 59 additions and 5 deletions

View File

@@ -93,6 +93,9 @@ function UserFormBody({ open, onOpenChange, user, onSuccess }: UserFormProps) {
// password here. Toggle off to set one manually. // password here. Toggle off to set one manually.
const [sendSetupEmail, setSendSetupEmail] = useState(true); const [sendSetupEmail, setSendSetupEmail] = useState(true);
const [displayName, setDisplayName] = useState(user?.displayName ?? ''); 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<PhoneInputValue | null>( const [phoneValue, setPhoneValue] = useState<PhoneInputValue | null>(
user?.phone ? { e164: user.phone, country: 'US' } : null, user?.phone ? { e164: user.phone, country: 'US' } : null,
); );
@@ -153,6 +156,7 @@ function UserFormBody({ open, onOpenChange, user, onSuccess }: UserFormProps) {
password: sendSetupEmail ? undefined : password, password: sendSetupEmail ? undefined : password,
sendSetupEmail, sendSetupEmail,
displayName, displayName,
username: username.trim() ? username.trim().toLowerCase() : undefined,
phone: phoneE164 ?? undefined, phone: phoneE164 ?? undefined,
roleId, roleId,
residentialAccess, residentialAccess,
@@ -236,6 +240,26 @@ function UserFormBody({ open, onOpenChange, user, onSuccess }: UserFormProps) {
</p> </p>
</div> </div>
{!isEdit && (
<div className="space-y-2">
<Label htmlFor="user-username">Username (optional)</Label>
<Input
id="user-username"
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder="e.g. abbie"
autoCapitalize="none"
autoCorrect="off"
spellCheck={false}
/>
<p className="text-xs text-muted-foreground">
Lets them sign in with a short username instead of their email. 230 lowercase
letters, digits, dot, underscore, or hyphen. Leave blank to sign in by email
only.
</p>
</div>
)}
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="user-email">Email</Label> <Label htmlFor="user-email">Email</Label>
<Input <Input

View File

@@ -303,10 +303,10 @@ export const userProfiles = pgTable(
displayName: text('display_name').notNull(), displayName: text('display_name').notNull(),
/** /**
* Optional sign-in alias. Lowercase a-z0-9 plus dot/underscore/hyphen, * Optional sign-in alias. Lowercase a-z0-9 plus dot/underscore/hyphen,
* 330 chars (shape pinned by `chk_user_profiles_username_shape`). * 230 chars (shape enforced in-app by `USERNAME_REGEX` in
* Case-insensitive uniqueness is enforced by a partial unique index on * `@/lib/validators/username`). Case-insensitive uniqueness is enforced
* LOWER(username); NULL allows the column to coexist with users who * by a partial unique index on LOWER(username); NULL allows the column to
* still sign in by email. See migration 0054. * coexist with users who still sign in by email. See migration 0054.
*/ */
username: text('username'), username: text('username'),
avatarUrl: text('avatar_url'), avatarUrl: text('avatar_url'),

View File

@@ -1,10 +1,11 @@
import { and, eq } from 'drizzle-orm'; import { and, eq, sql } from 'drizzle-orm';
import { db } from '@/lib/db'; import { db } from '@/lib/db';
import { account, session, user, userProfiles, userPortRoles, roles, ports } from '@/lib/db/schema'; import { account, session, user, userProfiles, userPortRoles, roles, ports } from '@/lib/db/schema';
import { auth } from '@/lib/auth'; import { auth } from '@/lib/auth';
import { createAuditLog, type AuditMeta } from '@/lib/audit'; import { createAuditLog, type AuditMeta } from '@/lib/audit';
import { ConflictError, ForbiddenError, NotFoundError, ValidationError } from '@/lib/errors'; import { ConflictError, ForbiddenError, NotFoundError, ValidationError } from '@/lib/errors';
import { USERNAME_REGEX, isReservedUsername } from '@/lib/validators/username';
import { emitToRoom } from '@/lib/socket/server'; import { emitToRoom } from '@/lib/socket/server';
import { sendEmail } from '@/lib/email'; import { sendEmail } from '@/lib/email';
import { adminEmailChangeEmail } from '@/lib/email/templates/admin-email-change'; import { adminEmailChangeEmail } from '@/lib/email/templates/admin-email-change';
@@ -153,6 +154,31 @@ export async function createUser(portId: string, data: CreateUserInput, meta: Au
}); });
if (!role) throw new ValidationError('Invalid role ID'); if (!role) throw new ValidationError('Invalid role ID');
// Optional sign-in username. Validated up front (before the auth user is
// minted) so an invalid/taken username can't leave an orphaned account.
// Mirrors the self-service /api/v1/me checks: shape + reserved + unique.
let username: string | null = null;
if (data.username && data.username.trim()) {
const candidate = data.username.trim().toLowerCase();
if (!USERNAME_REGEX.test(candidate)) {
throw new ValidationError(
'Username must be 230 lowercase letters, digits, dot, underscore, or hyphen.',
);
}
if (isReservedUsername(candidate)) {
throw new ValidationError('That username is reserved. Please pick another.');
}
const taken = await db
.select({ userId: userProfiles.userId })
.from(userProfiles)
.where(sql`LOWER(${userProfiles.username}) = ${candidate}`)
.limit(1);
if (taken.length > 0) {
throw new ConflictError('That username is already taken.');
}
username = candidate;
}
// Two onboarding modes: // Two onboarding modes:
// - setup-email (default when no password is supplied): provision the // - setup-email (default when no password is supplied): provision the
// account with a throwaway random password the admin never sees, then // 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({ await db.insert(userProfiles).values({
userId: newUserId, userId: newUserId,
displayName: data.displayName, displayName: data.displayName,
username,
firstName: data.firstName ?? null, firstName: data.firstName ?? null,
lastName: data.lastName ?? null, lastName: data.lastName ?? null,
phone: data.phone ?? null, phone: data.phone ?? null,

View File

@@ -13,6 +13,9 @@ export const createUserSchema = z
* password. When false, `password` must be supplied inline. */ * password. When false, `password` must be supplied inline. */
sendSetupEmail: z.boolean().optional(), sendSetupEmail: z.boolean().optional(),
displayName: z.string().min(1).max(200), 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(), firstName: z.string().min(1).max(200).nullable().optional(),
lastName: z.string().min(1).max(200).nullable().optional(), lastName: z.string().min(1).max(200).nullable().optional(),
phone: z.string().optional(), phone: z.string().optional(),