diff --git a/apps/web/components/settings/account.tsx b/apps/web/components/settings/account.tsx index 57d1264b1..5ec84e597 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,41 @@ 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, DialogTitle } from "@ui/components/dialog" +import * as DialogPrimitive from "@radix-ui/react-dialog" import { useCustomer } from "autumn-js/react" -import { Check, LoaderIcon, ChevronDown, Building2, Users } from "lucide-react" -import { useMemo, useState } from "react" +import { useMutation, useQuery } from "@tanstack/react-query" +import { + Check, + LoaderIcon, + ChevronDown, + Building2, + Users, + UserPlus, + Mail, + MoreHorizontal, + UserMinus, + X, + Tag, + Plus, +} from "lucide-react" +import { useMemo, useRef, useState } from "react" +import { toast } from "sonner" +import { useContainerTags } from "@/hooks/use-container-tags" +import { PopoverAnchor } from "@ui/components/popover" function SectionTitle({ children }: { children: React.ReactNode }) { return ( @@ -69,6 +102,21 @@ const ROLE_LABELS: Record = { member: "Member", } +type InviteRole = "admin" | "member" + +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 +141,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 +165,45 @@ 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 [inviteAccessType, setInviteAccessType] = useState< + "full" | "restricted" + >("full") + const [inviteAssignments, setInviteAssignments] = useState< + { containerTag: string; permission: "read" | "write" }[] + >([]) + const [tagQuery, setTagQuery] = useState("") + const [tagDropdownOpen, setTagDropdownOpen] = useState(false) + const tagInputRef = useRef(null) + const tagAnchorRef = useRef(null) + const { allProjects: allContainerTags } = useContainerTags() + + const selectedTagSet = new Set(inviteAssignments.map((a) => a.containerTag)) + const filteredTags = useMemo(() => { + const available = (allContainerTags ?? []) + .map((t) => t.containerTag) + .filter((t) => !selectedTagSet.has(t)) + if (!tagQuery) return available + return available.filter((t) => + t.toLowerCase().includes(tagQuery.toLowerCase()), + ) + }, [allContainerTags, selectedTagSet, tagQuery]) + + const showAccessType = inviteRole === "member" + const showTagPicker = + inviteRole === "member" && inviteAccessType === "restricted" const canSwitchOrg = (allOrgs?.length ?? 0) > 1 const { data: orgSummaries } = useOrgSummaries() @@ -127,6 +221,167 @@ 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 resetInviteForm = () => { + setInviteEmail("") + setInviteRole("member") + setInviteAccessType("full") + setInviteAssignments([]) + setTagQuery("") + } + + 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 isRestricted = + inviteRole === "member" && inviteAccessType === "restricted" + const result = await authClient.organization.inviteMember({ + email, + role: inviteRole, + organizationId: org.id, + resend: true, + ...(isRestricted && inviteAssignments.length > 0 + ? { + data: { + accessType: "restricted", + containerTags: inviteAssignments, + }, + } + : {}), + }) + if (result.error) { + throw new Error(result.error.message ?? "Failed to invite teammate") + } + return result.data + }, + onSuccess: async (invitation) => { + resetInviteForm() + 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,118 +573,656 @@ export default function Account() {
-
- Team members - {(org?.members?.length ?? 0) > 0 && ( - +
+ Team members +

- {org?.members?.length}{" "} - {org?.members?.length === 1 ? "member" : "members"} - - )} + Invite people into {org?.name ?? "your organization"} and manage + their access. +

+
+
+ {(org?.members?.length ?? 0) > 0 && ( + + {org?.members?.length}{" "} + {org?.members?.length === 1 ? "member" : "members"} + + )} + {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 name = m.user?.name ?? m.user?.email ?? "Unknown" - return ( +
    + {!canManageTeam && ( +
    +
    + +
    +

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

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

    + Pending invitations +

    +
      + {pendingInvitations.map((invitation) => (
    • 0 && "border-t border-white/[0.04]", - )} + key={invitation.id} + className="flex items-center gap-3 px-3 py-2.5 border-t border-white/[0.04] first:border-t-0 bg-white/[0.015]" > - - - - {(name.charAt(0) || "U").toUpperCase()} - - -
      -
      - + +
      +
      +

      + {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()} + + +
      +
      + + {name} + + {isYou && ( + + You + + )} +
      + {m.user?.email && ( - You + {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. + +
-
- + +
+ + { + setInviteDialogOpen(open) + if (!open && !inviteMemberMutation.isPending) { + resetInviteForm() + } + }} + > + +
+
+ + Invite teammate + +

+ Send an invitation to join your organization. +

+
+ + + Close + +
+ +
+
+ {/* Email */} +
+
+ + {/* Role */} +
+

- Invite teammates from your organization settings. - + Role +

+
+ + {/* Access type (only for Member) */} + {showAccessType && ( +
+

+ Access +

+
+ {(["full", "restricted"] as const).map((type) => { + const selected = inviteAccessType === type + return ( + + ) + })} +
+
+ )} + + {/* Container tag picker (only for Restricted) */} + {showTagPicker && ( +
+

+ Spaces +

+ + 0 || + (tagQuery.trim().length > 0 && + !selectedTagSet.has(tagQuery.trim()))) + } + onOpenChange={setTagDropdownOpen} + > + +
+ { + setTagQuery(e.target.value) + if (!tagDropdownOpen) setTagDropdownOpen(true) + }} + onClick={() => setTagDropdownOpen(true)} + onFocus={() => setTagDropdownOpen(true)} + placeholder="Search or create spaces..." + className={cn( + dmSans125ClassName(), + "h-full w-full bg-transparent pl-3 pr-8 text-[13px] text-[#FAFAFA] placeholder:text-[#525D6E] outline-none", + )} + /> + +
+
+ e.preventDefault()} + onPointerDownOutside={(e) => { + if (tagAnchorRef.current?.contains(e.target as Node)) { + e.preventDefault() + } + }} + > + {filteredTags.map((tag) => ( + + ))} + {tagQuery.trim().length > 0 && + !selectedTagSet.has(tagQuery.trim()) && + !(allContainerTags ?? []).some( + (t) => + t.containerTag.toLowerCase() === + tagQuery.trim().toLowerCase(), + ) && ( + + )} + +
+ + {/* Selected tags */} + {inviteAssignments.length > 0 && ( +
+ {inviteAssignments.map((a) => ( +
+ + + {a.containerTag} + +
+ {(["read", "write"] as const).map((perm) => { + const active = a.permission === perm + return ( + + ) + })} +
+
+ ))} +
+ )} + + {inviteAssignments.length === 0 && ( +

+ Select at least one space +

+ )} +
+ )}
- )} - - + +
+ + +
+
+
+
) } 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, }} > diff --git a/packages/ui/components/dialog.tsx b/packages/ui/components/dialog.tsx index b40fabba6..87d295c10 100644 --- a/packages/ui/components/dialog.tsx +++ b/packages/ui/components/dialog.tsx @@ -36,7 +36,7 @@ function DialogOverlay({ return (