feat(dashboard): add token gate page for unauthenticated access

Visiting localhost:5173 without ?token= now shows a clean token input
page instead of a broken dashboard. Token is persisted in sessionStorage
so refreshes work within the same browser session.

Bump version to 1.3.1.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Lum1104
2026-03-28 16:59:51 +08:00
Unverified
parent 07c49fd4b0
commit 26cc41cf03
6 changed files with 128 additions and 12 deletions
+1 -1
View File
@@ -9,7 +9,7 @@
{
"name": "understand-anything",
"description": "Multi-agent codebase analysis with interactive dashboard, guided tours, and skill commands",
"version": "1.3.0",
"version": "1.3.1",
"source": "./understand-anything-plugin"
}
]
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "understand-anything",
"description": "AI-powered codebase understanding — analyze, visualize, and explain any project",
"version": "1.3.0",
"version": "1.3.1",
"author": {
"name": "Lum1104"
},
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "understand-anything",
"displayName": "Understand Anything",
"description": "AI-powered codebase understanding — analyze, visualize, and explain any project",
"version": "1.3.0",
"version": "1.3.1",
"author": {
"name": "Lum1104"
},
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@understand-anything/skill",
"version": "1.3.0",
"version": "1.3.1",
"type": "module",
"main": "dist/index.js",
"types": "dist/index.d.ts",
@@ -1,4 +1,4 @@
import { useEffect, useState, useMemo } from "react";
import { useEffect, useState, useMemo, useCallback } from "react";
import { validateGraph } from "@understand-anything/core/schema";
import type { GraphIssue } from "@understand-anything/core/schema";
import { useDashboardStore } from "./store";
@@ -13,20 +13,57 @@ import PersonaSelector from "./components/PersonaSelector";
import ProjectOverview from "./components/ProjectOverview";
import KeyboardShortcutsHelp from "./components/KeyboardShortcutsHelp";
import WarningBanner from "./components/WarningBanner";
import TokenGate from "./components/TokenGate";
import { useKeyboardShortcuts } from "./hooks/useKeyboardShortcuts";
import type { KeyboardShortcut } from "./hooks/useKeyboardShortcuts";
import { ThemeProvider } from "./themes/index.ts";
import { ThemePicker } from "./components/ThemePicker.tsx";
import type { ThemeConfig } from "./themes/index.ts";
// Extract the access token from the URL so protected endpoints can be fetched.
const ACCESS_TOKEN = new URLSearchParams(window.location.search).get("token");
const SESSION_TOKEN_KEY = "understand-anything-token";
function tokenUrl(path: string): string {
return ACCESS_TOKEN ? `${path}?token=${ACCESS_TOKEN}` : path;
/**
* Resolve the access token from the URL query string or sessionStorage.
* If found in the URL, persist to sessionStorage and strip the param from the address bar.
*/
function resolveInitialToken(): string | null {
const params = new URLSearchParams(window.location.search);
const urlToken = params.get("token");
if (urlToken) {
sessionStorage.setItem(SESSION_TOKEN_KEY, urlToken);
// Clean the URL
params.delete("token");
const cleanSearch = params.toString();
const newUrl =
window.location.pathname + (cleanSearch ? `?${cleanSearch}` : "") + window.location.hash;
window.history.replaceState(null, "", newUrl);
return urlToken;
}
return sessionStorage.getItem(SESSION_TOKEN_KEY);
}
/** Build a URL with the token query param appended. */
function tokenUrl(path: string, token: string | null): string {
return token ? `${path}?token=${encodeURIComponent(token)}` : path;
}
function App() {
const [accessToken, setAccessToken] = useState<string | null>(resolveInitialToken);
const handleTokenValid = useCallback((token: string) => {
sessionStorage.setItem(SESSION_TOKEN_KEY, token);
setAccessToken(token);
}, []);
// Show the token gate when no token is available
if (accessToken === null) {
return <TokenGate onTokenValid={handleTokenValid} />;
}
return <Dashboard accessToken={accessToken} />;
}
function Dashboard({ accessToken }: { accessToken: string }) {
const graph = useDashboardStore((s) => s.graph);
const setGraph = useDashboardStore((s) => s.setGraph);
const selectedNodeId = useDashboardStore((s) => s.selectedNodeId);
@@ -41,7 +78,7 @@ function App() {
const [metaTheme, setMetaTheme] = useState<ThemeConfig | null>(null);
useEffect(() => {
fetch(tokenUrl("/meta.json"))
fetch(tokenUrl("/meta.json", accessToken))
.then((r) => (r.ok ? r.json() : null))
.then((meta) => {
if (meta?.theme) setMetaTheme(meta.theme);
@@ -133,7 +170,7 @@ function App() {
useKeyboardShortcuts(shortcuts);
useEffect(() => {
fetch(tokenUrl("/knowledge-graph.json"))
fetch(tokenUrl("/knowledge-graph.json", accessToken))
.then((res) => res.json())
.then((data: unknown) => {
const result = validateGraph(data);
@@ -162,7 +199,7 @@ function App() {
}, [setGraph]);
useEffect(() => {
fetch(tokenUrl("/diff-overlay.json"))
fetch(tokenUrl("/diff-overlay.json", accessToken))
.then((res) => {
if (!res.ok) return null;
return res.json();
@@ -0,0 +1,79 @@
import { useState } from "react";
interface TokenGateProps {
onTokenValid: (token: string) => void;
}
export default function TokenGate({ onTokenValid }: TokenGateProps) {
const [input, setInput] = useState("");
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
const token = input.trim();
if (!token) return;
setLoading(true);
setError(null);
try {
const res = await fetch(`/knowledge-graph.json?token=${encodeURIComponent(token)}`);
if (res.ok) {
onTokenValid(token);
} else if (res.status === 403) {
setError("Invalid token. Please check and try again.");
} else {
setError(`Unexpected response (${res.status}). Is the dashboard server running?`);
}
} catch (err) {
setError(
`Could not reach the server: ${err instanceof Error ? err.message : String(err)}`
);
} finally {
setLoading(false);
}
};
return (
<div className="h-screen w-screen flex items-center justify-center bg-root noise-overlay">
<div className="w-full max-w-md px-8 py-10 bg-surface border border-border-subtle rounded-lg shadow-2xl">
{/* Heading */}
<h1 className="font-serif text-2xl text-text-primary tracking-wide text-center mb-2">
Access Token Required
</h1>
<p className="text-text-muted text-sm text-center mb-8">
Paste the access token from your terminal. Look for the{" "}
<span role="img" aria-label="key">&#x1F511;</span> line.
</p>
{/* Form */}
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
<input
type="text"
value={input}
onChange={(e) => {
setInput(e.target.value);
if (error) setError(null);
}}
placeholder="Paste token here..."
autoFocus
className="w-full px-4 py-3 bg-elevated border border-border-subtle rounded text-text-primary placeholder:text-text-muted/50 font-mono text-sm focus:outline-none focus:border-accent transition-colors"
/>
{error && (
<p className="text-red-400 text-sm">{error}</p>
)}
<button
type="submit"
disabled={loading || !input.trim()}
className="w-full py-3 bg-accent text-root font-semibold rounded transition-all hover:brightness-110 disabled:opacity-40 disabled:cursor-not-allowed"
>
{loading ? "Validating..." : "Continue"}
</button>
</form>
</div>
</div>
);
}