From cdf7ead71bb29d46035503673b30fed4a1e97c89 Mon Sep 17 00:00:00 2001 From: shine1i Date: Wed, 11 Feb 2026 13:40:33 +0100 Subject: [PATCH] feat: new auth and refresh token on unauthorized --- docs/canvas-lab-architecture.md | 265 ++++++++++++++++++ studio/frontend/src/app/routes/login.tsx | 2 +- studio/frontend/src/app/routes/signup.tsx | 2 +- studio/frontend/src/features/auth/api.ts | 66 +++++ .../features/auth/components/auth-form.tsx | 72 +++-- studio/frontend/src/features/auth/index.ts | 1 + studio/frontend/src/features/auth/session.ts | 28 +- .../onboarding/components/wizard-footer.tsx | 2 +- .../onboarding/components/wizard-layout.tsx | 2 +- 9 files changed, 417 insertions(+), 23 deletions(-) create mode 100644 docs/canvas-lab-architecture.md create mode 100644 studio/frontend/src/features/auth/api.ts diff --git a/docs/canvas-lab-architecture.md b/docs/canvas-lab-architecture.md new file mode 100644 index 0000000000..343ecf6608 --- /dev/null +++ b/docs/canvas-lab-architecture.md @@ -0,0 +1,265 @@ +# Canvas Lab Architecture (Current) + +Root: +`/Volumes/Expansion/projects/new-ui-prototype/studio/frontend/src/features/canvas-lab` + +Goal of this layout: +- simple ownership +- low coupling +- predictable edit points +- behavior driven by config + store, not view side-effects + +## 1) Ownership Map (hard boundaries) + +### Page orchestration boundary +File: +`/Volumes/Expansion/projects/new-ui-prototype/studio/frontend/src/features/canvas-lab/canvas-lab-page.tsx` + +Owns: +- React Flow mount + wiring +- selector/orchestration glue from Zustand +- derived display graph (`deriveDisplayGraph`) +- modal/sheet open-close UI state + +Do not place here: +- config mutation rules +- connection legality rules +- payload/import mapping + +### Store mutation boundary +File: +`/Volumes/Expansion/projects/new-ui-prototype/studio/frontend/src/features/canvas-lab/stores/canvas-lab.ts` + +Owns: +- source-of-truth state (`configs`, `nodes`, `edges`, `processors`) +- mutation entrypoints (`updateConfig`, `onConnect`, `onNodesChange`, etc) +- selection/dialog state (`selectConfig`, `openConfig`) +- aux node position persistence (`auxNodePositions`) +- aux node size persistence (`auxNodeSizes`) + +Helper module: +`/Volumes/Expansion/projects/new-ui-prototype/studio/frontend/src/features/canvas-lab/stores/canvas-lab-helpers.ts` + +Owns: +- pure relation sync helpers (edge/config sync) +- rename/remove propagation helpers +- node data/layout transformation helpers + +Do not place in helpers: +- React component logic +- network/API calls + +### Graph rules boundary +Files: +- `.../utils/graph/canvas-connection.ts` +- `.../utils/graph/derive-display-graph.ts` +- `.../utils/graph.ts` (re-export shim) + +`canvas-connection.ts` owns: +- valid/invalid connection rules +- connect side-effects (config updates from edges) +- single-incoming relation enforcement + +`derive-display-graph.ts` owns: +- derived aux nodes/edges (LLM prompt/system/scorer projections) +- default aux positioning + +Do not place here: +- dialog form logic +- block creation defaults + +### Registry boundary +File: +`/Volumes/Expansion/projects/new-ui-prototype/studio/frontend/src/features/canvas-lab/blocks/registry.tsx` + +Owns: +- block metadata for sheet +- config factory per block +- dialog router per block type + +Notes: +- registry receives dialog option lists from page and forwards to block dialogs +- avoids dialog -> store dependency cycle + +Do not place here: +- cross-node graph mutation logic +- payload/export code + +### Import/export boundary +Files: +- `.../utils/payload/build-payload.ts` +- `.../utils/import/importer.ts` +- `.../utils/import/edges.ts` + +Owns: +- contract mapping between UI state and backend payload +- edge inference fallback when import payload has no `ui.edges` +- node width persistence via `ui.nodes[].width` + +Do not place here: +- ReactFlow render logic +- store actions + +## 2) Core Types + +Source: +`/Volumes/Expansion/projects/new-ui-prototype/studio/frontend/src/features/canvas-lab/types/index.ts` + +`NodeConfig` is business truth: +```ts +type NodeConfig = + | SamplerConfig + | LlmConfig + | ExpressionConfig + | ModelProviderConfig + | ModelConfig; +``` + +`CanvasNodeData` is derived display data (`nodeDataFromConfig`), not primary state. + +## 3) React Flow Composition + +Page wiring: +```ts +const NODE_TYPES = { builder: CanvasNode, aux: CanvasAuxNode }; +const EDGE_TYPES = { canvas: DataEdge, semantic: CanvasSemanticEdge }; +``` + +Data edges use auto path mode: +```ts +defaultEdgeOptions={{ + type: "canvas", + data: { key: "name", path: "auto" }, +}} +``` + +Dialog flow: +- node click -> `selectConfig` (no forced modal) +- node `Details` button -> `openConfig` + +Node sizing: +- default builder/aux node width is `400px` +- users can resize builder + aux nodes +- resized width is kept in canvas state and round-tripped through import/export +- sizing constants live in: +`/Volumes/Expansion/projects/new-ui-prototype/studio/frontend/src/features/canvas-lab/constants.ts` + +## 4) UI Mode Policy (Inline vs Dialog) + +File: +`/Volumes/Expansion/projects/new-ui-prototype/studio/frontend/src/features/canvas-lab/components/inline/inline-policy.ts` + +Inline: +- sampler: `uniform`, `gaussian`, `bernoulli`, `uuid` +- `model_provider`, `model_config` +- llm: `text`, `code` +- `expression` + +Dialog: +- sampler: `category`, `subcategory`, `datetime`, `timedelta`, `person`, `person_from_faker` +- llm: `structured`, `judge` + +Inline editors: +- `.../components/inline/inline-sampler.tsx` +- `.../components/inline/inline-model.tsx` +- `.../components/inline/inline-llm.tsx` +- `.../components/inline/inline-expression.tsx` + +## 5) Handle Contract (stable IDs) + +File: +`/Volumes/Expansion/projects/new-ui-prototype/studio/frontend/src/features/canvas-lab/utils/handles.ts` + +Stable IDs: +- `data-in`, `data-out` +- `semantic-in`, `semantic-out` +- `llm-prompt-in`, `llm-system-in`, `llm-input-out` +- `llm-judge-score-in-${index}` + +These are contract-level values for: +- connection validity +- edge inference +- payload consistency + +## 6) LLM Derived Aux Nodes + +Behavior source: +`.../utils/graph/derive-display-graph.ts` + +Rules: +- non-empty `prompt` => spawn editable prompt aux node +- non-empty `system_prompt` => spawn editable system aux node +- `llm_type === "judge"` => spawn scorer aux nodes from `scores[]` + +Aux nodes: +- are UI projections, not new payload schema entities +- have independent drag positions persisted in Zustand `auxNodePositions` +- have independent sizes persisted in Zustand `auxNodeSizes` +- are resizable (same hidden-control resize UX as builder nodes) +- are re-anchored near parent nodes after auto-layout/direction change + +## 7) Connect Rules (single source of truth) + +File: +`.../utils/graph/canvas-connection.ts` + +Rules: +- semantic lane only for model infra relations +- data lane for sampler/llm/expression flow +- model infra blocked from data lane + +Single incoming enforced for: +- `provider` +- `model_alias` +- `reference_column_name` +- `subcategory_parent` + +Connect side-effects: +- provider/model alias/ref-column update target config fields +- category->subcategory scaffolds mapping +- llm/expression data refs append template references + +## 8) Circular Dependency Prevention + +Current safe flow: +- store state -> page (`configs`) +- page derives dialog option lists (`modelConfigAliases`, `modelProviderOptions`, `datetimeOptions`) +- page passes these options -> `ConfigDialog` +- dialog passes options -> registry -> block dialogs (`LlmDialog`, `ModelConfigDialog`, `TimedeltaDialog`) + +No dialog component should import store directly. +Exception: read-only derived data (e.g. `available-variables.tsx` reads `configs` for variable list). + +## 9) Add New Block (exact flow) + +1. Add config type: +`.../types/index.ts` +2. Add defaults + `nodeDataFromConfig` mapping: +`.../utils/index.ts` +3. Add registry definition: +`.../blocks/registry.tsx` +4. Add dialog component + wire via `renderDialog`: +`.../dialogs/...` +5. Choose UI mode policy: +`.../components/inline/inline-policy.ts` +6. If inline, add inline editor: +`.../components/inline/...` +7. Add connect semantics if needed: +`.../utils/graph/canvas-connection.ts` +8. Add payload mapping: +`.../utils/payload/...` +9. Add import parse/inference update: +`.../utils/import/...` + +## 10) Keep It Simple Rules + +- Keep mutation logic in store/helpers only +- Keep graph legality/side-effects in graph utils only +- Keep view files free of business mutation branching +- Remove dead code in same pass as refactor +- Prefer narrow pure helpers over giant mixed functions + +If unsure where code belongs: +- “changes config/edges?” => store/helpers or graph utils +- “changes visuals only?” => components/page +- “changes payload contract?” => payload/import utils diff --git a/studio/frontend/src/app/routes/login.tsx b/studio/frontend/src/app/routes/login.tsx index 3b07adbcce..77e22123d8 100644 --- a/studio/frontend/src/app/routes/login.tsx +++ b/studio/frontend/src/app/routes/login.tsx @@ -3,7 +3,7 @@ import { lazy } from "react"; import { Route as rootRoute } from "./__root"; const LoginPage = lazy(() => - import("@/features/auth/login-page").then((m) => ({ default: m.LoginPage })), + import("@/features/auth").then((m) => ({ default: m.LoginPage })), ); export const Route = createRoute({ diff --git a/studio/frontend/src/app/routes/signup.tsx b/studio/frontend/src/app/routes/signup.tsx index 1620b04505..154193f9c8 100644 --- a/studio/frontend/src/app/routes/signup.tsx +++ b/studio/frontend/src/app/routes/signup.tsx @@ -3,7 +3,7 @@ import { lazy } from "react"; import { Route as rootRoute } from "./__root"; const SignupPage = lazy(() => - import("@/features/auth/signup-page").then((m) => ({ + import("@/features/auth").then((m) => ({ default: m.SignupPage, })), ); diff --git a/studio/frontend/src/features/auth/api.ts b/studio/frontend/src/features/auth/api.ts new file mode 100644 index 0000000000..6705caa5e4 --- /dev/null +++ b/studio/frontend/src/features/auth/api.ts @@ -0,0 +1,66 @@ +import { + clearAuthTokens, + getAuthToken, + getRefreshToken, + storeAuthTokens, +} from "./session"; + +type RefreshResponse = { + access_token: string; + refresh_token: string; +}; + +export async function refreshSession(): Promise { + const refreshToken = getRefreshToken(); + if (!refreshToken) return false; + + try { + const response = await fetch("/api/auth/refresh", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ refresh_token: refreshToken }), + }); + + if (!response.ok) { + clearAuthTokens(); + return false; + } + + const payload = (await response.json()) as RefreshResponse; + storeAuthTokens(payload.access_token, payload.refresh_token); + return true; + } catch { + return false; + } +} + +export async function authFetch( + input: RequestInfo | URL, + init?: RequestInit, +): Promise { + const headers = new Headers(init?.headers); + const accessToken = getAuthToken(); + if (accessToken) { + headers.set("Authorization", `Bearer ${accessToken}`); + } + + const response = await fetch(input, { ...init, headers }); + if (response.status !== 401) return response; + + const refreshed = await refreshSession(); + if (!refreshed) return response; + + const retryHeaders = new Headers(init?.headers); + const newToken = getAuthToken(); + if (newToken) { + retryHeaders.set("Authorization", `Bearer ${newToken}`); + } else { + clearAuthTokens(); + } + + return fetch(input, { ...init, headers: retryHeaders }); +} + +export function logout(): void { + clearAuthTokens(); +} diff --git a/studio/frontend/src/features/auth/components/auth-form.tsx b/studio/frontend/src/features/auth/components/auth-form.tsx index ae81bdbca7..588c245285 100644 --- a/studio/frontend/src/features/auth/components/auth-form.tsx +++ b/studio/frontend/src/features/auth/components/auth-form.tsx @@ -1,16 +1,19 @@ import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; -import { - getPostAuthRoute, - hasAuthToken, - resetOnboardingDone, - storeAuthToken, -} from "@/features/auth/session"; import { Link, useNavigate } from "@tanstack/react-router"; import { Eye, EyeOff } from "lucide-react"; import { useEffect, useState } from "react"; import type { FormEvent } from "react"; +import type { ReactElement } from "react"; +import { refreshSession } from "../api"; +import { + getPostAuthRoute, + hasAuthToken, + hasRefreshToken, + resetOnboardingDone, + storeAuthTokens, +} from "../session"; type AuthMode = "login" | "signup"; @@ -20,17 +23,19 @@ type AuthStatusResponse = { type TokenResponse = { access_token: string; + refresh_token: string; }; type AuthFormProps = { mode: AuthMode; }; -export function AuthForm({ mode }: AuthFormProps) { +export function AuthForm({ mode }: AuthFormProps): ReactElement | null { const navigate = useNavigate(); const [showPassword, setShowPassword] = useState(false); const [showConfirmPassword, setShowConfirmPassword] = useState(false); const [username, setUsername] = useState("admin"); + const [setupToken, setSetupToken] = useState(""); const [password, setPassword] = useState(""); const [confirmPassword, setConfirmPassword] = useState(""); const [loading, setLoading] = useState(false); @@ -42,7 +47,16 @@ export function AuthForm({ mode }: AuthFormProps) { let canceled = false; async function initializeAuthForm(): Promise { + if (hasRefreshToken()) { + const refreshed = await refreshSession(); + if (refreshed) { + if (!canceled) setStatusLoading(false); + navigate({ to: getPostAuthRoute() }); + return; + } + } if (hasAuthToken()) { + if (!canceled) setStatusLoading(false); navigate({ to: getPostAuthRoute() }); return; } @@ -92,34 +106,42 @@ export function AuthForm({ mode }: AuthFormProps) { event.preventDefault(); setError(null); - if (mode === "signup" && password !== confirmPassword) { + if (!isLoginMode && password !== confirmPassword) { setError("Passwords not match."); return; } + if (!isLoginMode && !setupToken.trim()) { + setError("Setup token required."); + return; + } setLoading(true); try { - const endpoint = mode === "login" ? "/api/auth/login" : "/api/auth/setup"; + const endpoint = isLoginMode ? "/api/auth/login" : "/api/auth/setup"; + const payload: { username: string; password: string; setup_token?: string } = { + username: username.trim(), + password, + }; + if (!isLoginMode) { + payload.setup_token = setupToken.trim(); + } const response = await fetch(endpoint, { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - username: username.trim(), - password, - }), + body: JSON.stringify(payload), }); if (!response.ok) { let message = "Auth failed."; - try { - const payload = (await response.json()) as { detail?: string }; - if (payload.detail) message = payload.detail; - } catch {} + const errorPayload = (await response + .json() + .catch(() => null)) as { detail?: string } | null; + if (errorPayload?.detail) message = errorPayload.detail; throw new Error(message); } const token = (await response.json()) as TokenResponse; if (!isLoginMode) resetOnboardingDone(); - storeAuthToken(token.access_token); + storeAuthTokens(token.access_token, token.refresh_token); navigate({ to: getPostAuthRoute() }); } catch (err: unknown) { setError(err instanceof Error ? err.message : "Auth failed."); @@ -185,6 +207,20 @@ export function AuthForm({ mode }: AuthFormProps) { + {!isLoginMode && ( +
+ + setSetupToken(event.target.value)} + required + /> +
+ )} + {!isLoginMode && (
diff --git a/studio/frontend/src/features/auth/index.ts b/studio/frontend/src/features/auth/index.ts index 627c0aa825..87ed7e4173 100644 --- a/studio/frontend/src/features/auth/index.ts +++ b/studio/frontend/src/features/auth/index.ts @@ -1,2 +1,3 @@ export { LoginPage } from "./login-page"; export { SignupPage } from "./signup-page"; +export { isOnboardingDone, markOnboardingDone } from "./session"; diff --git a/studio/frontend/src/features/auth/session.ts b/studio/frontend/src/features/auth/session.ts index aed231c7c7..69ff2ac2a2 100644 --- a/studio/frontend/src/features/auth/session.ts +++ b/studio/frontend/src/features/auth/session.ts @@ -1,4 +1,5 @@ export const AUTH_TOKEN_KEY = "unsloth_auth_token"; +export const AUTH_REFRESH_TOKEN_KEY = "unsloth_auth_refresh_token"; export const ONBOARDING_DONE_KEY = "unsloth_onboarding_done"; type PostAuthRoute = "/onboarding" | "/studio"; @@ -12,9 +13,34 @@ export function hasAuthToken(): boolean { return Boolean(localStorage.getItem(AUTH_TOKEN_KEY)); } -export function storeAuthToken(accessToken: string): void { +export function hasRefreshToken(): boolean { + if (!canUseStorage()) return false; + return Boolean(localStorage.getItem(AUTH_REFRESH_TOKEN_KEY)); +} + +export function getAuthToken(): string | null { + if (!canUseStorage()) return null; + return localStorage.getItem(AUTH_TOKEN_KEY); +} + +export function getRefreshToken(): string | null { + if (!canUseStorage()) return null; + return localStorage.getItem(AUTH_REFRESH_TOKEN_KEY); +} + +export function storeAuthTokens( + accessToken: string, + refreshToken: string, +): void { if (!canUseStorage()) return; localStorage.setItem(AUTH_TOKEN_KEY, accessToken); + localStorage.setItem(AUTH_REFRESH_TOKEN_KEY, refreshToken); +} + +export function clearAuthTokens(): void { + if (!canUseStorage()) return; + localStorage.removeItem(AUTH_TOKEN_KEY); + localStorage.removeItem(AUTH_REFRESH_TOKEN_KEY); } export function isOnboardingDone(): boolean { diff --git a/studio/frontend/src/features/onboarding/components/wizard-footer.tsx b/studio/frontend/src/features/onboarding/components/wizard-footer.tsx index d06225c721..c167303ec6 100644 --- a/studio/frontend/src/features/onboarding/components/wizard-footer.tsx +++ b/studio/frontend/src/features/onboarding/components/wizard-footer.tsx @@ -1,6 +1,6 @@ import { Button } from "@/components/ui/button"; import { STEPS } from "@/config/training"; -import { markOnboardingDone } from "@/features/auth/session"; +import { markOnboardingDone } from "@/features/auth"; import { useWizardStore } from "@/stores/training"; import { ArrowLeft02Icon, ArrowRight02Icon } from "@hugeicons/core-free-icons"; import { HugeiconsIcon } from "@hugeicons/react"; diff --git a/studio/frontend/src/features/onboarding/components/wizard-layout.tsx b/studio/frontend/src/features/onboarding/components/wizard-layout.tsx index 2d315ef123..698b1c9052 100644 --- a/studio/frontend/src/features/onboarding/components/wizard-layout.tsx +++ b/studio/frontend/src/features/onboarding/components/wizard-layout.tsx @@ -5,7 +5,7 @@ import { Suspense, lazy, useEffect, useRef, useState } from "react"; import type { ConfettiRef } from "@/components/ui/confetti"; import { STEPS } from "@/config/training"; -import { isOnboardingDone, markOnboardingDone } from "@/features/auth/session"; +import { isOnboardingDone, markOnboardingDone } from "@/features/auth"; import { useWizardStore } from "@/stores/training"; import { SplashScreen } from "./splash-screen"; import { WizardContent } from "./wizard-content";