Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 11 additions & 2 deletions packages/backend/src/settings/settings.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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],
})
Expand Down
50 changes: 49 additions & 1 deletion packages/backend/src/settings/site-settings.controller.spec.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -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);
});
});
36 changes: 34 additions & 2 deletions packages/backend/src/settings/site-settings.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -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')
Expand Down
3 changes: 2 additions & 1 deletion packages/frontend/src/app/admin/settings/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -27,7 +27,7 @@
const [footerMsg, setFooterMsg] = useState('');

// SSRF allowlist
const [ssrfHosts, setSsrfHosts] = useState<string[]>([]);

Check warning on line 30 in packages/frontend/src/app/admin/settings/page.tsx

View workflow job for this annotation

GitHub Actions / Frontend (lint, typecheck, build)

'ssrfHosts' is assigned a value but never used. Allowed unused vars must match /^_/u
const [ssrfEnvHosts, setSsrfEnvHosts] = useState<string[]>([]);
const [ssrfDraft, setSsrfDraft] = useState('');
const [ssrfMsg, setSsrfMsg] = useState('');
Expand Down Expand Up @@ -125,6 +125,7 @@
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) {
Expand Down
3 changes: 2 additions & 1 deletion packages/frontend/src/app/settings/admin/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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) {
Expand Down
29 changes: 26 additions & 3 deletions packages/frontend/src/components/footer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -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<FooterLink[]>([]);

// 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 },
Expand Down
6 changes: 6 additions & 0 deletions packages/frontend/src/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1038,8 +1038,14 @@ export const publicStats = {
export const siteSettings = {
footerLinks: () =>
request<Array<{ label: string; url: string }>>('/api/site-settings/footer-links'),
/** The signed-in member's workspace links (instance-wide ones as fallback). */
workspaceFooterLinks: (token: string) =>
request<Array<{ label: string; url: string }>>('/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) =>
Expand Down
67 changes: 67 additions & 0 deletions packages/frontend/tests/e2e/footer-links.spec.ts
Original file line number Diff line number Diff line change
@@ -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 });
});
Loading