feat: split user row into 2 lines + reserve operators.email column
Layout changes (apps/web/src/app/settings/users/user-row-client.tsx): - Row 1: username + 'you' chip on the LEFT (inline, alongside the username), role badge on the RIGHT. - Row 2: action buttons (Promote/Demote, Reset, Delete) right-aligned. - Earlier: identity stacked vertically with badge under username, and buttons crammed to the right of the same row. Schema (packages/db/src/schema.ts + migration 0013): - Added optional `email` column on operators (nullable, no NOT NULL). Reserved for future contact / recovery flows so today's operators don't need to backfill anything. - Partial unique index on lower(email) WHERE email IS NOT NULL keeps duplicates out without blocking NULLs. Migration applied to dev DB. 463 web tests still green. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
3af0dc7ca7
commit
496f882d9c
@ -59,28 +59,30 @@ export function UserRowClient({ user, isSelf, isLastAdmin }: UserRowClientProps)
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3 rounded-lg border p-4">
|
||||
{/* Row 1 — identity: username on the left, role badge + "you"
|
||||
chip on the right, all on one line. */}
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="min-w-0 space-y-1.5">
|
||||
<p className="text-sm font-medium leading-none truncate">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<p className="text-sm font-medium truncate">
|
||||
{user.username}
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className={
|
||||
isAdmin
|
||||
? "bg-emerald-500/15 text-emerald-700 dark:bg-emerald-500/20 dark:text-emerald-400 border-transparent"
|
||||
: "bg-slate-200/60 text-slate-600 dark:bg-slate-700/40 dark:text-slate-300 border-transparent"
|
||||
}
|
||||
>
|
||||
{user.role}
|
||||
</Badge>
|
||||
{isSelf && (
|
||||
<span className="text-xs text-muted-foreground">you</span>
|
||||
)}
|
||||
</div>
|
||||
{isSelf && (
|
||||
<span className="text-xs text-muted-foreground shrink-0">you</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-wrap justify-end gap-1.5">
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className={
|
||||
isAdmin
|
||||
? "shrink-0 bg-emerald-500/15 text-emerald-700 dark:bg-emerald-500/20 dark:text-emerald-400 border-transparent"
|
||||
: "shrink-0 bg-slate-200/60 text-slate-600 dark:bg-slate-700/40 dark:text-slate-300 border-transparent"
|
||||
}
|
||||
>
|
||||
{user.role}
|
||||
</Badge>
|
||||
</div>
|
||||
{/* Row 2 — actions: Promote/Demote, Reset, Delete, right-aligned. */}
|
||||
<div className="flex flex-wrap justify-end gap-1.5">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
@ -160,7 +162,6 @@ export function UserRowClient({ user, isSelf, isLastAdmin }: UserRowClientProps)
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</div>
|
||||
{resetVisible && (
|
||||
<div className="flex gap-2">
|
||||
|
||||
2
packages/db/migrations/0013_tricky_yellowjacket.sql
Normal file
2
packages/db/migrations/0013_tricky_yellowjacket.sql
Normal file
@ -0,0 +1,2 @@
|
||||
ALTER TABLE "operators" ADD COLUMN "email" text;--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "operators_email_uq" ON "operators" USING btree (lower("email")) WHERE "operators"."email" IS NOT NULL;
|
||||
1072
packages/db/migrations/meta/0013_snapshot.json
Normal file
1072
packages/db/migrations/meta/0013_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@ -92,6 +92,13 @@
|
||||
"when": 1778412502601,
|
||||
"tag": "0012_lucky_masked_marvel",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 13,
|
||||
"version": "7",
|
||||
"when": 1778418181504,
|
||||
"tag": "0013_tricky_yellowjacket",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@ -20,12 +20,22 @@ export const operators = pgTable(
|
||||
username: text("username").notNull(),
|
||||
passwordHash: text("password_hash"),
|
||||
displayName: text("display_name").notNull(),
|
||||
// Reserved for future contact / recovery flows. Optional + nullable
|
||||
// so today's operators don't have to backfill anything; admins can
|
||||
// populate it from the Users page when we wire that up.
|
||||
email: text("email"),
|
||||
role: text("role").notNull().default("admin"),
|
||||
defaultTimezone: text("default_timezone").notNull().default("Asia/Kuala_Lumpur"),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(t) => ({
|
||||
usernameUnique: uniqueIndex("operators_username_uq").on(sql`lower(${t.username})`),
|
||||
// Case-insensitive uniqueness only when an email IS set (NULLs
|
||||
// remain freely insertable). Lets future flows look up operators
|
||||
// by email without ambiguity.
|
||||
emailUnique: uniqueIndex("operators_email_uq")
|
||||
.on(sql`lower(${t.email})`)
|
||||
.where(sql`${t.email} IS NOT NULL`),
|
||||
}),
|
||||
);
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user