feat(admin): set a sign-in username when creating a user
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:
@@ -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<PhoneInputValue | null>(
|
||||
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) {
|
||||
</p>
|
||||
</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. 2–30 lowercase
|
||||
letters, digits, dot, underscore, or hyphen. Leave blank to sign in by email
|
||||
only.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="user-email">Email</Label>
|
||||
<Input
|
||||
|
||||
@@ -303,10 +303,10 @@ export const userProfiles = pgTable(
|
||||
displayName: text('display_name').notNull(),
|
||||
/**
|
||||
* Optional sign-in alias. Lowercase a-z0-9 plus dot/underscore/hyphen,
|
||||
* 3–30 chars (shape pinned by `chk_user_profiles_username_shape`).
|
||||
* Case-insensitive uniqueness is enforced by a partial unique index on
|
||||
* LOWER(username); NULL allows the column to coexist with users who
|
||||
* still sign in by email. See migration 0054.
|
||||
* 2–30 chars (shape enforced in-app by `USERNAME_REGEX` in
|
||||
* `@/lib/validators/username`). Case-insensitive uniqueness is enforced
|
||||
* by a partial unique index on LOWER(username); NULL allows the column to
|
||||
* coexist with users who still sign in by email. See migration 0054.
|
||||
*/
|
||||
username: text('username'),
|
||||
avatarUrl: text('avatar_url'),
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { and, eq } from 'drizzle-orm';
|
||||
import { and, eq, sql } from 'drizzle-orm';
|
||||
|
||||
import { db } from '@/lib/db';
|
||||
import { account, session, user, userProfiles, userPortRoles, roles, ports } from '@/lib/db/schema';
|
||||
import { auth } from '@/lib/auth';
|
||||
import { createAuditLog, type AuditMeta } from '@/lib/audit';
|
||||
import { ConflictError, ForbiddenError, NotFoundError, ValidationError } from '@/lib/errors';
|
||||
import { USERNAME_REGEX, isReservedUsername } from '@/lib/validators/username';
|
||||
import { emitToRoom } from '@/lib/socket/server';
|
||||
import { sendEmail } from '@/lib/email';
|
||||
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');
|
||||
|
||||
// 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 2–30 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:
|
||||
// - 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,
|
||||
|
||||
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user