"use client"; import { ArrowLeftIcon, BellIcon, CheckIcon, CopyIcon, CreditCardIcon, EllipsisIcon, KeyRoundIcon, LockIcon, MailIcon, PaletteIcon, PlusIcon, RotateCcwIcon, SettingsIcon, ShieldIcon, UserIcon, UserMinusIcon, UsersIcon, XIcon, } from "lucide-react"; import { AnimatePresence, motion, useReducedMotion } from "motion/react"; import { type CSSProperties, type FormEvent, Fragment, type ReactNode, useEffect, useId, useRef, useState } from "react"; import { cn } from "@/lib/utils"; import { AlertDialog, AlertDialogClose, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogPopup, AlertDialogTitle, } from "@/components/ui/alert-dialog"; import { Avatar, AvatarFallback } from "@/components/ui/avatar"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Menu, MenuItem, MenuPopup, MenuSeparator, MenuTrigger } from "@/components/ui/menu"; import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "@/components/ui/select"; export type SettingsSectionId = | "profile" | "appearance" | "notifications" | "security" | "general" | "members" | "billing" | "developer"; export type TeamHue = "amber" | "rose" | "violet" | "teal" | "sky" | "emerald" | "indigo" | "orange"; export interface TeamPermission { id: string; label: string; } export interface TeamPermissionArea { id: string; name: string; permissions: TeamPermission[]; } export interface TeamRole { id: string; name: string; description: string; locked?: boolean; from?: string; grants: string[]; } export interface TeamMember { id: string; name: string; email: string; roleId: string; hue: TeamHue; lastActive: string; } export interface TeamInvite { id: string; email: string; roleId: string; invitedBy: string; sent: string; } export interface TeamRolesProps { className?: string; areas?: TeamPermissionArea[]; roles?: TeamRole[]; members?: TeamMember[]; invites?: TeamInvite[]; currentUserId?: string; onNavigate?: (section: SettingsSectionId) => void; } const easeOut = [0.23, 1, 0.32, 1] as const; const spring = { type: "spring", stiffness: 500, damping: 40 } as const; const press = "transition-[color,background-color,box-shadow,scale] duration-150 ease-out active:scale-[0.97]"; const avatarHues: Record = { amber: "from-amber-300 to-orange-500", rose: "from-rose-300 to-pink-500", violet: "from-violet-400 to-indigo-500", teal: "from-teal-300 to-emerald-500", sky: "from-sky-300 to-blue-500", emerald: "from-emerald-300 to-teal-600", indigo: "from-indigo-400 to-violet-700", orange: "from-orange-300 to-red-500", }; const navGroups: { label: string; items: { id: SettingsSectionId; label: string; icon: ReactNode }[] }[] = [ { label: "Account", items: [ { id: "profile", label: "Profile", icon: }, { id: "appearance", label: "Appearance", icon: }, { id: "notifications", label: "Notifications", icon: }, { id: "security", label: "Security", icon: }, ], }, { label: "Workspace", items: [ { id: "general", label: "General", icon: }, { id: "members", label: "Members & roles", icon: }, { id: "billing", label: "Billing", icon: }, ], }, { label: "Developer", items: [{ id: "developer", label: "API keys & webhooks", icon: }], }, ]; const defaultAreas: TeamPermissionArea[] = [ { id: "projects", name: "Projects", permissions: [ { id: "projects.view", label: "View projects" }, { id: "projects.edit", label: "Create and edit projects" }, { id: "projects.delete", label: "Delete projects" }, { id: "projects.access", label: "Manage project access" }, ], }, { id: "billing", name: "Billing", permissions: [ { id: "billing.view", label: "View invoices and usage" }, { id: "billing.manage", label: "Change plan and payment" }, ], }, { id: "members", name: "Members", permissions: [ { id: "members.invite", label: "Invite people" }, { id: "members.roles", label: "Change roles" }, { id: "members.remove", label: "Remove people" }, ], }, { id: "settings", name: "Settings", permissions: [ { id: "settings.edit", label: "Edit workspace settings" }, { id: "settings.keys", label: "Manage API keys" }, { id: "settings.audit", label: "Export the audit log" }, ], }, ]; const allPermissionIds = defaultAreas.flatMap((a) => a.permissions.map((p) => p.id)); const defaultRoles: TeamRole[] = [ { id: "viewer", name: "Viewer", description: "Can look around, can’t change anything", grants: ["projects.view", "billing.view"] }, { id: "member", name: "Member", description: "Creates and ships projects", grants: ["projects.view", "projects.edit", "members.invite", "settings.keys"], }, { id: "admin", name: "Admin", description: "Everything except the plan and payment", grants: allPermissionIds.filter((id) => id !== "billing.manage"), }, { id: "owner", name: "Owner", description: "Full control of the workspace", locked: true, grants: allPermissionIds }, ]; const defaultMembers: TeamMember[] = [ { id: "m1", name: "Ada Lindqvist", email: "ada@ridgeline.app", roleId: "owner", hue: "violet", lastActive: "Now" }, { id: "m2", name: "Theo Marsh", email: "theo@ridgeline.app", roleId: "admin", hue: "sky", lastActive: "12 min ago" }, { id: "m3", name: "Priya Raman", email: "priya@ridgeline.app", roleId: "admin", hue: "teal", lastActive: "2 hours ago" }, { id: "m4", name: "Jonah Feld", email: "jonah@ridgeline.app", roleId: "member", hue: "amber", lastActive: "Yesterday" }, { id: "m5", name: "Inès Duarte", email: "ines@ridgeline.app", roleId: "member", hue: "rose", lastActive: "3 hours ago" }, { id: "m6", name: "Kofi Mensah", email: "kofi@ridgeline.app", roleId: "member", hue: "emerald", lastActive: "4 days ago" }, { id: "m7", name: "Sara Whitlock", email: "sara@ridgeline.app", roleId: "viewer", hue: "orange", lastActive: "Last week" }, { id: "m8", name: "Ren Takahashi", email: "ren@pinefold.co", roleId: "viewer", hue: "indigo", lastActive: "2 weeks ago" }, ]; const defaultInvites: TeamInvite[] = [ { id: "i1", email: "lucas@ridgeline.app", roleId: "member", invitedBy: "Theo", sent: "2 days ago" }, { id: "i2", email: "nadia@ordinal.studio", roleId: "viewer", invitedBy: "Ada", sent: "6 days ago" }, ]; function useReducedMotionAfterMount() { const prefers = useReducedMotion(); const [mounted, setMounted] = useState(false); useEffect(() => setMounted(true), []); return mounted && Boolean(prefers); } function initials(name: string) { return name .split(/\s+/) .map((p) => p[0]) .join("") .slice(0, 2) .toUpperCase(); } function plural(n: number, one: string, many = `${one}s`) { return `${n} ${n === 1 ? one : many}`; } function sameGrants(a: string[], b: string[]) { return a.length === b.length && a.every((g) => b.includes(g)); } function RidgelineShell({ active, onNavigate, children, className, }: { active: SettingsSectionId; onNavigate?: (section: SettingsSectionId) => void; children: ReactNode; className?: string; }) { const pillsRef = useRef(null); useEffect(() => { const scroller = pillsRef.current; const el = scroller?.querySelector(`[data-section="${active}"]`); if (!scroller || !el || scroller.scrollWidth <= scroller.clientWidth) return; scroller.scrollLeft = el.offsetLeft - 16; }, [active]); return (
Settings
{children}
); } function PersonAvatar({ name, hue }: { name: string; hue: TeamHue }) { return ( {initials(name)} ); } function RoleSelect({ roles, value, onChange, label, className, }: { roles: TeamRole[]; value: string; onChange: (id: string) => void; label: string; className?: string; }) { const options = roles.filter((r) => !r.locked); const items = options.map((r) => ({ value: r.id, label: r.name })); return ( ); } function CheckCell({ checked, locked, changed, label, reduce, onToggle, }: { checked: boolean; locked?: boolean; changed?: boolean; label: string; reduce: boolean; onToggle: () => void; }) { return ( ); } function RollingCount({ value, reduce }: { value: number; reduce: boolean }) { return ( {value} ); } function PermissionMatrix({ areas, roles, draft, saved, counts, reduce, onToggle, onRename, onRemove, }: { areas: TeamPermissionArea[]; roles: TeamRole[]; draft: Record; saved: Record; counts: Record; reduce: boolean; onToggle: (roleId: string, permissionId: string) => void; onRename: (roleId: string, name: string) => void; onRemove: (roleId: string) => void; }) { const [hover, setHover] = useState(null); const gridStyle = { "--cols": roles.length } as CSSProperties; const grid = "grid grid-cols-[minmax(7.5rem,1fr)_repeat(var(--cols),3.5rem)] sm:grid-cols-[minmax(11rem,1fr)_repeat(var(--cols),4.75rem)]"; const colBg = (id: string) => (hover === id ? "bg-accent/50" : ""); return (
setHover(null)}>
Permission
{roles.map((r) => { const changed = !sameGrants(draft[r.id] ?? [], saved[r.id] ?? []); const count = counts[r.id] ?? 0; return ( setHover(r.id)} role="columnheader" > {r.from ? ( !e.target.value.trim() && onRename(r.id, "Custom")} onChange={(e) => onRename(r.id, e.target.value)} value={r.name} /> ) : ( {r.locked && } {r.name} )} {changed && } {count === 1 ? "person" : "people"} ); })}
{areas.map((area) => (
{area.name}
{roles.map((r) => (
setHover(r.id)} role="cell" /> ))}
{area.permissions.map((p) => (
{p.label}
{roles.map((r) => { const on = (draft[r.id] ?? []).includes(p.id); const diverges = r.from ? on !== (draft[r.from] ?? []).includes(p.id) : false; return (
setHover(r.id)} role="cell" > onToggle(r.id, p.id)} reduce={reduce} />
); })}
))} ))} {roles.some((r) => r.from) && (
{roles.map((r) => (
setHover(r.id)} role="cell"> {r.from && ( )}
))}
)}
); } export function TeamRoles({ className, areas = defaultAreas, roles: initialRoles = defaultRoles, members: initialMembers = defaultMembers, invites: initialInvites = defaultInvites, currentUserId = "m1", onNavigate, }: TeamRolesProps) { const reduce = useReducedMotionAfterMount(); const id = useId(); const [roles, setRoles] = useState(initialRoles); const [saved, setSaved] = useState>(() => Object.fromEntries(initialRoles.map((r) => [r.id, r.grants]))); const [draft, setDraft] = useState>(saved); const [members, setMembers] = useState(initialMembers); const [invites, setInvites] = useState(initialInvites); const [email, setEmail] = useState(""); const [inviteRole, setInviteRole] = useState("member"); const [inviteError, setInviteError] = useState(null); const [resent, setResent] = useState([]); const [removing, setRemoving] = useState(null); const [removeOpen, setRemoveOpen] = useState(false); const [notice, setNotice] = useState<{ key: number; text: ReactNode; undo?: () => void } | null>(null); const [justSaved, setJustSaved] = useState(false); const counter = useRef(0); const savedTimer = useRef | null>(null); useEffect(() => { if (!notice) return; const t = setTimeout(() => setNotice(null), 4000); return () => clearTimeout(t); }, [notice]); useEffect(() => () => { if (savedTimer.current) clearTimeout(savedTimer.current); }, []); const say = (text: ReactNode, undo?: () => void) => { counter.current += 1; setNotice({ key: counter.current, text, undo }); }; const counts = Object.fromEntries(roles.map((r) => [r.id, members.filter((m) => m.roleId === r.id).length])); const changedRoles = roles.filter((r) => !sameGrants(draft[r.id] ?? [], saved[r.id] ?? [])); const affected = changedRoles.reduce((n, r) => n + (counts[r.id] ?? 0), 0); const customRole = roles.find((r) => r.from); const sendInvite = (e: FormEvent) => { e.preventDefault(); const value = email.trim(); if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) { setInviteError("Enter a full email address, like name@company.com."); return; } if (invites.some((i) => i.email === value) || members.some((m) => m.email === value)) { setInviteError("That person is already here or invited."); return; } counter.current += 1; setInvites((all) => [{ id: `new-${counter.current}`, email: value, roleId: inviteRole, invitedBy: "Ada", sent: "just now" }, ...all]); setEmail(""); say( <> Invite sent to {value} , ); }; const toggle = (roleId: string, permissionId: string) => setDraft((d) => { const current = d[roleId] ?? []; return { ...d, [roleId]: current.includes(permissionId) ? current.filter((p) => p !== permissionId) : [...current, permissionId] }; }); const addCustom = () => { counter.current += 1; const rid = `custom-${counter.current}`; const grants = [...(draft.member ?? [])]; setRoles((all) => { const ownerIndex = all.findIndex((r) => r.locked); const next = [...all]; next.splice(ownerIndex < 0 ? all.length : ownerIndex, 0, { id: rid, name: "Contractor", description: "Custom, based on Member", from: "member", grants, }); return next; }); setSaved((s) => ({ ...s, [rid]: grants })); setDraft((d) => ({ ...d, [rid]: grants })); }; const removeCustom = (rid: string) => { const role = roles.find((r) => r.id === rid); const fallback = role?.from ?? "viewer"; const before = { roles, members, invites, saved, draft }; setRoles((all) => all.filter((r) => r.id !== rid)); setMembers((all) => all.map((m) => (m.roleId === rid ? { ...m, roleId: fallback } : m))); setInvites((all) => all.map((i) => (i.roleId === rid ? { ...i, roleId: fallback } : i))); setSaved(({ [rid]: _, ...rest }) => rest); setDraft(({ [rid]: _, ...rest }) => rest); say( <> {role?.name} role removed , () => { setRoles(before.roles); setMembers(before.members); setInvites(before.invites); setSaved(before.saved); setDraft(before.draft); }, ); }; const save = () => { setSaved(draft); setRoles((all) => all.map((r) => ({ ...r, grants: draft[r.id] ?? r.grants }))); setJustSaved(true); if (savedTimer.current) clearTimeout(savedTimer.current); savedTimer.current = setTimeout(() => setJustSaved(false), 1600); }; const fade = reduce ? { duration: 0 } : { duration: 0.22, ease: easeOut }; const bar = changedRoles.length > 0 ? "dirty" : justSaved ? "saved" : null; const roleName = (rid: string) => roles.find((r) => r.id === rid)?.name ?? "Viewer"; return (

Members & roles

Invite people to Ridgeline and decide what each role can do.

Invite people

They’ll get an email with a link that works for 7 days.

{ setEmail(e.target.value); setInviteError(null); }} placeholder="name@company.com" type="email" value={email} /> {inviteError && (

{inviteError}

)}

Members

{plural(members.length, "person", "people")} in Ridgeline.

Name Role Last active Actions
    {members.map((m) => { const role = roles.find((r) => r.id === m.roleId); const you = m.id === currentUserId; return (

    {m.name} {you && (you)}

    {m.email}

    {role?.locked ? ( {role.name} ) : ( setMembers((all) => all.map((x) => (x.id === m.id ? { ...x, roleId: rid } : x)))} roles={roles} value={m.roleId} /> )} {m.lastActive}
    {m.lastActive === "Now" ? ( Now ) : ( m.lastActive )} } > navigator.clipboard?.writeText(m.email).catch(() => {})}> Copy email { setRemoving(m); setRemoveOpen(true); }} variant="destructive" > {role?.locked ? "Owner can’t be removed" : "Remove from workspace"}
    ); })}

Pending invites {invites.length}

    {invites.map((inv) => { const wasResent = resent.includes(inv.id); return (

    {inv.email}

    {roleName(inv.roleId)} · {wasResent ? "resent just now" : `invited by ${inv.invitedBy} ${inv.sent}`}

    ); })}
{invites.length === 0 &&

No invites waiting.

}

Roles

What each role can do. Owners can always do everything.

setRoles((all) => all.map((r) => (r.id === rid ? { ...r, name } : r)))} onToggle={toggle} reduce={reduce} roles={roles} saved={saved} />

{customRole ? ( <> marks where {customRole.name} differs from Member. Click its name to rename it. ) : ( "Need something in between? Duplicate Member and adjust it." )}

{bar ? ( {bar === "dirty" ? ( <>

Unsaved changes You have unsaved changes {" "} · {affected === 0 ? "no one has these roles yet" : `affects ${plural(affected, "person", "people")}`}

) : (

Roles saved

)}
) : notice ? (

{notice.text}

{notice.undo ? ( ) : ( )}
) : null}
Remove {removing?.name}? They lose access to Ridgeline straight away. Projects they created stay where they are. }>Cancel { if (!removing) return; const target = removing; setMembers((all) => all.filter((x) => x.id !== target.id)); say( <> {target.name} removed , ); }} variant="destructive" /> } > Remove
); }