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..817ab8998 100644 --- a/packages/backend/src/settings/site-settings.controller.spec.ts +++ b/packages/backend/src/settings/site-settings.controller.spec.ts @@ -1,6 +1,8 @@ import { GUARDS_METADATA } from '@nestjs/common/constants'; +import { plainToInstance } from 'class-transformer'; +import { validateSync } from 'class-validator'; 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 +26,49 @@ 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(); + }); +}); + +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 34fef43f0..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; } @@ -104,6 +109,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..621cec6bc 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 ( @@ -37,13 +38,35 @@ 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(); 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.filter((x) => isSafeLink(x?.url)) : [])) + .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 9ff83bbef..e6a16a1ac 100644 --- a/packages/frontend/src/lib/api.ts +++ b/packages/frontend/src/lib/api.ts @@ -1038,8 +1038,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..cb45234f1 --- /dev/null +++ b/packages/frontend/tests/e2e/footer-links.spec.ts @@ -0,0 +1,67 @@ +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' }, + { label: 'Sneaky', url: 'javascript:alert(1)' }, + ], + 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); + // 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 }) => { + 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 }); +});