diff --git a/apps/web/app/(app)/page.tsx b/apps/web/app/(app)/page.tsx index 020cad49..3f98c014 100644 --- a/apps/web/app/(app)/page.tsx +++ b/apps/web/app/(app)/page.tsx @@ -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 => { - 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", () => { diff --git a/apps/web/app/org/invite/[invitationId]/page.tsx b/apps/web/app/org/invite/[invitationId]/page.tsx new file mode 100644 index 00000000..e6c6d9da --- /dev/null +++ b/apps/web/app/org/invite/[invitationId]/page.tsx @@ -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 ( +
+
+
+ ) +} + +function PrimaryButton({ + children, + onClick, + disabled, +}: { + children: React.ReactNode + onClick: () => void + disabled?: boolean +}) { + return ( + + ) +} + +function SecondaryButton({ + children, + onClick, + disabled, +}: { + children: React.ReactNode + onClick: () => void + disabled?: boolean +}) { + return ( + + ) +} + +function IconTile({ children }: { children: React.ReactNode }) { + return ( +
+ {children} +
+ ) +} + +function Title({ children }: { children: React.ReactNode }) { + return ( +

+ {children} +

+ ) +} + +function Subtitle({ children }: { children: React.ReactNode }) { + return ( +

+ {children} +

+ ) +} + +function StatusCard({ + icon, + title, + description, + actionLabel, + onAction, +}: { + icon: React.ReactNode + title: string + description: string + actionLabel: string + onAction: () => void +}) { + return ( +
+
+
+ {icon} +
+ {title} + {description} +
+ {actionLabel} +
+
+
+ ) +} + +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("loading") + const [invitation, setInvitation] = useState(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 + + if (state === "no_session") { + const loginHref = `/login?redirect=${encodeURIComponent( + typeof window !== "undefined" ? window.location.href : "", + )}` + return ( +
+
+
+ + + +
+ You're not logged in + Log in to view and accept this invitation. +
+ router.push(loginHref)}> + Log in + +
+
+
+ ) + } + + if (state === "wrong_account") { + return ( + } + 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 ( + } + title={copy.title} + description={copy.body} + actionLabel="Go to dashboard" + onAction={() => router.push("/")} + /> + ) + } + + return ( +
+
+
+ + + +
+ {invitation?.organizationName} + + You've been invited to join{" "} + + {invitation?.organizationName} + {" "} + as {invitation?.role}. + + {invitation?.inviterEmail && ( +

+ Invited by {invitation.inviterEmail} +

+ )} + {session?.user?.email && ( +

+ Signed in as {session.user.email} +

+ )} +
+
+ + {accepting ? ( + <> + + Accepting… + + ) : ( + "Accept invitation" + )} + + + {declining ? ( + <> + + Declining… + + ) : ( + "Decline" + )} + +
+
+
+
+ ) +} diff --git a/apps/web/components/select-spaces-modal.tsx b/apps/web/components/select-spaces-modal.tsx index 7198d435..03b4ae76 100644 --- a/apps/web/components/select-spaces-modal.tsx +++ b/apps/web/components/select-spaces-modal.tsx @@ -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 ? ( + ) : isCbSpace ? ( + isShared ? ( + + {orgMembers.slice(0, 3).map((m, i) => ( + 0 && "-ml-2", + )} + > + + + {(m.user?.name ?? m.user?.email ?? "U") + .charAt(0) + .toUpperCase()} + + + ))} + {memberCount > 3 && ( + + +{memberCount - 3} + + )} + + ) : ( + + + + + {(user?.name ?? user?.email ?? "U") + .charAt(0) + .toUpperCase()} + + + + + + + ) ) : ( )} - - {plugin ? ( - <> - {plugin.label} - {pluginIdLabel && ( - - · {pluginIdLabel} - - )} - - ) : ( - displayName + + + {plugin ? ( + <> + {plugin.label} + {pluginIdLabel && ( + + · {pluginIdLabel} + + )} + + ) : ( + displayName + )} + + {descriptor && ( + + {descriptor} + )} + {isCbSpace && isDefaultBrain && ( + + Default + + )} )} {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({
)} - {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 && ( + <> +
+ Shared +
+ {shared.map(renderRow)} + + )} + {personal.length > 0 && ( + <> +
+ Personal +
+ {personal.map(renderRow)} + + )} + + ) + })() + : mainList.map(renderRow)} )} diff --git a/apps/web/components/settings/account.tsx b/apps/web/components/settings/account.tsx index 33fd2175..cb38e85c 100644 --- a/apps/web/components/settings/account.tsx +++ b/apps/web/components/settings/account.tsx @@ -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"} + + {currentRole} + {canManageTeam ? (