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:
yiekheng 2026-05-10 21:04:15 +08:00
parent 3af0dc7ca7
commit 496f882d9c
5 changed files with 1111 additions and 19 deletions

View File

@ -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">

View 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;

File diff suppressed because it is too large Load Diff

View File

@ -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
}
]
}

View File

@ -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`),
}),
);