mirror of
https://github.com/Luchexx/wacrm.git
synced 2026-11-02 09:19:51 +00:00
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:
commit
962e4fb27d
@ -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,6 +244,32 @@ export default function JoinPage() {
|
|||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="flex flex-col gap-2">
|
<CardContent className="flex flex-col gap-2">
|
||||||
|
{/* 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' ? (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
onClick={loadPeekAndAuth}
|
||||||
|
className="w-full bg-primary text-primary-foreground hover:bg-primary/90"
|
||||||
|
>
|
||||||
|
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">
|
<Link href="/signup">
|
||||||
<Button className="w-full bg-primary text-primary-foreground hover:bg-primary/90">
|
<Button className="w-full bg-primary text-primary-foreground hover:bg-primary/90">
|
||||||
Create a new account instead
|
Create a new account instead
|
||||||
@ -233,6 +283,8 @@ export default function JoinPage() {
|
|||||||
Sign in
|
Sign in
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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}>
|
||||||
|
{account.name}
|
||||||
</span>
|
</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}
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user