mirror of
https://github.com/supermemoryai/supermemory.git
synced 2026-08-28 05:25:33 +00:00
Merge 6f2ee73d6e into f11d8c4620
This commit is contained in:
commit
1253f824f2
4 changed files with 851 additions and 0 deletions
|
|
@ -63,6 +63,11 @@ Creating a workspace sets up your shared **Team Brain** and your private **My Br
|
|||
<Step title="Confirm company and domain">
|
||||
Click **Install to Slack**. Not an admin? This triggers Slack's own request-to-install flow instead.
|
||||
</Step>
|
||||
<Step title="Confirm a reassignment, if prompted">
|
||||
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.
|
||||
</Step>
|
||||
<Step title="Hand off to Slack">
|
||||
The web app hands off immediately — "we've DM'd you in Slack."
|
||||
</Step>
|
||||
|
|
|
|||
24
apps/web/app/(app)/slack/workspace-override/page.tsx
Normal file
24
apps/web/app/(app)/slack/workspace-override/page.tsx
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import { Suspense } from "react"
|
||||
import { SlackWorkspaceOverride } from "@/components/slack-workspace-override"
|
||||
|
||||
function OverrideLoading() {
|
||||
return (
|
||||
<div className="flex min-h-dvh items-center justify-center bg-[#08090C] p-4">
|
||||
<output className="flex items-center gap-3 text-[13px] text-[#9AA0A6]">
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="size-6 animate-spin rounded-full border-2 border-[#C73B1B]/30 border-t-[#C73B1B] motion-reduce:animate-none"
|
||||
/>
|
||||
<span className="sr-only">Loading Slack reassignment request</span>
|
||||
</output>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function SlackWorkspaceOverridePage() {
|
||||
return (
|
||||
<Suspense fallback={<OverrideLoading />}>
|
||||
<SlackWorkspaceOverride />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
593
apps/web/components/slack-workspace-override.tsx
Normal file
593
apps/web/components/slack-workspace-override.tsx
Normal file
|
|
@ -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 (
|
||||
<main
|
||||
className={cn(
|
||||
"relative flex h-dvh overflow-y-auto bg-[#08090C] p-4 text-[#FAFAFA]",
|
||||
dmSans125ClassName(),
|
||||
)}
|
||||
>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute inset-0 bg-[radial-gradient(60%_50%_at_50%_0%,rgba(75,160,250,0.06),transparent_70%)]"
|
||||
/>
|
||||
<div className="relative mx-auto my-auto w-full max-w-[460px] shrink-0 overflow-hidden rounded-[14px] bg-[#14161A] shadow-[inset_2.42px_2.42px_4.263px_rgba(11,15,21,0.7)]">
|
||||
{children}
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
function ConnectingHeader() {
|
||||
return (
|
||||
<div
|
||||
aria-label="Supermemory connecting to Slack"
|
||||
className="flex items-center justify-center gap-3"
|
||||
role="img"
|
||||
>
|
||||
<div className="flex size-12 items-center justify-center rounded-[13px] bg-[#0B0D11] shadow-[inset_0_0_0_1px_rgba(255,255,255,0.07)]">
|
||||
<Logo className="h-6 w-auto text-white" />
|
||||
</div>
|
||||
<div aria-hidden="true" className="flex gap-[5px]">
|
||||
{[0, 1, 2].map((dot) => (
|
||||
<span className="size-1.5 rounded-full bg-[#525660]" key={dot} />
|
||||
))}
|
||||
</div>
|
||||
<div className="flex size-12 items-center justify-center rounded-[13px] bg-[#0B0D11] shadow-[inset_0_0_0_1px_rgba(255,255,255,0.07)]">
|
||||
<SlackMark className="size-6" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function LoadingState({ checking = false }: { checking?: boolean }) {
|
||||
return (
|
||||
<CardShell>
|
||||
<div className="flex min-h-44 items-center justify-center p-8">
|
||||
<output className="flex items-center gap-3 text-[13px] text-[#9AA0A6]">
|
||||
<LoaderCircle
|
||||
aria-hidden="true"
|
||||
className="size-5 animate-spin text-[#C73B1B] motion-reduce:animate-none"
|
||||
/>
|
||||
{checking
|
||||
? "Checking the current reassignment status…"
|
||||
: "Loading Slack reassignment request…"}
|
||||
</output>
|
||||
</div>
|
||||
</CardShell>
|
||||
)
|
||||
}
|
||||
|
||||
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<HTMLElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
stateRef.current?.focus()
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<CardShell>
|
||||
<section
|
||||
aria-live="assertive"
|
||||
aria-labelledby="override-state-title"
|
||||
className="px-7 py-8 text-center"
|
||||
ref={stateRef}
|
||||
role="alert"
|
||||
tabIndex={-1}
|
||||
>
|
||||
<div className="mx-auto flex size-[52px] items-center justify-center rounded-[14px] bg-[#0B0D11] shadow-[inset_0_0_0_1px_rgba(255,255,255,0.07)]">
|
||||
<Icon aria-hidden="true" className="size-[26px] text-[#C79A2E]" />
|
||||
</div>
|
||||
<h1
|
||||
className="mt-5 text-[19px] font-semibold tracking-[-0.2px]"
|
||||
id="override-state-title"
|
||||
>
|
||||
{content.title}
|
||||
</h1>
|
||||
<p className="mx-auto mt-2.5 max-w-[350px] text-[13px] leading-[1.55] text-[#9AA0A6]">
|
||||
{body}
|
||||
</p>
|
||||
<div className="mt-6 flex flex-col gap-3">
|
||||
{error.code === "network_error" && onRetry && (
|
||||
<button
|
||||
className="flex h-11 items-center justify-center gap-2 rounded-[12px] bg-white text-[14px] font-semibold text-[#1D1C1D] transition-opacity hover:opacity-90 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-white"
|
||||
onClick={onRetry}
|
||||
type="button"
|
||||
>
|
||||
<RefreshCw aria-hidden="true" className="size-4" /> Check status
|
||||
</button>
|
||||
)}
|
||||
{error.code === "unauthorized" ? (
|
||||
<a
|
||||
className="flex h-11 items-center justify-center rounded-[12px] bg-white text-[14px] font-semibold text-[#1D1C1D] transition-opacity hover:opacity-90 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-white"
|
||||
href={loginHref}
|
||||
>
|
||||
Sign in to continue
|
||||
</a>
|
||||
) : error.code !== "network_error" ? (
|
||||
<a
|
||||
className="flex h-11 items-center justify-center gap-2 rounded-[12px] bg-white text-[14px] font-semibold text-[#1D1C1D] transition-opacity hover:opacity-90 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-white"
|
||||
href={restartHref}
|
||||
referrerPolicy="no-referrer"
|
||||
>
|
||||
<RefreshCw aria-hidden="true" className="size-4" /> Start over
|
||||
from Slack
|
||||
</a>
|
||||
) : null}
|
||||
<a
|
||||
className="rounded-lg py-1 text-[12px] text-[#737373] transition-colors hover:text-[#FAFAFA] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-white"
|
||||
href="/"
|
||||
>
|
||||
Go to Supermemory
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
</CardShell>
|
||||
)
|
||||
}
|
||||
|
||||
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<SlackWorkspaceOverrideError | null>(null)
|
||||
const errorRef = useRef<HTMLParagraphElement>(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 (
|
||||
<CardShell>
|
||||
<section aria-labelledby="override-title">
|
||||
<header className="px-6 pt-7 pb-5 text-center">
|
||||
<ConnectingHeader />
|
||||
<h1
|
||||
className="mt-5 text-[20px] font-semibold tracking-[-0.2px]"
|
||||
id="override-title"
|
||||
>
|
||||
This Slack is connected elsewhere
|
||||
</h1>
|
||||
<p className="mt-2.5 text-[13px] leading-5 text-[#737373]">
|
||||
The{" "}
|
||||
<strong className="font-semibold text-[#FAFAFA]">
|
||||
{request.teamName}
|
||||
</strong>{" "}
|
||||
workspace is already linked to a different Company Brain.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div className="mx-6 h-px bg-white/[0.06]" />
|
||||
<div className="flex items-center gap-3 px-6 py-3.5">
|
||||
<div className="flex size-9 shrink-0 items-center justify-center rounded-[9px] bg-white/[0.06]">
|
||||
<SlackMark className="size-5" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-[15px] font-semibold">
|
||||
{request.teamName}
|
||||
</p>
|
||||
<p className="mt-0.5 truncate text-[12px] text-[#737373]">
|
||||
Reassigning to “{request.targetOrgName}”
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mx-6 h-px bg-white/[0.06]" />
|
||||
|
||||
<div className="mx-6 mt-4 rounded-[14px] bg-[#101822] p-4 shadow-[inset_1.313px_1.313px_3.938px_rgba(0,0,0,0.7)]">
|
||||
<p className="flex items-center gap-2 text-[13px] font-semibold">
|
||||
<TriangleAlert
|
||||
aria-hidden="true"
|
||||
className="size-[15px] text-[#F2755A]"
|
||||
/>
|
||||
Overriding replaces the current connection
|
||||
</p>
|
||||
<ul className="mt-2.5 flex flex-col gap-2 text-[12.5px] leading-[1.45] text-[#A1A1AA]">
|
||||
<li className="flex gap-2">
|
||||
<X
|
||||
aria-hidden="true"
|
||||
className="mt-px size-3.5 shrink-0 text-[#5C6470]"
|
||||
/>
|
||||
<span>
|
||||
The currently connected Company Brain will stop receiving future
|
||||
DMs and channel activity from this workspace.
|
||||
</span>
|
||||
</li>
|
||||
<li className="flex gap-2">
|
||||
<ShieldCheck
|
||||
aria-hidden="true"
|
||||
className="mt-px size-3.5 shrink-0 text-[#5C6470]"
|
||||
/>
|
||||
<span>
|
||||
The Slack bot will answer from{" "}
|
||||
<strong className="font-semibold text-[#FAFAFA]">
|
||||
{request.targetOrgName}
|
||||
</strong>{" "}
|
||||
and act on its connected apps.
|
||||
</span>
|
||||
</li>
|
||||
<li className="flex gap-2">
|
||||
<History
|
||||
aria-hidden="true"
|
||||
className="mt-px size-3.5 shrink-0 text-[#5C6470]"
|
||||
/>
|
||||
<span>
|
||||
Only future Slack Company Brain activity moves. Supermemory has
|
||||
not reassigned the workspace yet.
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<p className="mx-6 mt-3.5 flex gap-2 text-[12.5px] leading-[1.5] text-[#A1A1AA]">
|
||||
<Check
|
||||
aria-hidden="true"
|
||||
className="mt-px size-3.5 shrink-0 text-[#3E8E5A]"
|
||||
/>
|
||||
<span>
|
||||
<strong className="font-semibold text-[#FAFAFA]">
|
||||
Supermemory hasn’t reassigned this workspace.
|
||||
</strong>{" "}
|
||||
Slack authorization already happened, so Slack may have refreshed
|
||||
the prior credentials.
|
||||
</span>
|
||||
</p>
|
||||
|
||||
<form
|
||||
className="px-6 pt-[18px]"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
void submitAction("confirm")
|
||||
}}
|
||||
>
|
||||
<label
|
||||
className="block text-[13px] text-[#A1A1AA]"
|
||||
htmlFor="slack-override-confirmation"
|
||||
>
|
||||
Type{" "}
|
||||
<code className="rounded-[5px] bg-white/[0.05] px-1.5 py-px font-mono text-[#FAFAFA]">
|
||||
{request.teamName}
|
||||
</code>{" "}
|
||||
or{" "}
|
||||
<code className="rounded-[5px] bg-white/[0.05] px-1.5 py-px font-mono text-[#FAFAFA]">
|
||||
OVERRIDE
|
||||
</code>{" "}
|
||||
to reassign.
|
||||
</label>
|
||||
<div className="relative mt-2.5">
|
||||
<input
|
||||
aria-describedby={errorCopy ? "slack-override-error" : undefined}
|
||||
aria-invalid={isFieldError}
|
||||
autoComplete="off"
|
||||
className={cn(
|
||||
"h-11 w-full rounded-[12px] border bg-[#0D121A] px-4 pr-11 font-mono text-[14px] tracking-[0.02em] text-white shadow-[inset_2.42px_2.42px_4.263px_rgba(11,15,21,0.7)] outline-none transition-colors placeholder:text-[#525D6E] focus:border-[#C73B1B]/55 focus:ring-2 focus:ring-[#C73B1B]/15 disabled:opacity-60",
|
||||
valid ? "border-[#3E8E5A]/55" : "border-white/[0.08]",
|
||||
isFieldError && "border-red-400/70",
|
||||
)}
|
||||
disabled={submitting !== null}
|
||||
id="slack-override-confirmation"
|
||||
onChange={(event) => {
|
||||
setConfirmation(event.target.value)
|
||||
if (isFieldError) setError(null)
|
||||
}}
|
||||
placeholder="Workspace name or OVERRIDE"
|
||||
spellCheck={false}
|
||||
value={confirmation}
|
||||
/>
|
||||
{valid && (
|
||||
<Check
|
||||
aria-hidden="true"
|
||||
className="absolute top-1/2 right-4 size-4 -translate-y-1/2 text-[#3E8E5A]"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{errorCopy && (
|
||||
<p
|
||||
className="mt-3 flex gap-2 text-[13px] leading-[1.45] text-red-400 outline-none"
|
||||
id="slack-override-error"
|
||||
ref={errorRef}
|
||||
tabIndex={-1}
|
||||
>
|
||||
<AlertCircle
|
||||
aria-hidden="true"
|
||||
className="mt-px size-[15px] shrink-0"
|
||||
/>
|
||||
{errorCopy}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex flex-col-reverse gap-2 py-[18px] sm:flex-row sm:items-center sm:justify-between">
|
||||
<button
|
||||
className="min-h-11 rounded-[10px] px-3 text-[13px] font-medium text-[#9AA0A6] transition-colors hover:text-[#FAFAFA] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-white disabled:cursor-not-allowed disabled:opacity-50"
|
||||
disabled={submitting !== null}
|
||||
onClick={() => void submitAction("cancel")}
|
||||
type="button"
|
||||
>
|
||||
{submitting === "cancel"
|
||||
? "Cancelling…"
|
||||
: "Cancel without reassigning"}
|
||||
</button>
|
||||
<button
|
||||
className={cn(
|
||||
"flex min-h-11 min-w-[190px] items-center justify-center gap-2 rounded-[12px] bg-[#0D121A] px-5 text-[14px] font-semibold shadow-[inset_1.313px_1.313px_3.938px_rgba(0,0,0,0.7)] transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#C73B1B] disabled:cursor-not-allowed disabled:opacity-45",
|
||||
valid &&
|
||||
"bg-[linear-gradient(180deg,rgba(199,59,27,0.16),rgba(199,59,27,0.06))] ring-1 ring-[#C73B1B]/35 hover:bg-[linear-gradient(180deg,rgba(199,59,27,0.24),rgba(199,59,27,0.1))]",
|
||||
)}
|
||||
disabled={!valid || submitting !== null}
|
||||
type="submit"
|
||||
>
|
||||
{submitting === "confirm" ? (
|
||||
<>
|
||||
<LoaderCircle
|
||||
aria-hidden="true"
|
||||
className="size-4 animate-spin text-[#C73B1B] motion-reduce:animate-none"
|
||||
/>
|
||||
Overriding…
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="size-1.5 rounded-full bg-[#C73B1B] shadow-[0_0_6px_rgba(199,59,27,0.7)]"
|
||||
/>
|
||||
{error ? "Try again" : "Override connection"}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
</CardShell>
|
||||
)
|
||||
}
|
||||
|
||||
export function SlackWorkspaceOverride() {
|
||||
const requestId = useSearchParams().get("request")?.trim() ?? ""
|
||||
const [screen, setScreen] = useState<ScreenState>(
|
||||
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 <LoadingState />
|
||||
if (screen.kind === "checking") return <LoadingState checking />
|
||||
if (screen.kind === "terminal")
|
||||
return (
|
||||
<TerminalState
|
||||
error={screen.error}
|
||||
indeterminateAction={screen.indeterminateAction}
|
||||
loginHref={loginHref}
|
||||
onRetry={() => checkStatus(screen.indeterminateAction ?? false)}
|
||||
/>
|
||||
)
|
||||
return (
|
||||
<PendingState
|
||||
onIndeterminate={() => checkStatus(true)}
|
||||
onTerminal={(error) => setScreen({ kind: "terminal", error })}
|
||||
request={screen.request}
|
||||
/>
|
||||
)
|
||||
}
|
||||
229
apps/web/lib/slack-workspace-override.ts
Normal file
229
apps/web/lib/slack-workspace-override.ts
Normal file
|
|
@ -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<T>(
|
||||
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<SlackWorkspaceOverrideInspection> {
|
||||
const result = await requestJson<InspectBody>(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<SlackWorkspaceOverrideResolution> {
|
||||
const result = await requestJson<ResolutionBody>(
|
||||
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 } }
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue