"use client" import { dmSans125ClassName } from "@/lib/fonts" import { cn } from "@lib/utils" import { useAuth } from "@lib/auth-context" import { authClient } from "@lib/auth" import { Avatar, AvatarFallback, AvatarImage } from "@ui/components/avatar" import { Popover, PopoverContent } 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 { useMutation, useQuery } from "@tanstack/react-query" import { Copy, LoaderIcon, ChevronDown, Users, UserPlus, Mail, MoreHorizontal, UserMinus, X, Pencil, Tag, Plus, } from "lucide-react" import { useQueryState } from "nuqs" import { useEffect, useMemo, useRef, useState } from "react" import { toast } from "sonner" import { useContainerTags } from "@/hooks/use-container-tags" import { PopoverAnchor } from "@ui/components/popover" import { OrgContext } from "@/components/settings/org-context" import { OrgPlanBadge } from "@/components/org-plan-badge" import { useTokenUsage } from "@/hooks/use-token-usage" import { useOrgSummaries } from "@/hooks/use-org-summaries" import { useCustomer } from "autumn-js/react" import { FileText, Layers, Plug, Search } from "lucide-react" import { $fetch } from "@lib/api" function SectionTitle({ children }: { children: React.ReactNode }) { return (

{children}

) } function SettingsCard({ children }: { children: React.ReactNode }) { return (
{children}
) } /** Matches ACTIVE / RECOMMENDED pills in billing settings. */ const ROLE_LABELS: Record = { owner: "Owner", admin: "Admin", 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] return r ? r.charAt(0).toUpperCase() + r.slice(1) : "Member" } function RolePill({ role }: { role: string }) { const r = role?.toLowerCase() ?? "" const isOwner = r === "owner" return ( {formatRole(role)} ) } 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() } export default function Account({ dialogPortalContainer, }: { dialogPortalContainer?: HTMLElement | null }) { const { user, org, refetchActiveOrg, refetchOrganizations } = useAuth() const autumn = useCustomer() const { currentPlan, searchesUsed } = useTokenUsage(autumn) const { data: orgSummaries } = useOrgSummaries() const orgSummary = orgSummaries?.find((s) => s.orgId === org?.id) 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 [isEditingOrgName, setIsEditingOrgName] = useState(false) const [orgNameDraft, setOrgNameDraft] = useState("") const tagInputRef = useRef(null) const inviteEmailInputRef = 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" useEffect(() => { setOrgNameDraft(org?.name ?? "") setIsEditingOrgName(false) }, [org?.name]) // Deep link: ?invite=1 (e.g. from the dashboard) opens the invite dialog. // Consumed below, once the role is known and only for admins/owners. const [inviteParam, setInviteParam] = useQueryState("invite") const activeMemberRoleQuery = useQuery({ queryKey: ["organization", org?.id, "active-member-role"], queryFn: async () => { if (!org?.id) return null const result = await authClient.organization.getActiveMember({ 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], ) // Only treat as a personal single-member org when members are actually loaded — // otherwise default to least privilege (member), never owner. const membersLoaded = Array.isArray(org?.members) const isSingleMemberPersonalOrg = membersLoaded && (org?.members?.length ?? 0) <= 1 && (!org?.members?.[0]?.userId || org.members[0].userId === user?.id) const currentRole = ( activeMemberRoleQuery.data ?? currentMember?.role ?? (isSingleMemberPersonalOrg ? "owner" : "member") ).toLowerCase() const canManageTeam = currentRole === "owner" || currentRole === "admin" const isOwner = currentRole === "owner" // Consume ?invite=1 only after the role resolves, and only for managers. useEffect(() => { if (inviteParam !== "1" || activeMemberRoleQuery.isLoading) return if (canManageTeam) setInviteDialogOpen(true) setInviteParam(null) }, [ inviteParam, activeMemberRoleQuery.isLoading, canManageTeam, setInviteParam, ]) const pendingInvitations = useMemo( () => (org?.invitations ?? []).filter(isPendingInvitation), [org?.invitations], ) const showTeamCard = !canManageTeam || pendingInvitations.length > 0 || (org?.members?.length ?? 0) > 1 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 updateOrgNameMutation = useMutation({ mutationFn: async (name: string) => { if (!org?.id) throw new Error("No active organization") const trimmed = name.trim() if (!trimmed) throw new Error("Enter an organization name") const result = await authClient.organization.update({ organizationId: org.id, data: { name: trimmed }, }) if (result.error) { throw new Error( result.error.message ?? "Failed to update organization name", ) } return trimmed }, onSuccess: async (name) => { setOrgNameDraft(name) setIsEditingOrgName(false) await Promise.all([refetchActiveOrg(), refetchOrganizations()]) toast.success("Organization name updated") }, onError: (error) => { toast.error(getErrorMessage(error, "Failed to update organization name")) }, }) const handleInviteSubmit = (event: React.FormEvent) => { event.preventDefault() if (!canManageTeam || inviteMemberMutation.isPending) return inviteMemberMutation.mutate() } const handleOrgNameSubmit = (event: React.FormEvent) => { event.preventDefault() if (!canManageTeam || updateOrgNameMutation.isPending) return const trimmed = orgNameDraft.trim() if (!trimmed || trimmed === org?.name) { setOrgNameDraft(org?.name ?? "") setIsEditingOrgName(false) return } updateOrgNameMutation.mutate(trimmed) } const memberSince = user?.createdAt ? new Date(user.createdAt).toLocaleDateString("en-US", { month: "short", year: "numeric", }) : "—" return (
{/* Identity */}
{user?.name?.charAt(0) ?? "U"}

{user?.name ?? "—"}

{user?.email ?? "—"}

{/* Fields */}
Organization {org?.id ? ( ) : null} {isEditingOrgName ? (
setOrgNameDraft(event.target.value)} disabled={updateOrgNameMutation.isPending} maxLength={80} className={cn( dmSans125ClassName(), "h-8 min-w-0 flex-1 rounded-[9px] border border-white/10 bg-black/30 px-2.5 text-[13px] font-medium tracking-[-0.13px] text-[#FAFAFA] outline-none transition-colors placeholder:text-[#525252] focus:border-[#4BA0FA]/60", )} placeholder="Organization name" />
) : (
{org?.name ?? "Personal"} {currentRole} {canManageTeam ? ( ) : null}
)}
Member since {memberSince}
Overview
{[ { label: "Memories", value: orgSummary?.documentCount, icon: FileText, }, { label: "Spaces", value: orgSummary?.containerTagCount, icon: Layers, }, { label: "Connections", value: orgSummary?.activeConnectors, icon: Plug, }, { label: "Searches", value: searchesUsed, icon: Search }, ].map(({ label, value, icon: Icon }) => (
{typeof value === "number" ? value.toLocaleString() : "—"} {label}
))}
{canManageTeam && }
Team members {(org?.members?.length ?? 0) > 0 && ( {org?.members?.length} )}

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

{canManageTeam && ( )}
{showTeamCard && (
{!canManageTeam && (

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) => { 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 (
  • {(name.charAt(0) || "U").toUpperCase()}
    {name} {isYou && ( You )}
    {m.user?.email && ( {m.user.email} )}
    {canEditMember ? ( ) : ( )} {canEditMember && ( removeMemberMutation.mutate(m.id) } disabled={ removeMemberMutation.isPending || !isOwner } > Remove member )}
  • ) })}
)}
)}
{ setInviteDialogOpen(open) if (!open && !inviteMemberMutation.isPending) { resetInviteForm() } }} > { event.preventDefault() inviteEmailInputRef.current?.focus() }} className="sm:max-w-[480px] border-none bg-[#1B1F24] p-0 gap-0 rounded-[22px] overflow-hidden" >
Invite teammate

Send an invitation to join your organization.

Close
{/* Email */}
setInviteEmail(event.target.value)} placeholder="colleague@company.com" autoComplete="email" className={cn( dmSans125ClassName(), "h-10 w-full rounded-[10px] border border-white/[0.06] bg-white/[0.02] pl-9 pr-3 text-[14px] text-[#FAFAFA] placeholder:text-[#525D6E] outline-none transition-colors focus:border-[#2261CA33]", )} />
{/* Role */}

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

)}
)}
) } function DigestPreferences() { const { data, isLoading } = useQuery({ queryKey: ["digest-preferences"], queryFn: async () => { const res = await $fetch("@get/digests/preferences") if (res.error) throw new Error("Failed") return res.data as { digestOptOut: boolean } }, }) const mutation = useMutation({ mutationFn: async (digestOptOut: boolean) => { const res = await $fetch("@post/digests/preferences", { body: { digestOptOut }, }) if (res.error) throw new Error("Failed") return res.data as { digestOptOut: boolean } }, onError: () => toast.error("Failed to update preference"), }) const optOut = mutation.data?.digestOptOut ?? data?.digestOptOut ?? false return (
Notifications

Weekly digest

Personalized weekly recap of your memories, delivered every Monday

) }