UI ux/move footer links to settings (#6606)

<img width="2056" height="1044" alt="Screenshot 2026-06-11 at 2 15
34 PM"
src="https://github.com/user-attachments/assets/e58a9f8f-7172-4f30-ab28-0760b66249c9"
/>
<img width="2056" height="1045" alt="Screenshot 2026-06-11 at 2 15
43 PM"
src="https://github.com/user-attachments/assets/890b7a0b-740f-4c7f-9a48-c9a2c28e8ded"
/>
This commit is contained in:
EthanHealy01
2026-06-11 20:43:33 +01:00
committed by GitHub
parent ee9fdeed6b
commit 41d2aa8174
17 changed files with 448 additions and 150 deletions
@@ -20,17 +20,18 @@
--cc-separator-border-color: #e0e0e0;
--cc-toggle-on-bg: #007bff;
--cc-toggle-off-bg: #667481;
--cc-toggle-on-knob-bg: #ffffff;
--cc-toggle-off-knob-bg: #ffffff;
/* Toggle colors mirror Mantine Switch (light scheme) */
--cc-toggle-on-bg: var(--mantine-primary-color-filled, #007bff);
--cc-toggle-off-bg: var(--mantine-color-gray-3, #dee2e6);
--cc-toggle-on-knob-bg: var(--mantine-color-white, #ffffff);
--cc-toggle-off-knob-bg: var(--mantine-color-white, #ffffff);
--cc-toggle-enabled-icon-color: #ffffff;
--cc-toggle-disabled-icon-color: #ffffff;
--cc-toggle-readonly-bg: #f1f3f4;
--cc-toggle-readonly-knob-bg: #79747e;
--cc-toggle-readonly-knob-icon-color: #f1f3f4;
--cc-toggle-readonly-bg: var(--mantine-color-disabled, #f1f3f4);
--cc-toggle-readonly-knob-bg: var(--mantine-color-gray-0, #f8f9fa);
--cc-toggle-readonly-knob-icon-color: transparent;
--cc-section-category-border: #e0e0e0;
@@ -69,17 +70,18 @@
--cc-separator-border-color: #555555;
--cc-toggle-on-bg: #4dabf7;
--cc-toggle-off-bg: #667481;
--cc-toggle-on-knob-bg: #2d2d2d;
--cc-toggle-off-knob-bg: #2d2d2d;
/* Toggle colors mirror Mantine Switch (dark scheme) */
--cc-toggle-on-bg: var(--mantine-primary-color-filled, #4dabf7);
--cc-toggle-off-bg: var(--mantine-color-dark-5, #555555);
--cc-toggle-on-knob-bg: var(--mantine-color-white, #ffffff);
--cc-toggle-off-knob-bg: var(--mantine-color-white, #ffffff);
--cc-toggle-enabled-icon-color: #2d2d2d;
--cc-toggle-disabled-icon-color: #2d2d2d;
--cc-toggle-readonly-bg: #555555;
--cc-toggle-readonly-knob-bg: #8e8e8e;
--cc-toggle-readonly-knob-icon-color: #555555;
--cc-toggle-readonly-bg: var(--mantine-color-disabled, #555555);
--cc-toggle-readonly-knob-bg: var(--mantine-color-dark-3, #8e8e8e);
--cc-toggle-readonly-knob-icon-color: transparent;
--cc-section-category-border: #555555;
@@ -176,9 +178,16 @@
color: var(--cc-primary-color) !important;
}
/* Lower z-index so cookie banner appears behind onboarding modals */
/* Banner sits above the chat FAB but behind all modals and onboarding; value
is Z_INDEX_COOKIE_CONSENT_BANNER, set as this variable by useCookieConsent */
#cc-main {
z-index: 100 !important;
z-index: var(--z-index-cookie-consent) !important;
}
/* Preferences dialog sits above the settings modal it opens from; value is
Z_INDEX_COOKIE_PREFERENCES_MODAL, set as this variable by useCookieConsent */
.show--preferences #cc-main {
z-index: var(--z-index-cookie-preferences) !important;
}
/* Ensure consent modal text is visible in both themes */
@@ -203,3 +212,63 @@
#cc-main .cm__link {
color: var(--cc-primary-color) !important;
}
/* ── Category toggles restyled to match Mantine Switch (size sm) ──────────
Mantine sm metrics: 38×20 track, 14px plain thumb, 2.5px inline padding,
150ms ease transitions, no icon inside the thumb. Colors come from the
--cc-toggle-* variables above, which point at the Mantine palette. */
#cc-main .section__toggle,
#cc-main .section__toggle-wrapper,
#cc-main .toggle__icon,
#cc-main .toggle__label {
width: 38px !important;
height: 20px !important;
border-radius: 1000px !important;
}
/* Track: flat fill, no outline ring or border */
#cc-main .toggle__icon {
border: none !important;
box-shadow: none !important;
transition: background-color 150ms ease !important;
}
#cc-main .section__toggle:checked ~ .toggle__icon {
border: none !important;
box-shadow: none !important;
}
/* Always-enabled categories = Mantine disabled switch (must out-prioritise
the !important checked-track rule above) */
#cc-main .section__toggle:checked:disabled ~ .toggle__icon {
background: var(--cc-toggle-readonly-bg) !important;
border: none !important;
box-shadow: none !important;
}
#cc-main .section__toggle:disabled {
cursor: not-allowed !important;
}
/* Thumb: small plain circle, vertically centred, no drop shadow */
#cc-main .toggle__icon-circle {
width: 14px !important;
height: 14px !important;
top: 3px !important;
left: 2.5px !important;
box-shadow: none !important;
transition:
transform 150ms ease,
background-color 150ms ease !important;
}
/* Checked thumb travel: 38 14 2.5 = 21.5px end position */
#cc-main .section__toggle:checked ~ .toggle__icon .toggle__icon-circle {
transform: translateX(19px) !important;
}
/* Mantine switches have no check/cross glyph inside the thumb */
#cc-main .toggle__icon-on,
#cc-main .toggle__icon-off {
display: none !important;
}
@@ -5860,6 +5860,10 @@ subscribeToPro = "Subscribe to Pro"
deleteConfirmBody = "This removes the policy and its workflow. Documents already processed are not affected."
deleteConfirmTitle = "Delete {{label}} policy?"
[policies]
deleteConfirmBody = "This removes the policy and its workflow. Documents already processed are not affected."
deleteConfirmTitle = "Delete {{label}} policy?"
[printFile]
title = "Print File"
@@ -6803,6 +6807,18 @@ title = "Keyboard Shortcuts"
mac = "Include ⌘ (Command), ⌥ (Option), or another modifier in your shortcut."
windows = "Include Ctrl, Alt, or another modifier in your shortcut."
[settings.legal]
label = "Legal"
title = "Legal"
[settings.legal.cookiePreferences]
description = "Review or change your cookie consent choices."
manage = "Manage"
[settings.legal.documents]
description = "Policies and legal information for this service."
title = "Legal Documents"
[settings.licensingAnalytics]
audit = "Audit"
plan = "Plan"
@@ -7490,10 +7506,6 @@ title = "Upload to Server"
updateButton = "Update on Server"
uploadButton = "Upload to Server"
[survey]
nav = "Survey"
title = "Stirling-PDF Survey"
[swagger]
tags = "api,documentation,swagger,endpoints,development"
title = "API Documentation"
@@ -11,11 +11,11 @@ import {
import { isBaseWorkbench } from "@app/types/workbench";
import { VIEWER_SUPPORTED_EXTENSIONS } from "@app/utils/fileUtils";
import { useAppConfig } from "@app/contexts/AppConfigContext";
import { useCookieConsent } from "@app/hooks/useCookieConsent";
import styles from "@app/components/layout/Workbench.module.css";
import WorkbenchBar from "@app/components/shared/WorkbenchBar";
import LandingPage from "@app/components/shared/LandingPage";
import Footer from "@app/components/shared/Footer";
import DismissAllErrorsButton from "@app/components/shared/DismissAllErrorsButton";
import { ChatFAB } from "@app/components/chat/ChatFAB";
@@ -37,6 +37,10 @@ export default function Workbench() {
const { isRainbowMode } = useRainbowThemeContext();
const { config } = useAppConfig();
// The consent banner used to be initialised by the footer; the legal links
// now live in Settings → Legal, so the workbench owns the banner lifecycle.
useCookieConsent({ analyticsEnabled: config?.enableAnalytics === true });
// Use context-based hooks to eliminate all prop drilling
const { selectors } = useFileState();
const { workbench: currentView } = useNavigationState();
@@ -252,15 +256,6 @@ export default function Workbench() {
{renderMainContent()}
</Suspense>
</Box>
<Footer
analyticsEnabled={config?.enableAnalytics === true}
termsAndConditions={config?.termsAndConditions}
privacyPolicy={config?.privacyPolicy}
cookiePolicy={config?.cookiePolicy}
impressum={config?.impressum}
accessibilityStatement={config?.accessibilityStatement}
/>
</Box>
);
}
@@ -12,6 +12,7 @@ import LocalIcon from "@app/components/shared/LocalIcon";
import { useConfigNavSections } from "@app/components/shared/config/configNavSections";
import { NavKey, VALID_NAV_KEYS } from "@app/components/shared/config/types";
import { useAppConfig } from "@app/contexts/AppConfigContext";
import { COOKIE_CONSENT_SCROLL_SHARD } from "@app/hooks/useCookieConsent";
import "@app/components/shared/AppConfigModal.css";
import { useIsMobile } from "@app/hooks/useIsMobile";
import {
@@ -216,6 +217,7 @@ const AppConfigModalInner: React.FC<AppConfigModalProps> = ({
padding={0}
fullScreen={isMobile}
styles={{ content: { overflowY: "hidden", overscrollBehavior: "none" } }}
removeScrollProps={{ shards: [COOKIE_CONSENT_SCROLL_SHARD] }}
>
<div className="modal-container">
{/* Left navigation */}
@@ -77,15 +77,6 @@ export default function Footer({
color: forceLightMode ? "#495057" : undefined,
}}
>
<a
className="footer-link px-3"
id="survey"
target="_blank"
rel="noopener noreferrer"
href="https://stirlingpdf.info/s/cm28y3niq000o56dv7liv8wsu"
>
{t("survey.nav", "Survey")}
</a>
<a
className="footer-link px-3"
target="_blank"
@@ -4,6 +4,7 @@ import { NavKey } from "@app/components/shared/config/types";
import HotkeysSection from "@app/components/shared/config/configSections/HotkeysSection";
import GeneralSection from "@app/components/shared/config/configSections/GeneralSection";
import HelpSection from "@app/components/shared/config/configSections/HelpSection";
import LegalSection from "@app/components/shared/config/configSections/LegalSection";
export interface ConfigNavItem {
key: NavKey;
@@ -70,6 +71,17 @@ export const useConfigNavSections = (
},
],
},
{
title: t("settings.legal.title", "Legal"),
items: [
{
key: "legal",
label: t("settings.legal.label", "Legal"),
icon: "gavel-rounded",
component: <LegalSection />,
},
],
},
];
return sections;
@@ -0,0 +1,143 @@
import React from "react";
import { Anchor, Button, Group, Paper, Stack, Text } from "@mantine/core";
import { useTranslation } from "react-i18next";
import LocalIcon from "@app/components/shared/LocalIcon";
import { useAppConfig } from "@app/contexts/AppConfigContext";
import { useFooterInfo } from "@app/hooks/useFooterInfo";
import { useCookieConsent } from "@app/hooks/useCookieConsent";
interface LegalLink {
key: string;
label: string;
href: string;
}
const LegalSection: React.FC = () => {
const { t } = useTranslation();
const { config } = useAppConfig();
const { footerInfo } = useFooterInfo();
const analyticsEnabled =
config?.enableAnalytics ?? footerInfo?.analyticsEnabled ?? false;
const privacyPolicy = config?.privacyPolicy ?? footerInfo?.privacyPolicy;
const termsAndConditions =
config?.termsAndConditions ?? footerInfo?.termsAndConditions;
const accessibilityStatement =
config?.accessibilityStatement ?? footerInfo?.accessibilityStatement;
const cookiePolicy = config?.cookiePolicy ?? footerInfo?.cookiePolicy;
const impressum = config?.impressum ?? footerInfo?.impressum;
const { showCookiePreferences } = useCookieConsent({
analyticsEnabled: analyticsEnabled === true,
});
const isValidLink = (link?: string) => link && link.trim().length > 0;
const legalLinks: LegalLink[] = [
{
key: "privacy",
label: t("legal.privacy", "Privacy Policy"),
href: isValidLink(privacyPolicy)
? privacyPolicy!
: "https://www.stirling.com/privacy",
},
{
key: "terms",
label: t("legal.terms", "Terms and Conditions"),
href: isValidLink(termsAndConditions)
? termsAndConditions!
: "https://www.stirling.com/terms",
},
...(isValidLink(accessibilityStatement)
? [
{
key: "accessibility",
label: t("legal.accessibility", "Accessibility"),
href: accessibilityStatement!,
},
]
: []),
...(isValidLink(cookiePolicy)
? [
{
key: "cookie",
label: t("legal.cookie", "Cookie Policy"),
href: cookiePolicy!,
},
]
: []),
...(isValidLink(impressum)
? [
{
key: "impressum",
label: t("legal.impressum", "Impressum"),
href: impressum!,
},
]
: []),
];
const renderLink = (link: LegalLink) => (
<Anchor
key={link.key}
href={link.href}
target="_blank"
rel="noopener noreferrer"
size="sm"
>
<Group gap={6} wrap="nowrap">
{link.label}
<LocalIcon icon="open-in-new-rounded" width="0.9rem" height="0.9rem" />
</Group>
</Anchor>
);
return (
<Stack gap="lg">
<Paper withBorder p="md" radius="md">
<Stack gap="md">
<div>
<Text fw={600} size="sm">
{t("settings.legal.documents.title", "Legal Documents")}
</Text>
<Text size="xs" c="dimmed" mt={4}>
{t(
"settings.legal.documents.description",
"Policies and legal information for this service.",
)}
</Text>
</div>
<Stack gap="sm">{legalLinks.map(renderLink)}</Stack>
</Stack>
</Paper>
{analyticsEnabled === true && (
<Paper withBorder p="md" radius="md">
<Group justify="space-between" align="center">
<div>
<Text fw={600} size="sm">
{t("legal.showCookieBanner", "Cookie Preferences")}
</Text>
<Text size="xs" c="dimmed" mt={4}>
{t(
"settings.legal.cookiePreferences.description",
"Review or change your cookie consent choices.",
)}
</Text>
</div>
<Button
variant="default"
size="sm"
id="cookieBanner"
onClick={showCookiePreferences}
>
{t("settings.legal.cookiePreferences.manage", "Manage")}
</Button>
</Group>
</Paper>
)}
</Stack>
);
};
export default LegalSection;
@@ -31,6 +31,7 @@ export const VALID_NAV_KEYS = [
"adminStorageSharing",
"adminMcp",
"help",
"legal",
"payg",
] as const;
@@ -2,6 +2,10 @@ import { useEffect, useState, useCallback } from "react";
import { useTranslation } from "react-i18next";
import { BASE_PATH } from "@app/constants/app";
import { useAppConfig } from "@app/contexts/AppConfigContext";
import {
Z_INDEX_COOKIE_CONSENT_BANNER,
Z_INDEX_COOKIE_PREFERENCES_MODAL,
} from "@app/styles/zIndex";
import { TOUR_STATE_EVENT, type TourStatePayload } from "@app/constants/events";
import { getCookieConsentOverrides } from "@app/extensions/cookieConsentConfig";
@@ -11,6 +15,8 @@ declare global {
run: (config: any) => void;
show: (show?: boolean) => void;
hide: () => void;
showPreferences: () => void;
hidePreferences: () => void;
getCookie: (name?: string) => any;
acceptedCategory: (category: string) => boolean;
acceptedService: (serviceName: string, category: string) => boolean;
@@ -23,6 +29,16 @@ interface CookieConsentConfig {
forceLightMode?: boolean;
}
// Shard so Mantine's scroll-lock doesn't swallow events on the consent dialog;
// lazy because #cc-main only exists post-load.
export const COOKIE_CONSENT_SCROLL_SHARD = {
get current(): HTMLElement | null {
return typeof document === "undefined"
? null
: document.getElementById("cc-main");
},
};
export const useCookieConsent = ({
analyticsEnabled = false,
forceLightMode = false,
@@ -34,6 +50,16 @@ export const useCookieConsent = ({
useEffect(() => {
if (!analyticsEnabled) return;
// Bridge the layering constants to the static cookie-consent stylesheets
document.documentElement.style.setProperty(
"--z-index-cookie-consent",
String(Z_INDEX_COOKIE_CONSENT_BANNER),
);
document.documentElement.style.setProperty(
"--z-index-cookie-preferences",
String(Z_INDEX_COOKIE_PREFERENCES_MODAL),
);
const mainCSS = document.createElement("link");
mainCSS.rel = "stylesheet";
mainCSS.href = `${BASE_PATH}/css/cookieconsent.css`;
@@ -391,7 +417,10 @@ export const useCookieConsent = ({
const showCookiePreferences = useCallback(() => {
if (isInitialized && window.CookieConsent) {
window.CookieConsent?.show(true);
// Open the detailed preferences dialog directly (not the consent
// banner) — it gets the `.show--preferences` class, which the CSS
// raises above the settings modal.
window.CookieConsent?.showPreferences();
}
}, [isInitialized]);
@@ -26,6 +26,11 @@ export const Z_INDEX_DRAG_BADGE = 1001;
// Modal that appears on top of config modal (e.g., restart confirmation, update modal)
export const Z_INDEX_OVER_CONFIG_MODAL = 2000;
// Cookie-consent banner — above the chat FAB (1050), below all modals and onboarding; reaches CSS via --z-index-cookie-consent
export const Z_INDEX_COOKIE_CONSENT_BANNER = 1060;
// Cookie-consent preferences dialog — above the config modal it opens from; reaches CSS via --z-index-cookie-preferences
export const Z_INDEX_COOKIE_PREFERENCES_MODAL = 1450;
// Sign-in modal — must appear above all app UI including config and analytics modals
export const Z_INDEX_SIGN_IN_MODAL = 9000;
@@ -1,103 +1,107 @@
import { test, expect } from "@app/tests/helpers/stub-test-base";
import { openSettings } from "@app/tests/helpers/ui-helpers";
test.describe("18. Cookie Preferences", () => {
test.describe("18.1 Cookie Banner", () => {
test("should open and configure cookie preferences from footer", async ({
test("should open and configure cookie preferences from Settings → Legal", async ({
page,
}) => {
// Step 1: Locate the "Cookie Preferences" button in the footer
const cookieButton = page
.locator(
'#cookieBanner, button:has-text("Preferensi Cookie"), button:has-text("Cookie Preferences")',
)
.first();
// Analytics must be enabled for the Cookie Preferences button to render
await page.route("**/api/v1/ui-data/footer-info", (route) =>
route.fulfill({ json: { analyticsEnabled: true } }),
);
await page.goto("/");
await page.waitForTimeout(1000);
const isVisible = await cookieButton.isVisible().catch(() => false);
if (!isVisible) {
test.skip(
true,
"Cookie Preferences button not visible — analytics may be disabled",
);
return;
}
// Step 1: The "Cookie Preferences" button lives in Settings → Legal
await openSettings(page);
const legalNav = page.locator('[data-tour="admin-legal-nav"]').first();
await expect(legalNav).toBeVisible({ timeout: 5000 });
await legalNav.click();
await expect(cookieButton).toBeVisible();
const cookieButton = page.locator("#cookieBanner").first();
await expect(cookieButton).toBeVisible({ timeout: 10000 });
// Step 2: Click the Cookie Preferences button
await cookieButton.click({ force: true });
// The consent library lazy-loads when the Legal section mounts
await page.waitForFunction(
() => (window as unknown as { CookieConsent?: unknown }).CookieConsent,
{ timeout: 10000 },
);
await page.waitForTimeout(500);
// Step 3: Verify the cookie consent dialog opens
// The CookieConsent library renders inside #cc-main
// Step 2: Click the button — it opens the detailed preferences dialog
// directly. No force: the click must land with the settings modal open,
// proving the dialog stacks above it.
await cookieButton.click();
// Step 3: Verify the preferences dialog opens inside #cc-main
const ccMain = page.locator("#cc-main");
const consentDialog = ccMain.getByRole("dialog").first();
await expect(consentDialog).toBeVisible({ timeout: 5000 });
const prefsDialog = ccMain.locator(".pm").first();
await expect(prefsDialog).toBeVisible({ timeout: 5000 });
// Step 4: Verify options are available
// The initial consent view shows: "Oke", "Tidak, terima kasih", "Kelola preferensi"
const okeBtn = ccMain
.locator('button:has-text("Oke"), button:has-text("OK")')
.first();
const noThanksBtn = ccMain
.locator(
'button:has-text("Tidak, terima kasih"), button:has-text("No Thanks")',
)
.first();
const manageBtn = ccMain
.locator(
'button:has-text("Kelola preferensi"), button:has-text("Manage preferences")',
)
// Step 4: Verify the dialog sits above the settings modal
// (Z_INDEX_CONFIG_MODAL = 1400)
const zIndex = await page.evaluate(() => {
const el = document.querySelector("#cc-main");
return el ? Number(getComputedStyle(el).zIndex) : -1;
});
expect(zIndex).toBeGreaterThan(1400);
// Step 5: Verify the preferences panel shows cookie categories
const necessaryCategory = ccMain
.locator("text=/Strictly Necessary|necessary/i")
.first();
const analyticsCategory = ccMain.locator("text=/Analytics/i").first();
await expect(necessaryCategory.or(analyticsCategory).first()).toBeVisible(
{ timeout: 3000 },
);
const hasOke = await okeBtn.isVisible().catch(() => false);
const hasNoThanks = await noThanksBtn.isVisible().catch(() => false);
const hasManage = await manageBtn.isVisible().catch(() => false);
expect(hasOke || hasNoThanks || hasManage).toBe(true);
// Step 5: Click "Kelola preferensi" to open the detailed preferences panel
if (hasManage) {
await manageBtn.click();
await page.waitForTimeout(500);
// Verify the preferences panel shows cookie categories
const necessaryCategory = ccMain
.locator(
"text=/Cookie yang Sangat Diperlukan|Strictly Necessary|necessary/i",
)
.first();
const analyticsCategory = ccMain
.locator("text=/Analitik|Analytics/i")
.first();
await expect(
necessaryCategory.or(analyticsCategory).first(),
).toBeVisible({ timeout: 3000 });
// Click "Terima semua" (Accept all) or "Simpan preferensi" (Save preferences) to close
const acceptAllBtn = ccMain
.locator(
'button:has-text("Terima semua"), button:has-text("Accept all")',
)
.first();
const savePrefBtn = ccMain
.locator(
'button:has-text("Simpan preferensi"), button:has-text("Save preferences")',
)
.first();
if (await acceptAllBtn.isVisible().catch(() => false)) {
await acceptAllBtn.click();
} else if (await savePrefBtn.isVisible().catch(() => false)) {
await savePrefBtn.click();
}
} else if (hasOke) {
await okeBtn.click();
} else if (hasNoThanks) {
await noThanksBtn.click();
// Step 6: Expand all sections and verify the dialog body wheel-scrolls
// while the settings modal is open (regression: the modal's
// react-remove-scroll lock swallowed wheel events on the dialog)
const expanders = ccMain.locator(
".pm__section--expandable .pm__section-title",
);
const expanderCount = await expanders.count();
for (let i = 0; i < expanderCount; i++) {
await expanders.nth(i).click();
}
const dialogBody = ccMain.locator(".pm__body").first();
const canScroll = await dialogBody.evaluate(
(el) => el.scrollHeight > el.clientHeight,
);
if (canScroll) {
const box = await dialogBody.boundingBox();
await page.mouse.move(
box!.x + box!.width / 2,
box!.y + box!.height / 2,
);
await page.mouse.wheel(0, 300);
await expect
.poll(() => dialogBody.evaluate((el) => el.scrollTop), {
timeout: 3000,
})
.toBeGreaterThan(0);
await dialogBody.evaluate((el) => {
el.scrollTop = 0;
});
}
// Step 6: Verify the dialog is dismissed
await expect(consentDialog).toBeHidden({ timeout: 5000 });
// Step 7: Accept all (or save) to close — again without force
const acceptAllBtn = ccMain
.locator('button:has-text("Accept all")')
.first();
const savePrefBtn = ccMain
.locator('button:has-text("Save preferences")')
.first();
if (await acceptAllBtn.isVisible().catch(() => false)) {
await acceptAllBtn.click();
} else {
await savePrefBtn.click();
}
// Step 8: Verify the dialog is dismissed
await expect(prefsDialog).toBeHidden({ timeout: 5000 });
});
});
});
@@ -1,5 +1,6 @@
import { test, expect } from "@app/tests/helpers/stub-test-base";
import { mockAppApis, seedCookieConsent } from "@app/tests/helpers/api-stubs";
import { openSettings } from "@app/tests/helpers/ui-helpers";
test.describe("2. Main Dashboard / Home Page", () => {
test.beforeEach(async ({ page }) => {
@@ -87,33 +88,29 @@ test.describe("2. Main Dashboard / Home Page", () => {
});
});
test.describe("2.4 Dashboard - Footer Links", () => {
test("should display footer links with correct URLs", async ({ page }) => {
await expect(page.getByText("Survey").first()).toBeVisible({
timeout: 10000,
});
test.describe("2.4 Dashboard - Legal Links", () => {
test("legal links live in Settings → Legal, not in a footer", async ({
page,
}) => {
await expect(
page.locator('[data-testid="config-button"]').first(),
).toBeVisible({ timeout: 10000 });
// The dashboard footer (survey + legal links) was removed
await expect(page.locator(".footer-link")).toHaveCount(0);
await expect(page.getByText("Survey")).toHaveCount(0);
await openSettings(page);
const legalNav = page.locator('[data-tour="admin-legal-nav"]').first();
await expect(legalNav).toBeVisible({ timeout: 10000 });
await legalNav.click();
await expect(page.getByText("Privacy Policy").first()).toBeVisible({
timeout: 10000,
});
await expect(page.getByText(/Terms/i).first()).toBeVisible({
timeout: 10000,
});
await expect(page.getByText("Discord").first()).toBeVisible({
timeout: 10000,
});
await expect(page.getByText("GitHub").first()).toBeVisible({
timeout: 10000,
});
const githubLink = page
.locator('a[href*="github.com/Stirling-Tools/Stirling-PDF"]')
.first();
await expect(githubLink).toBeVisible();
const discordLink = page
.locator('a[href*="discord.gg/Cn8pWhQRxZ"]')
.first();
await expect(discordLink).toBeVisible();
});
});
@@ -68,12 +68,16 @@ export const useConfigNavSections = (
],
};
// In local mode only show Preferences + Connection Mode — everything else
// requires a server and will 500 or show irrelevant admin UI.
// In local mode only show Preferences + Connection Mode + Legal — everything
// else requires a server and will 500 or show irrelevant admin UI.
if (isLocalMode) {
const result: ConfigNavSection[] = [];
if (sections.length > 0) result.push(sections[0]);
result.push(connectionModeSection);
const legalSection = sections.find((section) =>
section.items.some((item) => item.key === "legal"),
);
if (legalSection) result.push(legalSection);
return result;
}
@@ -12,7 +12,7 @@
.chat-fab-trigger {
position: absolute;
right: 16px;
bottom: calc(var(--footer-height, 2rem) + 16px);
bottom: 16px;
pointer-events: auto;
opacity: 1;
/* Include box-shadow so the ChatFABButton hover shadow still animates */
@@ -15,8 +15,7 @@ const PANEL_HEIGHT_PX = 520;
const PANEL_MIN_WIDTH_PX = 300;
const PANEL_MIN_HEIGHT_PX = 380;
const FAB_GAP_PX = 16;
// footer-height (2rem = 32px) + gap so the panel clears the footer
const FAB_BOTTOM_OFFSET_PX = 32 + FAB_GAP_PX;
const FAB_BOTTOM_OFFSET_PX = FAB_GAP_PX;
const RESET_MS = 380;
const RESET_EASING = "cubic-bezier(0.32, 0.72, 0, 1)";
@@ -9,6 +9,7 @@ import Overview from "@app/components/shared/config/configSections/Overview";
import { createSaasConfigNavSections } from "@app/components/shared/config/saasConfigNavSections";
import { NavKey } from "@app/components/shared/config/types";
import { withBasePath } from "@app/constants/app";
import { COOKIE_CONSENT_SCROLL_SHARD } from "@app/hooks/useCookieConsent";
import "@app/components/shared/AppConfigModal.css";
import {
Z_INDEX_OVER_FULLSCREEN_SURFACE,
@@ -167,6 +168,7 @@ const AppConfigModal: React.FC<AppConfigModalProps> = ({ opened, onClose }) => {
overlayProps={{ opacity: 0.35, blur: 2 }}
padding={0}
fullScreen={isMobile}
removeScrollProps={{ shards: [COOKIE_CONSENT_SCROLL_SHARD] }}
// Hidden (not closed) while a child overlay like the PAYG UpgradeModal
// is up — see the appConfig:overlay listener above. The focus trap and
// escape/outside-close must release too: the trap would steal focus
@@ -10,6 +10,7 @@ import PasswordSecurity from "@app/components/shared/config/configSections/Passw
import ApiKeys from "@app/components/shared/config/configSections/ApiKeys";
import Plan from "@app/components/shared/config/configSections/Plan";
import McpSection from "@app/components/shared/config/configSections/McpSection";
import LegalSection from "@app/components/shared/config/configSections/LegalSection";
import TeamSection from "@app/components/shared/config/configSections/TeamSection";
type OverviewComponent = React.ComponentType<{ onLogoutClick: () => void }>;
@@ -148,6 +149,36 @@ function appendMcpSection(
);
}
// Legal links (privacy policy, terms, etc.). Shown to anonymous users too —
// it's public information.
function appendLegalSection(
sections: ConfigNavSection[],
t: TFunction<"translation", undefined>,
): ConfigNavSection[] {
const hasLegal = sections.some((section) =>
section.items.some((item) => item.key === "legal"),
);
if (hasLegal) {
return sections;
}
return [
...sections,
{
title: t("settings.legal.title", "Legal"),
items: [
{
key: "legal" as const,
label: t("settings.legal.label", "Legal"),
icon: "gavel-rounded",
component: <LegalSection />,
},
],
},
];
}
export function createSaasConfigNavSections(
Overview: OverviewComponent,
onLogoutClick: () => void,
@@ -209,6 +240,8 @@ export function createSaasConfigNavSections(
sections = appendBillingSection(sections, t);
}
sections = appendLegalSection(sections, t);
if (isDev) {
console.debug("[AppConfigModal] SaaS navigation sections", sections);
}