mirror of
https://github.com/supermemoryai/supermemory.git
synced 2026-07-09 17:18:51 +00:00
feat(web): company brain — invite-accept page, space UX/visibility, org-scoped daily brief (#1112)
New /org/invite/[invitationId] page so consumer-org invitees can view and accept or decline invitations in the app instead of the console. Fixes ENG-811
This commit is contained in:
parent
ab3dcd7a73
commit
a6f7f346e2
6 changed files with 567 additions and 38 deletions
|
|
@ -109,7 +109,7 @@ function ViewErrorFallback() {
|
|||
|
||||
export default function NewPage() {
|
||||
const isMobile = useIsMobile()
|
||||
const { user, session, isSessionPending } = useAuth()
|
||||
const { user, session, isSessionPending, org } = useAuth()
|
||||
|
||||
const { selectedProject, selectedProjects, setSelectedProject } = useProject()
|
||||
const selectedProjectTag = selectedProjects[0]
|
||||
|
|
@ -370,10 +370,11 @@ export default function NewPage() {
|
|||
queryKey: [
|
||||
"memory-of-day",
|
||||
user?.id,
|
||||
org?.id,
|
||||
new Date().toISOString().slice(0, 10),
|
||||
],
|
||||
queryFn: async (): Promise<MemoryOfDay | null> => {
|
||||
const cacheKey = `memory-of-day:v2:${user?.id}:${new Date().toISOString().slice(0, 10)}`
|
||||
const cacheKey = `memory-of-day:v2:${user?.id}:${org?.id}:${new Date().toISOString().slice(0, 10)}`
|
||||
try {
|
||||
const stored = localStorage.getItem(cacheKey)
|
||||
if (stored) return JSON.parse(stored) as MemoryOfDay
|
||||
|
|
@ -394,7 +395,7 @@ export default function NewPage() {
|
|||
},
|
||||
staleTime: 24 * 60 * 60 * 1000,
|
||||
refetchOnWindowFocus: false,
|
||||
enabled: !!user,
|
||||
enabled: !!user && !!org,
|
||||
})
|
||||
|
||||
useHotkeys("c", () => {
|
||||
|
|
|
|||
388
apps/web/app/org/invite/[invitationId]/page.tsx
Normal file
388
apps/web/app/org/invite/[invitationId]/page.tsx
Normal file
|
|
@ -0,0 +1,388 @@
|
|||
"use client"
|
||||
|
||||
import { authClient, useSession } from "@lib/auth"
|
||||
import { useAuth } from "@lib/auth-context"
|
||||
import { cn } from "@lib/utils"
|
||||
import { Loader, Users, XCircle } from "lucide-react"
|
||||
import { useParams, useRouter } from "next/navigation"
|
||||
import { useCallback, useEffect, useState } from "react"
|
||||
import { toast } from "sonner"
|
||||
import { dmSans125ClassName } from "@/lib/fonts"
|
||||
|
||||
type InvitationData = {
|
||||
id: string
|
||||
email: string
|
||||
role: string
|
||||
status: string
|
||||
expiresAt: string
|
||||
organizationName: string
|
||||
organizationSlug: string
|
||||
organizationId: string
|
||||
inviterEmail?: string
|
||||
}
|
||||
|
||||
type InviteState =
|
||||
| "loading"
|
||||
| "no_session"
|
||||
| "ready"
|
||||
| "not_found"
|
||||
| "expired"
|
||||
| "already_accepted"
|
||||
| "wrong_account"
|
||||
|
||||
const pageWrapperClass =
|
||||
"flex items-center justify-center min-h-screen bg-background p-4"
|
||||
const cardClass = cn(
|
||||
"bg-[#14161A] rounded-[14px] p-6 w-full max-w-[400px]",
|
||||
"shadow-[inset_2.42px_2.42px_4.263px_rgba(11,15,21,0.7)]",
|
||||
)
|
||||
|
||||
function FullPageSpinner() {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen bg-background">
|
||||
<div className="size-6 border-2 border-[#4BA0FA] border-t-transparent rounded-full animate-spin" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function PrimaryButton({
|
||||
children,
|
||||
onClick,
|
||||
disabled,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
onClick: () => void
|
||||
disabled?: boolean
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
"relative w-full h-11 rounded-[10px] flex items-center justify-center",
|
||||
"text-[#FAFAFA] font-medium text-[14px] tracking-[-0.14px]",
|
||||
"cursor-pointer transition-opacity hover:opacity-90",
|
||||
"disabled:opacity-60 disabled:cursor-not-allowed",
|
||||
dmSans125ClassName(),
|
||||
)}
|
||||
style={{
|
||||
background:
|
||||
"linear-gradient(182.37deg, #0ff0d2 -91.53%, #5bd3fb -67.8%, #1e0ff0 95.17%)",
|
||||
boxShadow:
|
||||
"1px 1px 2px 0px #1A88FF inset, 0 2px 10px 0 rgba(5, 1, 0, 0.20)",
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
<div className="absolute inset-0 pointer-events-none rounded-[inherit] shadow-[inset_1px_1px_2px_1px_#1A88FF]" />
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function SecondaryButton({
|
||||
children,
|
||||
onClick,
|
||||
disabled,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
onClick: () => void
|
||||
disabled?: boolean
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
"w-full flex items-center justify-center gap-2 rounded-full h-10 px-4",
|
||||
"bg-[#0D121A] border border-[#1E293B] text-[#FAFAFA]",
|
||||
"text-[13px] font-medium cursor-pointer transition-colors hover:bg-[#1E293B]",
|
||||
"disabled:opacity-60 disabled:cursor-not-allowed",
|
||||
dmSans125ClassName(),
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function IconTile({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex size-10 items-center justify-center rounded-lg border border-[#1E293B] bg-[#080B0F]">
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Title({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<h2
|
||||
className={dmSans125ClassName("font-semibold text-[18px] text-[#FAFAFA]")}
|
||||
>
|
||||
{children}
|
||||
</h2>
|
||||
)
|
||||
}
|
||||
|
||||
function Subtitle({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<p className={dmSans125ClassName("text-[13px] text-[#737373] mt-1")}>
|
||||
{children}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
function StatusCard({
|
||||
icon,
|
||||
title,
|
||||
description,
|
||||
actionLabel,
|
||||
onAction,
|
||||
}: {
|
||||
icon: React.ReactNode
|
||||
title: string
|
||||
description: string
|
||||
actionLabel: string
|
||||
onAction: () => void
|
||||
}) {
|
||||
return (
|
||||
<div className={pageWrapperClass}>
|
||||
<div className={cardClass}>
|
||||
<div className="flex flex-col items-center gap-5">
|
||||
<IconTile>{icon}</IconTile>
|
||||
<div className="text-center">
|
||||
<Title>{title}</Title>
|
||||
<Subtitle>{description}</Subtitle>
|
||||
</div>
|
||||
<SecondaryButton onClick={onAction}>{actionLabel}</SecondaryButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function InvitePage() {
|
||||
const params = useParams<{ invitationId: string }>()
|
||||
const invitationId = params.invitationId
|
||||
const { data: session, isPending: sessionPending } = useSession()
|
||||
const { setActiveOrg, refetchOrganizations } = useAuth()
|
||||
const router = useRouter()
|
||||
|
||||
const [state, setState] = useState<InviteState>("loading")
|
||||
const [invitation, setInvitation] = useState<InvitationData | null>(null)
|
||||
const [accepting, setAccepting] = useState(false)
|
||||
const [declining, setDeclining] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (sessionPending) return
|
||||
if (!session) {
|
||||
setState("no_session")
|
||||
return
|
||||
}
|
||||
let cancelled = false
|
||||
;(async () => {
|
||||
const { data, error } = await authClient.organization.getInvitation({
|
||||
query: { id: invitationId },
|
||||
})
|
||||
if (cancelled) return
|
||||
if (error) {
|
||||
setState(error.status === 403 ? "wrong_account" : "not_found")
|
||||
return
|
||||
}
|
||||
if (!data) {
|
||||
setState("not_found")
|
||||
return
|
||||
}
|
||||
const inv = data as unknown as InvitationData
|
||||
if (inv.status === "accepted") setState("already_accepted")
|
||||
else if (inv.status === "canceled" || inv.status === "rejected")
|
||||
setState("not_found")
|
||||
else if (new Date(inv.expiresAt) < new Date()) setState("expired")
|
||||
else {
|
||||
setInvitation(inv)
|
||||
setState("ready")
|
||||
}
|
||||
})()
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [session, sessionPending, invitationId])
|
||||
|
||||
const handleAccept = useCallback(async () => {
|
||||
setAccepting(true)
|
||||
try {
|
||||
const { error } = await authClient.organization.acceptInvitation({
|
||||
invitationId,
|
||||
})
|
||||
if (error) {
|
||||
toast.error(error.message ?? "Failed to accept invitation")
|
||||
return
|
||||
}
|
||||
if (invitation?.organizationSlug) {
|
||||
await setActiveOrg(invitation.organizationSlug)
|
||||
}
|
||||
await refetchOrganizations()
|
||||
toast.success(
|
||||
`You've joined ${invitation?.organizationName ?? "the team"}`,
|
||||
)
|
||||
router.push("/")
|
||||
} finally {
|
||||
setAccepting(false)
|
||||
}
|
||||
}, [invitationId, invitation, setActiveOrg, refetchOrganizations, router])
|
||||
|
||||
const handleDecline = useCallback(async () => {
|
||||
setDeclining(true)
|
||||
try {
|
||||
const { error } = await authClient.organization.rejectInvitation({
|
||||
invitationId,
|
||||
})
|
||||
if (error) {
|
||||
toast.error(error.message ?? "Failed to decline invitation")
|
||||
return
|
||||
}
|
||||
toast.success("Invitation declined")
|
||||
router.push("/")
|
||||
} finally {
|
||||
setDeclining(false)
|
||||
}
|
||||
}, [invitationId, router])
|
||||
|
||||
if (state === "loading") return <FullPageSpinner />
|
||||
|
||||
if (state === "no_session") {
|
||||
const loginHref = `/login?redirect=${encodeURIComponent(
|
||||
typeof window !== "undefined" ? window.location.href : "",
|
||||
)}`
|
||||
return (
|
||||
<div className={pageWrapperClass}>
|
||||
<div className={cardClass}>
|
||||
<div className="flex flex-col items-center gap-5">
|
||||
<IconTile>
|
||||
<Users className="size-5 text-[#4BA0FA]" />
|
||||
</IconTile>
|
||||
<div className="text-center">
|
||||
<Title>You're not logged in</Title>
|
||||
<Subtitle>Log in to view and accept this invitation.</Subtitle>
|
||||
</div>
|
||||
<PrimaryButton onClick={() => router.push(loginHref)}>
|
||||
Log in
|
||||
</PrimaryButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (state === "wrong_account") {
|
||||
return (
|
||||
<StatusCard
|
||||
icon={<XCircle className="size-5 text-red-400" />}
|
||||
title="This invitation isn't for you"
|
||||
description={`It was sent to a different email${
|
||||
session?.user?.email ? ` than ${session.user.email}` : ""
|
||||
}.`}
|
||||
actionLabel="Go to dashboard"
|
||||
onAction={() => router.push("/")}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (
|
||||
state === "not_found" ||
|
||||
state === "expired" ||
|
||||
state === "already_accepted"
|
||||
) {
|
||||
const copy = {
|
||||
not_found: {
|
||||
title: "Invitation not found",
|
||||
body: "This invitation doesn't exist or has been revoked.",
|
||||
},
|
||||
expired: {
|
||||
title: "Invitation expired",
|
||||
body: "Ask your team admin to send a new one.",
|
||||
},
|
||||
already_accepted: {
|
||||
title: "Already joined",
|
||||
body: "You've already accepted this invitation.",
|
||||
},
|
||||
}[state]
|
||||
return (
|
||||
<StatusCard
|
||||
icon={<Users className="size-5 text-[#4BA0FA]" />}
|
||||
title={copy.title}
|
||||
description={copy.body}
|
||||
actionLabel="Go to dashboard"
|
||||
onAction={() => router.push("/")}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={pageWrapperClass}>
|
||||
<div className={cardClass}>
|
||||
<div className="flex flex-col items-center gap-5">
|
||||
<IconTile>
|
||||
<Users className="size-5 text-[#4BA0FA]" />
|
||||
</IconTile>
|
||||
<div className="text-center">
|
||||
<Title>{invitation?.organizationName}</Title>
|
||||
<Subtitle>
|
||||
You've been invited to join{" "}
|
||||
<strong className="text-[#A3A3A3]">
|
||||
{invitation?.organizationName}
|
||||
</strong>{" "}
|
||||
as {invitation?.role}.
|
||||
</Subtitle>
|
||||
{invitation?.inviterEmail && (
|
||||
<p
|
||||
className={dmSans125ClassName(
|
||||
"text-[12px] text-[#737373] mt-2",
|
||||
)}
|
||||
>
|
||||
Invited by {invitation.inviterEmail}
|
||||
</p>
|
||||
)}
|
||||
{session?.user?.email && (
|
||||
<p
|
||||
className={dmSans125ClassName(
|
||||
"text-[12px] text-[#737373] mt-1",
|
||||
)}
|
||||
>
|
||||
Signed in as {session.user.email}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex w-full flex-col gap-2.5">
|
||||
<PrimaryButton
|
||||
onClick={handleAccept}
|
||||
disabled={accepting || declining}
|
||||
>
|
||||
{accepting ? (
|
||||
<>
|
||||
<Loader className="size-4 animate-spin mr-2" />
|
||||
Accepting…
|
||||
</>
|
||||
) : (
|
||||
"Accept invitation"
|
||||
)}
|
||||
</PrimaryButton>
|
||||
<SecondaryButton
|
||||
onClick={handleDecline}
|
||||
disabled={declining || accepting}
|
||||
>
|
||||
{declining ? (
|
||||
<>
|
||||
<Loader className="size-4 animate-spin" />
|
||||
Declining…
|
||||
</>
|
||||
) : (
|
||||
"Decline"
|
||||
)}
|
||||
</SecondaryButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -5,6 +5,7 @@ import Image from "next/image"
|
|||
import { dmSans125ClassName, dmSansClassName } from "@/lib/fonts"
|
||||
import { Dialog, DialogContent, DialogTitle } from "@repo/ui/components/dialog"
|
||||
import { Drawer, DrawerContent, DrawerTitle } from "@repo/ui/components/drawer"
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@ui/components/avatar"
|
||||
import { cn } from "@lib/utils"
|
||||
import { useIsMobile } from "@hooks/use-mobile"
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog"
|
||||
|
|
@ -21,10 +22,11 @@ import {
|
|||
Loader,
|
||||
Pencil,
|
||||
Check,
|
||||
Lock,
|
||||
} from "lucide-react"
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
|
||||
import { toast } from "sonner"
|
||||
import { DEFAULT_PROJECT_ID } from "@lib/constants"
|
||||
import { DEFAULT_PROJECT_ID, SHARED_TEAM_BRAIN_TAG } from "@lib/constants"
|
||||
import { authClient } from "@lib/auth"
|
||||
import { useAuth } from "@lib/auth-context"
|
||||
import type { ContainerTagListType } from "@lib/types"
|
||||
|
|
@ -50,6 +52,7 @@ import { AUTO_CHAT_SPACE_ID } from "@/lib/chat-auto-space"
|
|||
import NovaOrb from "@/components/nova/nova-orb"
|
||||
import { AutoSpaceIcon } from "@/components/nova/auto-space-icon"
|
||||
import { SpaceGlyph } from "./space-glyph"
|
||||
import { useHasCompanyBrain } from "@/hooks/use-company-brain"
|
||||
|
||||
interface SelectSpacesModalProps {
|
||||
isOpen: boolean
|
||||
|
|
@ -130,8 +133,15 @@ export function SelectSpacesModal({
|
|||
)
|
||||
|
||||
const pluginMetaMap = usePluginSpaceMeta(pluginTags)
|
||||
const hasCompanyBrain = useHasCompanyBrain()
|
||||
|
||||
const allSpaces = useMemo(() => {
|
||||
const rest = projects
|
||||
.filter((p) => p.containerTag !== DEFAULT_PROJECT_ID)
|
||||
.sort(compareSpacesUserFirst)
|
||||
// Company brain orgs use real Private + Team Brain spaces; skip the
|
||||
// synthetic "My Space" default that would otherwise duplicate Private.
|
||||
if (hasCompanyBrain) return rest
|
||||
const defaultSpace = {
|
||||
id: "default",
|
||||
name: "My Space",
|
||||
|
|
@ -142,11 +152,8 @@ export function SelectSpacesModal({
|
|||
createdAt: "",
|
||||
updatedAt: "",
|
||||
} as ContainerTagListType
|
||||
const rest = projects
|
||||
.filter((p) => p.containerTag !== DEFAULT_PROJECT_ID)
|
||||
.sort(compareSpacesUserFirst)
|
||||
return [defaultSpace, ...rest]
|
||||
}, [projects])
|
||||
}, [projects, hasCompanyBrain])
|
||||
|
||||
const { categories, connectedCatalogIds } = useMemo<{
|
||||
categories: Category[]
|
||||
|
|
@ -588,6 +595,20 @@ export function SelectSpacesModal({
|
|||
)
|
||||
const isDefault = project.containerTag === DEFAULT_PROJECT_ID
|
||||
const isOwnSpace = isOwnConversationSpace(project, user?.id)
|
||||
const isCbSpace =
|
||||
hasCompanyBrain && !plugin && !isOwnSpace && !!project.visibility
|
||||
const isShared = project.visibility === "public"
|
||||
const orgName = org?.name ?? "your team"
|
||||
const orgMembers = org?.members ?? []
|
||||
const memberCount = orgMembers.length
|
||||
const isDefaultBrain = project.containerTag === SHARED_TEAM_BRAIN_TAG
|
||||
const descriptor = isCbSpace
|
||||
? isShared
|
||||
? `${orgName} · ${memberCount} ${
|
||||
memberCount === 1 ? "member" : "members"
|
||||
}`
|
||||
: "Only you"
|
||||
: null
|
||||
const canEdit = !isDefault && !plugin && !isOwnSpace
|
||||
const canBulkDelete = enableDelete && !isDefault
|
||||
const isEditing = editingProject?.containerTag === project.containerTag
|
||||
|
|
@ -716,6 +737,54 @@ export function SelectSpacesModal({
|
|||
)
|
||||
) : isOwnSpace ? (
|
||||
<NovaOrb size={20} className="shrink-0 blur-[0.55px]!" />
|
||||
) : isCbSpace ? (
|
||||
isShared ? (
|
||||
<span className="shrink-0 flex items-center" aria-hidden>
|
||||
{orgMembers.slice(0, 3).map((m, i) => (
|
||||
<Avatar
|
||||
key={m.id}
|
||||
className={cn(
|
||||
"size-6 ring-2 ring-[#14161A]",
|
||||
i > 0 && "-ml-2",
|
||||
)}
|
||||
>
|
||||
<AvatarImage
|
||||
src={m.user?.image ?? ""}
|
||||
alt=""
|
||||
className="object-cover"
|
||||
/>
|
||||
<AvatarFallback className="bg-[#1E232B] text-white text-[10px] font-medium">
|
||||
{(m.user?.name ?? m.user?.email ?? "U")
|
||||
.charAt(0)
|
||||
.toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
))}
|
||||
{memberCount > 3 && (
|
||||
<span className="-ml-2 flex size-6 items-center justify-center rounded-full bg-[#1E232B] text-[#A3A3A3] text-[9px] font-medium ring-2 ring-[#14161A]">
|
||||
+{memberCount - 3}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
) : (
|
||||
<span className="shrink-0 relative" aria-hidden>
|
||||
<Avatar className="size-6">
|
||||
<AvatarImage
|
||||
src={user?.image ?? ""}
|
||||
alt=""
|
||||
className="object-cover"
|
||||
/>
|
||||
<AvatarFallback className="bg-[#1E232B] text-white text-[10px] font-medium">
|
||||
{(user?.name ?? user?.email ?? "U")
|
||||
.charAt(0)
|
||||
.toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<span className="absolute -right-1 -bottom-1 flex size-3.5 items-center justify-center rounded-full bg-[#14161A] text-[#A3A3A3]">
|
||||
<Lock className="size-2" />
|
||||
</span>
|
||||
</span>
|
||||
)
|
||||
) : (
|
||||
<SpaceGlyph
|
||||
emoji={project.emoji}
|
||||
|
|
@ -723,23 +792,35 @@ export function SelectSpacesModal({
|
|||
className="shrink-0"
|
||||
/>
|
||||
)}
|
||||
<span
|
||||
className="min-w-0 flex-1 truncate text-[#fafafa] text-sm font-medium"
|
||||
title={plugin ? project.containerTag : displayName}
|
||||
>
|
||||
{plugin ? (
|
||||
<>
|
||||
{plugin.label}
|
||||
{pluginIdLabel && (
|
||||
<span className="ml-1.5 text-[12px] text-[#737373]">
|
||||
· {pluginIdLabel}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
displayName
|
||||
<span className="flex min-w-0 flex-1 flex-col">
|
||||
<span
|
||||
className="truncate text-[#fafafa] text-sm font-medium"
|
||||
title={plugin ? project.containerTag : displayName}
|
||||
>
|
||||
{plugin ? (
|
||||
<>
|
||||
{plugin.label}
|
||||
{pluginIdLabel && (
|
||||
<span className="ml-1.5 text-[12px] text-[#737373]">
|
||||
· {pluginIdLabel}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
displayName
|
||||
)}
|
||||
</span>
|
||||
{descriptor && (
|
||||
<span className="truncate text-[11px] text-[#737373]">
|
||||
{descriptor}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
{isCbSpace && isDefaultBrain && (
|
||||
<span className="ml-2 shrink-0 rounded-[4px] bg-[#4BA0FA]/15 px-1.5 py-0.5 text-[10px] font-medium text-[#4BA0FA]">
|
||||
Default
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
{canEdit && !isEditing && !isBulkDeleteMode && (
|
||||
|
|
@ -787,6 +868,7 @@ export function SelectSpacesModal({
|
|||
enableDelete,
|
||||
handleEditKeyDown,
|
||||
handleSelect,
|
||||
hasCompanyBrain,
|
||||
isBulkDeleteMode,
|
||||
onDeleteRequest,
|
||||
pluginMetaMap,
|
||||
|
|
@ -795,6 +877,11 @@ export function SelectSpacesModal({
|
|||
toggleBulkDeleteTag,
|
||||
updateProjectMutation.isPending,
|
||||
user?.id,
|
||||
org?.name,
|
||||
org?.members,
|
||||
user?.email,
|
||||
user?.image,
|
||||
user?.name,
|
||||
],
|
||||
)
|
||||
|
||||
|
|
@ -960,7 +1047,36 @@ export function SelectSpacesModal({
|
|||
</div>
|
||||
</>
|
||||
)}
|
||||
{mainList.map(renderRow)}
|
||||
{hasCompanyBrain && recentProjects.length === 0
|
||||
? (() => {
|
||||
const shared = mainList.filter(
|
||||
(p) => p.visibility === "public",
|
||||
)
|
||||
const personal = mainList.filter(
|
||||
(p) => p.visibility !== "public",
|
||||
)
|
||||
return (
|
||||
<>
|
||||
{shared.length > 0 && (
|
||||
<>
|
||||
<div className="px-3 pt-1 pb-0.5 text-[10px] uppercase tracking-[0.08em] text-[#737373]">
|
||||
Shared
|
||||
</div>
|
||||
{shared.map(renderRow)}
|
||||
</>
|
||||
)}
|
||||
{personal.length > 0 && (
|
||||
<>
|
||||
<div className="px-3 pt-2 pb-0.5 text-[10px] uppercase tracking-[0.08em] text-[#737373]">
|
||||
Personal
|
||||
</div>
|
||||
{personal.map(renderRow)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
})()
|
||||
: mainList.map(renderRow)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -194,16 +194,18 @@ export default function Account() {
|
|||
() => org?.members?.find((member) => member.userId === user?.id) ?? null,
|
||||
[org?.members, user?.id],
|
||||
)
|
||||
// Only treat as a personal single-member org when members are actually loaded —
|
||||
// otherwise default to least privilege (member), never owner.
|
||||
const membersLoaded = Array.isArray(org?.members)
|
||||
const isSingleMemberPersonalOrg =
|
||||
membersLoaded &&
|
||||
(org?.members?.length ?? 0) <= 1 &&
|
||||
(!org?.members?.[0]?.userId || org.members[0].userId === user?.id)
|
||||
const currentRole = isSingleMemberPersonalOrg
|
||||
? "owner"
|
||||
: (
|
||||
activeMemberRoleQuery.data ??
|
||||
currentMember?.role ??
|
||||
"member"
|
||||
).toLowerCase()
|
||||
const currentRole = (
|
||||
activeMemberRoleQuery.data ??
|
||||
currentMember?.role ??
|
||||
(isSingleMemberPersonalOrg ? "owner" : "member")
|
||||
).toLowerCase()
|
||||
const canManageTeam = currentRole === "owner" || currentRole === "admin"
|
||||
const isOwner = currentRole === "owner"
|
||||
|
||||
|
|
@ -496,6 +498,14 @@ export default function Account() {
|
|||
>
|
||||
{org?.name ?? "Personal"}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"inline-flex h-[18px] shrink-0 items-center justify-center rounded-[3px] bg-[#2E353D] px-1.5 text-[10px] font-mono font-medium uppercase tracking-[0.12em] text-[#A3A3A3]",
|
||||
)}
|
||||
>
|
||||
{currentRole}
|
||||
</span>
|
||||
{canManageTeam ? (
|
||||
<button
|
||||
type="button"
|
||||
|
|
@ -587,7 +597,7 @@ export default function Account() {
|
|||
</div>
|
||||
</section>
|
||||
|
||||
<OrgContext />
|
||||
{canManageTeam && <OrgContext />}
|
||||
|
||||
<DigestPreferences />
|
||||
|
||||
|
|
|
|||
|
|
@ -36,7 +36,8 @@ export function SettingsOrgSwitcher() {
|
|||
const [createName, setCreateName] = useState("")
|
||||
const [creating, setCreating] = useState(false)
|
||||
|
||||
// Clear a stale Radix `pointer-events: none` left on <body> so the dialog accepts clicks.
|
||||
// Safety net: a prior Radix overlay can leave `pointer-events: none` stuck on
|
||||
// <body>, making the dialog appear but ignore clicks. Clear it when it opens.
|
||||
useEffect(() => {
|
||||
if (createOpen) document.body.style.pointerEvents = ""
|
||||
}, [createOpen])
|
||||
|
|
@ -161,7 +162,8 @@ export function SettingsOrgSwitcher() {
|
|||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
// Defer dialog open: same-tick handoff leaves Radix's pointer-events stuck on <body>.
|
||||
// Open the dialog only after the popover has closed — opening both in
|
||||
// the same tick can leave Radix's `pointer-events: none` stuck on <body>.
|
||||
setOpen(false)
|
||||
setTimeout(() => setCreateOpen(true), 120)
|
||||
}}
|
||||
|
|
|
|||
|
|
@ -6,7 +6,8 @@ import { useQuery } from "@tanstack/react-query"
|
|||
import { cn } from "@lib/utils"
|
||||
import { $fetch } from "@lib/api"
|
||||
import { dmSans125ClassName, dmSansClassName } from "@/lib/fonts"
|
||||
import { DEFAULT_PROJECT_ID } from "@lib/constants"
|
||||
import { DEFAULT_PROJECT_ID, SHARED_TEAM_BRAIN_TAG } from "@lib/constants"
|
||||
import { useHasCompanyBrain } from "@/hooks/use-company-brain"
|
||||
import { ChevronDownIcon, Pencil, XIcon, Loader2, Trash2 } from "lucide-react"
|
||||
import type { ContainerTagListType } from "@lib/types"
|
||||
import { AUTO_CHAT_SPACE_ID } from "@/lib/chat-auto-space"
|
||||
|
|
@ -147,12 +148,16 @@ export function SpaceSelector({
|
|||
useProjectMutations()
|
||||
const { allProjects, isLoading } = useContainerTags()
|
||||
const { user } = useAuth()
|
||||
const hasCompanyBrain = useHasCompanyBrain()
|
||||
const defaultTag = hasCompanyBrain
|
||||
? SHARED_TEAM_BRAIN_TAG
|
||||
: DEFAULT_PROJECT_ID
|
||||
|
||||
useEffect(() => {
|
||||
setRecents(readRecents())
|
||||
}, [])
|
||||
|
||||
const activeTag = selectedProjects[0] ?? DEFAULT_PROJECT_ID
|
||||
const activeTag = selectedProjects[0] ?? defaultTag
|
||||
const { data: spaceCountData } = useQuery({
|
||||
queryKey: ["space-selector-count", activeTag],
|
||||
queryFn: async (): Promise<number> => {
|
||||
|
|
@ -194,7 +199,7 @@ export function SpaceSelector({
|
|||
isAuto: boolean
|
||||
isOwnSpace: boolean
|
||||
}>(() => {
|
||||
const containerTag = selectedProjects[0] ?? ""
|
||||
const containerTag = selectedProjects[0] ?? defaultTag
|
||||
if (includeAuto && containerTag === AUTO_CHAT_SPACE_ID) {
|
||||
return {
|
||||
name: "Auto",
|
||||
|
|
@ -233,7 +238,14 @@ export function SpaceSelector({
|
|||
isAuto: false,
|
||||
isOwnSpace,
|
||||
}
|
||||
}, [allProjects, selectedProjects, pluginMetaMap, includeAuto, user?.id])
|
||||
}, [
|
||||
allProjects,
|
||||
selectedProjects,
|
||||
pluginMetaMap,
|
||||
includeAuto,
|
||||
user?.id,
|
||||
defaultTag,
|
||||
])
|
||||
|
||||
const canEditCurrent =
|
||||
enableEdit &&
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue