"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 ? (
) : (
{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 && (
)}
{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
)}
)
})}
)}
)}
)
}
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
)
}