From 03d4c26b89c46ec8ee7823fe01b43758ad10bc9c Mon Sep 17 00:00:00 2001 From: Arnas Donauskas Date: Sun, 31 May 2026 19:39:41 +0300 Subject: [PATCH] feat(ui): visual polish from multi-user review (P1-P5, P7, P10) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR D — the final pass of the post-review fix series. Bundles the seven visual / UX polish items the design review flagged. P1 + P5 — sidebar account strip (`src/components/layout/sidebar.tsx`) - Owner used to be invisible in the role-chip slot (the previous check was `accountRole && accountRole !== "owner"`), so owners looked indistinguishable from admins. Now the chip always renders, with a per-role icon + colour pulled from a new `ROLE_CHIP` table. - Added `title={account.name}` on the truncated name so the full account name is visible on hover when it's clipped (long names + narrow sidebars). P2 + P3 + P10 — members tab (`members-tab.tsx`) - **P2** — destructive Remove / Revoke buttons used a neutral default that only turned red on hover. The destructive intent was invisible until the user moused over. Both buttons now have a red default state (`border-red-500/40 bg-red-500/10 text-red-300`) with a darker shade on hover. - **P3** — role chips were all slate. Replaced with a per-role colour scale: owner amber (scarce, immutable), admin primary (significant), agent slate (operational default), viewer muted slate (read-only). Mirrors the sidebar's `ROLE_CHIP` table so the two surfaces don't drift; once the surface stabilises this should hoist into a shared module. - **P10** — pending invitations section now opens with a one-line hint: "The plaintext invite URL is only shown once at creation for security — to re-share, revoke the invite below and create a new one." Pre-fix, admins kept looking for a "copy link" button on the pending rows; the design decision (don't store plaintext) is now self-explanatory. - Cleanup: removed the now-unused `ROLE_LABELS` constant (subsumed by `ROLE_CHIP[role].label`). P4 — amber "Save this link now" callout contrast - Pre-fix: `bg-amber-500/10 + text-amber-200` on slate-900 was borderline for WCAG AAA body-text contrast. - Post-fix: `bg-amber-500/15`, border bumped to `/50`, strong intro promoted to `amber-100` so the urgent copy reads with proper weight against the dark backdrop. P7 — Try-again button on join page server_error - Pre-fix: the only recourse for a server_error peek was to follow "Create a new account" / "Sign in" links — both lose the invite token. - Post-fix: extracted the peek+auth fetch as `loadPeekAndAuth`, call it from a new "Try again" primary button on the server_error card. Other failure reasons (not_found / used / expired) are terminal for this token so they keep the signup/sign-in escape hatches; only server_error gets retry. Verification - `npm run lint` → 0 errors (same 19 pre-existing warnings) - `npm run typecheck` → clean - `npm test` → 366/366 pass - `npm run build` → succeeds Closes out the post-review fix series. Combined with PRs A (#178, backend perf), B (#179, UI blocking), and C (#180, front-end correctness), every item from the consolidated review report is addressed. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/app/join/[token]/page.tsx | 80 ++++++++++++--- src/components/layout/sidebar.tsx | 86 +++++++++++++--- .../settings/invite-member-dialog.tsx | 16 ++- src/components/settings/members-tab.tsx | 98 ++++++++++++++----- 4 files changed, 225 insertions(+), 55 deletions(-) diff --git a/src/app/join/[token]/page.tsx b/src/app/join/[token]/page.tsx index 9cbcfe3..4767505 100644 --- a/src/app/join/[token]/page.tsx +++ b/src/app/join/[token]/page.tsx @@ -109,9 +109,33 @@ export default function JoinPage() { const [conflictMessage, setConflictMessage] = useState(null); const [signingOut, setSigningOut] = useState(false); + // Extracted so the "Try again" button on the server_error card + // can re-run the same logic without remounting the component. + const loadPeekAndAuth = useCallback(async () => { + if (!token) return; + setPeek(null); + setAuthedUserId(undefined); + try { + const [peekRes, authRes] = await Promise.all([ + fetch(`/api/invitations/${encodeURIComponent(token)}/peek`, { + cache: 'no-store', + }), + createClient().auth.getUser(), + ]); + const peekBody = (await peekRes.json()) as PeekResult; + setPeek(peekBody); + setAuthedUserId(authRes.data.user?.id ?? null); + } catch (err) { + console.error('[join] peek error:', err); + setPeek({ ok: false, reason: 'server_error' }); + setAuthedUserId(null); + } + }, [token]); + // Fetch peek + auth state on mount. The peek endpoint is // rate-limited per-IP (30/min) so double-mounting in React 19 - // strict mode dev is harmless. + // strict mode dev is harmless. We also use the `cancelled` flag + // to drop setState calls if the component unmounts mid-fetch. useEffect(() => { if (!token) return; let cancelled = false; @@ -220,19 +244,47 @@ export default function JoinPage() { - - - - - - + {/* For server_error the failure is transient — the network + flapped or the peek endpoint hiccupped. Try-again is + the right primary action; the "create account" / + "sign in" links stay as secondary options. Other + failure reasons (not_found / used / expired) are + terminal for this token, so no retry — just the + signup/sign-in escape hatches. */} + {peek.reason === 'server_error' ? ( + <> + + + + + + ) : ( + <> + + + + + + + + )} ); diff --git a/src/components/layout/sidebar.tsx b/src/components/layout/sidebar.tsx index 316d4bd..ce450a0 100644 --- a/src/components/layout/sidebar.tsx +++ b/src/components/layout/sidebar.tsx @@ -7,19 +7,61 @@ import { cn } from "@/lib/utils"; import { useAuth } from "@/hooks/use-auth"; import { useTotalUnread } from "@/hooks/use-total-unread"; import { - LayoutDashboard, - MessageSquare, - Users, + Crown, GitBranch, - Radio, - Zap, - Workflow, - Settings, + LayoutDashboard, LogOut, + MessageSquare, + Radio, + Settings, + Shield, User, + UserCog, + Users, UsersRound, + Workflow, X, + Zap, } from "lucide-react"; +import type { AccountRole } from "@/lib/auth/roles"; + +// Per-role chip metadata used in the sidebar's account strip + the +// Members tab roster. Keeping this near both consumers in a single +// place avoids drift between the two surfaces — when a designer +// wants to recolour "agent" rows, this is the one diff. +const ROLE_CHIP: Record< + AccountRole, + { icon: typeof Crown; label: string; className: string } +> = { + owner: { + icon: Crown, + label: "Owner", + // Amber: scarce, immutable, "the boss" — gets visual emphasis. + className: + "border-amber-500/40 bg-amber-500/10 text-amber-300", + }, + admin: { + icon: Shield, + label: "Admin", + // Primary-tinted: significant but not as scarce as owner. + className: + "border-primary/40 bg-primary/10 text-primary", + }, + agent: { + icon: UserCog, + label: "Agent", + // Neutral slate: the operational default. + className: + "border-slate-700 bg-slate-800 text-slate-300", + }, + viewer: { + icon: User, + label: "Viewer", + // Muted slate: read-only role; visually quieter than agent. + className: + "border-slate-800 bg-slate-900 text-slate-500", + }, +}; import { Avatar, AvatarFallback, @@ -232,12 +274,30 @@ export function Sidebar({ open = false, onClose }: SidebarProps) { aware of which shared account they're acting in. */} {accountSharingEnabled && account?.name ? (
- - {account.name} - {accountRole && accountRole !== "owner" ? ( - - {accountRole} - + + {/* `title=` exposes the full name on hover when it + gets truncated (long account names + narrow + sidebars). Cheap a11y win. */} + + {account.name} + + {accountRole ? ( + // Always render the chip — owners used to be + // invisible here, which made them indistinguishable + // from admins at a glance. Now everyone sees their + // role (with a colour cue) regardless of tier. + (() => { + const meta = ROLE_CHIP[accountRole]; + const Icon = meta.icon; + return ( + + + {meta.label} + + ); + })() ) : null}
) : null} diff --git a/src/components/settings/invite-member-dialog.tsx b/src/components/settings/invite-member-dialog.tsx index 5bf2887..4b243ad 100644 --- a/src/components/settings/invite-member-dialog.tsx +++ b/src/components/settings/invite-member-dialog.tsx @@ -208,10 +208,18 @@ export function InviteMemberDialog({ -
- Save this link now.{' '} - We never store the plaintext — once you close this dialog the - URL is gone. To re-share, revoke this invite and create a new one. + {/* Higher-contrast amber than the original 10% / amber-200. + Reviewed against slate-900 to meet WCAG AAA for body + text (target ratio 7:1). Border bumped to /50, bg to + /15, foreground promoted to amber-100 for the strong + intro, amber-200 for the body. */} +
+ + Save this link now. + {' '} + We never store the plaintext — once you close this dialog + the URL is gone. To re-share, revoke this invite and create + a new one.
{/* Anchor styled with `buttonVariants` rather than wrapping diff --git a/src/components/settings/members-tab.tsx b/src/components/settings/members-tab.tsx index 8fd69e6..230edcd 100644 --- a/src/components/settings/members-tab.tsx +++ b/src/components/settings/members-tab.tsx @@ -86,18 +86,37 @@ const EDITABLE_ROLES: { value: AccountRole; label: string; hint: string }[] = [ { value: 'viewer', label: 'Viewer', hint: 'Read-only across the app' }, ]; -const ROLE_ICONS: Record = { - owner: Crown, - admin: Shield, - agent: UserCog, - viewer: UserIcon, -}; - -const ROLE_LABELS: Record = { - owner: 'Owner', - admin: 'Admin', - agent: 'Agent', - viewer: 'Viewer', +// Per-role chip metadata. The colour scale runs amber (owner — +// scarce, immutable) → primary (admin — significant) → slate +// (agent — operational default) → muted slate (viewer — read- +// only). Mirrors the sidebar's ROLE_CHIP so the two surfaces +// don't drift; once the surface stabilises this should hoist +// into a shared module. +const ROLE_CHIP: Record< + AccountRole, + { icon: typeof Crown; label: string; className: string } +> = { + owner: { + icon: Crown, + label: 'Owner', + className: + 'border-amber-500/40 bg-amber-500/10 text-amber-300', + }, + admin: { + icon: Shield, + label: 'Admin', + className: 'border-primary/40 bg-primary/10 text-primary', + }, + agent: { + icon: UserCog, + label: 'Agent', + className: 'border-slate-700 bg-slate-800 text-slate-300', + }, + viewer: { + icon: UserIcon, + label: 'Viewer', + className: 'border-slate-800 bg-slate-900 text-slate-500', + }, }; function fmtDate(iso: string): string { @@ -279,7 +298,8 @@ export function MembersTab() {
    {members.map((member) => { - const RoleIcon = ROLE_ICONS[member.role]; + const roleMeta = ROLE_CHIP[member.role]; + const RoleIcon = roleMeta.icon; const isSelf = member.user_id === user?.id; const isOwnerRow = member.role === 'owner'; const isBusy = pendingMemberAction === member.user_id; @@ -368,21 +388,28 @@ export function MembersTab() { ) : ( - - - {ROLE_LABELS[member.role]} + + + {roleMeta.label} )} {/* Remove. Admin+ only; never on the owner row; - never on yourself. */} + never on yourself. Pre-polish styling was + neutral-default + red-on-hover — the + destructive intent was invisible until the + user moused over. Now red is the default + state with a darker shade on hover so the + affordance reads at-a-glance. */} {canManageMembers && !isOwnerRow && !isSelf && ( @@ -398,7 +425,7 @@ export function MembersTab() { {/* Pending invitations — admin+ only */}
    -
    +

    Pending invitations @@ -407,6 +434,18 @@ export function MembersTab() { {invitations.length}

    + {/* P10 — make the no-resend design explicit. Admins were + confused why the pending list shows roles + expiry but + no "copy link again" button. Stating the constraint up + front (rather than letting the user discover it by + looking for a button) keeps it from feeling like a bug. */} + {invitations.length > 0 ? ( +

    + The plaintext invite URL is only shown once at creation + for security — to re-share, revoke the invite below and + create a new one. +

    + ) : null} {invitations.length === 0 ? ( @@ -425,7 +464,10 @@ export function MembersTab() {
      - {invitations.map((inv) => ( + {invitations.map((inv) => { + const inviteRoleMeta = ROLE_CHIP[inv.role]; + const InviteRoleIcon = inviteRoleMeta.icon; + return (
    • {inv.label || 'Untitled invite'} - - {ROLE_LABELS[inv.role]} + + + {inviteRoleMeta.label}

    @@ -444,17 +489,22 @@ export function MembersTab() {

    + {/* Revoke: red default state, mirrors the + members-tab Remove button. Pre-polish version + read as a neutral secondary button until + hover. */} - ))} + ); + })}