feat: new auth and refresh token on unauthorized

This commit is contained in:
shine1i 2026-02-11 13:40:33 +01:00
commit cdf7ead71b
9 changed files with 417 additions and 23 deletions

View file

@ -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

View file

@ -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({

View file

@ -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,
})),
);

View file

@ -0,0 +1,66 @@
import {
clearAuthTokens,
getAuthToken,
getRefreshToken,
storeAuthTokens,
} from "./session";
type RefreshResponse = {
access_token: string;
refresh_token: string;
};
export async function refreshSession(): Promise<boolean> {
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<Response> {
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();
}

View file

@ -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<void> {
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) {
</div>
</div>
{!isLoginMode && (
<div className="space-y-2">
<Label htmlFor="setup-token">Setup token</Label>
<Input
id="setup-token"
autoComplete="off"
placeholder="Paste token from backend console"
value={setupToken}
onChange={(event) => setSetupToken(event.target.value)}
required
/>
</div>
)}
{!isLoginMode && (
<div className="space-y-2">
<Label htmlFor="confirm-password">Confirm password</Label>

View file

@ -1,2 +1,3 @@
export { LoginPage } from "./login-page";
export { SignupPage } from "./signup-page";
export { isOnboardingDone, markOnboardingDone } from "./session";

View file

@ -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 {

View file

@ -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";

View file

@ -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";