Skip to content

Commit d30313a

Browse files
authored
ENG-1703 Allow to access a Web UI with a space's credentials (#1005)
* ENG-1703: Send tokens over to vercel * typo * lint comment * use suspense * Do not send refresh token. All access tokens will be capped at 1h * Use table functions * lint * Referrer-policy headers to secure the token page, avoid transmitting the token * special case absent token * graphite: error message * type the client * Claude suggestions: Strip token from URL to avoid failure on reload; Ref to avoid the double load in dev * address review comments, wip * Error messages, correction to removing token from url * missing file * lint; make internalErrror into a callback * merge error in pnpm lock * excessive memoization
1 parent a4f7820 commit d30313a

10 files changed

Lines changed: 302 additions & 5 deletions

File tree

apps/obsidian/src/components/AdminPanelSettings.tsx

Lines changed: 53 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
import { useState, useCallback } from "react";
22
import { usePlugin } from "./PluginContext";
3-
import { Notice } from "obsidian";
3+
import { Notice, setIcon } from "obsidian";
44
import { updateUsername } from "~/utils/supabaseContext";
55
import { initializeSupabaseSync } from "~/utils/syncDgNodesToSupabase";
6+
import { nextRoot } from "@repo/utils/execContext";
7+
import { getLoggedInClient } from "~/utils/supabaseContext";
68

79
export const AdminPanelSettings = () => {
810
const plugin = usePlugin();
@@ -41,6 +43,31 @@ export const AdminPanelSettings = () => {
4143
await updateUsername(plugin, newValue);
4244
};
4345

46+
const handleLoginHandoff = async () => {
47+
const client = await getLoggedInClient(plugin);
48+
if (!client) {
49+
new Notice("Failed to connect to the database", 3000);
50+
return;
51+
}
52+
const sessionData = await client.auth.getSession();
53+
if (!sessionData.data.session) {
54+
new Notice("Failed to connect to the database", 3000);
55+
return;
56+
}
57+
/* eslint-disable @typescript-eslint/naming-convention */
58+
const { access_token, refresh_token } = sessionData.data.session;
59+
const { data, error } = await client.rpc("create_secret_token", {
60+
v_payload: JSON.stringify({ access_token, refresh_token }),
61+
expiry_interval: "45s",
62+
});
63+
/* eslint-enable @typescript-eslint/naming-convention */
64+
if (error || typeof data !== "string") {
65+
new Notice("Failed to connect to the database", 3000);
66+
return;
67+
}
68+
if (data) window.open(`${nextRoot()}/auth/token?t=${data}&url=/`, "_blank");
69+
};
70+
4471
return (
4572
<div className="general-settings">
4673
<div className="setting-item">
@@ -80,6 +107,31 @@ export const AdminPanelSettings = () => {
80107
/>
81108
</div>
82109
</div>
110+
<div
111+
className={
112+
"setting-item " + (plugin.settings.syncModeEnabled ? "" : "hidden")
113+
}
114+
>
115+
<div className="setting-item-info">
116+
<div className="setting-item-name">Group management</div>
117+
<div className="setting-item-description">
118+
This will allow you to view and manage your sharing groups
119+
</div>
120+
</div>
121+
<div className="setting-item-control">
122+
<button
123+
onClick={() => {
124+
void handleLoginHandoff();
125+
}}
126+
>
127+
Manage groups
128+
<span
129+
className="icon"
130+
ref={(el) => (el && setIcon(el, "arrow-up-right")) || undefined}
131+
/>
132+
</button>
133+
</div>
134+
</div>
83135
</div>
84136
);
85137
};

apps/roam/src/components/settings/AdminPanel.tsx

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
Tabs,
1313
} from "@blueprintjs/core";
1414
import { Select } from "@blueprintjs/select";
15+
import { render as renderToast } from "roamjs-components/components/Toast";
1516
import {
1617
getSupabaseContext,
1718
getLoggedInClient,
@@ -34,6 +35,7 @@ import {
3435
} from "~/components/settings/utils/accessors";
3536
import { FeatureFlagPanel } from "./components/BlockPropSettingPanels";
3637
import type { FeatureFlags } from "./utils/zodSchema";
38+
import { nextRoot } from "@repo/utils/execContext";
3739

3840
const NodeRow = ({ node }: { node: PConceptFull }) => {
3941
return (
@@ -266,7 +268,6 @@ const FeatureFlagsTab = (): React.ReactElement => {
266268
const [suggestiveOverlayValue, setSuggestiveOverlayValue] = useState(
267269
getFeatureFlag("Suggestive mode overlay enabled"),
268270
);
269-
270271
const syncAlreadyEnabled = duplicateNodeAlertValue || suggestiveOverlayValue;
271272

272273
const ensureSyncEnabled = (
@@ -290,6 +291,43 @@ const FeatureFlagsTab = (): React.ReactElement => {
290291
}
291292
};
292293

294+
const handleLoginHandoff = async () => {
295+
const client = await getLoggedInClient();
296+
if (!client) {
297+
renderToast({
298+
content: "Could not connect to database",
299+
intent: Intent.DANGER,
300+
id: "client-access",
301+
});
302+
return;
303+
}
304+
const sessionData = await client.auth.getSession();
305+
if (!sessionData.data.session) {
306+
internalError({
307+
error: "Client w/o session",
308+
type: "database-login",
309+
userMessage: "Could not connect to database",
310+
});
311+
return;
312+
}
313+
/* eslint-disable @typescript-eslint/naming-convention */
314+
const { access_token, refresh_token } = sessionData.data.session;
315+
const { data, error } = await client.rpc("create_secret_token", {
316+
v_payload: JSON.stringify({ access_token, refresh_token }),
317+
expiry_interval: "45s",
318+
});
319+
/* eslint-enable @typescript-eslint/naming-convention */
320+
if (error || typeof data !== "string") {
321+
internalError({
322+
error: "Call to create-secret-token",
323+
type: "create-secret-token",
324+
userMessage: "Could not connect to database",
325+
});
326+
return;
327+
}
328+
if (data) window.open(`${nextRoot()}/auth/token?t=${data}&url=/`, "_blank");
329+
};
330+
293331
return (
294332
<div className="flex flex-col gap-4 p-4">
295333
<FeatureFlagPanel
@@ -393,6 +431,17 @@ const FeatureFlagsTab = (): React.ReactElement => {
393431
>
394432
Send Error Email
395433
</Button>
434+
{syncAlreadyEnabled && (
435+
<Button
436+
className="w-96"
437+
icon="document-open"
438+
onClick={() => {
439+
void handleLoginHandoff();
440+
}}
441+
>
442+
Manage groups
443+
</Button>
444+
)}
396445
</div>
397446
);
398447
};
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import { LoginWithToken } from "~/components/auth/LoginWithToken";
2+
import { Suspense } from "react";
3+
4+
const Page = () => (
5+
<div className="flex min-h-svh w-full items-center justify-center p-6 md:p-10">
6+
<Suspense fallback={<>Logging in</>}>
7+
<LoginWithToken />
8+
</Suspense>
9+
</div>
10+
);
11+
12+
export default Page;
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
"use client";
2+
3+
import { createClient } from "~/utils/supabase/client";
4+
import { useRouter, useSearchParams } from "next/navigation";
5+
import { useState, useEffect, useCallback, useRef } from "react";
6+
import useInternalError from "~/utils/internalError";
7+
8+
export const LoginWithToken = () => {
9+
const loginAttempted = useRef(false);
10+
const searchParams = useSearchParams();
11+
const internalError = useInternalError();
12+
const router = useRouter();
13+
const [secretToken] = useState(() => searchParams.get("t"));
14+
useEffect(() => {
15+
if (secretToken && typeof window !== "undefined") {
16+
const url = new URL(window.location.href);
17+
url.searchParams.delete("t");
18+
window.history.replaceState({}, "", url);
19+
}
20+
}, [secretToken]);
21+
const [url] = useState(searchParams.get("url"));
22+
const [done, setDone] = useState(false);
23+
const [error, setError] = useState<string | null>(
24+
secretToken === null ? "Please provide token" : null,
25+
);
26+
27+
const login = useCallback(async () => {
28+
try {
29+
const client = createClient();
30+
const result = await client.rpc("get_secret_token", {
31+
token: secretToken!,
32+
});
33+
if (result.error) {
34+
setError("Could not connect to DiscourseGraphs");
35+
internalError({ error: result.error, type: "get-secret-token" });
36+
return;
37+
}
38+
if (result.data == null) {
39+
setError("Could not retrieve information, please try again.");
40+
internalError({ error: "missing token", type: "get-secret-token" });
41+
return;
42+
}
43+
if (typeof result.data !== "string") {
44+
setError(
45+
"DiscourseGraphs configuration error, the team has been warned",
46+
);
47+
internalError({
48+
error: "payload-not-string",
49+
type: "get-secret-token",
50+
});
51+
return;
52+
}
53+
const data = JSON.parse(result.data) as {
54+
/* eslint-disable @typescript-eslint/naming-convention */
55+
access_token: string;
56+
refresh_token: string;
57+
/* eslint-enable @typescript-eslint/naming-convention */
58+
};
59+
if (
60+
!data ||
61+
typeof data !== "object" ||
62+
!data.access_token ||
63+
!data.refresh_token
64+
) {
65+
setError(
66+
"DiscourseGraphs configuration error, the team has been warned",
67+
);
68+
internalError({ error: "misshaped-payload", type: "get-secret-token" });
69+
return;
70+
}
71+
const response = await client.auth.setSession(data);
72+
if (response.error) {
73+
setError(response.error.message);
74+
} else if (url) {
75+
router.replace(url);
76+
}
77+
} catch (error) {
78+
setError("Unkown error while logging you in.");
79+
internalError({ error, type: "token-login-exception" });
80+
} finally {
81+
setDone(true);
82+
}
83+
}, [secretToken, url, router, internalError]);
84+
useEffect(() => {
85+
if (!error && !done && !loginAttempted.current) {
86+
loginAttempted.current = true;
87+
void login();
88+
}
89+
}, [error, login, secretToken, done]);
90+
return (
91+
<div className="flex min-h-svh w-full items-center justify-center p-6 md:p-10">
92+
<div className="w-full max-w-sm">
93+
{error ? "Error: " + error : done ? "Logged in" : "Logging in"}
94+
</div>
95+
</div>
96+
);
97+
};
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
"use client";
2+
3+
import { useCallback } from "react";
4+
import type { Properties } from "posthog-js";
5+
import { usePostHog } from "posthog-js/react";
6+
7+
const NON_WORD = /\W+/g;
8+
export const useInternalError = () => {
9+
const posthog = usePostHog();
10+
return useCallback(
11+
({
12+
error,
13+
type,
14+
context,
15+
forceSendInDev = false,
16+
}: {
17+
error: unknown;
18+
type?: string;
19+
context?: Properties;
20+
forceSendInDev?: boolean;
21+
}): void => {
22+
if (process.env.NODE_ENV === "development" && forceSendInDev !== true) {
23+
console.error(error);
24+
} else {
25+
type = type || "Internal Error";
26+
const slugType = type.replaceAll(NON_WORD, "-").toLowerCase();
27+
context = {
28+
type,
29+
...(context || {}),
30+
};
31+
32+
if (typeof error === "string") {
33+
error = new Error(error);
34+
} else if (!(error instanceof Error)) {
35+
try {
36+
const serialized = JSON.stringify(error);
37+
error = new Error(serialized);
38+
} catch {
39+
error = new Error(typeof error);
40+
}
41+
}
42+
posthog.captureException(error, { ...context, type: slugType });
43+
}
44+
},
45+
[posthog],
46+
);
47+
};
48+
49+
export default useInternalError;
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import { createBrowserClient } from "@supabase/ssr";
2+
import type { Database } from "@repo/database/dbTypes";
3+
4+
export const createClient = () => {
5+
if (
6+
!process.env.NEXT_PUBLIC_SUPABASE_URL ||
7+
!process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY
8+
)
9+
throw new Error("Configuration error: supabase variables not configured.");
10+
return createBrowserClient<Database, "public">(
11+
process.env.NEXT_PUBLIC_SUPABASE_URL,
12+
process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY,
13+
);
14+
};

apps/website/next.config.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,11 @@ import { DOCS_REDIRECTS } from "./docsRouteMap";
55

66
config();
77

8+
// expose supabase credentials to the client
9+
process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY =
10+
process.env.SUPABASE_PUBLISHABLE_KEY;
11+
process.env.NEXT_PUBLIC_SUPABASE_URL = process.env.SUPABASE_URL;
12+
813
const withNextra = nextra({
914
contentDirBasePath: "/docs",
1015
});
@@ -22,6 +27,14 @@ const nextConfig: NextConfig = {
2227
"next-mdx-import-source-file": "./mdx-components.tsx",
2328
},
2429
},
30+
async headers() {
31+
return [
32+
{
33+
source: "/auth/token",
34+
headers: [{ key: "Referrer-Policy", value: "no-referrer" }],
35+
},
36+
];
37+
},
2538
};
2639

2740
export default withNextra(nextConfig);

apps/website/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
"@repo/ui": "workspace:*",
1919
"@supabase/ssr": "catalog:",
2020
"@supabase/supabase-js": "catalog:",
21+
"@supabase/auth-js": "catalog:",
2122
"clsx": "^2.1.1",
2223
"gray-matter": "^4.0.3",
2324
"lucide-react": "^0.540.0",

packages/utils/src/execContext.ts

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,19 @@
11
const IS_DEV = process.env.NODE_ENV !== "production";
22

3+
export const nextRoot = (): string => {
4+
if (
5+
process.env.NEXT_API_ROOT !== undefined &&
6+
process.env.NEXT_API_ROOT !== ""
7+
)
8+
return process.env.NEXT_API_ROOT.split("/").slice(0, 3).join("/");
9+
return IS_DEV ? "http://localhost:3000/" : "https://discoursegraphs.com/";
10+
};
11+
312
export const nextApiRoot = (): string => {
413
if (
514
process.env.NEXT_API_ROOT !== undefined &&
615
process.env.NEXT_API_ROOT !== ""
716
)
817
return process.env.NEXT_API_ROOT;
9-
return IS_DEV
10-
? "http://localhost:3000/api"
11-
: "https://discoursegraphs.com/api";
18+
return nextRoot() + "api";
1219
};

0 commit comments

Comments
 (0)