feat(web): select organization during plugin auth

This commit is contained in:
Vorflux AI 2026-08-23 22:35:52 +00:00
parent 3487666481
commit fce2ccb9d1
2 changed files with 334 additions and 150 deletions

View file

@ -3,11 +3,19 @@
import { useAuth } from "@lib/auth-context"
import { useSession } from "@lib/auth"
import { cn } from "@lib/utils"
import { Logo } from "@ui/assets/Logo"
import { dmSans125ClassName } from "@/lib/fonts"
import { ArrowRight, XCircle } from "lucide-react"
import { ArrowLeft, ArrowRight, LoaderIcon, XCircle } from "lucide-react"
import Image from "next/image"
import { useRouter, useSearchParams } from "next/navigation"
import { Suspense, useEffect, useMemo, useState } from "react"
import {
Suspense,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react"
import { PENDING_CONNECT_URL_KEY } from "@/lib/constants"
@ -172,7 +180,7 @@ function PluginLogoStack({ clients }: { clients: string[] }) {
)
}
type Status = "loading" | "creating" | "success" | "error"
type Status = "loading" | "selection" | "approval" | "creating" | "success"
const pageWrapperClass =
"flex items-center justify-center min-h-screen bg-background p-4"
@ -185,9 +193,16 @@ function AuthConnectContent() {
const params = useSearchParams()
const router = useRouter()
const { data: session, isPending } = useSession()
const { org, organizations, isRestoring } = useAuth()
const { organizations, isRestoring, setActiveOrg } = useAuth()
const [status, setStatus] = useState<Status>("loading")
const [error, setError] = useState<string | null>(null)
const [selectedOrgId, setSelectedOrgId] = useState<string | null>(null)
const [switchingOrgId, setSwitchingOrgId] = useState<string | null>(null)
const switchingOrgIdRef = useRef<string | null>(null)
const autoAttemptedOrgId = useRef<string | null>(null)
const listRef = useRef<HTMLDivElement>(null)
const [canScrollUp, setCanScrollUp] = useState(false)
const [canScrollDown, setCanScrollDown] = useState(false)
const callback = params.get("callback")
const client = params.get("client")
@ -214,10 +229,22 @@ function AuthConnectContent() {
requestedClients.length === 1 && validClient
? PLUGIN_INFO[validClient]
: null
const requestError = useMemo(() => {
if (!callback) return "Missing callback parameter."
if (!isValidLocalhostCallback(callback)) return "Invalid callback URL."
if (invalidClients.length > 0) {
return `Unsupported plugin requested: ${invalidClients.join(", ")}.`
}
if (requestedClients.length === 0) return "Invalid or missing client."
return null
}, [callback, invalidClients, requestedClients.length])
const selectedOrg =
organizations?.find((organization) => organization.id === selectedOrgId) ??
null
const multiOrg = (organizations?.length ?? 0) > 1
// Redirect new users (logged in but no organization) to onboarding.
// Store the current connect URL so onboarding can redirect back here.
const shouldRedirectToOnboarding =
!requestError &&
!isPending &&
!isRestoring &&
!!session &&
@ -225,6 +252,7 @@ function AuthConnectContent() {
organizations.length === 0
useEffect(() => {
if (requestError) return
if (isPending || isRestoring) return
if (!session) return
if (organizations === null) return // orgs query still pending
@ -236,41 +264,103 @@ function AuthConnectContent() {
console.warn("Failed to access sessionStorage for pending connect URL", e)
}
router.replace("/onboarding")
}, [isPending, isRestoring, session, organizations, router])
}, [isPending, isRestoring, session, organizations, router, requestError])
const selectOrganization = useCallback(
async (organization: NonNullable<typeof organizations>[number]) => {
if (switchingOrgIdRef.current) return
setError(null)
switchingOrgIdRef.current = organization.id
setSwitchingOrgId(organization.id)
try {
await setActiveOrg(organization.slug)
setSelectedOrgId(organization.id)
setStatus("approval")
} catch (err) {
console.error("Failed to switch organization:", err)
setError("Couldn't switch to that organization. Try again.")
setStatus("selection")
} finally {
switchingOrgIdRef.current = null
setSwitchingOrgId(null)
}
},
[setActiveOrg],
)
useEffect(() => {
if (requestError || isPending || isRestoring || organizations === null)
return
if (!session || organizations.length === 0 || status !== "loading") return
if (organizations.length > 1) {
setStatus("selection")
return
}
const onlyOrganization = organizations[0]
if (
!onlyOrganization ||
autoAttemptedOrgId.current === onlyOrganization.id
) {
return
}
autoAttemptedOrgId.current = onlyOrganization.id
setStatus("selection")
void selectOrganization(onlyOrganization)
}, [
requestError,
isPending,
isRestoring,
organizations,
session,
status,
selectOrganization,
])
useEffect(() => {
if (status !== "approval" || !selectedOrgId || organizations === null)
return
if (
organizations.some((organization) => organization.id === selectedOrgId)
) {
return
}
setSelectedOrgId(null)
setError("That organization is no longer available. Choose another one.")
setStatus("selection")
}, [organizations, selectedOrgId, status])
const measureFades = useCallback((element: HTMLDivElement | null) => {
if (!element) return
setCanScrollUp(element.scrollTop > 8)
setCanScrollDown(
element.scrollTop + element.clientHeight < element.scrollHeight - 8,
)
}, [])
useEffect(() => {
if (status !== "selection") return
measureFades(listRef.current)
}, [measureFades, status])
async function handleConnect() {
if (!callback) {
setStatus("error")
setError("Missing callback parameter.")
return
}
if (!isValidLocalhostCallback(callback)) {
setStatus("error")
setError("Invalid callback URL.")
return
}
if (invalidClients.length > 0) {
setStatus("error")
setError(`Unsupported plugin requested: ${invalidClients.join(", ")}.`)
return
}
if (requestedClients.length === 0) {
setStatus("error")
setError("Invalid or missing client.")
return
}
if (!session || !org) {
setStatus("error")
if (requestError || !callback) return
if (!session || !selectedOrg) {
setError(
"Your account is not fully set up yet. Please complete onboarding first.",
selectedOrgId
? "That organization is no longer available. Choose another one."
: "Select an organization before approving the connection.",
)
setStatus(multiOrg ? "selection" : "approval")
return
}
try {
setError(null)
setStatus("creating")
const fetchParams = new URLSearchParams({ callback })
fetchParams.set("client", requestedClients[0] ?? "")
fetchParams.set("orgId", selectedOrg.id)
const res = await fetch(`${API_URL}/v3/auth/key?${fetchParams}`, {
credentials: "include",
@ -306,105 +396,14 @@ function AuthConnectContent() {
window.location.href = redirectUrl.toString()
} catch (err) {
console.error("Failed to get API key:", err)
setStatus("error")
setError(err instanceof Error ? err.message : "Failed to get API key")
setStatus("approval")
}
}
// Show a spinner while session/org data is loading or while we're about
// to redirect to onboarding (prevents a brief flash of the connect card).
const isAuthLoading = isPending || isRestoring || organizations === null
useEffect(() => {
if (status !== "loading") return
if (rawRequestedClients.length === 0) {
setStatus("error")
setError("Invalid or missing client.")
return
}
if (invalidClients.length > 0) {
setStatus("error")
setError(`Unsupported plugin requested: ${invalidClients.join(", ")}.`)
}
}, [invalidClients, rawRequestedClients.length, status])
if (isAuthLoading || shouldRedirectToOnboarding) {
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>
)
}
if (status === "loading") {
return (
<div className={pageWrapperClass}>
<div className={cardClass}>
<div className="flex flex-col items-center gap-5">
<PluginLogoStack clients={requestedClients} />
<div className="text-center">
<h2
className={dmSans125ClassName(
"font-semibold text-[18px] text-[#FAFAFA]",
)}
>
Connect {displayName}
</h2>
<p
className={dmSans125ClassName(
"text-[13px] text-[#737373] mt-1",
)}
>
{pluginInfo?.description ??
(requestedClients.length > 1
? "Use one Supermemory account across these plugins."
: `Use your Supermemory account with ${displayName}.`)}
</p>
</div>
<ul className="w-full space-y-2.5">
{(pluginInfo?.features ?? MULTI_PLUGIN_FEATURES).map(
(feature) => (
<li key={feature} className="flex items-start gap-2.5">
<ArrowRight className="mt-0.5 size-3.5 shrink-0 text-[#4BA0FA]" />
<span
className={dmSans125ClassName(
"text-[13px] text-[#8B8B8B]",
)}
>
{feature}
</span>
</li>
),
)}
</ul>
<button
type="button"
onClick={handleConnect}
className={cn(
"relative w-full h-11 rounded-[10px] flex items-center justify-center",
"text-[#FAFAFA] font-medium text-[14px]",
"shadow-[0px_2px_10px_rgba(5,1,0,0.2)]",
"cursor-pointer transition-opacity hover:opacity-90",
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)",
}}
>
Approve Connection
<div className="absolute inset-0 pointer-events-none rounded-[inherit] shadow-[inset_1px_1px_2px_1px_#1A88FF]" />
</button>
</div>
</div>
</div>
)
}
if (status === "error") {
if (requestError) {
return (
<div className={pageWrapperClass}>
<div className={cardClass}>
@ -413,42 +412,223 @@ function AuthConnectContent() {
<div>
<h2
className={dmSans125ClassName(
"font-semibold text-[18px] text-[#FAFAFA]",
"text-[18px] font-semibold text-[#FAFAFA]",
)}
>
Connection failed
</h2>
<p
className={dmSans125ClassName(
"text-[13px] text-[#737373] mt-1",
"mt-1 text-[13px] text-[#737373]",
)}
>
{error}
{requestError}
</p>
</div>
<a
className={dmSans125ClassName(
"text-[12px] text-[#737373] transition-colors hover:text-[#FAFAFA]",
)}
href="https://app.supermemory.ai"
>
Go to app
</a>
</div>
</div>
</div>
)
}
<div className="flex flex-col gap-2 w-full">
<button
type="button"
onClick={() => void handleConnect()}
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]",
dmSans125ClassName(),
)}
>
Try again
</button>
<a
href="https://app.supermemory.ai"
className={dmSans125ClassName(
"text-[12px] text-[#737373] hover:text-[#FAFAFA] transition-colors",
)}
>
Go to app
</a>
if (isAuthLoading || shouldRedirectToOnboarding || status === "loading") {
return (
<div className="flex min-h-screen items-center justify-center bg-[#08090C]">
<div className="size-6 animate-spin rounded-full border-2 border-[#4BA0FA] border-t-transparent" />
</div>
)
}
if (status === "selection") {
return (
<div className="relative flex min-h-screen items-center justify-center bg-[#08090C] p-4">
<div
aria-hidden
className="pointer-events-none absolute inset-0"
style={{
background:
"radial-gradient(60% 50% at 50% 0%, rgba(75,160,250,0.05), transparent 70%)",
}}
/>
<div
className={cn(
"relative flex w-full max-w-[440px] flex-col",
dmSans125ClassName(),
)}
>
<div className="pt-2 pb-6 text-center">
<Logo className="mx-auto h-8 w-auto text-white" />
<h1 className="mt-6 text-[20px] font-semibold tracking-[-0.2px] text-[#FAFAFA]">
Select an organization
</h1>
<p className="mt-3 text-[13px] text-[#737373]">
Choose which organization to connect {displayName} to.
</p>
</div>
<div className="relative">
<div
className="flex max-h-[360px] flex-col gap-2 overflow-y-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
onScroll={(event) => measureFades(event.currentTarget)}
ref={listRef}
>
{organizations?.map((organization) => (
<button
aria-label={`Connect ${displayName} to ${organization.name}`}
className="flex w-full items-center gap-3 rounded-[12px] bg-[#14161A] px-4 py-3.5 text-left transition-colors hover:bg-[#1B1E25] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#4BA0FA] disabled:cursor-not-allowed disabled:opacity-60"
disabled={switchingOrgId !== null}
key={organization.id}
onClick={() => void selectOrganization(organization)}
type="button"
>
<div className="flex size-9 shrink-0 items-center justify-center rounded-[9px] bg-white/[0.06] text-[14px] font-semibold text-[#FAFAFA]">
{organization.name.charAt(0).toUpperCase() || "?"}
</div>
<span className="min-w-0 flex-1 truncate text-[15px] font-medium text-[#FAFAFA]">
{organization.name}
</span>
{switchingOrgId === organization.id && (
<LoaderIcon className="size-4 shrink-0 animate-spin text-[#9AA0A6]" />
)}
</button>
))}
</div>
<div
aria-hidden
className={cn(
"pointer-events-none absolute inset-x-0 top-0 h-14 bg-gradient-to-b from-[#08090C] to-transparent transition-opacity duration-300",
canScrollUp ? "opacity-100" : "opacity-0",
)}
/>
<div
aria-hidden
className={cn(
"pointer-events-none absolute inset-x-0 bottom-0 h-20 bg-gradient-to-t from-[#08090C] to-transparent transition-opacity duration-300",
canScrollDown ? "opacity-100" : "opacity-0",
)}
/>
</div>
{error && (
<p
aria-atomic="true"
className="pt-3 text-center text-[13px] text-red-400"
role="alert"
>
{error}
</p>
)}
</div>
</div>
)
}
if (status === "approval" || status === "creating") {
const creating = status === "creating"
return (
<div className="relative flex min-h-screen items-center justify-center bg-[#08090C] p-4">
<div
aria-hidden
className="pointer-events-none absolute inset-0"
style={{
background:
"radial-gradient(60% 50% at 50% 0%, rgba(75,160,250,0.05), transparent 70%)",
}}
/>
<div
className={cn(
"relative flex w-full max-w-[440px] flex-col overflow-hidden rounded-[14px] bg-[#14161A] shadow-[inset_2.42px_2.42px_4.263px_rgba(11,15,21,0.7)]",
dmSans125ClassName(),
)}
>
<div className="px-6 pt-7 pb-5 text-center">
<PluginLogoStack clients={requestedClients} />
<h2 className="mt-5 text-[19px] font-semibold tracking-[-0.2px] text-[#FAFAFA]">
Connect {displayName}
</h2>
<p className="mt-1.5 text-[13px] text-[#737373]">
{pluginInfo?.description ??
(requestedClients.length > 1
? "Use one Supermemory account across these plugins."
: `Use your Supermemory account with ${displayName}.`)}
</p>
</div>
<ul className="space-y-2.5 px-6 pb-5">
{(pluginInfo?.features ?? MULTI_PLUGIN_FEATURES).map((feature) => (
<li className="flex items-start gap-2.5" key={feature}>
<ArrowRight className="mt-0.5 size-3.5 shrink-0 text-[#4BA0FA]" />
<span className="text-[13px] text-[#8B8B8B]">{feature}</span>
</li>
))}
</ul>
<div className="mx-6 h-px bg-white/[0.06]" />
<div className="flex items-center justify-between gap-3 px-6 py-3.5">
<div className="min-w-0">
<span className="text-[11px] font-medium uppercase tracking-[0.08em] text-[#737373]">
Connecting to
</span>
<p className="truncate text-[14px] font-medium text-[#FAFAFA]">
{selectedOrg?.name ?? "Organization unavailable"}
</p>
</div>
{multiOrg && (
<button
className="flex shrink-0 items-center gap-1 rounded-[7px] px-2 py-1.5 text-[12px] text-[#9AA0A6] transition-colors hover:bg-white/[0.04] hover:text-[#FAFAFA] disabled:opacity-50"
disabled={creating}
onClick={() => {
setError(null)
setStatus("selection")
}}
type="button"
>
<ArrowLeft className="size-3.5" />
Change
</button>
)}
</div>
<div className="mx-6 h-px bg-white/[0.06]" />
{error && (
<p
aria-atomic="true"
className="px-6 pt-4 text-[13px] text-red-400"
role="alert"
>
{error}
</p>
)}
<div className="px-6 pt-4 pb-5">
<button
aria-busy={creating}
aria-label={
creating ? "Creating connection" : "Approve Connection"
}
className="relative flex h-11 w-full cursor-pointer items-center justify-center rounded-[10px] text-[14px] font-medium text-[#FAFAFA] transition-opacity hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-50"
disabled={creating || !selectedOrg}
onClick={() => void handleConnect()}
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)",
}}
type="button"
>
{creating ? (
<>
<LoaderIcon aria-hidden className="size-4 animate-spin" />
<span className="sr-only">Creating connection</span>
</>
) : (
"Approve Connection"
)}
<div className="pointer-events-none absolute inset-0 rounded-[inherit] shadow-[inset_1px_1px_2px_1px_#1A88FF]" />
</button>
</div>
</div>
</div>
@ -460,7 +640,6 @@ function AuthConnectContent() {
<div className="flex flex-col items-center gap-3">
<div className="size-6 border-2 border-[#4BA0FA] border-t-transparent rounded-full animate-spin" />
<p className={dmSans125ClassName("text-sm text-[#737373]")}>
{status === "creating" && `Connecting ${displayName}`}
{status === "success" &&
`Success! Redirecting back to ${displayName}`}
</p>

View file

@ -75,8 +75,13 @@ export function AuthProvider({ children }: { children: ReactNode }) {
const res = await authClient.organization.setActive({
organizationSlug: slug,
})
setOrg(res?.data ?? null)
localStorage.setItem(STORAGE_KEY, slug)
if (res.error || !res.data) {
throw new Error(res.error?.message ?? "Failed to switch organization")
}
try {
localStorage.setItem(STORAGE_KEY, slug)
} catch {}
setOrg(res.data)
}, [])
const clearActiveOrg = useCallback(async () => {