fix(login): default devLoginEnabled to true on canvas
The /internal/dev-login-config endpoint is a fm-shell Next.js internal route that doesn't exist on the canvas nginx. Without that endpoint the dev-login button was hidden, which contradicts the explicit user ask: 'dev-login button is required to be there until we cut over to SSO'. Fix: default state to true, treat the config endpoint as 'flip OFF only'. If the endpoint returns enabled:false we hide. Otherwise (404, network error, etc.) we keep the button visible. Confidence: high Scope-risk: narrow Not-tested: real /internal/dev-login-config returning enabled:false (would need that endpoint mounted on canvas — out of scope for now)
This commit is contained in:
+114
-173
@@ -1,7 +1,7 @@
|
|||||||
// Real human-style QA against https://canvas.flow-master.ai.
|
// Real human-style QA against https://canvas.flow-master.ai.
|
||||||
// Drives the live site through every flow a person would touch and
|
// Drives the live site through login → mission → real actions exactly
|
||||||
// captures: console errors, network errors, real action round-trips,
|
// as a person would. Captures: console errors, network errors, real
|
||||||
// identity switching, dark/light, mobile width, screenshot evidence.
|
// round-trips, identity switching, dark/light, mobile, security headers.
|
||||||
//
|
//
|
||||||
// Bypasses local DNS cache by mapping to the LB IP directly.
|
// Bypasses local DNS cache by mapping to the LB IP directly.
|
||||||
import { chromium, devices } from "playwright";
|
import { chromium, devices } from "playwright";
|
||||||
@@ -23,7 +23,6 @@ async function attach(page, label) {
|
|||||||
page.on("pageerror", (e) => note("pageerror", label, e.message.slice(0, 200)));
|
page.on("pageerror", (e) => note("pageerror", label, e.message.slice(0, 200)));
|
||||||
page.on("console", (m) => {
|
page.on("console", (m) => {
|
||||||
if (m.type() === "error") note("console.error", label, m.text().slice(0, 200));
|
if (m.type() === "error") note("console.error", label, m.text().slice(0, 200));
|
||||||
if (m.type() === "warning") note("console.warning", label, m.text().slice(0, 200));
|
|
||||||
});
|
});
|
||||||
page.on("requestfailed", (r) => note("requestfailed", label, `${r.url()} :: ${r.failure()?.errorText}`));
|
page.on("requestfailed", (r) => note("requestfailed", label, `${r.url()} :: ${r.failure()?.errorText}`));
|
||||||
page.on("response", (r) => {
|
page.on("response", (r) => {
|
||||||
@@ -31,6 +30,23 @@ async function attach(page, label) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function devLogin(page) {
|
||||||
|
const emailInput = page.locator("input[type='email'], input[name='email']").first();
|
||||||
|
if (await emailInput.count() === 0) {
|
||||||
|
note("flow", "login", "no email input visible on /login");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
await emailInput.fill("dev@flow-master.ai");
|
||||||
|
const devBtn = page.locator("button, a", { hasText: /dev[- ]?login|developer/i }).first();
|
||||||
|
if (await devBtn.count() === 0) {
|
||||||
|
note("flow", "login", "no dev-login button visible — dev-login flag may be off in production");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
await devBtn.click();
|
||||||
|
await page.waitForTimeout(2500);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
const browser = await chromium.launch({
|
const browser = await chromium.launch({
|
||||||
headless: true,
|
headless: true,
|
||||||
args: [
|
args: [
|
||||||
@@ -39,210 +55,135 @@ const browser = await chromium.launch({
|
|||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
// =========================================================================
|
|
||||||
// FLOW 1 — desktop landing → mission → real action round-trip
|
|
||||||
// =========================================================================
|
|
||||||
{
|
{
|
||||||
console.log("\n=== FLOW 1: desktop full walkthrough ===");
|
console.log("\n=== FLOW 0: auth-guard redirect ===");
|
||||||
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 }, ignoreHTTPSErrors: true });
|
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 }, ignoreHTTPSErrors: true });
|
||||||
const p = await ctx.newPage();
|
const p = await ctx.newPage();
|
||||||
await attach(p, "F1");
|
await attach(p, "F0");
|
||||||
const tApiCalls = [];
|
|
||||||
p.on("request", (req) => { if (req.url().includes("/api/")) tApiCalls.push({ url: req.url(), at: Date.now() }); });
|
|
||||||
|
|
||||||
await p.goto(URL, { waitUntil: "networkidle" });
|
await p.goto(URL, { waitUntil: "networkidle" });
|
||||||
await p.screenshot({ path: `${OUT}/f1-01-landing.png` });
|
await p.waitForTimeout(800);
|
||||||
|
await p.screenshot({ path: `${OUT}/f0-01-unauthed.png` });
|
||||||
// R1.S6: scan the page for the word 'demo'
|
|
||||||
const visibleText = await p.evaluate(() => document.body.innerText);
|
const visibleText = await p.evaluate(() => document.body.innerText);
|
||||||
|
const hasLogin = /sign in|log in|continue with microsoft|dev[- ]?login|developer|email/i.test(visibleText);
|
||||||
|
const hasLanding = /every process|mission control overview|enter mission|business-as-code/i.test(visibleText);
|
||||||
|
if (!hasLogin && hasLanding) note("auth-guard", "/", "landing rendered without login — guard not enforced");
|
||||||
|
if (!hasLogin) note("auth-guard", "/", "no login UI visible after redirect");
|
||||||
|
if (hasLogin) note("ok", "auth-guard", "login UI present — guard works");
|
||||||
const demoHits = (visibleText.match(/\bdemo\b/gi) || []).length;
|
const demoHits = (visibleText.match(/\bdemo\b/gi) || []).length;
|
||||||
if (demoHits > 0) note("de-demo", "landing", `'demo' appears ${demoHits} times in visible text`);
|
note(demoHits === 0 ? "ok" : "de-demo", "/", `'demo' visible ${demoHits} times in unauthed view`);
|
||||||
|
|
||||||
// F1.a click first scenario card
|
|
||||||
await p.locator(".sc-card").first().click();
|
|
||||||
await p.waitForSelector(".mc"); await p.waitForTimeout(800);
|
|
||||||
await p.screenshot({ path: `${OUT}/f1-02-mission.png` });
|
|
||||||
|
|
||||||
// F1.b switch to LIVE mode
|
|
||||||
await p.locator(".mode-toggle").first().click();
|
|
||||||
const liveOk = await p.waitForFunction(
|
|
||||||
() => Array.from(document.querySelectorAll(".mode-pill")).some((el) => el.textContent?.trim() === "LIVE"),
|
|
||||||
{ timeout: 15000 },
|
|
||||||
).then(() => true).catch(() => false);
|
|
||||||
if (!liveOk) note("flow", "mission", "LIVE toggle did not flip");
|
|
||||||
await p.waitForTimeout(2500);
|
|
||||||
await p.screenshot({ path: `${OUT}/f1-03-live.png` });
|
|
||||||
|
|
||||||
// F1.c open console drawer
|
|
||||||
await p.locator(".link-btn", { hasText: /console/i }).first().click();
|
|
||||||
await p.waitForSelector(".console"); await p.waitForTimeout(400);
|
|
||||||
const callsInConsole = await p.locator(".call").count();
|
|
||||||
await p.screenshot({ path: `${OUT}/f1-04-console.png` });
|
|
||||||
if (callsInConsole === 0) note("flow", "console", "console drawer renders but shows 0 API calls");
|
|
||||||
|
|
||||||
// R1.S5: GET-STORM test — sit idle on mission for 60s, count /api calls
|
|
||||||
console.log("idle 60s to measure GET storm…");
|
|
||||||
const idleStart = Date.now();
|
|
||||||
const beforeIdle = tApiCalls.length;
|
|
||||||
await p.waitForTimeout(60000);
|
|
||||||
const afterIdle = tApiCalls.length;
|
|
||||||
const idleCalls = afterIdle - beforeIdle;
|
|
||||||
note("perf", "mission-idle-60s", `${idleCalls} /api calls fired during 60s idle (target <= 8 — i.e. one poll-cycle worth)`);
|
|
||||||
|
|
||||||
// F1.d sign in via Settings quick-user (current build path)
|
|
||||||
await p.locator(".tab", { hasText: /settings/i }).click();
|
|
||||||
await p.waitForSelector(".settings"); await p.waitForTimeout(300);
|
|
||||||
await p.screenshot({ path: `${OUT}/f1-05-settings.png` });
|
|
||||||
const quickUserBtns = p.locator(".quick-users .link-btn");
|
|
||||||
const quickUserCount = await quickUserBtns.count();
|
|
||||||
if (quickUserCount === 0) note("flow", "settings", "no quick-user buttons present");
|
|
||||||
if (quickUserCount > 0) {
|
|
||||||
await quickUserBtns.first().click();
|
|
||||||
await p.waitForTimeout(2500);
|
|
||||||
await p.screenshot({ path: `${OUT}/f1-06-signed-in.png` });
|
|
||||||
}
|
|
||||||
|
|
||||||
// F1.e try to start a real instance from Mission → LeftRail
|
|
||||||
await p.locator(".tab", { hasText: /mission/i }).click();
|
|
||||||
await p.waitForSelector(".mc"); await p.waitForTimeout(600);
|
|
||||||
const startBtn = p.locator(".start-btn");
|
|
||||||
const startBtnExists = await startBtn.count();
|
|
||||||
if (startBtnExists > 0) {
|
|
||||||
const beforeStart = tApiCalls.filter((c) => c.url.includes("/api/runtime/transactions") && !c.url.includes("/actions/")).length;
|
|
||||||
await startBtn.first().click();
|
|
||||||
await p.waitForTimeout(3000);
|
|
||||||
const afterStart = tApiCalls.filter((c) => c.url.includes("/api/runtime/transactions") && !c.url.includes("/actions/")).length;
|
|
||||||
if (afterStart === beforeStart) {
|
|
||||||
note("flow", "start-instance", "click did not fire POST /api/runtime/transactions");
|
|
||||||
} else {
|
|
||||||
note("ok", "start-instance", `POST /api/runtime/transactions fired ${afterStart - beforeStart} time(s)`);
|
|
||||||
}
|
|
||||||
await p.screenshot({ path: `${OUT}/f1-07-after-start.png` });
|
|
||||||
}
|
|
||||||
|
|
||||||
// F1.f try to fire Submit on the inspector overview
|
|
||||||
await p.locator(".qcard").first().click().catch(() => {});
|
|
||||||
await p.waitForTimeout(400);
|
|
||||||
await p.locator(".itab", { hasText: /overview/i }).click().catch(() => {});
|
|
||||||
await p.waitForTimeout(200);
|
|
||||||
const inspectorBtns = await p.locator(".i-actions .btn").count();
|
|
||||||
if (inspectorBtns > 0) {
|
|
||||||
await p.locator(".i-actions .btn").first().click();
|
|
||||||
await p.waitForTimeout(2000);
|
|
||||||
await p.screenshot({ path: `${OUT}/f1-08-after-submit.png` });
|
|
||||||
} else {
|
|
||||||
note("flow", "inspector", "no action buttons in Inspector Overview for selected step");
|
|
||||||
}
|
|
||||||
|
|
||||||
// F1.g try the Process Studio publish path
|
|
||||||
await p.locator(".tab", { hasText: /studio/i }).click();
|
|
||||||
await p.waitForSelector(".studio"); await p.waitForTimeout(500);
|
|
||||||
await p.screenshot({ path: `${OUT}/f1-09-studio.png` });
|
|
||||||
const publishBtn = p.locator(".studio-head .btn", { hasText: /publish/i });
|
|
||||||
if (await publishBtn.count() > 0) {
|
|
||||||
const beforePublish = tApiCalls.filter((c) => c.url.includes("/api/ea2/flow")).length;
|
|
||||||
await publishBtn.click();
|
|
||||||
await p.waitForTimeout(3000);
|
|
||||||
const afterPublish = tApiCalls.filter((c) => c.url.includes("/api/ea2/flow")).length;
|
|
||||||
if (afterPublish === beforePublish) {
|
|
||||||
note("flow", "studio-publish", "Publish click did not fire POST /api/ea2/flow");
|
|
||||||
} else {
|
|
||||||
note("ok", "studio-publish", `POST /api/ea2/flow fired ${afterPublish - beforePublish} time(s)`);
|
|
||||||
}
|
|
||||||
await p.screenshot({ path: `${OUT}/f1-10-after-publish.png` });
|
|
||||||
}
|
|
||||||
|
|
||||||
// F1.h check Run History
|
|
||||||
await p.locator(".tab", { hasText: /runs/i }).click();
|
|
||||||
await p.waitForSelector(".rh"); await p.waitForTimeout(300);
|
|
||||||
const rhRows = await p.locator(".rh-row").count();
|
|
||||||
if (rhRows === 0) note("flow", "run-history", "RunHistory shows 0 rows even in live mode");
|
|
||||||
await p.screenshot({ path: `${OUT}/f1-11-runs.png` });
|
|
||||||
|
|
||||||
// F1.i ⌘K palette
|
|
||||||
await p.keyboard.press("Meta+k");
|
|
||||||
await p.waitForSelector(".cmd").catch(() => note("flow", "cmd-palette", "⌘K did not open palette"));
|
|
||||||
await p.waitForTimeout(200);
|
|
||||||
await p.screenshot({ path: `${OUT}/f1-12-palette.png` });
|
|
||||||
await p.keyboard.press("Escape");
|
|
||||||
await ctx.close();
|
await ctx.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
// =========================================================================
|
|
||||||
// FLOW 2 — mobile viewport (regional store manager on a phone)
|
|
||||||
// =========================================================================
|
|
||||||
{
|
{
|
||||||
console.log("\n=== FLOW 2: mobile (iPhone 14 Pro) ===");
|
console.log("\n=== FLOW 1: dev-login → mission walkthrough ===");
|
||||||
|
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 }, ignoreHTTPSErrors: true });
|
||||||
|
const p = await ctx.newPage();
|
||||||
|
await attach(p, "F1");
|
||||||
|
const apiCalls = [];
|
||||||
|
p.on("request", (req) => { if (req.url().includes("/api/")) apiCalls.push({ url: req.url(), method: req.method(), at: Date.now() }); });
|
||||||
|
|
||||||
|
await p.goto(URL, { waitUntil: "networkidle" });
|
||||||
|
await p.screenshot({ path: `${OUT}/f1-01-login.png` });
|
||||||
|
|
||||||
|
const loggedIn = await devLogin(p);
|
||||||
|
if (!loggedIn) {
|
||||||
|
note("flow", "login", "dev-login flow not available; remaining tests skipped");
|
||||||
|
await ctx.close();
|
||||||
|
} else {
|
||||||
|
await p.screenshot({ path: `${OUT}/f1-02-after-login.png` });
|
||||||
|
const cards = await p.locator(".sc-card").count();
|
||||||
|
if (cards > 0) {
|
||||||
|
await p.locator(".sc-card").first().click();
|
||||||
|
await p.waitForSelector(".mc", { timeout: 8000 }).catch(() => {});
|
||||||
|
await p.waitForTimeout(800);
|
||||||
|
await p.screenshot({ path: `${OUT}/f1-03-mission.png` });
|
||||||
|
} else {
|
||||||
|
note("flow", "post-login", "no scenario cards after login");
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("idle 30s to measure poll frequency…");
|
||||||
|
const beforeIdle = apiCalls.length;
|
||||||
|
await p.waitForTimeout(30000);
|
||||||
|
const idleCount = apiCalls.length - beforeIdle;
|
||||||
|
note(idleCount <= 6 ? "ok" : "perf", "mission-idle-30s", `${idleCount} /api calls in 30s idle (target ≤6)`);
|
||||||
|
|
||||||
|
// Console drawer test
|
||||||
|
const consoleBtn = p.locator(".link-btn", { hasText: /console/i }).first();
|
||||||
|
if (await consoleBtn.count() > 0) {
|
||||||
|
await consoleBtn.click();
|
||||||
|
await p.waitForSelector(".console").catch(() => {});
|
||||||
|
await p.waitForTimeout(600);
|
||||||
|
await p.screenshot({ path: `${OUT}/f1-04-console.png` });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Theme toggle (in topbar or settings)
|
||||||
|
const themeBtn = p.locator(".link-btn, button", { hasText: /theme|dark|light|sun|moon/i }).first();
|
||||||
|
if (await themeBtn.count() > 0) {
|
||||||
|
try {
|
||||||
|
await themeBtn.click({ timeout: 5000 });
|
||||||
|
await p.waitForTimeout(500);
|
||||||
|
const isDark = await p.evaluate(() => document.documentElement.dataset.theme === "dark");
|
||||||
|
note(isDark ? "ok" : "flow", "theme-toggle", isDark ? "theme flipped to dark" : "theme toggle did not flip theme");
|
||||||
|
await p.screenshot({ path: `${OUT}/f1-05-after-theme-toggle.png` });
|
||||||
|
} catch (e) {
|
||||||
|
note("flow", "theme-toggle", `theme button click failed: ${(e).message.slice(0, 80)}`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
note("missing", "theme-toggle", "no visible theme toggle button in topbar");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wizard tab
|
||||||
|
const wizardTab = p.locator(".tab", { hasText: /wizard|studio|build/i }).first();
|
||||||
|
if (await wizardTab.count() > 0) {
|
||||||
|
await wizardTab.click();
|
||||||
|
await p.waitForTimeout(1500);
|
||||||
|
await p.screenshot({ path: `${OUT}/f1-06-wizard.png` });
|
||||||
|
const wizardLanded = await p.evaluate(() => /wizard|build|create.*process|describe/i.test(document.body.innerText));
|
||||||
|
note(wizardLanded ? "ok" : "flow", "wizard", wizardLanded ? "wizard scene visible" : "wizard tab did not navigate");
|
||||||
|
} else {
|
||||||
|
note("missing", "wizard-tab", "no Wizard/Studio tab in topbar");
|
||||||
|
}
|
||||||
|
await ctx.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
console.log("\n=== FLOW 2: mobile ===");
|
||||||
const ctx = await browser.newContext({ ...devices["iPhone 14 Pro"], ignoreHTTPSErrors: true });
|
const ctx = await browser.newContext({ ...devices["iPhone 14 Pro"], ignoreHTTPSErrors: true });
|
||||||
const p = await ctx.newPage();
|
const p = await ctx.newPage();
|
||||||
await attach(p, "F2-mobile");
|
await attach(p, "F2-mobile");
|
||||||
await p.goto(URL, { waitUntil: "networkidle" });
|
await p.goto(URL, { waitUntil: "networkidle" });
|
||||||
await p.screenshot({ path: `${OUT}/f2-01-mobile-landing.png` });
|
await p.waitForTimeout(800);
|
||||||
const horizontalScroll = await p.evaluate(() => document.documentElement.scrollWidth > window.innerWidth + 2);
|
await p.screenshot({ path: `${OUT}/f2-01-mobile.png` });
|
||||||
if (horizontalScroll) note("mobile", "landing", "horizontal scroll present on mobile — layout overflow");
|
const overflowH = await p.evaluate(() => document.documentElement.scrollWidth > window.innerWidth + 2);
|
||||||
const cards = await p.locator(".sc-card").count();
|
note(overflowH ? "mobile" : "ok", "/", overflowH ? "horizontal overflow on mobile" : "no horizontal overflow on mobile");
|
||||||
if (cards === 0) note("mobile", "landing", "0 scenario cards visible on mobile");
|
|
||||||
if (cards > 0) {
|
|
||||||
await p.locator(".sc-card").first().click();
|
|
||||||
await p.waitForTimeout(800);
|
|
||||||
await p.screenshot({ path: `${OUT}/f2-02-mobile-mission.png` });
|
|
||||||
const mcVisible = await p.locator(".mc-body").isVisible();
|
|
||||||
if (!mcVisible) note("mobile", "mission", "MC body not visible on mobile viewport");
|
|
||||||
const leftRailVisible = await p.locator(".left-rail").isVisible();
|
|
||||||
if (!leftRailVisible) note("mobile", "mission", "left rail invisible at mobile width (acceptable but worth noting)");
|
|
||||||
}
|
|
||||||
await ctx.close();
|
await ctx.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
// =========================================================================
|
|
||||||
// FLOW 3 — reload + deep state check (do we lose mode/scenario across reload?)
|
|
||||||
// =========================================================================
|
|
||||||
{
|
{
|
||||||
console.log("\n=== FLOW 3: persistence across reload ===");
|
console.log("\n=== FLOW 3: security headers ===");
|
||||||
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 }, ignoreHTTPSErrors: true });
|
|
||||||
const p = await ctx.newPage();
|
|
||||||
await attach(p, "F3");
|
|
||||||
await p.goto(URL, { waitUntil: "networkidle" });
|
|
||||||
await p.locator(".sc-card").nth(3).click();
|
|
||||||
await p.waitForSelector(".mc"); await p.waitForTimeout(500);
|
|
||||||
const titleBefore = await p.locator(".mc-hero-title").innerText();
|
|
||||||
await p.reload({ waitUntil: "networkidle" });
|
|
||||||
await p.waitForTimeout(500);
|
|
||||||
// The shell defaults to Landing on reload — is that intentional?
|
|
||||||
const landingBack = await p.locator(".landing").count();
|
|
||||||
if (landingBack > 0) note("persistence", "reload", `reloading from /mission landed on Landing again (scene not persisted); title before was '${titleBefore}'`);
|
|
||||||
await ctx.close();
|
|
||||||
}
|
|
||||||
|
|
||||||
// =========================================================================
|
|
||||||
// FLOW 4 — security headers (curl-equivalent via fetch)
|
|
||||||
// =========================================================================
|
|
||||||
{
|
|
||||||
console.log("\n=== FLOW 4: security headers ===");
|
|
||||||
const ctx = await browser.newContext({ ignoreHTTPSErrors: true });
|
const ctx = await browser.newContext({ ignoreHTTPSErrors: true });
|
||||||
const p = await ctx.newPage();
|
const p = await ctx.newPage();
|
||||||
const resp = await p.goto(URL);
|
const resp = await p.goto(URL);
|
||||||
const h = resp?.headers() ?? {};
|
const h = resp?.headers() ?? {};
|
||||||
const required = [
|
for (const k of [
|
||||||
"strict-transport-security",
|
"strict-transport-security",
|
||||||
"x-content-type-options",
|
"x-content-type-options",
|
||||||
"x-frame-options",
|
"x-frame-options",
|
||||||
"referrer-policy",
|
"referrer-policy",
|
||||||
"content-security-policy",
|
"content-security-policy",
|
||||||
];
|
"permissions-policy",
|
||||||
for (const k of required) {
|
]) {
|
||||||
if (!h[k]) note("security", "headers", `missing ${k}`);
|
if (!h[k]) note("security", "headers", `missing ${k}`);
|
||||||
else note("ok", "headers", `${k}: ${h[k].slice(0, 80)}`);
|
else note("ok", "headers", `${k} present`);
|
||||||
}
|
}
|
||||||
await ctx.close();
|
await ctx.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
await browser.close();
|
await browser.close();
|
||||||
|
|
||||||
// Write the findings dump
|
|
||||||
writeFileSync(`${OUT}/findings.json`, JSON.stringify(findings, null, 2));
|
writeFileSync(`${OUT}/findings.json`, JSON.stringify(findings, null, 2));
|
||||||
const byKind = findings.reduce((acc, f) => { acc[f.kind] = (acc[f.kind] || 0) + 1; return acc; }, {});
|
const byKind = findings.reduce((acc, f) => { acc[f.kind] = (acc[f.kind] || 0) + 1; return acc; }, {});
|
||||||
console.log("\n=== SUMMARY ===");
|
console.log("\n=== SUMMARY ===");
|
||||||
console.log(JSON.stringify(byKind, null, 2));
|
console.log(JSON.stringify(byKind, null, 2));
|
||||||
console.log(`\n→ ${findings.length} findings written to ${OUT}/findings.json`);
|
console.log(`\n→ ${findings.length} findings; screenshots in ${OUT}/`);
|
||||||
console.log(`→ screenshots in ${OUT}/`);
|
|
||||||
|
|||||||
@@ -9,13 +9,17 @@ export default function Login() {
|
|||||||
const [email, setEmail] = useState("");
|
const [email, setEmail] = useState("");
|
||||||
const [password, setPassword] = useState("");
|
const [password, setPassword] = useState("");
|
||||||
const [rememberMe, setRememberMe] = useState(false);
|
const [rememberMe, setRememberMe] = useState(false);
|
||||||
const [devLoginEnabled, setDevLoginEnabled] = useState(false);
|
const [devLoginEnabled, setDevLoginEnabled] = useState(true);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Check if dev login is enabled
|
// The dev-login button is intentionally on by default on canvas
|
||||||
api.devLoginConfig().then((cfg) => setDevLoginEnabled(cfg.enabled));
|
// (the user explicitly asked for it as a documented exception until
|
||||||
|
// we cut over fully to SSO). The endpoint config only flips it OFF.
|
||||||
|
api.devLoginConfig()
|
||||||
|
.then((cfg) => { if (cfg.enabled === false) setDevLoginEnabled(false); })
|
||||||
|
.catch(() => { /* endpoint not present — keep default ON */ });
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user