add team management in nova account settings

This commit is contained in:
Ishaan Gupta 2026-05-21 14:39:33 +05:30
parent 53ae4af5ab
commit 984eace2f2
2 changed files with 469 additions and 85 deletions

View file

@ -3,6 +3,7 @@
import { dmSans125ClassName } from "@/lib/fonts"
import { cn } from "@lib/utils"
import { useAuth } from "@lib/auth-context"
import { authClient } from "@lib/auth"
import { useOrgSummaries } from "@/hooks/use-org-summaries"
import { Avatar, AvatarFallback, AvatarImage } from "@ui/components/avatar"
import {
@ -12,9 +13,35 @@ import {
type PlanType,
} from "@/hooks/use-token-usage"
import { Popover, PopoverContent, PopoverTrigger } from "@ui/components/popover"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@ui/components/select"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@ui/components/dropdown-menu"
import { useCustomer } from "autumn-js/react"
import { Check, LoaderIcon, ChevronDown, Building2, Users } from "lucide-react"
import { useMutation } from "@tanstack/react-query"
import {
Check,
LoaderIcon,
ChevronDown,
Building2,
Users,
UserPlus,
Mail,
MoreHorizontal,
UserMinus,
X,
} from "lucide-react"
import { useMemo, useState } from "react"
import { toast } from "sonner"
function SectionTitle({ children }: { children: React.ReactNode }) {
return (
@ -69,6 +96,21 @@ const ROLE_LABELS: Record<string, string> = {
member: "Member",
}
type InviteRole = "admin" | "member"
function getErrorMessage(error: unknown, fallback: string) {
if (error instanceof Error && error.message) return error.message
if (
error &&
typeof error === "object" &&
"message" in error &&
typeof error.message === "string"
) {
return error.message
}
return fallback
}
function formatRole(role: string): string {
const r = role?.toLowerCase() ?? ""
if (ROLE_LABELS[r]) return ROLE_LABELS[r]
@ -93,6 +135,17 @@ function RolePill({ role }: { role: string }) {
)
}
function isPendingInvitation(invitation: {
status?: string
expiresAt?: Date | string
}) {
if (invitation.status && invitation.status.toLowerCase() !== "pending") {
return false
}
if (!invitation.expiresAt) return true
return new Date(invitation.expiresAt).getTime() > Date.now()
}
function resolveOrgPlan(
orgId: string,
isCurrent: boolean,
@ -106,10 +159,18 @@ function resolveOrgPlan(
}
export default function Account() {
const { user, org, organizations: allOrgs, setActiveOrg } = useAuth()
const {
user,
org,
organizations: allOrgs,
setActiveOrg,
refetchActiveOrg,
} = useAuth()
const autumn = useCustomer()
const [switchingOrgId, setSwitchingOrgId] = useState<string | null>(null)
const [orgMenuOpen, setOrgMenuOpen] = useState(false)
const [inviteEmail, setInviteEmail] = useState("")
const [inviteRole, setInviteRole] = useState<InviteRole>("member")
const canSwitchOrg = (allOrgs?.length ?? 0) > 1
const { data: orgSummaries } = useOrgSummaries()
@ -127,6 +188,123 @@ export default function Account() {
const { currentPlan } = useTokenUsage(autumn)
const currentMember = useMemo(
() => org?.members?.find((member) => member.userId === user?.id) ?? null,
[org?.members, user?.id],
)
const currentRole = currentMember?.role?.toLowerCase() ?? "member"
const canManageTeam = currentRole === "owner" || currentRole === "admin"
const isOwner = currentRole === "owner"
const pendingInvitations = useMemo(
() => (org?.invitations ?? []).filter(isPendingInvitation),
[org?.invitations],
)
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 result = await authClient.organization.inviteMember({
email,
role: inviteRole,
organizationId: org.id,
resend: true,
})
if (result.error) {
throw new Error(result.error.message ?? "Failed to invite teammate")
}
return result.data
},
onSuccess: async (invitation) => {
setInviteEmail("")
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 ?? []) {
@ -318,8 +496,19 @@ export default function Account() {
</section>
<section id="team-members" className="flex flex-col gap-4">
<div className="flex items-center justify-between px-2">
<SectionTitle>Team members</SectionTitle>
<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>
{(org?.members?.length ?? 0) > 0 && (
<span
className={cn(
@ -333,101 +522,287 @@ export default function Account() {
)}
</div>
<SettingsCard>
{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 name = m.user?.name ?? m.user?.email ?? "Unknown"
return (
<div className="flex flex-col gap-5">
{canManageTeam ? (
<form
onSubmit={handleInviteSubmit}
className="grid grid-cols-1 gap-3 md:grid-cols-[minmax(0,1fr)_132px_auto]"
>
<label className="sr-only" htmlFor="team-invite-email">
Email address
</label>
<div className="relative min-w-0">
<Mail className="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-[#737373]" />
<input
id="team-invite-email"
type="email"
value={inviteEmail}
onChange={(event) => setInviteEmail(event.target.value)}
placeholder="teammate@company.com"
autoComplete="email"
className={cn(
dmSans125ClassName(),
"h-10 w-full rounded-[10px] border border-white/[0.08] bg-[#0D0F14] pl-9 pr-3 text-[14px] text-[#FAFAFA] placeholder:text-[#525D6E] outline-none transition-colors focus:border-[#4BA0FA]/50",
)}
/>
</div>
<Select
value={inviteRole}
onValueChange={(value) => setInviteRole(value as InviteRole)}
>
<SelectTrigger className="h-10 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>
<button
type="submit"
disabled={
!inviteEmail.trim() ||
!org?.id ||
inviteMemberMutation.isPending
}
className={cn(
dmSans125ClassName(),
"inline-flex h-10 items-center justify-center gap-2 rounded-[10px] bg-[#4BA0FA] px-4 text-[14px] font-semibold text-[#00171A] transition-opacity hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-45",
)}
>
{inviteMemberMutation.isPending ? (
<LoaderIcon className="size-4 animate-spin" />
) : (
<UserPlus className="size-4" />
)}
Invite
</button>
</form>
) : (
<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={m.id}
className={cn(
"flex items-center gap-3 py-2.5",
idx > 0 && "border-t border-white/[0.04]",
)}
key={invitation.id}
className="flex items-center gap-3 px-3 py-2.5 border-t border-white/[0.04] first:border-t-0 bg-white/[0.015]"
>
<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",
)}
<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"
>
{name}
</span>
{isYou && (
<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(),
"text-[10.5px] uppercase tracking-[0.1em] text-[#737373] font-mono",
"font-medium text-[14px] tracking-[-0.14px] text-[#FAFAFA] truncate",
)}
>
You
{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>
{m.user?.email && (
<span
className={cn(
dmSans125ClassName(),
"text-[12px] tracking-[-0.12px] text-[#737373] truncate",
)}
{canEditMember ? (
<Select
value={memberRole}
onValueChange={(value) => {
if (value === memberRole) return
updateMemberRoleMutation.mutate({
memberId: m.id,
role: value as InviteRole,
})
}}
>
{m.user.email}
</span>
<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} />
)}
</div>
<RolePill role={m.role} />
</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]" />
{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 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 from your organization settings.
</span>
</div>
</div>
)}
)}
</div>
</SettingsCard>
</section>
</div>

View file

@ -28,6 +28,7 @@ interface AuthContextType {
setActiveOrg: (orgSlug: string) => Promise<void>
clearActiveOrg: () => void
updateOrgMetadata: (partial: Record<string, unknown>) => void
refetchActiveOrg: () => Promise<Organization | null>
refetchOrganizations: () => Promise<unknown>
}
@ -81,6 +82,13 @@ export function AuthProvider({ children }: { children: ReactNode }) {
})
}, [])
const refetchActiveOrg = useCallback(async () => {
const full = await authClient.organization.getFullOrganization()
const nextOrg = full?.data ?? null
setOrg(nextOrg)
return nextOrg
}, [])
useEffect(() => {
if (isSessionPending) return
@ -198,6 +206,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
setActiveOrg,
clearActiveOrg,
updateOrgMetadata,
refetchActiveOrg,
refetchOrganizations,
}}
>