From e62ab7f1292c6673bd490d8a15626748b2b0ac22 Mon Sep 17 00:00:00 2001 From: "helpcode.ai" <340500827+helpcodeai@users.noreply.github.com> Date: Mon, 12 Oct 2026 17:29:03 +0200 Subject: [PATCH 1/2] Show the workspace's own footer links to its members Admins save footer links per workspace (org_settings), but the footer read only the instance-wide list from the public route, so on Cloud the links a workspace admin saved never appeared. A new GET /api/settings/footer-links (any signed-in member) returns the caller's workspace links with the instance-wide ones as fallback; the footer uses it when signed in, falls back to the public list on error, and reloads after an admin saves. --- .../backend/src/settings/settings.module.ts | 13 +++- .../settings/site-settings.controller.spec.ts | 33 +++++++++- .../src/settings/site-settings.controller.ts | 27 ++++++++ .../frontend/src/app/admin/settings/page.tsx | 3 +- .../frontend/src/app/settings/admin/page.tsx | 3 +- packages/frontend/src/components/footer.tsx | 22 ++++++- packages/frontend/src/lib/api.ts | 6 ++ .../frontend/tests/e2e/footer-links.spec.ts | 62 +++++++++++++++++++ 8 files changed, 161 insertions(+), 8 deletions(-) create mode 100644 packages/frontend/tests/e2e/footer-links.spec.ts diff --git a/packages/backend/src/settings/settings.module.ts b/packages/backend/src/settings/settings.module.ts index 472ef1d8e..c27858ee8 100644 --- a/packages/backend/src/settings/settings.module.ts +++ b/packages/backend/src/settings/settings.module.ts @@ -2,12 +2,21 @@ import { Module } from '@nestjs/common'; import { SiteSettingsService } from './site-settings.service'; import { OrgSettingsService } from './org-settings.service'; import { EmailService } from './email.service'; -import { SiteSettingsPublicController, SiteSettingsAdminController } from './site-settings.controller'; +import { + SiteSettingsPublicController, + SiteSettingsMemberController, + SiteSettingsAdminController, +} from './site-settings.controller'; import { SsrfPolicyService } from '../common/ssrf-policy.service'; import { UnsubscribeController } from './unsubscribe.controller'; @Module({ - controllers: [SiteSettingsPublicController, SiteSettingsAdminController, UnsubscribeController], + controllers: [ + SiteSettingsPublicController, + SiteSettingsMemberController, + SiteSettingsAdminController, + UnsubscribeController, + ], providers: [SiteSettingsService, OrgSettingsService, EmailService, SsrfPolicyService], exports: [SiteSettingsService, OrgSettingsService, EmailService, SsrfPolicyService], }) diff --git a/packages/backend/src/settings/site-settings.controller.spec.ts b/packages/backend/src/settings/site-settings.controller.spec.ts index 7533ee8cb..50646e981 100644 --- a/packages/backend/src/settings/site-settings.controller.spec.ts +++ b/packages/backend/src/settings/site-settings.controller.spec.ts @@ -1,6 +1,6 @@ import { GUARDS_METADATA } from '@nestjs/common/constants'; import { NotFoundException } from '@nestjs/common'; -import { SiteSettingsAdminController } from './site-settings.controller'; +import { SiteSettingsAdminController, SiteSettingsMemberController } from './site-settings.controller'; import { SelfHostedOnlyGuard } from '../common/self-hosted-only.guard'; import { DeploymentService } from '../common/deployment.service'; @@ -24,3 +24,34 @@ describe('SiteSettingsAdminController SSRF allowlist routes', () => { expect(selfHosted.canActivate({} as any)).toBe(true); }); }); + +// Admins save footer links per workspace, but the footer read only the +// instance-wide list, so on Cloud a workspace's own links never showed. +describe('SiteSettingsMemberController footer links', () => { + it("returns the caller's workspace links, or the fallback the service gives", async () => { + const orgSettings = { + getFooterLinks: jest.fn(async (orgId: string) => + orgId === 'org1' ? [{ label: 'Privacy', url: 'https://acme.test/privacy' }] : null, + ), + }; + const controller = new SiteSettingsMemberController(orgSettings as any); + await expect(controller.getFooterLinks({ user: { organizationId: 'org1' } })).resolves.toEqual([ + { label: 'Privacy', url: 'https://acme.test/privacy' }, + ]); + await expect(controller.getFooterLinks({ user: { organizationId: 'org2' } })).resolves.toEqual([]); + expect(orgSettings.getFooterLinks).toHaveBeenCalledWith('org1'); + }); + + it('answers an empty list without a workspace on the token', async () => { + const orgSettings = { getFooterLinks: jest.fn() }; + const controller = new SiteSettingsMemberController(orgSettings as any); + await expect(controller.getFooterLinks({ user: {} })).resolves.toEqual([]); + expect(orgSettings.getFooterLinks).not.toHaveBeenCalled(); + }); + + it('requires a signed-in user, with no role restriction', () => { + const guards = Reflect.getMetadata(GUARDS_METADATA, SiteSettingsMemberController) ?? []; + expect(guards).toHaveLength(1); + expect(Reflect.getMetadata('roles', SiteSettingsMemberController)).toBeUndefined(); + }); +}); diff --git a/packages/backend/src/settings/site-settings.controller.ts b/packages/backend/src/settings/site-settings.controller.ts index 34fef43f0..db11e6478 100644 --- a/packages/backend/src/settings/site-settings.controller.ts +++ b/packages/backend/src/settings/site-settings.controller.ts @@ -104,6 +104,33 @@ export class SiteSettingsPublicController { } } +// ── Member endpoints ──────────────────────────────────────────────────────── + +/** + * The footer of a signed-in member shows their workspace's links. Admins save + * them per workspace (PUT /api/admin/settings/footer-links), but the footer + * used to read only the instance-wide list from the public route above, so on + * Cloud a workspace's own links never appeared. Any member may read them: they + * are shown to everyone in the workspace anyway. + */ +@ApiTags('Site Settings') +@ApiBearerAuth() +@UseGuards(AuthGuard('jwt')) +@Controller('api/settings') +export class SiteSettingsMemberController { + constructor(private readonly orgSettings: OrgSettingsService) {} + + @Get('footer-links') + @ApiOperation({ + summary: "Footer links of the caller's workspace, falling back to the instance-wide links", + }) + async getFooterLinks(@Req() req: any) { + const orgId = req.user?.organizationId; + if (!orgId) return []; + return (await this.orgSettings.getFooterLinks(orgId)) || []; + } +} + // ── Admin endpoints ───────────────────────────────────────────────────────── @ApiTags('Site Settings') diff --git a/packages/frontend/src/app/admin/settings/page.tsx b/packages/frontend/src/app/admin/settings/page.tsx index 9338b802c..e6d1492d9 100644 --- a/packages/frontend/src/app/admin/settings/page.tsx +++ b/packages/frontend/src/app/admin/settings/page.tsx @@ -2,7 +2,7 @@ import { useState, useEffect } from 'react'; import { useAuth } from '@/lib/auth-context'; -import { adminSettings } from '@/lib/api'; +import { adminSettings, FOOTER_LINKS_CHANGED } from '@/lib/api'; import { AppShell } from '@/components/app-shell'; import { Button } from '@/components/ui/button'; import { Card } from '@/components/ui/card'; @@ -125,6 +125,7 @@ export default function AdminSettingsPage() { if (!token) return; try { await adminSettings.updateFooterLinks(footerLinks.filter(l => l.label && l.url), token); + window.dispatchEvent(new Event(FOOTER_LINKS_CHANGED)); setFooterMsg('Footer links saved'); setTimeout(() => setFooterMsg(''), 3000); } catch (err: any) { diff --git a/packages/frontend/src/app/settings/admin/page.tsx b/packages/frontend/src/app/settings/admin/page.tsx index e947259c2..3bae5f5ee 100644 --- a/packages/frontend/src/app/settings/admin/page.tsx +++ b/packages/frontend/src/app/settings/admin/page.tsx @@ -3,7 +3,7 @@ import { useState, useEffect } from 'react'; import Link from 'next/link'; import { useAuth } from '@/lib/auth-context'; -import { adminSettings, type AlertWebhookInput } from '@/lib/api'; +import { adminSettings, type AlertWebhookInput, FOOTER_LINKS_CHANGED } from '@/lib/api'; import { Button } from '@/components/ui/button'; import { Card } from '@/components/ui/card'; import { AppSelect } from '@/components/ui/select'; @@ -207,6 +207,7 @@ export default function SettingsAdminPage() { if (!token) return; try { await adminSettings.updateFooterLinks(footerLinks.filter(l => l.label && l.url), token); + window.dispatchEvent(new Event(FOOTER_LINKS_CHANGED)); setFooterMsg('Footer links saved'); setTimeout(() => setFooterMsg(''), 3000); } catch (err: any) { diff --git a/packages/frontend/src/components/footer.tsx b/packages/frontend/src/components/footer.tsx index db94c3fbb..2041e3209 100644 --- a/packages/frontend/src/components/footer.tsx +++ b/packages/frontend/src/components/footer.tsx @@ -2,7 +2,8 @@ import { useState, useEffect } from 'react'; import { useTheme } from '@/lib/theme-context'; -import { siteSettings } from '@/lib/api'; +import { siteSettings, FOOTER_LINKS_CHANGED } from '@/lib/api'; +import { useAuth } from '@/lib/auth-context'; function SunIcon({ size = 16 }: { size?: number }) { return ( @@ -39,11 +40,26 @@ interface FooterLink { export function Footer() { const { theme, setTheme } = useTheme(); + const { token } = useAuth(); const [links, setLinks] = useState([]); + // Signed in: the workspace's own links (what its admin saved). Signed out, + // or if that call fails: the instance-wide links. useEffect(() => { - siteSettings.footerLinks().then(setLinks).catch(() => {}); - }, []); + let live = true; + const load = () => { + const req = token + ? siteSettings.workspaceFooterLinks(token).catch(() => siteSettings.footerLinks()) + : siteSettings.footerLinks(); + req.then((l) => live && setLinks(Array.isArray(l) ? l : [])).catch(() => {}); + }; + load(); + window.addEventListener(FOOTER_LINKS_CHANGED, load); + return () => { + live = false; + window.removeEventListener(FOOTER_LINKS_CHANGED, load); + }; + }, [token]); const themeOptions = [ { value: 'light' as const, label: 'Light', icon: SunIcon }, diff --git a/packages/frontend/src/lib/api.ts b/packages/frontend/src/lib/api.ts index 323f535d3..1f0a3b67a 100644 --- a/packages/frontend/src/lib/api.ts +++ b/packages/frontend/src/lib/api.ts @@ -1014,8 +1014,14 @@ export const publicStats = { export const siteSettings = { footerLinks: () => request>('/api/site-settings/footer-links'), + /** The signed-in member's workspace links (instance-wide ones as fallback). */ + workspaceFooterLinks: (token: string) => + request>('/api/settings/footer-links', { token }), }; +/** Fired after an admin saves the footer links, so the footer reloads them. */ +export const FOOTER_LINKS_CHANGED = 'amcp:footer-links-changed'; + // Admin Settings export const adminSettings = { getSmtp: (token: string) => diff --git a/packages/frontend/tests/e2e/footer-links.spec.ts b/packages/frontend/tests/e2e/footer-links.spec.ts new file mode 100644 index 000000000..b8dee0649 --- /dev/null +++ b/packages/frontend/tests/e2e/footer-links.spec.ts @@ -0,0 +1,62 @@ +import { test, expect, type Page } from '@playwright/test'; + +/** + * Admins save footer links per workspace. The footer used to read only the + * instance-wide list from the public route, so on Cloud a workspace's own links + * never appeared. Signed in, it now asks for the workspace's links. + */ + +const ADMIN = { + id: 'u1', + email: 'admin@example.test', + name: 'Ada Admin', + role: 'ADMIN', + organizationId: 'o1', + emailVerified: true, +}; + +async function session(page: Page, opts: { workspaceLinks: unknown; publicLinks: unknown; workspaceStatus?: number }) { + const origin = test.info().project.use.baseURL ?? 'http://localhost:3100'; + await page.context().addCookies([{ name: 'amcp_token', value: 't', url: origin }]); + await page.addInitScript((user) => { + localStorage.setItem('amcp_token', 't'); + localStorage.setItem('amcp_user', JSON.stringify(user)); + }, ADMIN); + await page.route(/\/(api|health)\//, async (route) => { + const p = new URL(route.request().url()).pathname; + const json = (body: unknown, status = 200) => + route.fulfill({ status, contentType: 'application/json', body: JSON.stringify(body) }); + if (p === '/health/server-info') { + return json({ deploymentMode: 'cloud', mcpAuthMode: 'oauth2', hasUsers: true, registrationEnabled: true, ssoProviders: [] }); + } + if (p.endsWith('/api/settings/footer-links')) return json(opts.workspaceLinks, opts.workspaceStatus ?? 200); + if (p.endsWith('/api/site-settings/footer-links')) return json(opts.publicLinks); + if (p.endsWith('/users/me/onboarding-state')) return json({ onboardingCompletedAt: new Date().toISOString() }); + if (p.endsWith('/users/me')) return json(ADMIN); + if (p.endsWith('/license/status')) return json({ plan: 'cloud_team', status: 'active', expiresAt: null, features: {} }); + if (p.endsWith('/connectors') || p.endsWith('/adapters')) return json([]); + return json({}); + }); +} + +test('a signed-in member sees their workspace footer links', async ({ page }) => { + await session(page, { + workspaceLinks: [{ label: 'Acme privacy', url: 'https://acme.test/privacy' }], + publicLinks: [{ label: 'Instance imprint', url: 'https://instance.test/imprint' }], + }); + await page.goto('/connectors'); + const link = page.getByRole('link', { name: 'Acme privacy' }); + await expect(link).toBeVisible({ timeout: 15_000 }); + await expect(link).toHaveAttribute('href', 'https://acme.test/privacy'); + await expect(page.getByRole('link', { name: 'Instance imprint' })).toHaveCount(0); +}); + +test('falls back to the instance-wide links if the workspace call fails', async ({ page }) => { + await session(page, { + workspaceLinks: { message: 'boom' }, + workspaceStatus: 500, + publicLinks: [{ label: 'Instance imprint', url: 'https://instance.test/imprint' }], + }); + await page.goto('/connectors'); + await expect(page.getByRole('link', { name: 'Instance imprint' })).toBeVisible({ timeout: 15_000 }); +}); From 072e6107172c481e738e0e98a3496cc99957d806 Mon Sep 17 00:00:00 2001 From: "helpcode.ai" <340500827+helpcodeai@users.noreply.github.com> Date: Mon, 12 Oct 2026 17:34:28 +0200 Subject: [PATCH 2/2] Footer links: only https, http and mailto URLs The links a workspace admin saves are now shown on the app's own origin to every member of that workspace, so the URL is limited to web and mail links on save (no javascript:, data: or other schemes), and the footer skips any link saved before this check. --- .../settings/site-settings.controller.spec.ts | 17 +++++++++++++++++ .../src/settings/site-settings.controller.ts | 9 +++++++-- packages/frontend/src/components/footer.tsx | 9 ++++++++- .../frontend/tests/e2e/footer-links.spec.ts | 7 ++++++- 4 files changed, 38 insertions(+), 4 deletions(-) diff --git a/packages/backend/src/settings/site-settings.controller.spec.ts b/packages/backend/src/settings/site-settings.controller.spec.ts index 50646e981..817ab8998 100644 --- a/packages/backend/src/settings/site-settings.controller.spec.ts +++ b/packages/backend/src/settings/site-settings.controller.spec.ts @@ -1,4 +1,6 @@ import { GUARDS_METADATA } from '@nestjs/common/constants'; +import { plainToInstance } from 'class-transformer'; +import { validateSync } from 'class-validator'; import { NotFoundException } from '@nestjs/common'; import { SiteSettingsAdminController, SiteSettingsMemberController } from './site-settings.controller'; import { SelfHostedOnlyGuard } from '../common/self-hosted-only.guard'; @@ -55,3 +57,18 @@ describe('SiteSettingsMemberController footer links', () => { expect(Reflect.getMetadata('roles', SiteSettingsMemberController)).toBeUndefined(); }); }); + +describe('footer link URLs', () => { + // The DTO class is not exported; reach it through the route's metadata. + const [, dtoType] = Reflect.getMetadata('design:paramtypes', SiteSettingsAdminController.prototype, 'updateFooterLinks'); + const errors = (url: string) => + validateSync(plainToInstance(dtoType, { links: [{ label: 'x', url }] })); + + it.each(['https://acme.test/privacy', 'http://intranet.local/imprint', 'mailto:legal@acme.test'])('accepts %s', (url) => { + expect(errors(url)).toHaveLength(0); + }); + + it.each(['javascript:alert(1)', 'data:text/html,hi', 'JaVaScRiPt:alert(1)', '/relative', 'ftp://x'])('refuses %s', (url) => { + expect(errors(url).length).toBeGreaterThan(0); + }); +}); diff --git a/packages/backend/src/settings/site-settings.controller.ts b/packages/backend/src/settings/site-settings.controller.ts index db11e6478..d8d330e46 100644 --- a/packages/backend/src/settings/site-settings.controller.ts +++ b/packages/backend/src/settings/site-settings.controller.ts @@ -11,7 +11,7 @@ import { } from '@nestjs/common'; import { ApiTags, ApiBearerAuth, ApiOperation, ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { AuthGuard } from '@nestjs/passport'; -import { IsString, IsNumber, IsOptional, IsBoolean, IsArray, ValidateNested } from 'class-validator'; +import { IsString, IsNumber, IsOptional, IsBoolean, IsArray, ValidateNested, Matches } from 'class-validator'; import { Type } from 'class-transformer'; import { Roles, RolesGuard } from '../auth/roles.guard'; import { SiteSettingsService } from './site-settings.service'; @@ -62,8 +62,13 @@ class FooterLinkDto { @IsString() label: string; - @ApiProperty({ description: 'Target URL.', example: 'https://example.com/privacy' }) + // Shown as a link on the app's own origin to every member of the workspace, + // so only web and mail links: no javascript:, data: or other schemes. + @ApiProperty({ description: 'Target URL (https://, http:// or mailto:).', example: 'https://example.com/privacy' }) @IsString() + @Matches(/^(https?:\/\/[^\s]+|mailto:[^\s]+)$/i, { + message: 'Footer link URLs must start with https://, http:// or mailto:', + }) url: string; } diff --git a/packages/frontend/src/components/footer.tsx b/packages/frontend/src/components/footer.tsx index 2041e3209..621cec6bc 100644 --- a/packages/frontend/src/components/footer.tsx +++ b/packages/frontend/src/components/footer.tsx @@ -38,6 +38,11 @@ interface FooterLink { url: string; } +/** Links saved before the server checked the scheme: only web and mail links render. */ +function isSafeLink(url: unknown): boolean { + return typeof url === 'string' && /^(https?:\/\/|mailto:)/i.test(url.trim()); +} + export function Footer() { const { theme, setTheme } = useTheme(); const { token } = useAuth(); @@ -51,7 +56,9 @@ export function Footer() { const req = token ? siteSettings.workspaceFooterLinks(token).catch(() => siteSettings.footerLinks()) : siteSettings.footerLinks(); - req.then((l) => live && setLinks(Array.isArray(l) ? l : [])).catch(() => {}); + req + .then((l) => live && setLinks(Array.isArray(l) ? l.filter((x) => isSafeLink(x?.url)) : [])) + .catch(() => {}); }; load(); window.addEventListener(FOOTER_LINKS_CHANGED, load); diff --git a/packages/frontend/tests/e2e/footer-links.spec.ts b/packages/frontend/tests/e2e/footer-links.spec.ts index b8dee0649..cb45234f1 100644 --- a/packages/frontend/tests/e2e/footer-links.spec.ts +++ b/packages/frontend/tests/e2e/footer-links.spec.ts @@ -41,7 +41,10 @@ async function session(page: Page, opts: { workspaceLinks: unknown; publicLinks: test('a signed-in member sees their workspace footer links', async ({ page }) => { await session(page, { - workspaceLinks: [{ label: 'Acme privacy', url: 'https://acme.test/privacy' }], + workspaceLinks: [ + { label: 'Acme privacy', url: 'https://acme.test/privacy' }, + { label: 'Sneaky', url: 'javascript:alert(1)' }, + ], publicLinks: [{ label: 'Instance imprint', url: 'https://instance.test/imprint' }], }); await page.goto('/connectors'); @@ -49,6 +52,8 @@ test('a signed-in member sees their workspace footer links', async ({ page }) => await expect(link).toBeVisible({ timeout: 15_000 }); await expect(link).toHaveAttribute('href', 'https://acme.test/privacy'); await expect(page.getByRole('link', { name: 'Instance imprint' })).toHaveCount(0); + // A link saved before the server checked the scheme is not rendered. + await expect(page.getByRole('link', { name: 'Sneaky' })).toHaveCount(0); }); test('falls back to the instance-wide links if the workspace call fails', async ({ page }) => {