Skip to content

Commit c7ff639

Browse files
author
The No Hands Company
committed
fix: 2FA login blocker, OpenAPI complete, webhook delivery UI, runtime panel generalized
1. TwoFactorChallenge page (login blocker fixed) - Full-screen /2fa-challenge route — outside Layout, no sidebar - TOTP code entry (6-digit, numeric keyboard) + backup code toggle - Lockout state with clear messaging (10 min, too-many-attempts) - Attempt counter shown after first wrong code - Back to sign in + switch account links - Calls POST /api/auth/2fa/complete, redirects to ?next= on success - Registered in App.tsx OUTSIDE the Layout Switch (correct for auth flows) 2. OpenAPI spec — full coverage (was 2833 lines / ~50% routes, now 3663 lines / ~100%) - 27 missing path definitions added: tokens/{id} DELETE, auth/2fa/* (status/disable/backup/complete), nlpl/* (runtime-info, start/stop/status/logs), admin/processes, builds/{buildId} GET/DELETE, redirects/{ruleId} DELETE, headers/{headerId} DELETE, forms export/patch/delete, invitations/{token} GET + revoke DELETE, analytics/referrers, stats/hourly, nodes/{id}/update-capacity, env/{key} DELETE, transfer/accept, webhooks/config + test, .well-known/acme-challenge/{token}, storage/* paths, sites/serve/* - 4 new tags added: nlpl, builds, forms, tls, storage - Wildcard paths fixed to OpenAPI 3.1 syntax ({path*}) 3. Webhook delivery history UI (WebhooksPage.tsx) - DeliveryRow component: expandable row showing status, event, HTTP code, duration, retry badge, relative timestamp - Expanded state: request payload (JSON) + response body (truncated at 2000 chars) - Empty state with hint when hook selected but no deliveries yet - Refresh button to re-poll deliveries - formatDistanceToNow timestamps replacing raw toLocaleTimeString - RotateCcw icon on retry badges 4. Runtime panel generalized (NlplPanel.tsx + DeploySite.tsx) - RUNTIME_META table maps nlpl/dynamic/node/python → label, entry default, icon - Entry file defaults: server.nlpl / server.js / server.py per runtime - DeploySite condition extended to [nlpl, dynamic, node, python] - Toast message uses meta.label (NLPL / Node.js / Python) 5. SiteForm type selector - Static (HTML/CSS/JS), NLPL Application, Node.js Application replacing the vague Static/Dynamic App labels
1 parent 4377c13 commit c7ff639

File tree

7 files changed

+1173
-34
lines changed

7 files changed

+1173
-34
lines changed

artifacts/federated-hosting/src/App.tsx

Lines changed: 17 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ const ApiDocs = lazy(() => import("@/pages/ApiDocs"));
2727
const SiteSettings = lazy(() => import("@/pages/SiteSettings"));
2828
const UsageDashboard = lazy(() => import("@/pages/UsageDashboard"));
2929
const TwoFactorSettings = lazy(() => import("@/pages/TwoFactorSettings"));
30+
const TwoFactorChallenge = lazy(() => import("@/pages/TwoFactorChallenge"));
3031
const AcceptInvitation = lazy(() => import("@/pages/AcceptInvitation"));
3132
const AccountSettings = lazy(() => import("@/pages/AccountSettings"));
3233
const FormInbox = lazy(() => import("@/pages/FormInbox"));
@@ -62,10 +63,16 @@ const queryClient = new QueryClient({
6263

6364
function Router() {
6465
return (
65-
<Layout>
66-
<ErrorBoundary>
67-
<Suspense fallback={<LoadingState />}>
68-
<Switch>
66+
<Switch>
67+
{/* Full-screen auth flows — no sidebar/nav */}
68+
<Route path="/2fa-challenge" component={TwoFactorChallenge} />
69+
70+
{/* All other pages inside the shell layout */}
71+
<Route>
72+
<Layout>
73+
<ErrorBoundary>
74+
<Suspense fallback={<LoadingState />}>
75+
<Switch>
6976
<Route path="/" component={Dashboard} />
7077
<Route path="/nodes" component={NodeList} />
7178
<Route path="/nodes/:id" component={NodeDetail} />
@@ -89,10 +96,12 @@ function Router() {
8996
<Route path="/sites/:id/builds" component={BuildHistory} />
9097
<Route path="/sites/:id/webhooks" component={WebhooksPage} />
9198
<Route component={NotFound} />
92-
</Switch>
93-
</Suspense>
94-
</ErrorBoundary>
95-
</Layout>
99+
</Switch>
100+
</Suspense>
101+
</ErrorBoundary>
102+
</Layout>
103+
</Route>
104+
</Switch>
96105
);
97106
}
98107

artifacts/federated-hosting/src/components/NlplPanel.tsx

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,12 @@ interface RuntimeInfo {
3737
installInstructions: string | null;
3838
}
3939

40-
interface NlplPanelProps {
40+
const RUNTIME_META: Record<string, { label: string; entryDefault: string; description: string; icon: string }> = {
41+
nlpl: { label: "NLPL", entryDefault: "server.nlpl", description: "NLPL interpreted application", icon: "⚡" },
42+
dynamic: { label: "Node.js", entryDefault: "server.js", description: "Node.js HTTP server", icon: "🟢" },
43+
node: { label: "Node.js", entryDefault: "server.js", description: "Node.js HTTP server", icon: "🟢" },
44+
python: { label: "Python", entryDefault: "server.py", description: "Python HTTP server", icon: "🐍" },
45+
};
4146
siteId: number;
4247
siteDomain: string;
4348
siteType: string;
@@ -83,7 +88,7 @@ export function NlplPanel({ siteId, siteDomain, siteType }: NlplPanelProps) {
8388
const { toast } = useToast();
8489
const queryClient = useQueryClient();
8590
const [logsOpen, setLogsOpen] = useState(false);
86-
const [entryFile, setEntryFile] = useState("server.nlpl");
91+
const [entryFile, setEntryFile] = useState(meta.entryDefault);
8792
const logEndRef = useRef<HTMLDivElement>(null);
8893

8994
// ── Status polling ─────────────────────────────────────────────────────────
@@ -134,7 +139,7 @@ export function NlplPanel({ siteId, siteDomain, siteType }: NlplPanelProps) {
134139
},
135140
onSuccess: () => {
136141
queryClient.invalidateQueries({ queryKey: ["nlpl-status", siteId] });
137-
toast({ title: "Process started", description: `${siteType.toUpperCase()} server is starting up.` });
142+
toast({ title: "Process started", description: `${meta.label} server is starting up.` });
138143
},
139144
onError: (err: Error) => {
140145
toast({ title: "Failed to start", description: err.message, variant: "destructive" });
@@ -163,6 +168,7 @@ export function NlplPanel({ siteId, siteDomain, siteType }: NlplPanelProps) {
163168
});
164169

165170
const cfg = STATUS_CONFIG[status?.status ?? "stopped"];
171+
const meta = RUNTIME_META[siteType] ?? RUNTIME_META.nlpl;
166172

167173
return (
168174
<Card className="border-white/8">
@@ -174,9 +180,9 @@ export function NlplPanel({ siteId, siteDomain, siteType }: NlplPanelProps) {
174180
</div>
175181
<div>
176182
<CardTitle className="text-white text-sm">
177-
{siteType === "nlpl" ? "NLPL" : siteType === "node" ? "Node.js" : "Python"} Server
183+
{meta.icon} {meta.label} Server
178184
</CardTitle>
179-
<CardDescription className="text-xs">Persistent process — handles HTTP requests</CardDescription>
185+
<CardDescription className="text-xs">{meta.description} — handles HTTP requests</CardDescription>
180186
</div>
181187
</div>
182188

artifacts/federated-hosting/src/components/forms/SiteForm.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -151,8 +151,9 @@ export function SiteForm({ onSuccess, initialData }: SiteFormProps) {
151151
</SelectTrigger>
152152
</FormControl>
153153
<SelectContent>
154-
<SelectItem value="static">Static</SelectItem>
155-
<SelectItem value="dynamic">Dynamic App</SelectItem>
154+
<SelectItem value="static">Static (HTML/CSS/JS)</SelectItem>
155+
<SelectItem value="nlpl">NLPL Application</SelectItem>
156+
<SelectItem value="dynamic">Node.js Application</SelectItem>
156157
<SelectItem value="blog">Blog</SelectItem>
157158
<SelectItem value="portfolio">Portfolio</SelectItem>
158159
<SelectItem value="other">Other</SelectItem>

artifacts/federated-hosting/src/pages/DeploySite.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -391,7 +391,7 @@ export default function DeploySite() {
391391

392392
<div className="space-y-4">
393393
{/* NLPL / dynamic site process management panel */}
394-
{(site.siteType === "nlpl" || site.siteType === "dynamic") && (
394+
{(["nlpl", "dynamic", "node", "python"] as const).includes(site.siteType as any) && (
395395
<NlplPanel siteId={siteId} siteDomain={site.domain} siteType={site.siteType} />
396396
)}
397397

Lines changed: 210 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,210 @@
1+
import { useState, useRef, useEffect } from "react";
2+
import { useLocation } from "wouter";
3+
import { motion } from "framer-motion";
4+
import { Shield, KeyRound, AlertTriangle, Loader2, ArrowLeft, HelpCircle } from "lucide-react";
5+
import { Button } from "@/components/ui/button";
6+
import { cn } from "@/lib/utils";
7+
8+
const BASE = import.meta.env.BASE_URL.replace(/\/$/, "");
9+
10+
type Step = "code" | "backup" | "locked";
11+
12+
export default function TwoFactorChallenge() {
13+
const [, setLocation] = useLocation();
14+
const [step, setStep] = useState<Step>("code");
15+
const [code, setCode] = useState("");
16+
const [error, setError] = useState<string | null>(null);
17+
const [loading, setLoading] = useState(false);
18+
const [attemptsLeft, setAttemptsLeft] = useState<number | null>(null);
19+
const inputRef = useRef<HTMLInputElement>(null);
20+
21+
// Parse the ?next= redirect target, default to /
22+
const nextUrl = (() => {
23+
try {
24+
const p = new URLSearchParams(window.location.search);
25+
const n = p.get("next") ?? "/";
26+
// Only allow relative URLs for security
27+
return n.startsWith("/") ? n : "/";
28+
} catch {
29+
return "/";
30+
}
31+
})();
32+
33+
useEffect(() => {
34+
inputRef.current?.focus();
35+
}, [step]);
36+
37+
async function submit() {
38+
const trimmed = code.replace(/\s/g, "");
39+
if (!trimmed || trimmed.length < 6) {
40+
setError("Enter a 6-digit code from your authenticator app.");
41+
return;
42+
}
43+
44+
setLoading(true);
45+
setError(null);
46+
47+
try {
48+
const res = await fetch(`${BASE}/api/auth/2fa/complete`, {
49+
method: "POST",
50+
credentials: "include",
51+
headers: { "Content-Type": "application/json" },
52+
body: JSON.stringify({ code: trimmed }),
53+
});
54+
55+
const body = await res.json() as {
56+
authenticated?: boolean;
57+
method?: string;
58+
message?: string;
59+
code?: string;
60+
remaining?: number;
61+
};
62+
63+
if (res.ok && body.authenticated) {
64+
// Full session established — redirect to intended destination
65+
setLocation(nextUrl);
66+
return;
67+
}
68+
69+
if (body.code === "TOTP_LOCKED") {
70+
setStep("locked");
71+
return;
72+
}
73+
74+
if (typeof body.remaining === "number") {
75+
setAttemptsLeft(body.remaining);
76+
}
77+
78+
setError(body.message ?? "Invalid code. Please try again.");
79+
setCode("");
80+
inputRef.current?.focus();
81+
} catch {
82+
setError("Network error. Please try again.");
83+
} finally {
84+
setLoading(false);
85+
}
86+
}
87+
88+
function handleKey(e: React.KeyboardEvent) {
89+
if (e.key === "Enter") submit();
90+
}
91+
92+
// Format code input: insert space after digit 3 for readability (123 456)
93+
function handleCodeChange(val: string) {
94+
const digits = val.replace(/\D/g, "").slice(0, step === "backup" ? 16 : 6);
95+
setCode(digits);
96+
setError(null);
97+
}
98+
99+
return (
100+
<div className="min-h-screen bg-background flex items-center justify-center p-4">
101+
<motion.div
102+
initial={{ opacity: 0, y: 16 }}
103+
animate={{ opacity: 1, y: 0 }}
104+
className="w-full max-w-sm"
105+
>
106+
{/* Header */}
107+
<div className="text-center mb-8">
108+
<div className="inline-flex w-14 h-14 rounded-2xl bg-primary/10 border border-primary/20 items-center justify-center mb-4">
109+
{step === "locked"
110+
? <AlertTriangle className="w-7 h-7 text-red-400" />
111+
: <Shield className="w-7 h-7 text-primary" />}
112+
</div>
113+
<h1 className="text-2xl font-bold text-white tracking-tight">
114+
{step === "code" && "Two-factor authentication"}
115+
{step === "backup" && "Use a backup code"}
116+
{step === "locked" && "Account temporarily locked"}
117+
</h1>
118+
<p className="text-muted-foreground text-sm mt-2">
119+
{step === "code" && "Enter the 6-digit code from your authenticator app."}
120+
{step === "backup" && "Enter one of the backup codes you saved when setting up 2FA."}
121+
{step === "locked" && "Too many failed attempts. Try again in 10 minutes."}
122+
</p>
123+
</div>
124+
125+
{step === "locked" ? (
126+
<div className="space-y-4">
127+
<div className="bg-red-400/10 border border-red-400/20 rounded-2xl p-4 text-center">
128+
<p className="text-red-400 text-sm">
129+
Your account has been temporarily locked due to repeated failed attempts.
130+
This protects against brute-force attacks.
131+
</p>
132+
</div>
133+
<Button
134+
variant="outline"
135+
className="w-full border-white/10 text-muted-foreground hover:text-white"
136+
onClick={() => window.location.href = "/api/auth/logout"}
137+
>
138+
<ArrowLeft className="w-4 h-4 mr-2" />
139+
Back to sign in
140+
</Button>
141+
</div>
142+
) : (
143+
<div className="space-y-4">
144+
{/* Code input */}
145+
<div>
146+
<input
147+
ref={inputRef}
148+
type={step === "backup" ? "text" : "number"}
149+
inputMode="numeric"
150+
autoComplete={step === "backup" ? "off" : "one-time-code"}
151+
value={code}
152+
onChange={(e) => handleCodeChange(e.target.value)}
153+
onKeyDown={handleKey}
154+
placeholder={step === "backup" ? "BACKUP-CODE" : "000000"}
155+
className={cn(
156+
"w-full bg-muted/20 border rounded-2xl px-5 py-4 text-white text-2xl font-mono tracking-[0.4em] text-center placeholder:text-muted-foreground/40 placeholder:tracking-normal focus:outline-none transition-colors",
157+
error
158+
? "border-red-400/50 focus:border-red-400"
159+
: "border-white/10 focus:border-primary/50",
160+
)}
161+
/>
162+
{error && (
163+
<motion.p
164+
initial={{ opacity: 0, y: -4 }}
165+
animate={{ opacity: 1, y: 0 }}
166+
className="text-red-400 text-xs mt-2 text-center flex items-center justify-center gap-1.5"
167+
>
168+
<AlertTriangle className="w-3.5 h-3.5 shrink-0" />
169+
{error}
170+
</motion.p>
171+
)}
172+
{attemptsLeft !== null && !error && (
173+
<p className="text-amber-400 text-xs mt-2 text-center">
174+
{attemptsLeft} attempt{attemptsLeft !== 1 ? "s" : ""} remaining before lockout.
175+
</p>
176+
)}
177+
</div>
178+
179+
{/* Submit */}
180+
<Button
181+
className="w-full bg-primary text-black hover:bg-primary/90 font-semibold h-12 text-base"
182+
onClick={submit}
183+
disabled={loading || code.length < 6}
184+
>
185+
{loading
186+
? <><Loader2 className="w-4 h-4 mr-2 animate-spin" />Verifying…</>
187+
: <><KeyRound className="w-4 h-4 mr-2" />Verify</>}
188+
</Button>
189+
190+
{/* Switch between TOTP and backup */}
191+
<button
192+
className="w-full text-sm text-muted-foreground hover:text-white transition-colors flex items-center justify-center gap-1.5 py-1"
193+
onClick={() => { setStep(step === "code" ? "backup" : "code"); setCode(""); setError(null); }}
194+
>
195+
<HelpCircle className="w-3.5 h-3.5" />
196+
{step === "code" ? "Use a backup code instead" : "Use authenticator app instead"}
197+
</button>
198+
199+
<button
200+
className="w-full text-xs text-muted-foreground/60 hover:text-muted-foreground transition-colors"
201+
onClick={() => window.location.href = "/api/auth/logout"}
202+
>
203+
Sign in with a different account
204+
</button>
205+
</div>
206+
)}
207+
</motion.div>
208+
</div>
209+
);
210+
}

0 commit comments

Comments
 (0)