fix: attach web server to project without token
This commit is contained in:
parent
eeb80bbbdd
commit
f87e9bc0d9
5 changed files with 215 additions and 50 deletions
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue