diff --git a/apps/docs/company-brain/setup.mdx b/apps/docs/company-brain/setup.mdx index 1244c643..d74db4e0 100644 --- a/apps/docs/company-brain/setup.mdx +++ b/apps/docs/company-brain/setup.mdx @@ -63,6 +63,11 @@ Creating a workspace sets up your shared **Team Brain** and your private **My Br Click **Install to Slack**. Not an admin? This triggers Slack's own request-to-install flow instead. + + If the Slack workspace is already linked to another Supermemory organization, the admin who started the install sees a warning before Supermemory changes the connection. The warning names the Slack workspace and the active target organization, explains what future Company Brain activity will move, and requires the admin to type the workspace name or `OVERRIDE`. + + Slack authorization has already happened at this point, so Slack may have refreshed or invalidated the prior credentials. Until confirmation, Supermemory does not reassign its workspace mapping. Cancelling the request or letting its 15-minute confirmation window expire also leaves that Supermemory mapping unchanged, but it does not guarantee the prior Slack connection still works. Restart the install from Slack to recover or try again. + The web app hands off immediately — "we've DM'd you in Slack." diff --git a/apps/web/app/(app)/slack/workspace-override/page.tsx b/apps/web/app/(app)/slack/workspace-override/page.tsx new file mode 100644 index 00000000..fdda9eca --- /dev/null +++ b/apps/web/app/(app)/slack/workspace-override/page.tsx @@ -0,0 +1,24 @@ +import { Suspense } from "react" +import { SlackWorkspaceOverride } from "@/components/slack-workspace-override" + +function OverrideLoading() { + return ( +
+ + +
+ ) +} + +export default function SlackWorkspaceOverridePage() { + return ( + }> + + + ) +} diff --git a/apps/web/components/slack-workspace-override.tsx b/apps/web/components/slack-workspace-override.tsx new file mode 100644 index 00000000..1d415390 --- /dev/null +++ b/apps/web/components/slack-workspace-override.tsx @@ -0,0 +1,593 @@ +"use client" + +import { SlackMark } from "@/components/brain-connector-icons" +import { dmSans125ClassName } from "@/lib/fonts" +import { getBackendUrl } from "@/lib/url-helpers" +import { + getSafeAppDestination, + getSafeSlackOverrideDestination, + inspectSlackWorkspaceOverride, + isSlackOverrideConfirmationValid, + resolveSlackWorkspaceOverride, + type SlackWorkspaceOverrideError, + type SlackWorkspaceOverrideRequest, +} from "@/lib/slack-workspace-override" +import { cn } from "@lib/utils" +import { Logo } from "@ui/assets/Logo" +import { + AlertCircle, + Check, + Clock3, + History, + LoaderCircle, + RefreshCw, + ShieldCheck, + TriangleAlert, + X, +} from "lucide-react" +import { useSearchParams } from "next/navigation" +import { useCallback, useEffect, useRef, useState } from "react" + +type ScreenState = + | { kind: "loading" } + | { kind: "checking"; indeterminateAction: boolean } + | { kind: "pending"; request: SlackWorkspaceOverrideRequest } + | { + kind: "terminal" + error: SlackWorkspaceOverrideError + indeterminateAction?: boolean + } + +const restartHref = `${getBackendUrl()}/brain/slack/oauth/install` + +function CardShell({ children }: { children: React.ReactNode }) { + return ( +
+
+ ) +} + +function ConnectingHeader() { + return ( +
+
+ +
+ +
+ +
+
+ ) +} + +function LoadingState({ checking = false }: { checking?: boolean }) { + return ( + +
+ + +
+
+ ) +} + +const terminalContent = { + expired: { + title: "This request has expired", + body: "Your Slack authorization timed out before you confirmed. Supermemory did not reassign the workspace. To reassign it, restart from Slack — you’ll re-authorize as part of that flow.", + icon: Clock3, + }, + cancelled: { + title: "This request was cancelled", + body: "Supermemory did not reassign the workspace. Slack authorization already happened, so the prior connection may need to be authorized again.", + icon: X, + }, + workspace_changed: { + title: "The Slack connection changed", + body: "The connection changed while this page was open. Supermemory did not apply this request. Start a new install from Slack to continue safely.", + icon: History, + }, + unauthorized: { + title: "Sign in to continue", + body: "The admin who started this request must sign in to the original Supermemory organization and restart or complete the flow.", + icon: ShieldCheck, + }, + forbidden: { + title: "This request needs the original admin", + body: "The admin who started this request must use the original Supermemory organization. If their role changed, restart from Slack after restoring access.", + icon: ShieldCheck, + }, + not_found: { + title: "This request is unavailable", + body: "It may be invalid or no longer available. Supermemory did not reassign a workspace from this page. Start a new install from Slack.", + icon: AlertCircle, + }, + invalid_request: { + title: "This request is invalid", + body: "This page needs a valid Slack reassignment request. Start the install again from Slack.", + icon: AlertCircle, + }, + unknown: { + title: "This request could not be loaded", + body: "Supermemory did not reassign the workspace. Start the install again from Slack.", + icon: AlertCircle, + }, + network_error: { + title: "The request status is unknown", + body: "Supermemory could not verify whether the last action completed. Check the current status before taking another action.", + icon: AlertCircle, + }, +} as const + +function TerminalState({ + error, + indeterminateAction = false, + loginHref, + onRetry, +}: { + error: SlackWorkspaceOverrideError + indeterminateAction?: boolean + loginHref: string + onRetry?: () => void +}) { + const content = + terminalContent[error.code as keyof typeof terminalContent] ?? + terminalContent.unknown + const body = + error.code === "network_error" && !indeterminateAction + ? "Supermemory could not load the current request status. Check the status before taking an action." + : content.body + const Icon = content.icon + const stateRef = useRef(null) + + useEffect(() => { + stateRef.current?.focus() + }, []) + + return ( + +
+
+
+

+ {content.title} +

+

+ {body} +

+
+ {error.code === "network_error" && onRetry && ( + + )} + {error.code === "unauthorized" ? ( + + Sign in to continue + + ) : error.code !== "network_error" ? ( + + + ) : null} + + Go to Supermemory + +
+
+
+ ) +} + +function PendingState({ + request, + onTerminal, + onIndeterminate, +}: { + request: SlackWorkspaceOverrideRequest + onTerminal: (error: SlackWorkspaceOverrideError) => void + onIndeterminate: () => void +}) { + const [confirmation, setConfirmation] = useState("") + const [submitting, setSubmitting] = useState<"confirm" | "cancel" | null>( + null, + ) + const [error, setError] = useState(null) + const errorRef = useRef(null) + const valid = isSlackOverrideConfirmationValid(confirmation, request.teamName) + + useEffect(() => { + const remaining = new Date(request.expiresAt).getTime() - Date.now() + if (!Number.isFinite(remaining)) return + if (remaining <= 0) { + onTerminal({ code: "expired", status: 410 }) + return + } + const timeout = window.setTimeout( + () => onTerminal({ code: "expired", status: 410 }), + remaining, + ) + return () => window.clearTimeout(timeout) + }, [request.expiresAt, onTerminal]) + + const showError = useCallback((nextError: SlackWorkspaceOverrideError) => { + setError(nextError) + setSubmitting(null) + requestAnimationFrame(() => errorRef.current?.focus()) + }, []) + + const submitAction = async (action: "confirm" | "cancel") => { + if (submitting || (action === "confirm" && !valid)) return + setSubmitting(action) + setError(null) + const result = await resolveSlackWorkspaceOverride( + request.requestId, + action, + action === "confirm" ? confirmation : undefined, + ) + if (result.kind === "error") { + if (result.error.code === "network_error") return onIndeterminate() + if ( + result.error.code !== "invalid_confirmation" && + result.error.code !== "unknown" + ) { + return onTerminal(result.error) + } + return showError(result.error) + } + if (result.kind === "connected") { + window.location.assign( + getSafeSlackOverrideDestination( + result.destination, + result.teamName, + window.location.origin, + ), + ) + return + } + window.location.assign( + getSafeAppDestination(result.destination, "/", window.location.origin), + ) + } + + const isFieldError = error?.code === "invalid_confirmation" + const errorCopy = isFieldError + ? `Type ${request.teamName} or OVERRIDE to confirm.` + : error + ? "Supermemory couldn’t reassign the workspace, so it did not reassign it. Restart from Slack if this request no longer works." + : null + + return ( + +
+
+ +

+ This Slack is connected elsewhere +

+

+ The{" "} + + {request.teamName} + {" "} + workspace is already linked to a different Company Brain. +

+
+ +
+
+
+ +
+
+

+ {request.teamName} +

+

+ Reassigning to “{request.targetOrgName}” +

+
+
+
+ +
+

+

+
    +
  • +
  • +
  • +
  • +
  • +
  • +
+
+ +

+

+ +
{ + event.preventDefault() + void submitAction("confirm") + }} + > + +
+ { + setConfirmation(event.target.value) + if (isFieldError) setError(null) + }} + placeholder="Workspace name or OVERRIDE" + spellCheck={false} + value={confirmation} + /> + {valid && ( +
+ {errorCopy && ( +

+

+ )} +
+ + +
+
+
+
+ ) +} + +export function SlackWorkspaceOverride() { + const requestId = useSearchParams().get("request")?.trim() ?? "" + const [screen, setScreen] = useState( + requestId + ? { kind: "loading" } + : { kind: "terminal", error: { code: "invalid_request", status: null } }, + ) + const shouldInspect = screen.kind === "loading" || screen.kind === "checking" + const indeterminateAction = + screen.kind === "checking" ? screen.indeterminateAction : false + const loginHref = + typeof window === "undefined" + ? "/login" + : `/login?redirect=${encodeURIComponent(window.location.href)}` + + const checkStatus = useCallback( + (afterIndeterminateAction: boolean) => { + if (!requestId) return + setScreen({ + kind: "checking", + indeterminateAction: afterIndeterminateAction, + }) + }, + [requestId], + ) + + useEffect(() => { + if (!requestId || !shouldInspect) return + let active = true + void inspectSlackWorkspaceOverride(requestId).then((result) => { + if (!active) return + if (result.kind === "pending") { + setScreen({ kind: "pending", request: result.request }) + } else if (result.kind === "connected") { + window.location.assign( + getSafeSlackOverrideDestination( + result.destination, + result.teamName, + window.location.origin, + ), + ) + } else if (result.kind === "cancelled") { + setScreen({ + kind: "terminal", + error: { code: "cancelled", status: 200 }, + }) + } else { + setScreen({ + kind: "terminal", + error: result.error, + indeterminateAction, + }) + } + }) + return () => { + active = false + } + }, [indeterminateAction, requestId, shouldInspect]) + + if (screen.kind === "loading") return + if (screen.kind === "checking") return + if (screen.kind === "terminal") + return ( + checkStatus(screen.indeterminateAction ?? false)} + /> + ) + return ( + checkStatus(true)} + onTerminal={(error) => setScreen({ kind: "terminal", error })} + request={screen.request} + /> + ) +} diff --git a/apps/web/lib/slack-workspace-override.ts b/apps/web/lib/slack-workspace-override.ts new file mode 100644 index 00000000..b743a93d --- /dev/null +++ b/apps/web/lib/slack-workspace-override.ts @@ -0,0 +1,229 @@ +import { getBackendUrl } from "@/lib/url-helpers" + +export type SlackWorkspaceOverrideRequest = { + requestId: string + teamName: string + targetOrgName: string + expiresAt: string +} + +export type SlackWorkspaceOverrideErrorCode = + | "invalid_confirmation" + | "cancelled" + | "expired" + | "workspace_changed" + | "unauthorized" + | "forbidden" + | "not_found" + | "invalid_request" + | "network_error" + | "unknown" + +export type SlackWorkspaceOverrideError = { + code: SlackWorkspaceOverrideErrorCode + status: number | null +} + +export type SlackWorkspaceOverrideInspection = + | { kind: "pending"; request: SlackWorkspaceOverrideRequest } + | { kind: "connected"; teamName: string | null; destination: string } + | { kind: "cancelled" } + | { kind: "error"; error: SlackWorkspaceOverrideError } + +export type SlackWorkspaceOverrideResolution = + | { kind: "connected"; teamName: string | null; destination: string } + | { kind: "cancelled"; destination: string } + | { kind: "error"; error: SlackWorkspaceOverrideError } + +type ApiErrorBody = { code?: string; error?: string } + +type InspectBody = + | { + outcome: "pending" + teamName: string + targetOrgName: string + expiresAt: string + } + | { outcome: "connected"; teamName: string | null; destination: string } + | { outcome: "cancelled" } + +type ResolutionBody = + | { + outcome: "confirmed" | "connected" + teamName: string | null + destination: string + } + | { outcome: "cancelled"; destination: string } + +function normalizeConfirmation(value: string): string { + return value.trim().normalize("NFKC").toLowerCase() +} + +export function isSlackOverrideConfirmationValid( + value: string, + workspaceName: string, +): boolean { + return ( + value.trim().normalize("NFKC") === "OVERRIDE" || + normalizeConfirmation(value) === normalizeConfirmation(workspaceName) + ) +} + +export function getSlackOverrideError( + status: number, + body?: ApiErrorBody, +): SlackWorkspaceOverrideError { + const serverCode = body?.code ?? body?.error + if (serverCode === "invalid_confirmation") { + return { code: "invalid_confirmation", status } + } + if (serverCode === "expired") return { code: "expired", status } + if (serverCode === "workspace_changed" || serverCode === "superseded") { + return { code: "workspace_changed", status } + } + + switch (status) { + case 400: + return { code: "invalid_request", status } + case 401: + return { code: "unauthorized", status } + case 403: + return { code: "forbidden", status } + case 404: + return { code: "not_found", status } + case 409: + return { code: "workspace_changed", status } + case 410: + return { code: "expired", status } + default: + return { code: "unknown", status } + } +} + +export function buildSlackConnectedDestination( + teamName: string | null, +): string { + const params = new URLSearchParams({ slack: "connected" }) + if (teamName) params.set("team", teamName) + return `/?${params.toString()}` +} + +export function getSafeSlackOverrideDestination( + destination: string | undefined, + teamName: string | null, + appOrigin: string, +): string { + return getSafeAppDestination( + destination, + buildSlackConnectedDestination(teamName), + appOrigin, + ) +} + +export function getSafeAppDestination( + destination: string | undefined, + fallback: string, + appOrigin: string, +): string { + if (!destination) return fallback + try { + const url = new URL(destination, appOrigin) + if (url.origin !== appOrigin || url.username || url.password) + return fallback + return `${url.pathname}${url.search}${url.hash}` + } catch { + return fallback + } +} + +function getOverrideUrl(requestId: string, action?: "confirm" | "cancel") { + const base = `${getBackendUrl()}/brain/slack/oauth/override/${encodeURIComponent(requestId)}` + return action ? `${base}/${action}` : base +} + +async function requestJson( + url: string, + init?: RequestInit, +): Promise<{ body?: T; error?: SlackWorkspaceOverrideError }> { + try { + const response = await fetch(url, { + credentials: "include", + ...init, + headers: { Accept: "application/json", ...init?.headers }, + }) + const body = (await response.json().catch(() => undefined)) as + | T + | ApiErrorBody + | undefined + if (!response.ok) { + return { + error: getSlackOverrideError(response.status, body as ApiErrorBody), + } + } + return { body: body as T } + } catch { + return { error: { code: "network_error", status: null } } + } +} + +export async function inspectSlackWorkspaceOverride( + requestId: string, +): Promise { + const result = await requestJson(getOverrideUrl(requestId), { + cache: "no-store", + }) + if (result.error) return { kind: "error", error: result.error } + const body = result.body + if (body?.outcome === "pending") { + return { + kind: "pending", + request: { + requestId, + teamName: body.teamName, + targetOrgName: body.targetOrgName, + expiresAt: body.expiresAt, + }, + } + } + if (body?.outcome === "connected") { + return { + kind: "connected", + teamName: body.teamName, + destination: body.destination, + } + } + if (body?.outcome === "cancelled") return { kind: "cancelled" } + return { kind: "error", error: { code: "unknown", status: 200 } } +} + +export async function resolveSlackWorkspaceOverride( + requestId: string, + action: "confirm" | "cancel", + confirmation?: string, +): Promise { + const result = await requestJson( + getOverrideUrl(requestId, action), + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify( + action === "confirm" ? { confirmation: confirmation ?? "" } : {}, + ), + }, + ) + if (result.error) return { kind: "error", error: result.error } + if ( + result.body?.outcome === "confirmed" || + result.body?.outcome === "connected" + ) { + return { + kind: "connected", + teamName: result.body.teamName, + destination: result.body.destination, + } + } + if (result.body?.outcome === "cancelled") { + return { kind: "cancelled", destination: result.body.destination } + } + return { kind: "error", error: { code: "unknown", status: 200 } } +}