diff --git a/apps/obsidian/src/components/AdminPanelSettings.tsx b/apps/obsidian/src/components/AdminPanelSettings.tsx index 23e1ebab1..0b1fbe87f 100644 --- a/apps/obsidian/src/components/AdminPanelSettings.tsx +++ b/apps/obsidian/src/components/AdminPanelSettings.tsx @@ -1,8 +1,10 @@ import { useState, useCallback } from "react"; import { usePlugin } from "./PluginContext"; -import { Notice } from "obsidian"; +import { Notice, setIcon } from "obsidian"; import { updateUsername } from "~/utils/supabaseContext"; import { initializeSupabaseSync } from "~/utils/syncDgNodesToSupabase"; +import { nextRoot } from "@repo/utils/execContext"; +import { getLoggedInClient } from "~/utils/supabaseContext"; export const AdminPanelSettings = () => { const plugin = usePlugin(); @@ -41,6 +43,31 @@ export const AdminPanelSettings = () => { await updateUsername(plugin, newValue); }; + const handleLoginHandoff = async () => { + const client = await getLoggedInClient(plugin); + if (!client) { + new Notice("Failed to connect to the database", 3000); + return; + } + const sessionData = await client.auth.getSession(); + if (!sessionData.data.session) { + new Notice("Failed to connect to the database", 3000); + return; + } + /* eslint-disable @typescript-eslint/naming-convention */ + const { access_token, refresh_token } = sessionData.data.session; + const { data, error } = await client.rpc("create_secret_token", { + v_payload: JSON.stringify({ access_token, refresh_token }), + expiry_interval: "45s", + }); + /* eslint-enable @typescript-eslint/naming-convention */ + if (error || typeof data !== "string") { + new Notice("Failed to connect to the database", 3000); + return; + } + if (data) window.open(`${nextRoot()}/auth/token?t=${data}&url=/`, "_blank"); + }; + return (
@@ -80,6 +107,31 @@ export const AdminPanelSettings = () => { />
+
+
+
Group management
+
+ This will allow you to view and manage your sharing groups +
+
+
+ +
+
); }; diff --git a/apps/roam/src/components/settings/AdminPanel.tsx b/apps/roam/src/components/settings/AdminPanel.tsx index b0309e40f..28fdf0237 100644 --- a/apps/roam/src/components/settings/AdminPanel.tsx +++ b/apps/roam/src/components/settings/AdminPanel.tsx @@ -12,6 +12,7 @@ import { Tabs, } from "@blueprintjs/core"; import { Select } from "@blueprintjs/select"; +import { render as renderToast } from "roamjs-components/components/Toast"; import { getSupabaseContext, getLoggedInClient, @@ -34,6 +35,7 @@ import { } from "~/components/settings/utils/accessors"; import { FeatureFlagPanel } from "./components/BlockPropSettingPanels"; import type { FeatureFlags } from "./utils/zodSchema"; +import { nextRoot } from "@repo/utils/execContext"; const NodeRow = ({ node }: { node: PConceptFull }) => { return ( @@ -266,7 +268,6 @@ const FeatureFlagsTab = (): React.ReactElement => { const [suggestiveOverlayValue, setSuggestiveOverlayValue] = useState( getFeatureFlag("Suggestive mode overlay enabled"), ); - const syncAlreadyEnabled = duplicateNodeAlertValue || suggestiveOverlayValue; const ensureSyncEnabled = ( @@ -290,6 +291,43 @@ const FeatureFlagsTab = (): React.ReactElement => { } }; + const handleLoginHandoff = async () => { + const client = await getLoggedInClient(); + if (!client) { + renderToast({ + content: "Could not connect to database", + intent: Intent.DANGER, + id: "client-access", + }); + return; + } + const sessionData = await client.auth.getSession(); + if (!sessionData.data.session) { + internalError({ + error: "Client w/o session", + type: "database-login", + userMessage: "Could not connect to database", + }); + return; + } + /* eslint-disable @typescript-eslint/naming-convention */ + const { access_token, refresh_token } = sessionData.data.session; + const { data, error } = await client.rpc("create_secret_token", { + v_payload: JSON.stringify({ access_token, refresh_token }), + expiry_interval: "45s", + }); + /* eslint-enable @typescript-eslint/naming-convention */ + if (error || typeof data !== "string") { + internalError({ + error: "Call to create-secret-token", + type: "create-secret-token", + userMessage: "Could not connect to database", + }); + return; + } + if (data) window.open(`${nextRoot()}/auth/token?t=${data}&url=/`, "_blank"); + }; + return (
{ > Send Error Email + {syncAlreadyEnabled && ( + + )}
); }; diff --git a/apps/website/app/(home)/auth/token/page.tsx b/apps/website/app/(home)/auth/token/page.tsx new file mode 100644 index 000000000..92270d0f2 --- /dev/null +++ b/apps/website/app/(home)/auth/token/page.tsx @@ -0,0 +1,12 @@ +import { LoginWithToken } from "~/components/auth/LoginWithToken"; +import { Suspense } from "react"; + +const Page = () => ( +
+ Logging in}> + + +
+); + +export default Page; diff --git a/apps/website/app/components/auth/LoginWithToken.tsx b/apps/website/app/components/auth/LoginWithToken.tsx new file mode 100644 index 000000000..b9eb1a0c8 --- /dev/null +++ b/apps/website/app/components/auth/LoginWithToken.tsx @@ -0,0 +1,97 @@ +"use client"; + +import { createClient } from "~/utils/supabase/client"; +import { useRouter, useSearchParams } from "next/navigation"; +import { useState, useEffect, useCallback, useRef } from "react"; +import useInternalError from "~/utils/internalError"; + +export const LoginWithToken = () => { + const loginAttempted = useRef(false); + const searchParams = useSearchParams(); + const internalError = useInternalError(); + const router = useRouter(); + const [secretToken] = useState(() => searchParams.get("t")); + useEffect(() => { + if (secretToken && typeof window !== "undefined") { + const url = new URL(window.location.href); + url.searchParams.delete("t"); + window.history.replaceState({}, "", url); + } + }, [secretToken]); + const [url] = useState(searchParams.get("url")); + const [done, setDone] = useState(false); + const [error, setError] = useState( + secretToken === null ? "Please provide token" : null, + ); + + const login = useCallback(async () => { + try { + const client = createClient(); + const result = await client.rpc("get_secret_token", { + token: secretToken!, + }); + if (result.error) { + setError("Could not connect to DiscourseGraphs"); + internalError({ error: result.error, type: "get-secret-token" }); + return; + } + if (result.data == null) { + setError("Could not retrieve information, please try again."); + internalError({ error: "missing token", type: "get-secret-token" }); + return; + } + if (typeof result.data !== "string") { + setError( + "DiscourseGraphs configuration error, the team has been warned", + ); + internalError({ + error: "payload-not-string", + type: "get-secret-token", + }); + return; + } + const data = JSON.parse(result.data) as { + /* eslint-disable @typescript-eslint/naming-convention */ + access_token: string; + refresh_token: string; + /* eslint-enable @typescript-eslint/naming-convention */ + }; + if ( + !data || + typeof data !== "object" || + !data.access_token || + !data.refresh_token + ) { + setError( + "DiscourseGraphs configuration error, the team has been warned", + ); + internalError({ error: "misshaped-payload", type: "get-secret-token" }); + return; + } + const response = await client.auth.setSession(data); + if (response.error) { + setError(response.error.message); + } else if (url) { + router.replace(url); + } + } catch (error) { + setError("Unkown error while logging you in."); + internalError({ error, type: "token-login-exception" }); + } finally { + setDone(true); + } + }, [secretToken, url, router, internalError]); + useEffect(() => { + if (!error && !done && !loginAttempted.current) { + loginAttempted.current = true; + void login(); + } + }, [error, login, secretToken, done]); + return ( +
+
+ {error ? "Error: " + error : done ? "Logged in" : "Logging in"} +
+
+ ); +}; diff --git a/apps/website/app/utils/internalError.ts b/apps/website/app/utils/internalError.ts new file mode 100644 index 000000000..3b7653ac8 --- /dev/null +++ b/apps/website/app/utils/internalError.ts @@ -0,0 +1,49 @@ +"use client"; + +import { useCallback } from "react"; +import type { Properties } from "posthog-js"; +import { usePostHog } from "posthog-js/react"; + +const NON_WORD = /\W+/g; +export const useInternalError = () => { + const posthog = usePostHog(); + return useCallback( + ({ + error, + type, + context, + forceSendInDev = false, + }: { + error: unknown; + type?: string; + context?: Properties; + forceSendInDev?: boolean; + }): void => { + if (process.env.NODE_ENV === "development" && forceSendInDev !== true) { + console.error(error); + } else { + type = type || "Internal Error"; + const slugType = type.replaceAll(NON_WORD, "-").toLowerCase(); + context = { + type, + ...(context || {}), + }; + + if (typeof error === "string") { + error = new Error(error); + } else if (!(error instanceof Error)) { + try { + const serialized = JSON.stringify(error); + error = new Error(serialized); + } catch { + error = new Error(typeof error); + } + } + posthog.captureException(error, { ...context, type: slugType }); + } + }, + [posthog], + ); +}; + +export default useInternalError; diff --git a/apps/website/app/utils/supabase/client.ts b/apps/website/app/utils/supabase/client.ts new file mode 100644 index 000000000..fbac86366 --- /dev/null +++ b/apps/website/app/utils/supabase/client.ts @@ -0,0 +1,14 @@ +import { createBrowserClient } from "@supabase/ssr"; +import type { Database } from "@repo/database/dbTypes"; + +export const createClient = () => { + if ( + !process.env.NEXT_PUBLIC_SUPABASE_URL || + !process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY + ) + throw new Error("Configuration error: supabase variables not configured."); + return createBrowserClient( + process.env.NEXT_PUBLIC_SUPABASE_URL, + process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY, + ); +}; diff --git a/apps/website/next.config.ts b/apps/website/next.config.ts index 167835927..c21ace710 100644 --- a/apps/website/next.config.ts +++ b/apps/website/next.config.ts @@ -5,6 +5,11 @@ import { DOCS_REDIRECTS } from "./docsRouteMap"; config(); +// expose supabase credentials to the client +process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY = + process.env.SUPABASE_PUBLISHABLE_KEY; +process.env.NEXT_PUBLIC_SUPABASE_URL = process.env.SUPABASE_URL; + const withNextra = nextra({ contentDirBasePath: "/docs", }); @@ -22,6 +27,14 @@ const nextConfig: NextConfig = { "next-mdx-import-source-file": "./mdx-components.tsx", }, }, + async headers() { + return [ + { + source: "/auth/token", + headers: [{ key: "Referrer-Policy", value: "no-referrer" }], + }, + ]; + }, }; export default withNextra(nextConfig); diff --git a/apps/website/package.json b/apps/website/package.json index 9b83d5a80..1e3a16746 100644 --- a/apps/website/package.json +++ b/apps/website/package.json @@ -18,6 +18,7 @@ "@repo/ui": "workspace:*", "@supabase/ssr": "catalog:", "@supabase/supabase-js": "catalog:", + "@supabase/auth-js": "catalog:", "clsx": "^2.1.1", "gray-matter": "^4.0.3", "lucide-react": "^0.540.0", diff --git a/packages/utils/src/execContext.ts b/packages/utils/src/execContext.ts index 738871f9b..19ee5ddae 100644 --- a/packages/utils/src/execContext.ts +++ b/packages/utils/src/execContext.ts @@ -1,12 +1,19 @@ const IS_DEV = process.env.NODE_ENV !== "production"; +export const nextRoot = (): string => { + if ( + process.env.NEXT_API_ROOT !== undefined && + process.env.NEXT_API_ROOT !== "" + ) + return process.env.NEXT_API_ROOT.split("/").slice(0, 3).join("/"); + return IS_DEV ? "http://localhost:3000/" : "https://discoursegraphs.com/"; +}; + export const nextApiRoot = (): string => { if ( process.env.NEXT_API_ROOT !== undefined && process.env.NEXT_API_ROOT !== "" ) return process.env.NEXT_API_ROOT; - return IS_DEV - ? "http://localhost:3000/api" - : "https://discoursegraphs.com/api"; + return nextRoot() + "api"; }; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5df3195dc..3e1ffc055 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -439,6 +439,9 @@ importers: '@repo/ui': specifier: workspace:* version: link:../../packages/ui + '@supabase/auth-js': + specifier: 'catalog:' + version: 2.105.4 '@supabase/ssr': specifier: 'catalog:' version: 0.10.3(@supabase/supabase-js@2.105.4)