From 984eace2f29461806adfe6662dfbd74ade060eea Mon Sep 17 00:00:00 2001 From: Ishaan Gupta Date: Thu, 21 May 2026 14:39:33 +0530 Subject: [PATCH 1/3] add team management in nova account settings --- apps/web/components/settings/account.tsx | 545 +++++++++++++++++++---- packages/lib/auth-context.tsx | 9 + 2 files changed, 469 insertions(+), 85 deletions(-) diff --git a/apps/web/components/settings/account.tsx b/apps/web/components/settings/account.tsx index 57d1264b1..aaa2319c4 100644 --- a/apps/web/components/settings/account.tsx +++ b/apps/web/components/settings/account.tsx @@ -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 = { 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(null) const [orgMenuOpen, setOrgMenuOpen] = useState(false) + const [inviteEmail, setInviteEmail] = useState("") + const [inviteRole, setInviteRole] = useState("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) => { + event.preventDefault() + if (!canManageTeam || inviteMemberMutation.isPending) return + inviteMemberMutation.mutate() + } + const planByOrgId = useMemo(() => { const map = new Map() for (const summary of orgSummaries ?? []) { @@ -318,8 +496,19 @@ export default function Account() {
-
- Team members +
+
+ Team members +

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

+
{(org?.members?.length ?? 0) > 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, idx) => { - const isYou = m.userId === user?.id - const name = m.user?.name ?? m.user?.email ?? "Unknown" - return ( +
    + {canManageTeam ? ( +
    + +
    + + 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", + )} + /> +
    + + +
    + ) : ( +
    +
    + +
    +

    + Only organization owners and admins can invite teammates or + change roles. +

    +
    + )} + + {pendingInvitations.length > 0 && ( +
    +

    + Pending invitations +

    +
      + {pendingInvitations.map((invitation) => (
    • 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]" > - - - - {(name.charAt(0) || "U").toUpperCase()} - - -
      -
      - + +
      +
      +

      + {invitation.email} +

      +

      + Invited as {formatRole(invitation.role)} +

      +
      + {canManageTeam && ( +
      + +
      + )} +
    • + ))} +
    +
    + )} + + {org?.members && org.members.length > 0 ? ( +
      + {[...org.members] + .sort((a, b) => { + const rolePriority = (r: string) => + r === "owner" ? 0 : r === "admin" ? 1 : 2 + const diff = + rolePriority(a.role.toLowerCase()) - + rolePriority(b.role.toLowerCase()) + if (diff !== 0) return diff + return (a.user?.name ?? "").localeCompare( + b.user?.name ?? "", + ) + }) + .map((m, 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 ( +
    • 0 && "border-t border-white/[0.04]", + )} + > + + + + {(name.charAt(0) || "U").toUpperCase()} + + +
      +
      + + {name} + + {isYou && ( + + You + + )} +
      + {m.user?.email && ( - You + {m.user.email} )}
      - {m.user?.email && ( - { + if (value === memberRole) return + updateMemberRoleMutation.mutate({ + memberId: m.id, + role: value as InviteRole, + }) + }} > - {m.user.email} - + + + + + Member + Admin + + + ) : ( + )} -
    - - - ) - })} -
- ) : ( -
-
- -
-
- - Just you for now - - - Invite teammates from your organization settings. - + {canEditMember && ( + + + + + + + removeMemberMutation.mutate(m.id) + } + disabled={ + removeMemberMutation.isPending || !isOwner + } + > + + Remove member + + + + )} + + ) + })} + + ) : ( +
+
+ +
+
+ + Just you for now + + + Invite teammates to start collaborating. + +
-
- )} + )} +
diff --git a/packages/lib/auth-context.tsx b/packages/lib/auth-context.tsx index 838a74e61..485628131 100644 --- a/packages/lib/auth-context.tsx +++ b/packages/lib/auth-context.tsx @@ -28,6 +28,7 @@ interface AuthContextType { setActiveOrg: (orgSlug: string) => Promise clearActiveOrg: () => void updateOrgMetadata: (partial: Record) => void + refetchActiveOrg: () => Promise refetchOrganizations: () => Promise } @@ -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, }} > From 730001cfa9168fb6c07fd3c6940be302e10f53aa Mon Sep 17 00:00:00 2001 From: Ishaan Gupta Date: Thu, 21 May 2026 15:39:12 +0530 Subject: [PATCH 2/3] check admin settings --- apps/web/components/settings/account.tsx | 29 ++++++++++++++++++++++-- 1 file changed, 27 insertions(+), 2 deletions(-) diff --git a/apps/web/components/settings/account.tsx b/apps/web/components/settings/account.tsx index aaa2319c4..31768b9a0 100644 --- a/apps/web/components/settings/account.tsx +++ b/apps/web/components/settings/account.tsx @@ -27,7 +27,7 @@ import { DropdownMenuTrigger, } from "@ui/components/dropdown-menu" import { useCustomer } from "autumn-js/react" -import { useMutation } from "@tanstack/react-query" +import { useMutation, useQuery } from "@tanstack/react-query" import { Check, LoaderIcon, @@ -188,11 +188,36 @@ export default function Account() { 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 currentRole = currentMember?.role?.toLowerCase() ?? "member" + 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" From ebd99b5d68e352b66c88e253619441d2d9143b6d Mon Sep 17 00:00:00 2001 From: Ishaan Gupta Date: Fri, 22 May 2026 13:08:45 +0530 Subject: [PATCH 3/3] implement invite modal in Nova team settings --- apps/web/components/settings/account.tsx | 300 +++++++++++++++++++---- 1 file changed, 254 insertions(+), 46 deletions(-) diff --git a/apps/web/components/settings/account.tsx b/apps/web/components/settings/account.tsx index 31768b9a0..96cf4baac 100644 --- a/apps/web/components/settings/account.tsx +++ b/apps/web/components/settings/account.tsx @@ -26,6 +26,12 @@ import { DropdownMenuItem, DropdownMenuTrigger, } from "@ui/components/dropdown-menu" +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from "@ui/components/dialog" import { useCustomer } from "autumn-js/react" import { useMutation, useQuery } from "@tanstack/react-query" import { @@ -38,6 +44,7 @@ import { Mail, MoreHorizontal, UserMinus, + ShieldCheck, X, } from "lucide-react" import { useMemo, useState } from "react" @@ -98,6 +105,27 @@ const ROLE_LABELS: Record = { type InviteRole = "admin" | "member" +const INVITE_PERMISSION_OPTIONS: Record< + InviteRole, + { title: string; description: string; permissions: string[] } +> = { + member: { + title: "Member access", + description: "Use the organization workspace with standard access.", + permissions: ["Read organization access", "Use shared memories"], + }, + admin: { + title: "Admin access", + description: "Manage teammates and organization-level team settings.", + permissions: [ + "Invite and cancel invitations", + "Change member roles", + "Remove members", + "Update organization settings", + ], + }, +} + function getErrorMessage(error: unknown, fallback: string) { if (error instanceof Error && error.message) return error.message if ( @@ -169,6 +197,7 @@ export default function Account() { const autumn = useCustomer() const [switchingOrgId, setSwitchingOrgId] = useState(null) const [orgMenuOpen, setOrgMenuOpen] = useState(false) + const [inviteDialogOpen, setInviteDialogOpen] = useState(false) const [inviteEmail, setInviteEmail] = useState("") const [inviteRole, setInviteRole] = useState("member") const canSwitchOrg = (allOrgs?.length ?? 0) > 1 @@ -244,6 +273,8 @@ export default function Account() { }, onSuccess: async (invitation) => { setInviteEmail("") + setInviteRole("member") + setInviteDialogOpen(false) await refetchActiveOrg() toast.success("Invitation sent", { description: invitation?.email @@ -549,60 +580,43 @@ export default function Account() {
{canManageTeam ? ( -
- -
- - 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", - )} - /> +
+
+
+ +
+
+

+ Invite teammate +

+

+ Choose role and permission preset before sending. +

+
- - +
) : (
@@ -830,6 +844,200 @@ export default function Account() {
+ + { + setInviteDialogOpen(open) + if (!open && !inviteMemberMutation.isPending) { + setInviteEmail("") + setInviteRole("member") + } + }} + > + + + + Invite teammate + + +
+
+ +
+ + 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", + )} + /> +
+
+ +
+

+ Role +

+
+ {(["member", "admin"] as const).map((role) => { + const selected = inviteRole === role + return ( + + ) + })} +
+
+ +
+

+ Permissions +

+
+ {(["member", "admin"] as const).map((role) => { + const option = INVITE_PERMISSION_OPTIONS[role] + const selected = inviteRole === role + return ( + + ) + })} +
+
+ +
+ + +
+
+
+
) }