fix(demo): boot to dashboard, sample-data notice, robust mobile editor icon (#464)

The demo signs in as an admin on load (no login/change-password screen; /login and /change-password bounce to the dashboard), authEnabled stays true so the People/Teams/Roles/Security settings tabs remain available, the banner notes the admin data is sample data, and the mobile editor icon is an inline SVG so it always renders.
This commit is contained in:
SnapOtter
2026-07-07 23:12:37 +08:00
committed by GitHub
parent 7329bc6a13
commit a94b69b14f
6 changed files with 107 additions and 57 deletions
+2 -1
View File
@@ -8,7 +8,8 @@ export function DemoBanner() {
return (
<div className="relative z-[9999] flex items-center justify-center gap-3 bg-primary px-4 py-2 text-sm text-primary-foreground">
<span>
This is a live demo. Processing is disabled.{" "}
This is a live demo. All users, teams, and activity shown are sample data, and file
processing is disabled.{" "}
<a
href="https://github.com/snapotter-hq/SnapOtter"
target="_blank"
+26 -4
View File
@@ -736,9 +736,11 @@ export function matchDemoRoute(url: string, method: string, body?: unknown): Res
}
if (path === "/api/auth/session" && method === "GET") {
const token = localStorage.getItem("snapotter-token");
if (!token) return json({ error: "Unauthorized" }, 401);
const state = loadState();
// The demo is always signed in as an admin, so it boots straight into the
// dashboard with no login or change-password screen, and every settings
// tab (including the auth-gated People/Teams/Roles/Security ones) stays
// available. mustChangePassword is always false; the session is returned
// regardless of token so a stray logout can't drop you onto /login.
return json({
user: {
id: 1,
@@ -746,7 +748,8 @@ export function matchDemoRoute(url: string, method: string, body?: unknown): Res
displayName: "Demo User",
role: "admin",
permissions: PERMISSIONS,
mustChangePassword: !state.passwordChanged,
mustChangePassword: false,
mfaRequired: false,
loginMethod: "local",
hasLocalPassword: true,
},
@@ -1036,7 +1039,26 @@ export function matchDemoRoute(url: string, method: string, body?: unknown): Res
return null;
}
/**
* Boot the demo as an already-signed-in admin so there is no login screen.
* Runs before the app mounts: seeds an auth token and, if the URL is a login
* or change-password route (manual navigation, or the logout redirect),
* rewrites it to the dashboard before React Router reads the location.
*/
function primeDemoAuth() {
try {
localStorage.setItem("snapotter-token", "demo-token");
localStorage.setItem("snapotter-username", "demo");
} catch {}
const p = window.location.pathname;
if (p === "/login" || p === "/change-password") {
window.history.replaceState(null, "", "/");
}
}
export function installMocks() {
primeDemoAuth();
const originalFetch = window.fetch.bind(window);
window.fetch = async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
@@ -1,19 +1,32 @@
/**
* Image-editor glyph for the mobile bottom nav: a framed photo with an editing
* pencil. Rendered as an inline SVG (like the sibling Lucide nav icons) so it
* has no external-asset dependency. It previously used a CSS mask over
* /edit-image.png, which rendered as nothing whenever that asset failed to load
* (e.g. it was missing from the demo build), leaving the icon invisible.
*/
export function ImageEditIcon({ className }: { className?: string }) {
return (
<span
<svg
className={className}
style={{
display: "inline-block",
backgroundColor: "currentColor",
maskImage: "url(/edit-image.png)",
maskSize: "contain",
maskRepeat: "no-repeat",
maskPosition: "center",
WebkitMaskImage: "url(/edit-image.png)",
WebkitMaskSize: "contain",
WebkitMaskRepeat: "no-repeat",
WebkitMaskPosition: "center",
}}
/>
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M19 11V6a2 2 0 0 0-2-2H5a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h5" />
<circle cx="7.5" cy="8" r="1.6" />
<path d="M3.5 15.5 6.5 12.5 9 15 12 12" />
<g transform="translate(10.8 8.2) scale(0.62)">
<path
vectorEffect="non-scaling-stroke"
d="M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z"
/>
<path vectorEffect="non-scaling-stroke" d="m15 5 4 4" />
</g>
</svg>
);
}
+31 -30
View File
@@ -1,6 +1,6 @@
import { expect, test } from "@playwright/test";
test("demo preview uses the real app theme and reaches a tool page", async ({ page }) => {
test("demo boots straight into the dashboard with no login screen", async ({ page }) => {
const consoleErrors: string[] = [];
const pageErrors: string[] = [];
@@ -11,9 +11,12 @@ test("demo preview uses the real app theme and reaches a tool page", async ({ pa
pageErrors.push(error.message);
});
await page.goto("/login");
await expect(page.getByText("This is a live demo. Processing is disabled.")).toBeVisible();
await expect(page.getByRole("heading", { name: "Login" })).toBeVisible();
// Visiting the root lands directly on the dashboard, not the login page.
await page.goto("/");
await expect(page).toHaveURL(/\/$/);
// The demo banner is visible and makes clear the admin data is sample data.
await expect(page.getByText(/live demo/i).first()).toBeVisible();
await expect(page.getByText(/sample data/i).first()).toBeVisible();
const theme = await page.evaluate(() => {
const bannerLink = Array.from(document.querySelectorAll("a")).find((link) =>
@@ -34,23 +37,17 @@ test("demo preview uses the real app theme and reaches a tool page", async ({ pa
expect(theme.themeColor?.toLowerCase()).toBe("#e07832");
expect(theme.bannerBackground).toBe("rgb(224, 120, 50)");
await page.getByLabel("Username").fill("demo");
await page.getByLabel("Password").fill("demo");
await page.getByRole("button", { name: /^login$/i }).click();
await page.waitForURL(/\/change-password$/);
await expect(page.getByRole("heading", { name: "Change your password" })).toBeVisible();
await page.evaluate(() => {
localStorage.setItem("snapotter-demo-state", JSON.stringify({ passwordChanged: true }));
});
await page.goto("/");
// The dashboard tool grid renders without any login/change-password gate.
const allTab = page.getByRole("button", { name: /^All\s*\d+$/ });
await expect(allTab).toBeVisible();
const allCount = Number((await allTab.textContent())?.match(/\d+$/)?.[0] ?? 0);
expect(allCount).toBeGreaterThan(100);
// Even hitting /login directly bounces to the dashboard.
await page.goto("/login");
await expect(page).toHaveURL(/\/$/);
await expect(allTab).toBeVisible();
await page.goto("/image/compress");
await expect(page.getByRole("heading", { name: "Compress" })).toBeVisible();
await expect(page.getByText("Drop your files here")).toBeVisible();
@@ -76,13 +73,8 @@ test("admin settings sections render sample data without crashing", async ({ pag
}
});
// Seed an authenticated session (past the forced change-password) so we land
// straight in the app, then open Settings from the avatar menu.
await page.addInitScript(() => {
localStorage.setItem("snapotter-token", "demo-token");
localStorage.setItem("snapotter-demo-state", JSON.stringify({ passwordChanged: true }));
});
// No login step needed: the demo is signed in on load. Open Settings from
// the avatar menu straight away.
await page.goto("/");
await page.getByTestId("user-menu").click();
await page.getByRole("button", { name: "Settings", exact: true }).click();
@@ -109,11 +101,20 @@ test("admin settings sections render sample data without crashing", async ({ pag
expect(pageErrors).toEqual([]);
});
test("serves the editor icon asset so the mobile nav icon renders", async ({ request }) => {
// The mobile bottom-nav editor icon is a CSS mask over /edit-image.png. When
// that asset was missing from the demo build the mask resolved to nothing and
// the icon vanished on phones. Guard the asset so it can't regress.
const response = await request.get("/edit-image.png");
expect(response.status()).toBe(200);
expect(response.headers()["content-type"]).toContain("image");
test("mobile bottom nav renders a visible image-editor icon", async ({ page }) => {
// The editor icon used to be a CSS mask over /edit-image.png and vanished on
// phones when that asset didn't load. It is now an inline SVG. Render the
// mobile nav (viewport under the 768px breakpoint) and assert the editor
// link's icon is actually drawn with a non-zero box.
await page.setViewportSize({ width: 390, height: 844 });
await page.goto("/");
const editorLink = page.getByRole("link", { name: /editor/i });
await expect(editorLink).toBeVisible();
const icon = editorLink.locator("svg");
await expect(icon).toBeVisible();
const box = await icon.boundingBox();
expect(box?.width ?? 0).toBeGreaterThan(0);
expect(box?.height ?? 0).toBeGreaterThan(0);
});
+11
View File
@@ -36,6 +36,17 @@ describe("demo mock API", () => {
});
});
it("returns an authenticated admin session so the demo skips the login screen", async () => {
const response = matchDemoRoute("/api/auth/session", "GET");
expect(response?.status).toBe(200);
const data = (await readJson(response as Response)) as {
user: { role: string; mustChangePassword: boolean; permissions: string[] };
};
expect(data.user.role).toBe("admin");
expect(data.user.mustChangePassword).toBe(false);
expect(Array.isArray(data.user.permissions)).toBe(true);
});
// These lock in the exact response shapes the admin settings screens read.
// A missing array here is what produced the "can't access property filter"
// crash: the People tab calls /auth/users, and the mock used to answer only
+10 -8
View File
@@ -66,18 +66,20 @@ describe("MobileBottomNav", () => {
expect(screen.queryByText("Settings")).toBeNull();
});
it("renders icons for each navigation item", () => {
it("renders an svg icon for each navigation item", () => {
renderNav(() => {});
// 4 items use lucide SVG icons (Tools, Automate, Files, Settings)
// Editor uses ImageEditIcon which is a CSS-masked <span>, not SVG
// All five items (Tools, Automate, Editor, Files, Settings) render inline
// SVG icons. The Editor icon used to be a CSS-masked <span> that rendered
// as nothing when its mask asset failed to load; it is now an inline SVG
// like the rest, so it always draws.
const svgs = document.querySelectorAll("nav svg");
expect(svgs.length).toBe(4);
expect(svgs.length).toBe(5);
// The Editor icon is a span with a mask-image
const spans = document.querySelectorAll("nav span");
const maskedSpan = Array.from(spans).find((s) => (s as HTMLElement).style.maskImage !== "");
expect(maskedSpan).toBeDefined();
const editorLink = Array.from(document.querySelectorAll("a")).find(
(a) => a.getAttribute("href") === "/editor",
);
expect(editorLink?.querySelector("svg")).not.toBeNull();
});
it("nav has backdrop blur and border-top styling", () => {