diff --git a/apps/web/app/slack/link/page.tsx b/apps/web/app/slack/link/page.tsx new file mode 100644 index 00000000..f91c3dd0 --- /dev/null +++ b/apps/web/app/slack/link/page.tsx @@ -0,0 +1,536 @@ +"use client" + +import { authClient } from "@lib/auth" +import { useAuth } from "@lib/auth-context" +import { cn } from "@lib/utils" +import { Logo } from "@ui/assets/Logo" +import { ArrowRight, Check, LoaderIcon } from "lucide-react" +import { AnimatePresence, motion } from "motion/react" +import { useSearchParams } from "next/navigation" +import { type ReactNode, useCallback, useEffect, useState } from "react" +import { SlackMark } from "@/components/brain-connector-icons" +import { dmSans125ClassName } from "@/lib/fonts" +import { getBackendUrl } from "@/lib/url-helpers" + +const GRADIENT_BG = + "linear-gradient(182.37deg, #0ff0d2 -91.53%, #5bd3fb -67.8%, #1e0ff0 95.17%)" +const GRADIENT_SHADOW = + "1px 1px 2px 0px #1A88FF inset, 0 2px 10px 0 rgba(5, 1, 0, 0.20)" + +type LinkPreview = { + status: "ready" + orgName: string + teamId: string + teamName: string | null + slackDisplayName: string | null + slackEmail: string | null + signedInEmail: string + isOrgMember: boolean + requiresRelink: boolean +} + +type PageState = + | { kind: "loading" } + | { kind: "ready"; preview: LinkPreview } + | { kind: "linking"; preview: LinkPreview } + | { kind: "linked"; orgName: string; teamId: string } + | { + kind: "error" + reason: "expired" | "used" | "invalid" | "not_in_org" | "unknown" + } + +const ERROR_COPY: Record< + Extract["reason"], + { title: string; body: string } +> = { + expired: { + title: "This link has expired", + body: "Return to Slack and ask Company Brain again to generate a fresh account link.", + }, + used: { + title: "This link was already used", + body: "Your account may already be connected. Return to Slack and try your request again.", + }, + invalid: { + title: "We couldn't verify this link", + body: "Return to Slack and use the latest link sent by Company Brain.", + }, + not_in_org: { + title: "This account isn't in the workspace", + body: "Sign in with a Supermemory account that already belongs to this organization, or ask an admin to add you.", + }, + unknown: { + title: "We couldn't finish the connection", + body: "Nothing was changed. Please try again, or return to Slack for a fresh link.", + }, +} + +function loginRedirectUrl(): string { + const redirect = window.location.href + return `/login?redirect=${encodeURIComponent(redirect)}` +} + +async function readJson(response: Response): Promise> { + return (await response.json().catch(() => ({}))) as Record +} + +export default function SlackAccountLinkPage() { + const params = useSearchParams() + const token = params.get("token") + const { session, user, isSessionPending } = useAuth() + const [state, setState] = useState({ kind: "loading" }) + + const loadPreview = useCallback(async () => { + if (!token) { + setState({ kind: "error", reason: "invalid" }) + return + } + const response = await fetch( + `${getBackendUrl()}/brain/slack/account-link/${encodeURIComponent(token)}`, + { + credentials: "include", + headers: { "X-App-Source": "nova" }, + }, + ) + const body = await readJson(response) + if (!response.ok) { + const reason = body.status + setState({ + kind: "error", + reason: + reason === "expired" || reason === "used" || reason === "invalid" + ? reason + : "unknown", + }) + return + } + setState({ kind: "ready", preview: body as LinkPreview }) + }, [token]) + + useEffect(() => { + if (isSessionPending) return + if (!session) { + window.location.replace(loginRedirectUrl()) + return + } + void loadPreview().catch(() => { + setState({ kind: "error", reason: "unknown" }) + }) + }, [isSessionPending, session, loadPreview]) + + const confirmLink = async (preview: LinkPreview) => { + if (!token) return + setState({ kind: "linking", preview }) + try { + const response = await fetch( + `${getBackendUrl()}/brain/slack/account-link/${encodeURIComponent(token)}`, + { + method: "POST", + credentials: "include", + headers: { "X-App-Source": "nova" }, + }, + ) + const body = await readJson(response) + if (!response.ok) { + const reason = body.status + setState({ + kind: "error", + reason: + reason === "not_in_org" || + reason === "expired" || + reason === "used" || + reason === "invalid" + ? reason + : "unknown", + }) + return + } + setState({ + kind: "linked", + orgName: + typeof body.orgName === "string" ? body.orgName : preview.orgName, + teamId: preview.teamId, + }) + } catch { + setState({ kind: "error", reason: "unknown" }) + } + } + + const switchAccount = async () => { + await authClient.signOut() + window.location.assign(loginRedirectUrl()) + } + + const recheck = () => { + setState({ kind: "loading" }) + void loadPreview().catch(() => { + setState({ kind: "error", reason: "unknown" }) + }) + } + + return ( + + + {state.kind === "loading" ? ( + + +
+ +

+ Verifying your secure link… +

+
+
+
+ ) : null} + + {state.kind === "ready" || state.kind === "linking" ? ( + + +
+ +

+ {state.preview.isOrgMember + ? `Link Slack to ${state.preview.orgName}` + : `This account isn't in ${state.preview.orgName}`} +

+

+ {state.preview.isOrgMember + ? "Company Brain will recognize you by your Slack identity, even when your emails differ." + : `Switch to a Supermemory account that belongs to ${state.preview.orgName}, or ask an admin to add ${state.preview.signedInEmail}.`} +

+
+ +
+ +
+ +
+ + {state.preview.isOrgMember && state.preview.requiresRelink ? ( +

+ This Slack identity is linked to another Supermemory account. + Confirming will replace that link for {state.preview.orgName}. +

+ ) : null} + +
+ {state.preview.isOrgMember ? ( + <> + void switchAccount()} + > + Switch account + + void confirmLink(state.preview)} + > + {state.kind === "linking" ? ( + + ) : ( + <> + + {state.preview.requiresRelink + ? "Replace and link" + : "Confirm link"} + + )} + + + ) : ( + <> + + I've been added — check again + + void switchAccount()}> + Switch account + + + )} +
+ +

+ Signed in as {state.preview.signedInEmail} +

+ + + ) : null} + + {state.kind === "linked" ? ( + + +
+
+ +
+

+ Slack now knows who you are +

+

+ Your account is linked to {state.orgName}. Return to Slack and + retry your Company Brain request. +

+ + Return to Slack + +
+ +
+ + + ) : null} + + {state.kind === "error" ? ( + + + void switchAccount()} + /> + + + ) : null} + + + ) +} + +function CardShell({ children }: { children: ReactNode }) { + return ( +
+
+
+ {children} +
+
+ ) +} + +function Card({ children }: { children: ReactNode }) { + return ( +
+ {children} +
+ ) +} + +function Fade({ children }: { children: ReactNode }) { + return ( + + {children} + + ) +} + +function ConnectingHeader() { + return ( +
+
+ +
+
+ {["a", "b", "c"].map((k, i) => ( + + ))} +
+
+ +
+
+ ) +} + +function InfoRow({ + label, + name, + detail, + warn, +}: { + label: string + name: string + detail?: string + warn?: boolean +}) { + return ( +
+
+ + {label} + +

+ {name} +

+ {detail ? ( +

{detail}

+ ) : null} +
+ {warn ? ( + + Not a member + + ) : null} +
+ ) +} + +function TextButton({ + children, + onClick, + disabled, +}: { + children: ReactNode + onClick: () => void + disabled?: boolean +}) { + return ( + + ) +} + +function NeutralButton({ + children, + onClick, + disabled, +}: { + children: ReactNode + onClick: () => void + disabled?: boolean +}) { + return ( + + ) +} + +function GradientButton({ + children, + onClick, + disabled, +}: { + children: ReactNode + onClick: () => void + disabled?: boolean +}) { + return ( + + ) +} + +function ErrorState({ + reason, + onSwitchAccount, +}: { + reason: Extract["reason"] + onSwitchAccount: () => void +}) { + const copy = ERROR_COPY[reason] + return ( +
+
+ +
+

+ {copy.title} +

+

+ {copy.body} +

+ {reason === "not_in_org" ? ( +
+ + Switch account + +
+ ) : ( + + Return to Slack + + + )} +
+ ) +}