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.
|
// 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. 2–30 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
|
||||||
|
|||||||
@@ -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,
|
||||||
* 3–30 chars (shape pinned by `chk_user_profiles_username_shape`).
|
* 2–30 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'),
|
||||||
|
|||||||
@@ -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 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:
|
// 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,
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
Reference in New Issue
Block a user