diff --git a/apps/web/components/settings/account.tsx b/apps/web/components/settings/account.tsx index 57d1264b1..96cf4baac 100644 --- a/apps/web/components/settings/account.tsx +++ b/apps/web/components/settings/account.tsx @@ -3,6 +3,7 @@ import { dmSans125ClassName } from "@/lib/fonts" import { cn } from "@lib/utils" import { useAuth } from "@lib/auth-context" +import { authClient } from "@lib/auth" import { useOrgSummaries } from "@/hooks/use-org-summaries" import { Avatar, AvatarFallback, AvatarImage } from "@ui/components/avatar" import { @@ -12,9 +13,42 @@ import { type PlanType, } from "@/hooks/use-token-usage" import { Popover, PopoverContent, PopoverTrigger } from "@ui/components/popover" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@ui/components/select" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@ui/components/dropdown-menu" +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from "@ui/components/dialog" import { useCustomer } from "autumn-js/react" -import { Check, LoaderIcon, ChevronDown, Building2, Users } from "lucide-react" +import { useMutation, useQuery } from "@tanstack/react-query" +import { + Check, + LoaderIcon, + ChevronDown, + Building2, + Users, + UserPlus, + Mail, + MoreHorizontal, + UserMinus, + ShieldCheck, + X, +} from "lucide-react" import { useMemo, useState } from "react" +import { toast } from "sonner" function SectionTitle({ children }: { children: React.ReactNode }) { return ( @@ -69,6 +103,42 @@ const ROLE_LABELS: Record = { member: "Member", } +type InviteRole = "admin" | "member" + +const INVITE_PERMISSION_OPTIONS: Record< + InviteRole, + { title: string; description: string; permissions: string[] } +> = { + member: { + title: "Member access", + description: "Use the organization workspace with standard access.", + permissions: ["Read organization access", "Use shared memories"], + }, + admin: { + title: "Admin access", + description: "Manage teammates and organization-level team settings.", + permissions: [ + "Invite and cancel invitations", + "Change member roles", + "Remove members", + "Update organization settings", + ], + }, +} + +function getErrorMessage(error: unknown, fallback: string) { + if (error instanceof Error && error.message) return error.message + if ( + error && + typeof error === "object" && + "message" in error && + typeof error.message === "string" + ) { + return error.message + } + return fallback +} + function formatRole(role: string): string { const r = role?.toLowerCase() ?? "" if (ROLE_LABELS[r]) return ROLE_LABELS[r] @@ -93,6 +163,17 @@ function RolePill({ role }: { role: string }) { ) } +function isPendingInvitation(invitation: { + status?: string + expiresAt?: Date | string +}) { + if (invitation.status && invitation.status.toLowerCase() !== "pending") { + return false + } + if (!invitation.expiresAt) return true + return new Date(invitation.expiresAt).getTime() > Date.now() +} + function resolveOrgPlan( orgId: string, isCurrent: boolean, @@ -106,10 +187,19 @@ function resolveOrgPlan( } export default function Account() { - const { user, org, organizations: allOrgs, setActiveOrg } = useAuth() + const { + user, + org, + organizations: allOrgs, + setActiveOrg, + refetchActiveOrg, + } = useAuth() const autumn = useCustomer() const [switchingOrgId, setSwitchingOrgId] = useState(null) const [orgMenuOpen, setOrgMenuOpen] = useState(false) + const [inviteDialogOpen, setInviteDialogOpen] = useState(false) + const [inviteEmail, setInviteEmail] = useState("") + const [inviteRole, setInviteRole] = useState("member") const canSwitchOrg = (allOrgs?.length ?? 0) > 1 const { data: orgSummaries } = useOrgSummaries() @@ -127,6 +217,150 @@ export default function Account() { const { currentPlan } = useTokenUsage(autumn) + const activeMemberRoleQuery = useQuery({ + queryKey: ["organization", org?.id, "active-member-role"], + queryFn: async () => { + if (!org?.id) return null + const result = await authClient.organization.getActiveMemberRole({ + query: { organizationId: org.id }, + }) + if (result.error) { + throw new Error(result.error.message ?? "Failed to load team role") + } + return result.data?.role ?? null + }, + enabled: !!org?.id, + retry: false, + }) + + const currentMember = useMemo( + () => org?.members?.find((member) => member.userId === user?.id) ?? null, + [org?.members, user?.id], + ) + const isSingleMemberPersonalOrg = + (org?.members?.length ?? 0) <= 1 && + (!org?.members?.[0]?.userId || org.members[0].userId === user?.id) + const currentRole = isSingleMemberPersonalOrg + ? "owner" + : ( + activeMemberRoleQuery.data ?? + currentMember?.role ?? + "member" + ).toLowerCase() + const canManageTeam = currentRole === "owner" || currentRole === "admin" + const isOwner = currentRole === "owner" + + const pendingInvitations = useMemo( + () => (org?.invitations ?? []).filter(isPendingInvitation), + [org?.invitations], + ) + + const inviteMemberMutation = useMutation({ + mutationFn: async () => { + if (!org?.id) throw new Error("No active organization") + const email = inviteEmail.trim().toLowerCase() + if (!email) throw new Error("Enter an email address") + const result = await authClient.organization.inviteMember({ + email, + role: inviteRole, + organizationId: org.id, + resend: true, + }) + if (result.error) { + throw new Error(result.error.message ?? "Failed to invite teammate") + } + return result.data + }, + onSuccess: async (invitation) => { + setInviteEmail("") + setInviteRole("member") + setInviteDialogOpen(false) + await refetchActiveOrg() + toast.success("Invitation sent", { + description: invitation?.email + ? `${invitation.email} can now join ${org?.name ?? "your organization"}.` + : undefined, + }) + }, + onError: (error) => { + toast.error(getErrorMessage(error, "Failed to invite teammate")) + }, + }) + + const updateMemberRoleMutation = useMutation({ + mutationFn: async ({ + memberId, + role, + }: { + memberId: string + role: InviteRole + }) => { + if (!org?.id) throw new Error("No active organization") + const result = await authClient.organization.updateMemberRole({ + memberId, + role, + organizationId: org.id, + }) + if (result.error) { + throw new Error(result.error.message ?? "Failed to update role") + } + return result.data + }, + onSuccess: async () => { + await refetchActiveOrg() + toast.success("Role updated") + }, + onError: (error) => { + toast.error(getErrorMessage(error, "Failed to update role")) + }, + }) + + const removeMemberMutation = useMutation({ + mutationFn: async (memberIdOrEmail: string) => { + if (!org?.id) throw new Error("No active organization") + const result = await authClient.organization.removeMember({ + memberIdOrEmail, + organizationId: org.id, + }) + if (result.error) { + throw new Error(result.error.message ?? "Failed to remove member") + } + return result.data + }, + onSuccess: async () => { + await refetchActiveOrg() + toast.success("Member removed") + }, + onError: (error) => { + toast.error(getErrorMessage(error, "Failed to remove member")) + }, + }) + + const cancelInvitationMutation = useMutation({ + mutationFn: async (invitationId: string) => { + const result = await authClient.organization.cancelInvitation({ + invitationId, + }) + if (result.error) { + throw new Error(result.error.message ?? "Failed to cancel invitation") + } + return result.data + }, + onSuccess: async () => { + await refetchActiveOrg() + toast.success("Invitation canceled") + }, + onError: (error) => { + toast.error(getErrorMessage(error, "Failed to cancel invitation")) + }, + }) + + const handleInviteSubmit = (event: React.FormEvent) => { + event.preventDefault() + if (!canManageTeam || inviteMemberMutation.isPending) return + inviteMemberMutation.mutate() + } + const planByOrgId = useMemo(() => { const map = new Map() for (const summary of orgSummaries ?? []) { @@ -318,8 +552,19 @@ export default function Account() {
-
- Team members +
+
+ Team members +

+ Invite people into {org?.name ?? "your organization"} and manage + their access. +

+
{(org?.members?.length ?? 0) > 0 && ( - {org?.members && org.members.length > 0 ? ( -
    - {[...org.members] - .sort((a, b) => { - const rolePriority = (r: string) => - r === "owner" ? 0 : r === "admin" ? 1 : 2 - const diff = - rolePriority(a.role.toLowerCase()) - - rolePriority(b.role.toLowerCase()) - if (diff !== 0) return diff - return (a.user?.name ?? "").localeCompare(b.user?.name ?? "") - }) - .map((m, idx) => { - const isYou = m.userId === user?.id - const name = m.user?.name ?? m.user?.email ?? "Unknown" - return ( -
  • + {canManageTeam ? ( +
    +
    +
    + +
    +
    +

    0 && "border-t border-white/[0.04]", + dmSans125ClassName(), + "text-[14px] font-medium tracking-[-0.14px] text-[#FAFAFA]", )} > - - - - {(name.charAt(0) || "U").toUpperCase()} - - -

    -
    - +

    + Choose role and permission preset before sending. +

    +
    +
    + +
    + ) : ( +
    +
    + +
    +

    + Only organization owners and admins can invite teammates or + change roles. +

    +
    + )} + + {pendingInvitations.length > 0 && ( +
    +

    + Pending invitations +

    +
      + {pendingInvitations.map((invitation) => ( +
    • +
      + +
      +
      +

      + {invitation.email} +

      +

      + Invited as {formatRole(invitation.role)} +

      +
      + {canManageTeam && ( +
      + +
      + )} +
    • + ))} +
    +
    + )} + + {org?.members && org.members.length > 0 ? ( +
      + {[...org.members] + .sort((a, b) => { + const rolePriority = (r: string) => + r === "owner" ? 0 : r === "admin" ? 1 : 2 + const diff = + rolePriority(a.role.toLowerCase()) - + rolePriority(b.role.toLowerCase()) + if (diff !== 0) return diff + return (a.user?.name ?? "").localeCompare( + b.user?.name ?? "", + ) + }) + .map((m, idx) => { + const isYou = m.userId === user?.id + const memberRole = m.role.toLowerCase() + const name = m.user?.name ?? m.user?.email ?? "Unknown" + const canEditMember = + canManageTeam && !isYou && memberRole !== "owner" + return ( +
    • 0 && "border-t border-white/[0.04]", + )} + > + + + + {(name.charAt(0) || "U").toUpperCase()} + + +
      +
      - You + {name} + + {isYou && ( + + You + + )} +
      + {m.user?.email && ( + + {m.user.email} )}
      - {m.user?.email && ( - { + if (value === memberRole) return + updateMemberRoleMutation.mutate({ + memberId: m.id, + role: value as InviteRole, + }) + }} > - {m.user.email} - + + + + + Member + Admin + + + ) : ( + )} -
    - -
  • - ) - })} -
- ) : ( -
-
- + {canEditMember && ( + + + + + + + removeMemberMutation.mutate(m.id) + } + disabled={ + removeMemberMutation.isPending || !isOwner + } + > + + Remove member + + + + )} + + ) + })} + + ) : ( +
+
+ +
+
+ + Just you for now + + + Invite teammates to start collaborating. + +
-
- - Just you for now - - + +
+ + { + setInviteDialogOpen(open) + if (!open && !inviteMemberMutation.isPending) { + setInviteEmail("") + setInviteRole("member") + } + }} + > + + + + Invite teammate + + +
+
+ +
+ + setInviteEmail(event.target.value)} + placeholder="teammate@company.com" + autoComplete="email" className={cn( dmSans125ClassName(), - "text-[12px] tracking-[-0.12px] text-[#737373]", + "h-10 w-full rounded-[10px] border border-white/[0.08] bg-[#0D0F14] pl-9 pr-3 text-[14px] text-[#FAFAFA] placeholder:text-[#525D6E] outline-none transition-colors focus:border-[#4BA0FA]/50", )} - > - Invite teammates from your organization settings. - + />
- )} - - + +
+

+ Role +

+
+ {(["member", "admin"] as const).map((role) => { + const selected = inviteRole === role + return ( + + ) + })} +
+
+ +
+

+ Permissions +

+
+ {(["member", "admin"] as const).map((role) => { + const option = INVITE_PERMISSION_OPTIONS[role] + const selected = inviteRole === role + return ( + + ) + })} +
+
+ +
+ + +
+
+
+
) } diff --git a/packages/lib/auth-context.tsx b/packages/lib/auth-context.tsx index 838a74e61..485628131 100644 --- a/packages/lib/auth-context.tsx +++ b/packages/lib/auth-context.tsx @@ -28,6 +28,7 @@ interface AuthContextType { setActiveOrg: (orgSlug: string) => Promise clearActiveOrg: () => void updateOrgMetadata: (partial: Record) => void + refetchActiveOrg: () => Promise refetchOrganizations: () => Promise } @@ -81,6 +82,13 @@ export function AuthProvider({ children }: { children: ReactNode }) { }) }, []) + const refetchActiveOrg = useCallback(async () => { + const full = await authClient.organization.getFullOrganization() + const nextOrg = full?.data ?? null + setOrg(nextOrg) + return nextOrg + }, []) + useEffect(() => { if (isSessionPending) return @@ -198,6 +206,7 @@ export function AuthProvider({ children }: { children: ReactNode }) { setActiveOrg, clearActiveOrg, updateOrgMetadata, + refetchActiveOrg, refetchOrganizations, }} >