mirror of
https://github.com/supermemoryai/supermemory.git
synced 2026-09-05 08:06:19 +00:00
Co-authored-by: ved015 <vedant.04.mahajan@gmail.com> Co-authored-by: Mahesh Sanikommu <maheshthedev@gmail.com>
1228 lines
38 KiB
TypeScript
1228 lines
38 KiB
TypeScript
"use client"
|
|
|
|
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 {
|
|
PLAN_DISPLAY_NAMES,
|
|
PLAN_RANK,
|
|
useTokenUsage,
|
|
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 { 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 (
|
|
<p
|
|
className={cn(
|
|
dmSans125ClassName(),
|
|
"font-semibold text-[20px] tracking-[-0.2px] text-[#FAFAFA] px-2",
|
|
)}
|
|
>
|
|
{children}
|
|
</p>
|
|
)
|
|
}
|
|
|
|
function SettingsCard({ children }: { children: React.ReactNode }) {
|
|
return (
|
|
<div
|
|
className={cn(
|
|
"relative bg-[#14161A] rounded-[14px] p-6 w-full overflow-hidden",
|
|
"shadow-[inset_2.42px_2.42px_4.263px_rgba(11,15,21,0.7)]",
|
|
)}
|
|
>
|
|
{children}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
/** Matches ACTIVE / RECOMMENDED pills in billing settings. */
|
|
const orgPlanBadgeBase = cn(
|
|
dmSans125ClassName(),
|
|
"inline-flex h-[18px] min-w-[42px] shrink-0 items-center justify-center rounded-[3px] px-1.5 text-[10px] uppercase",
|
|
)
|
|
|
|
const ORG_PLAN_BADGE_STYLES: Record<PlanType, string> = {
|
|
free: "bg-[#2E353D] font-mono font-medium tracking-[0.12em] text-[#A3A3A3]",
|
|
pro: "bg-[#4BA0FA] font-bold tracking-[0.36px] text-[#00171A]",
|
|
scale: "bg-[#0054AD] font-bold tracking-[0.36px] text-[#FAFAFA]",
|
|
enterprise: "bg-[#FAFAFA] font-bold tracking-[0.36px] text-[#0D121A]",
|
|
}
|
|
|
|
function OrgPlanBadge({ plan }: { plan: PlanType }) {
|
|
return (
|
|
<span className={cn(orgPlanBadgeBase, ORG_PLAN_BADGE_STYLES[plan])}>
|
|
{PLAN_DISPLAY_NAMES[plan]}
|
|
</span>
|
|
)
|
|
}
|
|
|
|
const ROLE_LABELS: Record<string, string> = {
|
|
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 (
|
|
<span
|
|
className={cn(
|
|
dmSans125ClassName(),
|
|
"inline-flex items-center rounded-md px-2 py-0.5 text-[11px] font-medium tracking-[0.02em]",
|
|
isOwner
|
|
? "bg-[#4BA0FA]/10 text-[#4BA0FA]"
|
|
: "bg-white/5 text-[#A3A3A3]",
|
|
)}
|
|
>
|
|
{formatRole(role)}
|
|
</span>
|
|
)
|
|
}
|
|
|
|
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,
|
|
currentPlan: PlanType,
|
|
planByOrgId: Map<string, PlanType>,
|
|
): PlanType {
|
|
const fromSummary = planByOrgId.get(orgId)
|
|
if (fromSummary) return fromSummary
|
|
if (isCurrent) return currentPlan
|
|
return "free"
|
|
}
|
|
|
|
export default function Account() {
|
|
const {
|
|
user,
|
|
org,
|
|
organizations: allOrgs,
|
|
setActiveOrg,
|
|
refetchActiveOrg,
|
|
} = useAuth()
|
|
const autumn = useCustomer()
|
|
const [switchingOrgId, setSwitchingOrgId] = useState<string | null>(null)
|
|
const [orgMenuOpen, setOrgMenuOpen] = useState(false)
|
|
const [inviteDialogOpen, setInviteDialogOpen] = useState(false)
|
|
const [inviteEmail, setInviteEmail] = useState("")
|
|
const [inviteRole, setInviteRole] = useState<InviteRole>("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<HTMLInputElement>(null)
|
|
const tagAnchorRef = useRef<HTMLDivElement>(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()
|
|
|
|
const handleOrgSwitch = async (orgSlug: string, orgId: string) => {
|
|
if (orgId === org?.id) return
|
|
setSwitchingOrgId(orgId)
|
|
try {
|
|
await setActiveOrg(orgSlug)
|
|
window.location.reload()
|
|
} catch (error) {
|
|
console.error("Failed to switch organization:", error)
|
|
setSwitchingOrgId(null)
|
|
}
|
|
}
|
|
|
|
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<HTMLFormElement>) => {
|
|
event.preventDefault()
|
|
if (!canManageTeam || inviteMemberMutation.isPending) return
|
|
inviteMemberMutation.mutate()
|
|
}
|
|
|
|
const planByOrgId = useMemo(() => {
|
|
const map = new Map<string, PlanType>()
|
|
for (const summary of orgSummaries ?? []) {
|
|
map.set(summary.orgId, summary.plan)
|
|
}
|
|
return map
|
|
}, [orgSummaries])
|
|
|
|
const sortedOrgsForMenu = useMemo(() => {
|
|
if (!allOrgs?.length) return []
|
|
return [...allOrgs].sort((a, b) => {
|
|
const planA = resolveOrgPlan(
|
|
a.id,
|
|
a.id === org?.id,
|
|
currentPlan,
|
|
planByOrgId,
|
|
)
|
|
const planB = resolveOrgPlan(
|
|
b.id,
|
|
b.id === org?.id,
|
|
currentPlan,
|
|
planByOrgId,
|
|
)
|
|
const rankDiff = PLAN_RANK[planB] - PLAN_RANK[planA]
|
|
if (rankDiff !== 0) return rankDiff
|
|
return a.name.localeCompare(b.name)
|
|
})
|
|
}, [allOrgs, org?.id, currentPlan, planByOrgId])
|
|
|
|
const memberSince = user?.createdAt
|
|
? new Date(user.createdAt).toLocaleDateString("en-US", {
|
|
month: "short",
|
|
year: "numeric",
|
|
})
|
|
: "—"
|
|
|
|
return (
|
|
<div className="flex flex-col gap-8 w-full">
|
|
<section id="profile-details" className="flex flex-col gap-4">
|
|
<SectionTitle>Profile Details</SectionTitle>
|
|
<SettingsCard>
|
|
<div className="flex flex-col gap-6">
|
|
{/* Avatar + Name/Email */}
|
|
<div className="flex items-center gap-4">
|
|
<div className="relative size-16 rounded-full bg-linear-to-b from-[#0D121A] to-black overflow-hidden shrink-0">
|
|
<Avatar className="size-full">
|
|
<AvatarImage
|
|
src={user?.image ?? ""}
|
|
alt={user?.name ?? "User"}
|
|
className="object-cover"
|
|
/>
|
|
<AvatarFallback className="bg-transparent text-white text-xl">
|
|
{user?.name?.charAt(0) ?? "U"}
|
|
</AvatarFallback>
|
|
</Avatar>
|
|
</div>
|
|
<div className="flex flex-col gap-1.5">
|
|
<p
|
|
className={cn(
|
|
dmSans125ClassName(),
|
|
"font-semibold text-[20px] tracking-[-0.2px] text-[#FAFAFA]",
|
|
)}
|
|
>
|
|
{user?.name ?? "—"}
|
|
</p>
|
|
<p
|
|
className={cn(
|
|
dmSans125ClassName(),
|
|
"font-medium text-[16px] tracking-[-0.16px] text-[#FAFAFA]",
|
|
)}
|
|
>
|
|
{user?.email ?? "—"}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex gap-4">
|
|
<div className="flex-1 flex flex-col gap-2">
|
|
<p
|
|
className={cn(
|
|
dmSans125ClassName(),
|
|
"font-medium text-[16px] tracking-[-0.16px] text-[#737373]",
|
|
)}
|
|
>
|
|
Organization
|
|
</p>
|
|
<Popover
|
|
open={orgMenuOpen && canSwitchOrg}
|
|
onOpenChange={(open) => {
|
|
if (canSwitchOrg) setOrgMenuOpen(open)
|
|
}}
|
|
>
|
|
<PopoverTrigger
|
|
disabled={!canSwitchOrg}
|
|
className={cn(
|
|
"flex items-center gap-2 transition-opacity",
|
|
canSwitchOrg
|
|
? "cursor-pointer hover:opacity-90"
|
|
: "cursor-default",
|
|
dmSans125ClassName(),
|
|
)}
|
|
>
|
|
<span
|
|
className={cn(
|
|
dmSans125ClassName(),
|
|
"font-medium text-[16px] tracking-[-0.16px] text-[#FAFAFA]",
|
|
)}
|
|
>
|
|
{org?.name ?? "Personal"}
|
|
</span>
|
|
{canSwitchOrg && (
|
|
<ChevronDown className="size-4 text-[#737373]" />
|
|
)}
|
|
</PopoverTrigger>
|
|
{canSwitchOrg && (
|
|
<PopoverContent
|
|
align="start"
|
|
className="w-80 max-h-80 overflow-y-auto bg-[#1B1F24] rounded-[12px] border-white/10 p-1.5 shadow-[0px_4px_16px_rgba(0,0,0,0.4)]"
|
|
>
|
|
{sortedOrgsForMenu.map((organization) => {
|
|
const isCurrent = organization.id === org?.id
|
|
const isSwitching = switchingOrgId === organization.id
|
|
const plan = resolveOrgPlan(
|
|
organization.id,
|
|
isCurrent,
|
|
currentPlan,
|
|
planByOrgId,
|
|
)
|
|
return (
|
|
<button
|
|
key={organization.id}
|
|
type="button"
|
|
disabled={isCurrent || isSwitching}
|
|
onClick={() =>
|
|
handleOrgSwitch(
|
|
organization.slug,
|
|
organization.id,
|
|
)
|
|
}
|
|
className={cn(
|
|
"w-full flex items-center gap-3 px-3 py-2.5 rounded-[8px] text-left transition-colors",
|
|
isCurrent
|
|
? "bg-white/5"
|
|
: "hover:bg-white/5 cursor-pointer",
|
|
"disabled:opacity-60 disabled:cursor-default",
|
|
dmSans125ClassName(),
|
|
)}
|
|
>
|
|
<Building2 className="size-4 text-[#737373] shrink-0" />
|
|
<p className="min-w-0 flex-1 truncate text-[14px] tracking-[-0.14px] text-[#FAFAFA]">
|
|
{organization.name}
|
|
</p>
|
|
{isSwitching ? (
|
|
<LoaderIcon className="size-4 shrink-0 animate-spin text-[#4BA0FA]" />
|
|
) : isCurrent ? (
|
|
<Check className="size-4 shrink-0 text-[#4BA0FA]" />
|
|
) : (
|
|
<span className="size-4 shrink-0" aria-hidden />
|
|
)}
|
|
<OrgPlanBadge plan={plan} />
|
|
</button>
|
|
)
|
|
})}
|
|
</PopoverContent>
|
|
)}
|
|
</Popover>
|
|
</div>
|
|
<div className="flex-1 flex flex-col gap-2">
|
|
<p
|
|
className={cn(
|
|
dmSans125ClassName(),
|
|
"font-medium text-[16px] tracking-[-0.16px] text-[#737373]",
|
|
)}
|
|
>
|
|
Member since
|
|
</p>
|
|
<p
|
|
className={cn(
|
|
dmSans125ClassName(),
|
|
"font-medium text-[16px] tracking-[-0.16px] text-[#FAFAFA]",
|
|
)}
|
|
>
|
|
{memberSince}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</SettingsCard>
|
|
</section>
|
|
|
|
<section id="team-members" className="flex flex-col gap-4">
|
|
<div className="flex flex-wrap items-center justify-between gap-3 px-2">
|
|
<div className="flex flex-col gap-1">
|
|
<SectionTitle>Team members</SectionTitle>
|
|
<p
|
|
className={cn(
|
|
dmSans125ClassName(),
|
|
"text-[13px] tracking-[-0.13px] text-[#737373] px-2",
|
|
)}
|
|
>
|
|
Invite people into {org?.name ?? "your organization"} and manage
|
|
their access.
|
|
</p>
|
|
</div>
|
|
<div className="flex items-center gap-3">
|
|
{(org?.members?.length ?? 0) > 0 && (
|
|
<span
|
|
className={cn(
|
|
dmSans125ClassName(),
|
|
"text-[13px] tracking-[-0.13px] text-[#737373] tabular-nums",
|
|
)}
|
|
>
|
|
{org?.members?.length}{" "}
|
|
{org?.members?.length === 1 ? "member" : "members"}
|
|
</span>
|
|
)}
|
|
{canManageTeam && (
|
|
<button
|
|
type="button"
|
|
onClick={() => setInviteDialogOpen(true)}
|
|
disabled={!org?.id}
|
|
className={cn(
|
|
dmSans125ClassName(),
|
|
"inline-flex h-9 items-center justify-center gap-2 rounded-full bg-[#14161A] px-4 text-[13px] font-semibold text-[#FAFAFA] shadow-inside-out transition-colors hover:bg-[#121820] disabled:cursor-not-allowed disabled:opacity-45",
|
|
)}
|
|
>
|
|
<UserPlus className="size-3.5" />
|
|
Invite member
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
<SettingsCard>
|
|
<div className="flex flex-col gap-5">
|
|
{!canManageTeam && (
|
|
<div className="flex items-center gap-3 rounded-[12px] border border-white/[0.06] bg-white/[0.02] p-3">
|
|
<div className="size-9 rounded-full bg-white/[0.04] flex items-center justify-center shrink-0">
|
|
<Users className="size-4 text-[#737373]" />
|
|
</div>
|
|
<p
|
|
className={cn(
|
|
dmSans125ClassName(),
|
|
"text-[13px] tracking-[-0.13px] text-[#737373]",
|
|
)}
|
|
>
|
|
Only organization owners and admins can invite teammates or
|
|
change roles.
|
|
</p>
|
|
</div>
|
|
)}
|
|
|
|
{pendingInvitations.length > 0 && (
|
|
<div className="flex flex-col gap-2">
|
|
<p
|
|
className={cn(
|
|
dmSans125ClassName(),
|
|
"text-[11px] uppercase tracking-[0.12em] text-[#737373] font-mono",
|
|
)}
|
|
>
|
|
Pending invitations
|
|
</p>
|
|
<ul className="flex flex-col rounded-[12px] border border-white/[0.05] overflow-hidden">
|
|
{pendingInvitations.map((invitation) => (
|
|
<li
|
|
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]"
|
|
>
|
|
<div className="size-9 rounded-full bg-[#0D121A] flex items-center justify-center shrink-0">
|
|
<Mail className="size-4 text-[#737373]" />
|
|
</div>
|
|
<div className="min-w-0 flex-1">
|
|
<p
|
|
className={cn(
|
|
dmSans125ClassName(),
|
|
"truncate text-[14px] font-medium tracking-[-0.14px] text-[#FAFAFA]",
|
|
)}
|
|
>
|
|
{invitation.email}
|
|
</p>
|
|
<p
|
|
className={cn(
|
|
dmSans125ClassName(),
|
|
"text-[12px] tracking-[-0.12px] text-[#737373]",
|
|
)}
|
|
>
|
|
Invited as {formatRole(invitation.role)}
|
|
</p>
|
|
</div>
|
|
{canManageTeam && (
|
|
<div className="flex shrink-0 items-center gap-1">
|
|
<button
|
|
type="button"
|
|
disabled={cancelInvitationMutation.isPending}
|
|
onClick={() =>
|
|
cancelInvitationMutation.mutate(invitation.id)
|
|
}
|
|
className="flex size-8 items-center justify-center rounded-[8px] text-[#8A5247] hover:bg-[#1A0F0C]/60 hover:text-[#C73B1B] disabled:opacity-50"
|
|
aria-label={`Cancel invitation for ${invitation.email}`}
|
|
title="Cancel invitation"
|
|
>
|
|
<X className="size-4" />
|
|
</button>
|
|
</div>
|
|
)}
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</div>
|
|
)}
|
|
|
|
{org?.members && org.members.length > 0 ? (
|
|
<ul className="flex flex-col">
|
|
{[...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 (
|
|
<li
|
|
key={m.id}
|
|
className={cn(
|
|
"flex items-center gap-3 py-2.5",
|
|
idx > 0 && "border-t border-white/[0.04]",
|
|
)}
|
|
>
|
|
<Avatar className="size-9 shrink-0 bg-[#0D121A]">
|
|
<AvatarImage
|
|
src={m.user?.image ?? ""}
|
|
alt={name}
|
|
className="object-cover"
|
|
/>
|
|
<AvatarFallback className="bg-transparent text-white text-[13px] font-medium">
|
|
{(name.charAt(0) || "U").toUpperCase()}
|
|
</AvatarFallback>
|
|
</Avatar>
|
|
<div className="flex-1 min-w-0 flex flex-col gap-0.5">
|
|
<div className="flex items-center gap-2 min-w-0">
|
|
<span
|
|
className={cn(
|
|
dmSans125ClassName(),
|
|
"font-medium text-[14px] tracking-[-0.14px] text-[#FAFAFA] truncate",
|
|
)}
|
|
>
|
|
{name}
|
|
</span>
|
|
{isYou && (
|
|
<span
|
|
className={cn(
|
|
dmSans125ClassName(),
|
|
"text-[10.5px] uppercase tracking-[0.1em] text-[#737373] font-mono",
|
|
)}
|
|
>
|
|
You
|
|
</span>
|
|
)}
|
|
</div>
|
|
{m.user?.email && (
|
|
<span
|
|
className={cn(
|
|
dmSans125ClassName(),
|
|
"text-[12px] tracking-[-0.12px] text-[#737373] truncate",
|
|
)}
|
|
>
|
|
{m.user.email}
|
|
</span>
|
|
)}
|
|
</div>
|
|
{canEditMember ? (
|
|
<Select
|
|
value={memberRole}
|
|
onValueChange={(value) => {
|
|
if (value === memberRole) return
|
|
updateMemberRoleMutation.mutate({
|
|
memberId: m.id,
|
|
role: value as InviteRole,
|
|
})
|
|
}}
|
|
>
|
|
<SelectTrigger className="h-8 w-[112px] rounded-[8px] border-white/[0.08] bg-[#0D0F14] text-[#FAFAFA]">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="member">Member</SelectItem>
|
|
<SelectItem value="admin">Admin</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
) : (
|
|
<RolePill role={m.role} />
|
|
)}
|
|
{canEditMember && (
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger asChild>
|
|
<button
|
|
type="button"
|
|
className="flex size-8 shrink-0 items-center justify-center rounded-[8px] text-[#737373] hover:bg-white/[0.05] hover:text-[#FAFAFA]"
|
|
aria-label={`Team actions for ${name}`}
|
|
>
|
|
<MoreHorizontal className="size-4" />
|
|
</button>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent align="end" className="w-44">
|
|
<DropdownMenuItem
|
|
className="text-[#C73B1B] focus:text-[#C73B1B]"
|
|
onSelect={() =>
|
|
removeMemberMutation.mutate(m.id)
|
|
}
|
|
disabled={
|
|
removeMemberMutation.isPending || !isOwner
|
|
}
|
|
>
|
|
<UserMinus className="size-4" />
|
|
Remove member
|
|
</DropdownMenuItem>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
)}
|
|
</li>
|
|
)
|
|
})}
|
|
</ul>
|
|
) : (
|
|
<div className="flex items-center gap-3 py-2">
|
|
<div className="size-9 rounded-full bg-white/[0.04] flex items-center justify-center shrink-0">
|
|
<Users className="size-4 text-[#737373]" />
|
|
</div>
|
|
<div className="flex flex-col">
|
|
<span
|
|
className={cn(
|
|
dmSans125ClassName(),
|
|
"font-medium text-[14px] tracking-[-0.14px] text-[#FAFAFA]",
|
|
)}
|
|
>
|
|
Just you for now
|
|
</span>
|
|
<span
|
|
className={cn(
|
|
dmSans125ClassName(),
|
|
"text-[12px] tracking-[-0.12px] text-[#737373]",
|
|
)}
|
|
>
|
|
Invite teammates to start collaborating.
|
|
</span>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</SettingsCard>
|
|
</section>
|
|
|
|
<Dialog
|
|
open={inviteDialogOpen}
|
|
onOpenChange={(open) => {
|
|
setInviteDialogOpen(open)
|
|
if (!open && !inviteMemberMutation.isPending) {
|
|
resetInviteForm()
|
|
}
|
|
}}
|
|
>
|
|
<DialogContent
|
|
showCloseButton={false}
|
|
className="sm:max-w-[480px] border-none bg-[#1B1F24] p-0 gap-0 rounded-[22px] overflow-hidden"
|
|
>
|
|
<div className="flex items-start justify-between gap-3 px-6 pt-6 pb-4">
|
|
<div className="flex flex-col gap-1">
|
|
<DialogTitle
|
|
className={cn(
|
|
dmSans125ClassName(),
|
|
"text-[18px] font-semibold tracking-[-0.18px] text-[#FAFAFA]",
|
|
)}
|
|
>
|
|
Invite teammate
|
|
</DialogTitle>
|
|
<p
|
|
className={cn(
|
|
dmSans125ClassName(),
|
|
"text-[13px] tracking-[-0.13px] text-[#737373]",
|
|
)}
|
|
>
|
|
Send an invitation to join your organization.
|
|
</p>
|
|
</div>
|
|
<DialogPrimitive.Close
|
|
className="flex size-7 shrink-0 items-center justify-center rounded-full border border-[rgba(115,115,115,0.2)] bg-[#0D121A] transition-opacity hover:opacity-100 focus:outline-hidden"
|
|
style={{
|
|
boxShadow: "inset 1.313px 1.313px 3.938px 0px rgba(0,0,0,0.7)",
|
|
}}
|
|
>
|
|
<X className="size-4" stroke="#737373" />
|
|
<span className="sr-only">Close</span>
|
|
</DialogPrimitive.Close>
|
|
</div>
|
|
|
|
<form onSubmit={handleInviteSubmit} className="flex flex-col">
|
|
<div className="flex flex-col gap-5 px-6">
|
|
{/* Email */}
|
|
<div className="flex flex-col gap-1.5">
|
|
<label
|
|
htmlFor="team-invite-email"
|
|
className={cn(
|
|
dmSans125ClassName(),
|
|
"text-[13px] font-medium text-[#FAFAFA]",
|
|
)}
|
|
>
|
|
Email
|
|
</label>
|
|
<div className="relative min-w-0">
|
|
<Mail className="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-[#525D6E]" />
|
|
<input
|
|
id="team-invite-email"
|
|
type="email"
|
|
value={inviteEmail}
|
|
onChange={(event) => 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]",
|
|
)}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Role */}
|
|
<div className="flex flex-col gap-1.5">
|
|
<p
|
|
className={cn(
|
|
dmSans125ClassName(),
|
|
"text-[13px] font-medium text-[#FAFAFA]",
|
|
)}
|
|
>
|
|
Role
|
|
</p>
|
|
<Select
|
|
value={inviteRole}
|
|
onValueChange={(value) => {
|
|
const role = value as InviteRole
|
|
setInviteRole(role)
|
|
if (role === "admin") {
|
|
setInviteAccessType("full")
|
|
setInviteAssignments([])
|
|
}
|
|
}}
|
|
>
|
|
<SelectTrigger className="h-9 w-full rounded-[10px] border-white/[0.08] bg-[#0D0F14] text-[#FAFAFA]">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="member">Member</SelectItem>
|
|
<SelectItem value="admin">Admin</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
{/* Access type (only for Member) */}
|
|
{showAccessType && (
|
|
<div className="flex flex-col gap-1.5">
|
|
<p
|
|
className={cn(
|
|
dmSans125ClassName(),
|
|
"text-[13px] font-medium text-[#FAFAFA]",
|
|
)}
|
|
>
|
|
Access
|
|
</p>
|
|
<div className="grid grid-cols-2 gap-2">
|
|
{(["full", "restricted"] as const).map((type) => {
|
|
const selected = inviteAccessType === type
|
|
return (
|
|
<button
|
|
key={type}
|
|
type="button"
|
|
onClick={() => {
|
|
setInviteAccessType(type)
|
|
if (type === "full") setInviteAssignments([])
|
|
}}
|
|
className={cn(
|
|
dmSans125ClassName(),
|
|
"flex items-center justify-center h-9 rounded-[10px] border text-[13px] font-medium transition-colors cursor-pointer",
|
|
selected
|
|
? "border-white/10 bg-[#14161A] text-[#FAFAFA] shadow-inside-out"
|
|
: "border-[#161F2C] bg-[#0D121A] text-[#737373] hover:bg-[#14161A] hover:text-[#FAFAFA]",
|
|
)}
|
|
>
|
|
{type === "full" ? "Full Access" : "Restricted"}
|
|
</button>
|
|
)
|
|
})}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Container tag picker (only for Restricted) */}
|
|
{showTagPicker && (
|
|
<div className="flex flex-col gap-2">
|
|
<p
|
|
className={cn(
|
|
dmSans125ClassName(),
|
|
"text-[13px] font-medium text-[#FAFAFA]",
|
|
)}
|
|
>
|
|
Spaces
|
|
</p>
|
|
|
|
<Popover
|
|
open={
|
|
tagDropdownOpen &&
|
|
(filteredTags.length > 0 ||
|
|
(tagQuery.trim().length > 0 &&
|
|
!selectedTagSet.has(tagQuery.trim())))
|
|
}
|
|
onOpenChange={setTagDropdownOpen}
|
|
>
|
|
<PopoverAnchor asChild>
|
|
<div
|
|
ref={tagAnchorRef}
|
|
className={cn(
|
|
"relative flex items-center w-full h-10",
|
|
"rounded-[10px] border border-white/[0.06] bg-white/[0.02]",
|
|
"transition-colors focus-within:border-[#2261CA33]",
|
|
)}
|
|
>
|
|
<input
|
|
ref={tagInputRef}
|
|
type="text"
|
|
value={tagQuery}
|
|
onChange={(e) => {
|
|
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",
|
|
)}
|
|
/>
|
|
<ChevronDown
|
|
className={cn(
|
|
"absolute right-3 top-1/2 -translate-y-1/2 size-3.5 text-[#525D6E] pointer-events-none transition-transform",
|
|
tagDropdownOpen && "rotate-180",
|
|
)}
|
|
/>
|
|
</div>
|
|
</PopoverAnchor>
|
|
<PopoverContent
|
|
align="start"
|
|
sideOffset={4}
|
|
className="w-[var(--radix-popover-trigger-width)] p-1 max-h-[200px] overflow-y-auto bg-[#1B1F24] border border-white/[0.08] rounded-[10px] shadow-[0px_4px_16px_rgba(0,0,0,0.4)]"
|
|
onOpenAutoFocus={(e) => e.preventDefault()}
|
|
onPointerDownOutside={(e) => {
|
|
if (tagAnchorRef.current?.contains(e.target as Node)) {
|
|
e.preventDefault()
|
|
}
|
|
}}
|
|
>
|
|
{filteredTags.map((tag) => (
|
|
<button
|
|
key={tag}
|
|
type="button"
|
|
onClick={() => {
|
|
if (!selectedTagSet.has(tag)) {
|
|
setInviteAssignments([
|
|
...inviteAssignments,
|
|
{ containerTag: tag, permission: "read" },
|
|
])
|
|
}
|
|
setTagQuery("")
|
|
setTagDropdownOpen(false)
|
|
}}
|
|
onMouseDown={(e) => e.preventDefault()}
|
|
className={cn(
|
|
dmSans125ClassName(),
|
|
"flex items-center gap-2 w-full h-8 px-3 text-[13px] text-[#FAFAFA] rounded-[8px] cursor-pointer hover:bg-white/[0.06]",
|
|
)}
|
|
>
|
|
<Tag className="size-3.5 text-[#525D6E]" />
|
|
{tag}
|
|
</button>
|
|
))}
|
|
{tagQuery.trim().length > 0 &&
|
|
!selectedTagSet.has(tagQuery.trim()) &&
|
|
!(allContainerTags ?? []).some(
|
|
(t) =>
|
|
t.containerTag.toLowerCase() ===
|
|
tagQuery.trim().toLowerCase(),
|
|
) && (
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
const sanitized = tagQuery.trim()
|
|
if (!selectedTagSet.has(sanitized)) {
|
|
setInviteAssignments([
|
|
...inviteAssignments,
|
|
{
|
|
containerTag: sanitized,
|
|
permission: "read",
|
|
},
|
|
])
|
|
}
|
|
setTagQuery("")
|
|
setTagDropdownOpen(false)
|
|
}}
|
|
onMouseDown={(e) => e.preventDefault()}
|
|
className={cn(
|
|
dmSans125ClassName(),
|
|
"flex items-center gap-2 w-full h-8 px-3 text-[13px] text-[#8FC8FF] rounded-[8px] cursor-pointer hover:bg-white/[0.06]",
|
|
)}
|
|
>
|
|
<Plus className="size-3.5" />
|
|
Create “{tagQuery.trim()}”
|
|
</button>
|
|
)}
|
|
</PopoverContent>
|
|
</Popover>
|
|
|
|
{/* Selected tags */}
|
|
{inviteAssignments.length > 0 && (
|
|
<div className="flex flex-col rounded-[10px] border border-white/[0.06] overflow-hidden">
|
|
{inviteAssignments.map((a) => (
|
|
<div
|
|
key={a.containerTag}
|
|
className="flex items-center gap-2 px-3 py-2 border-b border-white/[0.04] last:border-0 bg-white/[0.015]"
|
|
>
|
|
<button
|
|
type="button"
|
|
onClick={() =>
|
|
setInviteAssignments(
|
|
inviteAssignments.filter(
|
|
(t) => t.containerTag !== a.containerTag,
|
|
),
|
|
)
|
|
}
|
|
className="text-[#525D6E] hover:text-[#C73B1B] transition-colors shrink-0"
|
|
>
|
|
<X className="size-3.5" />
|
|
</button>
|
|
<span
|
|
className={cn(
|
|
dmSans125ClassName(),
|
|
"text-[13px] text-[#FAFAFA] truncate min-w-0 flex-1",
|
|
)}
|
|
>
|
|
{a.containerTag}
|
|
</span>
|
|
<div className="grid grid-cols-2 gap-1 shrink-0">
|
|
{(["read", "write"] as const).map((perm) => {
|
|
const active = a.permission === perm
|
|
return (
|
|
<button
|
|
key={perm}
|
|
type="button"
|
|
onClick={() =>
|
|
setInviteAssignments(
|
|
inviteAssignments.map((t) =>
|
|
t.containerTag === a.containerTag
|
|
? { ...t, permission: perm }
|
|
: t,
|
|
),
|
|
)
|
|
}
|
|
className={cn(
|
|
dmSans125ClassName(),
|
|
"h-7 px-3 rounded-[8px] border text-[12px] font-medium transition-colors cursor-pointer capitalize",
|
|
active
|
|
? "border-white/10 bg-[#14161A] text-[#FAFAFA] shadow-inside-out"
|
|
: "border-[#161F2C] bg-[#0D121A] text-[#737373] hover:bg-[#14161A] hover:text-[#FAFAFA]",
|
|
)}
|
|
>
|
|
{perm}
|
|
</button>
|
|
)
|
|
})}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{inviteAssignments.length === 0 && (
|
|
<p
|
|
className={cn(
|
|
dmSans125ClassName(),
|
|
"text-[12px] text-[#525D6E]",
|
|
)}
|
|
>
|
|
Select at least one space
|
|
</p>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="flex justify-end gap-2 px-6 py-4 mt-5">
|
|
<button
|
|
type="button"
|
|
onClick={() => setInviteDialogOpen(false)}
|
|
className={cn(
|
|
dmSans125ClassName(),
|
|
"h-9 rounded-full border border-[#161F2C] bg-[#0D121A] px-4 text-[13px] font-medium text-[#737373] transition-colors hover:bg-[#14161A] hover:text-white",
|
|
)}
|
|
>
|
|
Cancel
|
|
</button>
|
|
<button
|
|
type="submit"
|
|
disabled={
|
|
!inviteEmail.trim() ||
|
|
!org?.id ||
|
|
inviteMemberMutation.isPending ||
|
|
(showTagPicker && inviteAssignments.length === 0)
|
|
}
|
|
className={cn(
|
|
dmSans125ClassName(),
|
|
"inline-flex h-9 items-center justify-center gap-2 rounded-full bg-[#14161A] px-4 text-[13px] font-semibold text-[#FAFAFA] shadow-inside-out transition-colors hover:bg-[#121820] disabled:cursor-not-allowed disabled:opacity-45",
|
|
)}
|
|
>
|
|
{inviteMemberMutation.isPending ? (
|
|
<LoaderIcon className="size-4 animate-spin" />
|
|
) : (
|
|
<UserPlus className="size-4" />
|
|
)}
|
|
Send invite
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</div>
|
|
)
|
|
}
|