mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
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:
@@ -8,7 +8,8 @@ export function DemoBanner() {
|
|||||||
return (
|
return (
|
||||||
<div className="relative z-[9999] flex items-center justify-center gap-3 bg-primary px-4 py-2 text-sm text-primary-foreground">
|
<div className="relative z-[9999] flex items-center justify-center gap-3 bg-primary px-4 py-2 text-sm text-primary-foreground">
|
||||||
<span>
|
<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
|
<a
|
||||||
href="https://github.com/snapotter-hq/SnapOtter"
|
href="https://github.com/snapotter-hq/SnapOtter"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
|
|||||||
@@ -736,9 +736,11 @@ export function matchDemoRoute(url: string, method: string, body?: unknown): Res
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (path === "/api/auth/session" && method === "GET") {
|
if (path === "/api/auth/session" && method === "GET") {
|
||||||
const token = localStorage.getItem("snapotter-token");
|
// The demo is always signed in as an admin, so it boots straight into the
|
||||||
if (!token) return json({ error: "Unauthorized" }, 401);
|
// dashboard with no login or change-password screen, and every settings
|
||||||
const state = loadState();
|
// 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({
|
return json({
|
||||||
user: {
|
user: {
|
||||||
id: 1,
|
id: 1,
|
||||||
@@ -746,7 +748,8 @@ export function matchDemoRoute(url: string, method: string, body?: unknown): Res
|
|||||||
displayName: "Demo User",
|
displayName: "Demo User",
|
||||||
role: "admin",
|
role: "admin",
|
||||||
permissions: PERMISSIONS,
|
permissions: PERMISSIONS,
|
||||||
mustChangePassword: !state.passwordChanged,
|
mustChangePassword: false,
|
||||||
|
mfaRequired: false,
|
||||||
loginMethod: "local",
|
loginMethod: "local",
|
||||||
hasLocalPassword: true,
|
hasLocalPassword: true,
|
||||||
},
|
},
|
||||||
@@ -1036,7 +1039,26 @@ export function matchDemoRoute(url: string, method: string, body?: unknown): Res
|
|||||||
return null;
|
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() {
|
export function installMocks() {
|
||||||
|
primeDemoAuth();
|
||||||
|
|
||||||
const originalFetch = window.fetch.bind(window);
|
const originalFetch = window.fetch.bind(window);
|
||||||
|
|
||||||
window.fetch = async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
|
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 }) {
|
export function ImageEditIcon({ className }: { className?: string }) {
|
||||||
return (
|
return (
|
||||||
<span
|
<svg
|
||||||
className={className}
|
className={className}
|
||||||
style={{
|
viewBox="0 0 24 24"
|
||||||
display: "inline-block",
|
fill="none"
|
||||||
backgroundColor: "currentColor",
|
stroke="currentColor"
|
||||||
maskImage: "url(/edit-image.png)",
|
strokeWidth={2}
|
||||||
maskSize: "contain",
|
strokeLinecap="round"
|
||||||
maskRepeat: "no-repeat",
|
strokeLinejoin="round"
|
||||||
maskPosition: "center",
|
aria-hidden="true"
|
||||||
WebkitMaskImage: "url(/edit-image.png)",
|
>
|
||||||
WebkitMaskSize: "contain",
|
<path d="M19 11V6a2 2 0 0 0-2-2H5a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h5" />
|
||||||
WebkitMaskRepeat: "no-repeat",
|
<circle cx="7.5" cy="8" r="1.6" />
|
||||||
WebkitMaskPosition: "center",
|
<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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { expect, test } from "@playwright/test";
|
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 consoleErrors: string[] = [];
|
||||||
const pageErrors: 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);
|
pageErrors.push(error.message);
|
||||||
});
|
});
|
||||||
|
|
||||||
await page.goto("/login");
|
// Visiting the root lands directly on the dashboard, not the login page.
|
||||||
await expect(page.getByText("This is a live demo. Processing is disabled.")).toBeVisible();
|
await page.goto("/");
|
||||||
await expect(page.getByRole("heading", { name: "Login" })).toBeVisible();
|
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 theme = await page.evaluate(() => {
|
||||||
const bannerLink = Array.from(document.querySelectorAll("a")).find((link) =>
|
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.themeColor?.toLowerCase()).toBe("#e07832");
|
||||||
expect(theme.bannerBackground).toBe("rgb(224, 120, 50)");
|
expect(theme.bannerBackground).toBe("rgb(224, 120, 50)");
|
||||||
|
|
||||||
await page.getByLabel("Username").fill("demo");
|
// The dashboard tool grid renders without any login/change-password gate.
|
||||||
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("/");
|
|
||||||
const allTab = page.getByRole("button", { name: /^All\s*\d+$/ });
|
const allTab = page.getByRole("button", { name: /^All\s*\d+$/ });
|
||||||
await expect(allTab).toBeVisible();
|
await expect(allTab).toBeVisible();
|
||||||
const allCount = Number((await allTab.textContent())?.match(/\d+$/)?.[0] ?? 0);
|
const allCount = Number((await allTab.textContent())?.match(/\d+$/)?.[0] ?? 0);
|
||||||
expect(allCount).toBeGreaterThan(100);
|
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 page.goto("/image/compress");
|
||||||
await expect(page.getByRole("heading", { name: "Compress" })).toBeVisible();
|
await expect(page.getByRole("heading", { name: "Compress" })).toBeVisible();
|
||||||
await expect(page.getByText("Drop your files here")).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
|
// No login step needed: the demo is signed in on load. Open Settings from
|
||||||
// straight in the app, then open Settings from the avatar menu.
|
// the avatar menu straight away.
|
||||||
await page.addInitScript(() => {
|
|
||||||
localStorage.setItem("snapotter-token", "demo-token");
|
|
||||||
localStorage.setItem("snapotter-demo-state", JSON.stringify({ passwordChanged: true }));
|
|
||||||
});
|
|
||||||
|
|
||||||
await page.goto("/");
|
await page.goto("/");
|
||||||
await page.getByTestId("user-menu").click();
|
await page.getByTestId("user-menu").click();
|
||||||
await page.getByRole("button", { name: "Settings", exact: true }).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([]);
|
expect(pageErrors).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("serves the editor icon asset so the mobile nav icon renders", async ({ request }) => {
|
test("mobile bottom nav renders a visible image-editor icon", async ({ page }) => {
|
||||||
// The mobile bottom-nav editor icon is a CSS mask over /edit-image.png. When
|
// The editor icon used to be a CSS mask over /edit-image.png and vanished on
|
||||||
// that asset was missing from the demo build the mask resolved to nothing and
|
// phones when that asset didn't load. It is now an inline SVG. Render the
|
||||||
// the icon vanished on phones. Guard the asset so it can't regress.
|
// mobile nav (viewport under the 768px breakpoint) and assert the editor
|
||||||
const response = await request.get("/edit-image.png");
|
// link's icon is actually drawn with a non-zero box.
|
||||||
expect(response.status()).toBe(200);
|
await page.setViewportSize({ width: 390, height: 844 });
|
||||||
expect(response.headers()["content-type"]).toContain("image");
|
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);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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.
|
// 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"
|
// 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
|
// crash: the People tab calls /auth/users, and the mock used to answer only
|
||||||
|
|||||||
@@ -66,18 +66,20 @@ describe("MobileBottomNav", () => {
|
|||||||
expect(screen.queryByText("Settings")).toBeNull();
|
expect(screen.queryByText("Settings")).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders icons for each navigation item", () => {
|
it("renders an svg icon for each navigation item", () => {
|
||||||
renderNav(() => {});
|
renderNav(() => {});
|
||||||
|
|
||||||
// 4 items use lucide SVG icons (Tools, Automate, Files, Settings)
|
// All five items (Tools, Automate, Editor, Files, Settings) render inline
|
||||||
// Editor uses ImageEditIcon which is a CSS-masked <span>, not SVG
|
// 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");
|
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 editorLink = Array.from(document.querySelectorAll("a")).find(
|
||||||
const spans = document.querySelectorAll("nav span");
|
(a) => a.getAttribute("href") === "/editor",
|
||||||
const maskedSpan = Array.from(spans).find((s) => (s as HTMLElement).style.maskImage !== "");
|
);
|
||||||
expect(maskedSpan).toBeDefined();
|
expect(editorLink?.querySelector("svg")).not.toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("nav has backdrop blur and border-top styling", () => {
|
it("nav has backdrop blur and border-top styling", () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user