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,27 +59,29 @@ export function UserRowClient({ user, isSelf, isLastAdmin }: UserRowClientProps)
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-3 rounded-lg border p-4">
|
<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="flex items-center justify-between gap-3">
|
||||||
<div className="min-w-0 space-y-1.5">
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
<p className="text-sm font-medium leading-none truncate">
|
<p className="text-sm font-medium truncate">
|
||||||
{user.username}
|
{user.username}
|
||||||
</p>
|
</p>
|
||||||
<div className="flex items-center gap-2">
|
{isSelf && (
|
||||||
|
<span className="text-xs text-muted-foreground shrink-0">you</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<Badge
|
<Badge
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
className={
|
className={
|
||||||
isAdmin
|
isAdmin
|
||||||
? "bg-emerald-500/15 text-emerald-700 dark:bg-emerald-500/20 dark:text-emerald-400 border-transparent"
|
? "shrink-0 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"
|
: "shrink-0 bg-slate-200/60 text-slate-600 dark:bg-slate-700/40 dark:text-slate-300 border-transparent"
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{user.role}
|
{user.role}
|
||||||
</Badge>
|
</Badge>
|
||||||
{isSelf && (
|
|
||||||
<span className="text-xs text-muted-foreground">you</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
{/* Row 2 — actions: Promote/Demote, Reset, Delete, right-aligned. */}
|
||||||
<div className="flex flex-wrap justify-end gap-1.5">
|
<div className="flex flex-wrap justify-end gap-1.5">
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
@ -161,7 +163,6 @@ export function UserRowClient({ user, isSelf, isLastAdmin }: UserRowClientProps)
|
|||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
{resetVisible && (
|
{resetVisible && (
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Input
|
<Input
|
||||||
|
|||||||
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,
|
"when": 1778412502601,
|
||||||
"tag": "0012_lucky_masked_marvel",
|
"tag": "0012_lucky_masked_marvel",
|
||||||
"breakpoints": true
|
"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(),
|
username: text("username").notNull(),
|
||||||
passwordHash: text("password_hash"),
|
passwordHash: text("password_hash"),
|
||||||
displayName: text("display_name").notNull(),
|
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"),
|
role: text("role").notNull().default("admin"),
|
||||||
defaultTimezone: text("default_timezone").notNull().default("Asia/Kuala_Lumpur"),
|
defaultTimezone: text("default_timezone").notNull().default("Asia/Kuala_Lumpur"),
|
||||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||||
},
|
},
|
||||||
(t) => ({
|
(t) => ({
|
||||||
usernameUnique: uniqueIndex("operators_username_uq").on(sql`lower(${t.username})`),
|
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