fix: attach web server to project without token

This commit is contained in:
Mikael Hugo 2026-05-17 16:25:47 +02:00
parent eeb80bbbdd
commit f87e9bc0d9
5 changed files with 215 additions and 50 deletions

View file

@ -23,28 +23,31 @@ export default function Login() {
};
return (
<div
style={{
maxWidth: 400,
margin: "100px auto",
padding: 32,
border: "1px solid #ccc",
borderRadius: 8,
}}
>
<h2>Sign in to SF</h2>
<form onSubmit={handleSubmit}>
<div className="flex min-h-screen items-center justify-center bg-background px-6 text-foreground">
<form
onSubmit={handleSubmit}
className="w-full max-w-sm space-y-4 rounded border border-border bg-card p-6 shadow-sm"
>
<div>
<h1 className="text-lg font-semibold">Sign in to SF</h1>
<p className="mt-1 text-sm text-muted-foreground">
Enter the local web password for this server.
</p>
</div>
<input
type="password"
placeholder="Password"
value={password}
onChange={(e) => setPassword(e.target.value)}
style={{ width: "100%", padding: 8, marginBottom: 16 }}
className="h-10 w-full rounded border border-input bg-background px-3 text-sm outline-none ring-offset-background placeholder:text-muted-foreground focus-visible:ring-2 focus-visible:ring-ring"
/>
<button type="submit" style={{ width: "100%", padding: 8 }}>
<button
type="submit"
className="h-10 w-full rounded bg-primary px-4 text-sm font-medium text-primary-foreground hover:bg-primary/90"
>
Sign In
</button>
{error && <div style={{ color: "red", marginTop: 12 }}>{error}</div>}
{error && <div className="text-sm text-destructive">{error}</div>}
</form>
</div>
);

View file

@ -37,7 +37,7 @@ import { UpdateBanner } from "@/components/sf/update-banner";
import { VisualizerView } from "@/components/sf/visualizer-view";
import { Badge } from "@/components/ui/badge";
import { Skeleton } from "@/components/ui/skeleton";
import { getAuthToken } from "@/lib/auth";
import { authFetch, getAuthToken } from "@/lib/auth";
import { DevOverridesProvider } from "@/lib/dev-overrides";
import {
ProjectStoreManagerProvider,
@ -722,6 +722,26 @@ function ProjectAwareWorkspace() {
);
const activeStore = activeProjectCwd ? manager.getActiveStore() : null;
useEffect(() => {
if (activeProjectCwd) return;
let cancelled = false;
authFetch("/api/boot", { cache: "no-store" })
.then(async (response) => {
if (!response.ok) return null;
return (await response.json()) as { project?: { cwd?: string } | null };
})
.then((boot) => {
const cwd = boot?.project?.cwd;
if (!cancelled && cwd) manager.switchProject(cwd);
})
.catch(() => {
// No configured project: leave the project picker visible.
});
return () => {
cancelled = true;
};
}, [activeProjectCwd, manager]);
// Shut down all projects when the tab actually closes.
// IMPORTANT: pagehide fires both on real page unload AND on mobile/Safari
// tab switches (bfcache entry). When event.persisted is true the page is

View file

@ -1348,7 +1348,7 @@ export function ProjectSelectionGate() {
{/* Project rows — table-like, dense */}
<div className="rounded-md border border-border bg-card overflow-hidden divide-y divide-border">
{filteredProjects.map((project) => {
const style = KIND_STYLE[project.kind];
const style = KIND_STYLE[project.kind] ?? KIND_STYLE.blank;
const KindIcon = style.icon;
const stack = techStack(project.signals);
const progress = project.progress

View file

@ -25,8 +25,12 @@ const AUTH_STORAGE_KEY = "sf-auth-token";
let cachedToken: string | null = null;
/**
* Extract the auth token from the URL fragment, localStorage, or prompt for login.
* If not found, redirect to /login page.
* Extract the auth token from the URL fragment or localStorage.
*
* Purpose: let launched web sessions use the bearer token fragment while still
* allowing direct dev/server mode when the server has not configured
* SF_WEB_AUTH_TOKEN. Missing tokens are handled by authFetch after the server
* proves it is protected with a 401.
*/
export function getAuthToken(): string | null {
if (cachedToken !== null) return cachedToken;
@ -60,10 +64,6 @@ export function getAuthToken(): string | null {
}
} catch {}
// 3. If not found, redirect to login
if (window.location.pathname !== "/login") {
window.location.href = "/login";
}
return null;
}
@ -98,10 +98,9 @@ export function authHeaders(
/**
* Wrapper around `fetch()` that automatically injects the auth token.
*
* When no token is available (missing `#token=` fragment and no localStorage
* entry), returns a synthetic 401 Response instead of making an unauthenticated
* request that will fail server-side anyway. This lets callers handle the
* missing-token case uniformly rather than silently cascading 401s.
* When no token is available, the request is sent unauthenticated. The server
* allows that in direct dev/server mode when SF_WEB_AUTH_TOKEN is unset. If the
* server returns 401, redirect to the login page.
*/
export async function authFetch(
input: RequestInfo | URL,
@ -109,10 +108,15 @@ export async function authFetch(
): Promise<Response> {
const token = getAuthToken();
if (!token) {
return new Response(JSON.stringify({ error: "No auth token available" }), {
status: 401,
headers: { "Content-Type": "application/json" },
});
const response = await fetch(input, init);
if (
response.status === 401 &&
typeof window !== "undefined" &&
window.location.pathname !== "/login"
) {
window.location.href = "/login";
}
return response;
}
const headers = new Headers(init?.headers);
@ -120,7 +124,15 @@ export async function authFetch(
headers.set("Authorization", `Bearer ${token}`);
}
return fetch(input, { ...init, headers });
const response = await fetch(input, { ...init, headers });
if (
response.status === 401 &&
typeof window !== "undefined" &&
window.location.pathname !== "/login"
) {
window.location.href = "/login";
}
return response;
}
/**

View file

@ -1,25 +1,155 @@
import { readdirSync, statSync } from "node:fs";
import { join } from "node:path";
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
import { basename, join } from "node:path";
import type { NextApiRequest, NextApiResponse } from "next";
// Returns a list of subfolders in the dev root that contain a .sf directory
export default function handler(req: NextApiRequest, res: NextApiResponse) {
const devRoot = req.query.devRoot as string;
if (!devRoot) return res.status(400).json({ error: "Missing devRoot" });
let projects: string[] = [];
type ProjectMetadata = {
name: string;
path: string;
kind: "active-sf" | "empty-sf" | "brownfield" | "blank";
signals: {
hasSfFolder: boolean;
hasPlanningFolder: boolean;
hasGitRepo: boolean;
hasPackageJson: boolean;
fileCount: number;
hasMilestones?: boolean;
hasCargo?: boolean;
hasGoMod?: boolean;
hasPyproject?: boolean;
isMonorepo: boolean;
};
lastModified: number;
progress?: {
activeMilestone: string | null;
activeSlice: string | null;
phase: string | null;
milestonesCompleted: number;
milestonesTotal: number;
} | null;
};
const EXCLUDED_DIRS = new Set(["node_modules", ".git"]);
function detectProject(path: string): ProjectMetadata["signals"] {
const hasGitRepo = existsSync(join(path, ".git"));
const hasSfFolder = existsSync(join(path, ".sf"));
const hasPlanningFolder = existsSync(join(path, "planning"));
const hasPackageJson = existsSync(join(path, "package.json"));
const hasCargo = existsSync(join(path, "Cargo.toml"));
const hasGoMod = existsSync(join(path, "go.mod"));
const hasPyproject = existsSync(join(path, "pyproject.toml"));
const hasMilestones = existsSync(join(path, ".sf", "milestones"));
const fileCount = readdirSync(path).filter(
(entry) => entry !== ".git",
).length;
const isMonorepo =
existsSync(join(path, "pnpm-workspace.yaml")) ||
existsSync(join(path, "lerna.json")) ||
existsSync(join(path, "package.json"));
return {
hasSfFolder,
hasPlanningFolder,
hasGitRepo,
hasPackageJson,
fileCount,
hasMilestones,
hasCargo,
hasGoMod,
hasPyproject,
isMonorepo,
};
}
function readProgress(path: string): ProjectMetadata["progress"] {
try {
const entries = readdirSync(devRoot, { withFileTypes: true });
projects = entries
.filter((entry) => entry.isDirectory())
.filter((entry) => {
try {
return statSync(join(devRoot, entry.name, ".sf")).isDirectory();
} catch {
return false;
}
})
.map((entry) => entry.name);
res.status(200).json({ projects });
const content = readFileSync(join(path, ".sf", "STATE.md"), "utf8");
let activeMilestone: string | null = null;
let activeSlice: string | null = null;
let phase: string | null = null;
let milestonesCompleted = 0;
let milestonesTotal = 0;
for (const line of content.split("\n")) {
const trimmed = line.trim();
if (trimmed.startsWith("**Active Milestone:**")) {
activeMilestone =
trimmed.replace("**Active Milestone:**", "").trim() || null;
} else if (trimmed.startsWith("**Active Slice:**")) {
activeSlice = trimmed.replace("**Active Slice:**", "").trim() || null;
} else if (trimmed.startsWith("**Phase:**")) {
phase = trimmed.replace("**Phase:**", "").trim() || null;
} else if (trimmed.startsWith("- \u2705")) {
milestonesCompleted++;
milestonesTotal++;
} else if (trimmed.startsWith("- \u{1f504}")) {
milestonesTotal++;
}
}
return {
activeMilestone,
activeSlice,
phase,
milestonesCompleted,
milestonesTotal,
};
} catch {
return null;
}
}
function projectMetadata(
path: string,
includeProgress: boolean,
): ProjectMetadata {
const stat = statSync(path);
const signals = detectProject(path);
const kind = signals.hasSfFolder
? signals.hasMilestones
? "active-sf"
: "empty-sf"
: signals.hasGitRepo || signals.hasPackageJson
? "brownfield"
: "blank";
return {
name: basename(path),
path,
kind,
signals,
lastModified: stat.mtimeMs,
...(includeProgress ? { progress: readProgress(path) } : {}),
};
}
function discoverProjects(root: string, includeProgress: boolean) {
const rootSignals = detectProject(root);
if (rootSignals.hasSfFolder || rootSignals.isMonorepo) {
return [projectMetadata(root, includeProgress)];
}
return readdirSync(root, { withFileTypes: true })
.filter((entry) => entry.isDirectory())
.filter((entry) => !entry.name.startsWith("."))
.filter((entry) => !EXCLUDED_DIRS.has(entry.name))
.map((entry) => join(root, entry.name))
.filter((path) => {
const signals = detectProject(path);
return (
signals.hasSfFolder || signals.hasGitRepo || signals.hasPackageJson
);
})
.map((path) => projectMetadata(path, includeProgress))
.sort((a, b) => a.name.localeCompare(b.name));
}
// Returns discovered projects under a configured development root.
export default function handler(req: NextApiRequest, res: NextApiResponse) {
const rootParam = req.query.root ?? req.query.devRoot;
const devRoot = Array.isArray(rootParam) ? rootParam[0] : rootParam;
if (!devRoot) return res.status(400).json({ error: "Missing devRoot" });
try {
const includeProgress = req.query.detail === "true";
res.status(200).json(discoverProjects(devRoot, includeProgress));
} catch (e) {
res.status(500).json({ error: (e as Error).message });
}