feat: new auth and refresh token on unauthorized
This commit is contained in:
parent
3d0b90a862
commit
cdf7ead71b
9 changed files with 417 additions and 23 deletions
265
docs/canvas-lab-architecture.md
Normal file
265
docs/canvas-lab-architecture.md
Normal 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
|
||||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})),
|
||||
);
|
||||
|
|
|
|||
66
studio/frontend/src/features/auth/api.ts
Normal file
66
studio/frontend/src/features/auth/api.ts
Normal 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();
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -1,2 +1,3 @@
|
|||
export { LoginPage } from "./login-page";
|
||||
export { SignupPage } from "./signup-page";
|
||||
export { isOnboardingDone, markOnboardingDone } from "./session";
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue