mirror of
https://github.com/supermemoryai/supermemory.git
synced 2026-08-28 05:25:33 +00:00
Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com> Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
1189 lines
37 KiB
TypeScript
1189 lines
37 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 { 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 {
|
|
LoaderIcon,
|
|
ChevronDown,
|
|
Users,
|
|
UserPlus,
|
|
Mail,
|
|
MoreHorizontal,
|
|
UserMinus,
|
|
X,
|
|
Pencil,
|
|
Tag,
|
|
Plus,
|
|
} from "lucide-react"
|
|
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"
|
|
|
|
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 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()
|
|
}
|
|
|
|
export default function Account() {
|
|
const { user, org, refetchActiveOrg, refetchOrganizations } = useAuth()
|
|
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 [isEditingOrgName, setIsEditingOrgName] = useState(false)
|
|
const [orgNameDraft, setOrgNameDraft] = useState("")
|
|
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"
|
|
|
|
useEffect(() => {
|
|
setOrgNameDraft(org?.name ?? "")
|
|
setIsEditingOrgName(false)
|
|
}, [org?.name])
|
|
|
|
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],
|
|
)
|
|
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 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<HTMLFormElement>) => {
|
|
event.preventDefault()
|
|
if (!canManageTeam || inviteMemberMutation.isPending) return
|
|
inviteMemberMutation.mutate()
|
|
}
|
|
|
|
const handleOrgNameSubmit = (event: React.FormEvent<HTMLFormElement>) => {
|
|
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 (
|
|
<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 min-w-0 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 min-w-0 flex-col gap-1.5">
|
|
<p
|
|
className={cn(
|
|
dmSans125ClassName(),
|
|
"truncate font-semibold text-[20px] tracking-[-0.2px] text-[#FAFAFA]",
|
|
)}
|
|
>
|
|
{user?.name ?? "—"}
|
|
</p>
|
|
<p
|
|
className={cn(
|
|
dmSans125ClassName(),
|
|
"truncate font-medium text-[16px] tracking-[-0.16px] text-[#FAFAFA]",
|
|
)}
|
|
>
|
|
{user?.email ?? "—"}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex flex-col gap-4 sm:flex-row">
|
|
<div className="flex min-w-0 flex-1 flex-col gap-2">
|
|
<p
|
|
className={cn(
|
|
dmSans125ClassName(),
|
|
"font-medium text-[16px] tracking-[-0.16px] text-[#737373]",
|
|
)}
|
|
>
|
|
Organization
|
|
</p>
|
|
{isEditingOrgName ? (
|
|
<form
|
|
onSubmit={handleOrgNameSubmit}
|
|
className="flex min-w-0 max-w-full items-center gap-1.5 sm:max-w-[360px]"
|
|
>
|
|
<input
|
|
value={orgNameDraft}
|
|
onChange={(event) => setOrgNameDraft(event.target.value)}
|
|
disabled={updateOrgNameMutation.isPending}
|
|
maxLength={80}
|
|
className={cn(
|
|
dmSans125ClassName(),
|
|
"h-9 min-w-0 flex-1 rounded-[9px] border border-white/10 bg-black/30 px-3 text-[14px] font-medium tracking-[-0.14px] text-[#FAFAFA] outline-none transition-colors placeholder:text-[#525252] focus:border-[#4BA0FA]/60",
|
|
)}
|
|
placeholder="Organization name"
|
|
/>
|
|
<div className="flex shrink-0 items-center gap-1">
|
|
<button
|
|
type="submit"
|
|
disabled={
|
|
updateOrgNameMutation.isPending ||
|
|
!orgNameDraft.trim() ||
|
|
orgNameDraft.trim() === org?.name
|
|
}
|
|
className={cn(
|
|
dmSans125ClassName(),
|
|
"inline-flex h-8 items-center justify-center gap-1 rounded-full border border-transparent bg-[#0D121A] px-2.5 text-[11px] font-semibold text-[#FAFAFA] shadow-[inset_1.5px_1.5px_4.5px_rgba(0,0,0,0.7)] transition-opacity hover:opacity-80 disabled:cursor-not-allowed disabled:opacity-50",
|
|
)}
|
|
>
|
|
{updateOrgNameMutation.isPending ? (
|
|
<LoaderIcon className="size-3 animate-spin" />
|
|
) : null}
|
|
Save
|
|
</button>
|
|
<button
|
|
type="button"
|
|
disabled={updateOrgNameMutation.isPending}
|
|
aria-label="Cancel organization name edit"
|
|
title="Cancel"
|
|
onClick={() => {
|
|
setOrgNameDraft(org?.name ?? "")
|
|
setIsEditingOrgName(false)
|
|
}}
|
|
className={cn(
|
|
"inline-flex size-8 items-center justify-center rounded-full bg-[#0D121A] text-[#737373] shadow-inside-out transition-colors hover:text-[#FAFAFA] disabled:cursor-not-allowed disabled:opacity-50",
|
|
)}
|
|
>
|
|
<X className="size-3.5" />
|
|
</button>
|
|
</div>
|
|
</form>
|
|
) : (
|
|
<div className="flex min-w-0 max-w-full items-center gap-2">
|
|
<span
|
|
className={cn(
|
|
dmSans125ClassName(),
|
|
"truncate font-medium text-[16px] tracking-[-0.16px] text-[#FAFAFA]",
|
|
)}
|
|
>
|
|
{org?.name ?? "Personal"}
|
|
</span>
|
|
{canManageTeam ? (
|
|
<button
|
|
type="button"
|
|
aria-label="Edit organization name"
|
|
title="Edit organization name"
|
|
onClick={() => {
|
|
setOrgNameDraft(org?.name ?? "")
|
|
setIsEditingOrgName(true)
|
|
}}
|
|
className={cn(
|
|
"inline-flex size-7 shrink-0 items-center justify-center rounded-md text-[#FAFAFA] transition-colors hover:bg-white/5",
|
|
)}
|
|
>
|
|
<Pencil className="size-3.5" />
|
|
</button>
|
|
) : null}
|
|
</div>
|
|
)}
|
|
</div>
|
|
<div className="flex min-w-0 flex-1 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>
|
|
|
|
<OrgContext />
|
|
|
|
<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)
|
|
}
|
|
title={
|
|
showTagPicker && inviteAssignments.length === 0
|
|
? "Select at least one space for restricted access"
|
|
: !inviteEmail.trim()
|
|
? "Enter an email address to send an invite"
|
|
: undefined
|
|
}
|
|
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>
|
|
)
|
|
}
|