Merge pull request #181 from ArnasDon/feat/account-sharing-visual-polish

feat(ui): visual polish from multi-user review (P1-P5, P7, P10)
This commit is contained in:
Arnas Donauskas 2026-05-31 18:41:48 +02:00 committed by GitHub
commit 962e4fb27d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 225 additions and 55 deletions

View File

@ -109,9 +109,33 @@ export default function JoinPage() {
const [conflictMessage, setConflictMessage] = useState<string | null>(null); const [conflictMessage, setConflictMessage] = useState<string | null>(null);
const [signingOut, setSigningOut] = useState(false); 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 // Fetch peek + auth state on mount. The peek endpoint is
// rate-limited per-IP (30/min) so double-mounting in React 19 // 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(() => { useEffect(() => {
if (!token) return; if (!token) return;
let cancelled = false; let cancelled = false;
@ -220,19 +244,47 @@ export default function JoinPage() {
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent className="flex flex-col gap-2"> <CardContent className="flex flex-col gap-2">
<Link href="/signup"> {/* For server_error the failure is transient — the network
<Button className="w-full bg-primary text-primary-foreground hover:bg-primary/90"> flapped or the peek endpoint hiccupped. Try-again is
Create a new account instead the right primary action; the "create account" /
</Button> "sign in" links stay as secondary options. Other
</Link> failure reasons (not_found / used / expired) are
<Link href="/login"> terminal for this token, so no retry — just the
<Button signup/sign-in escape hatches. */}
variant="outline" {peek.reason === 'server_error' ? (
className="w-full border-slate-700 text-slate-300 hover:bg-slate-800 hover:text-white" <>
> <Button
Sign in onClick={loadPeekAndAuth}
</Button> className="w-full bg-primary text-primary-foreground hover:bg-primary/90"
</Link> >
Try again
</Button>
<Link href="/signup">
<Button
variant="outline"
className="w-full border-slate-700 text-slate-300 hover:bg-slate-800 hover:text-white"
>
Create a new account instead
</Button>
</Link>
</>
) : (
<>
<Link href="/signup">
<Button className="w-full bg-primary text-primary-foreground hover:bg-primary/90">
Create a new account instead
</Button>
</Link>
<Link href="/login">
<Button
variant="outline"
className="w-full border-slate-700 text-slate-300 hover:bg-slate-800 hover:text-white"
>
Sign in
</Button>
</Link>
</>
)}
</CardContent> </CardContent>
</Card> </Card>
); );

View File

@ -7,19 +7,61 @@ import { cn } from "@/lib/utils";
import { useAuth } from "@/hooks/use-auth"; import { useAuth } from "@/hooks/use-auth";
import { useTotalUnread } from "@/hooks/use-total-unread"; import { useTotalUnread } from "@/hooks/use-total-unread";
import { import {
LayoutDashboard, Crown,
MessageSquare,
Users,
GitBranch, GitBranch,
Radio, LayoutDashboard,
Zap,
Workflow,
Settings,
LogOut, LogOut,
MessageSquare,
Radio,
Settings,
Shield,
User, User,
UserCog,
Users,
UsersRound, UsersRound,
Workflow,
X, X,
Zap,
} from "lucide-react"; } 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 { import {
Avatar, Avatar,
AvatarFallback, AvatarFallback,
@ -238,12 +280,30 @@ export function Sidebar({ open = false, onClose }: SidebarProps) {
aware of which shared account they're acting in. */} aware of which shared account they're acting in. */}
{accountSharingEnabled && account?.name ? ( {accountSharingEnabled && account?.name ? (
<div className="mb-2 flex items-center gap-2 px-3 text-xs text-slate-500"> <div className="mb-2 flex items-center gap-2 px-3 text-xs text-slate-500">
<UsersRound className="size-3.5" /> <UsersRound className="size-3.5 shrink-0" />
<span className="truncate">{account.name}</span> {/* `title=` exposes the full name on hover when it
{accountRole && accountRole !== "owner" ? ( gets truncated (long account names + narrow
<span className="ml-auto rounded-full border border-slate-700 bg-slate-800 px-1.5 py-0.5 text-[10px] uppercase tracking-wider text-slate-400"> sidebars). Cheap a11y win. */}
{accountRole} <span className="truncate" title={account.name}>
</span> {account.name}
</span>
{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 (
<span
className={`ml-auto inline-flex shrink-0 items-center gap-1 rounded-full border px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wider ${meta.className}`}
>
<Icon className="size-3" />
{meta.label}
</span>
);
})()
) : null} ) : null}
</div> </div>
) : null} ) : null}

View File

@ -224,10 +224,18 @@ export function InviteMemberDialog({
</Button> </Button>
</div> </div>
<div className="rounded-md border border-amber-500/30 bg-amber-500/10 px-3 py-2 text-xs text-amber-200"> {/* Higher-contrast amber than the original 10% / amber-200.
<strong className="font-medium">Save this link now.</strong>{' '} Reviewed against slate-900 to meet WCAG AAA for body
We never store the plaintext — once you close this dialog the text (target ratio 7:1). Border bumped to /50, bg to
URL is gone. To re-share, revoke this invite and create a new one. /15, foreground promoted to amber-100 for the strong
intro, amber-200 for the body. */}
<div className="rounded-md border border-amber-500/50 bg-amber-500/15 px-3 py-2 text-xs text-amber-200">
<strong className="font-semibold text-amber-100">
Save this link now.
</strong>{' '}
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.
</div> </div>
{/* Anchor styled with `buttonVariants` rather than wrapping {/* Anchor styled with `buttonVariants` rather than wrapping

View File

@ -86,18 +86,37 @@ const EDITABLE_ROLES: { value: AccountRole; label: string; hint: string }[] = [
{ value: 'viewer', label: 'Viewer', hint: 'Read-only across the app' }, { value: 'viewer', label: 'Viewer', hint: 'Read-only across the app' },
]; ];
const ROLE_ICONS: Record<AccountRole, typeof Crown> = { // Per-role chip metadata. The colour scale runs amber (owner —
owner: Crown, // scarce, immutable) → primary (admin — significant) → slate
admin: Shield, // (agent — operational default) → muted slate (viewer — read-
agent: UserCog, // only). Mirrors the sidebar's ROLE_CHIP so the two surfaces
viewer: UserIcon, // don't drift; once the surface stabilises this should hoist
}; // into a shared module.
const ROLE_CHIP: Record<
const ROLE_LABELS: Record<AccountRole, string> = { AccountRole,
owner: 'Owner', { icon: typeof Crown; label: string; className: string }
admin: 'Admin', > = {
agent: 'Agent', owner: {
viewer: 'Viewer', 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 { function fmtDate(iso: string): string {
@ -299,7 +318,8 @@ export function MembersTab() {
<CardContent className="p-0"> <CardContent className="p-0">
<ul className="divide-y divide-slate-800"> <ul className="divide-y divide-slate-800">
{members.map((member) => { {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 isSelf = member.user_id === user?.id;
const isOwnerRow = member.role === 'owner'; const isOwnerRow = member.role === 'owner';
const isBusy = pendingMemberAction === member.user_id; const isBusy = pendingMemberAction === member.user_id;
@ -388,21 +408,28 @@ export function MembersTab() {
</SelectContent> </SelectContent>
</Select> </Select>
) : ( ) : (
<span className="inline-flex items-center gap-1.5 rounded-md border border-slate-700 bg-slate-800 px-2.5 py-1 text-xs font-medium text-slate-200"> <span
<RoleIcon className="size-3.5 text-slate-400" /> className={`inline-flex items-center gap-1.5 rounded-md border px-2.5 py-1 text-xs font-medium ${roleMeta.className}`}
{ROLE_LABELS[member.role]} >
<RoleIcon className="size-3.5" />
{roleMeta.label}
</span> </span>
)} )}
{/* Remove. Admin+ only; never on the owner row; {/* 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 && ( {canManageMembers && !isOwnerRow && !isSelf && (
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
onClick={() => setRemovingMember(member)} onClick={() => setRemovingMember(member)}
disabled={isBusy} disabled={isBusy}
className="border-slate-700 text-slate-300 hover:bg-red-500/10 hover:border-red-500/40 hover:text-red-300" className="border-red-500/40 bg-red-500/10 text-red-300 hover:bg-red-500/20 hover:border-red-500/60 hover:text-red-200"
> >
<Trash2 className="size-4" /> <Trash2 className="size-4" />
</Button> </Button>
@ -418,7 +445,7 @@ export function MembersTab() {
{/* Pending invitations — admin+ only */} {/* Pending invitations — admin+ only */}
<RequireRole min="admin"> <RequireRole min="admin">
<div> <div>
<div className="mb-3 flex items-center gap-2"> <div className="mb-2 flex items-center gap-2">
<UsersRound className="size-4 text-slate-400" /> <UsersRound className="size-4 text-slate-400" />
<h3 className="text-sm font-semibold text-white"> <h3 className="text-sm font-semibold text-white">
Pending invitations Pending invitations
@ -427,6 +454,18 @@ export function MembersTab() {
{invitations.length} {invitations.length}
</Badge> </Badge>
</div> </div>
{/* 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 ? (
<p className="mb-3 text-xs text-slate-500">
The plaintext invite URL is only shown once at creation
for security — to re-share, revoke the invite below and
create a new one.
</p>
) : null}
{invitations.length === 0 ? ( {invitations.length === 0 ? (
<Card className="bg-slate-900 border-slate-700 ring-0 ring-transparent"> <Card className="bg-slate-900 border-slate-700 ring-0 ring-transparent">
@ -445,7 +484,10 @@ export function MembersTab() {
<Card className="bg-slate-900 border-slate-700 ring-0 ring-transparent"> <Card className="bg-slate-900 border-slate-700 ring-0 ring-transparent">
<CardContent className="p-0"> <CardContent className="p-0">
<ul className="divide-y divide-slate-800"> <ul className="divide-y divide-slate-800">
{invitations.map((inv) => ( {invitations.map((inv) => {
const inviteRoleMeta = ROLE_CHIP[inv.role];
const InviteRoleIcon = inviteRoleMeta.icon;
return (
<li <li
key={inv.id} key={inv.id}
className="flex items-center gap-4 px-4 py-3" className="flex items-center gap-4 px-4 py-3"
@ -455,8 +497,11 @@ export function MembersTab() {
<span className="text-sm font-medium text-white"> <span className="text-sm font-medium text-white">
{inv.label || 'Untitled invite'} {inv.label || 'Untitled invite'}
</span> </span>
<span className="inline-flex items-center gap-1.5 rounded-md border border-slate-700 bg-slate-800 px-2 py-0.5 text-[11px] font-medium text-slate-300"> <span
{ROLE_LABELS[inv.role]} className={`inline-flex items-center gap-1 rounded-md border px-2 py-0.5 text-[11px] font-medium ${inviteRoleMeta.className}`}
>
<InviteRoleIcon className="size-3" />
{inviteRoleMeta.label}
</span> </span>
</div> </div>
<p className="mt-0.5 text-xs text-slate-500"> <p className="mt-0.5 text-xs text-slate-500">
@ -464,17 +509,22 @@ export function MembersTab() {
</p> </p>
</div> </div>
{/* Revoke: red default state, mirrors the
members-tab Remove button. Pre-polish version
read as a neutral secondary button until
hover. */}
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
onClick={() => handleRevoke(inv)} onClick={() => handleRevoke(inv)}
className="border-slate-700 text-slate-300 hover:bg-red-500/10 hover:border-red-500/40 hover:text-red-300" className="border-red-500/40 bg-red-500/10 text-red-300 hover:bg-red-500/20 hover:border-red-500/60 hover:text-red-200"
> >
<MailX className="size-4" /> <MailX className="size-4" />
Revoke Revoke
</Button> </Button>
</li> </li>
))} );
})}
</ul> </ul>
</CardContent> </CardContent>
</Card> </Card>