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)