Skip to content

Commit 2980bc9

Browse files
authored
feat: public viewer dark mode, expiration notice, hide_expiration (#223)
* fix: public viewer HTML dashboards render charts via blob: URL iframe The srcdoc iframe inherited the parent page's restrictive CSP, which blocked external module imports (esm.sh) needed by React/Recharts. Switch sandboxedIframe and jsxIframe to use blob: URL iframes instead, which do not inherit parent CSP. Add frame-src blob: to the non-JSX CSP. * feat: public viewer dark mode, expiration notice, and per-share hide_expiration Add light/dark mode toggle to public viewer with system default detection and localStorage persistence. Theme affects page chrome, markdown, and text content only — HTML/JSX iframe content is isolated with white background regardless of theme. Add expiration countdown and privacy notice bar below header. Expiration can be hidden per-share via hide_expiration field on share creation. Add migration 000018 for hide_expiration column on portal_shares. * feat: per-share configurable notice text and robots.txt Add notice_text field to portal shares, configurable per-share at creation time. Defaults to "Proprietary & Confidential. Only share with authorized viewers." Set to empty string to hide the notice entirely. Max 500 characters. Add /robots.txt endpoint to prevent search engine indexing.
1 parent 4b837d9 commit 2980bc9

19 files changed

+724
-78
lines changed

cmd/mcp-data-platform/main.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -269,6 +269,12 @@ func startHTTPServer(ctx context.Context, mcpServer *mcp.Server, p *platform.Pla
269269
mux.Handle("/healthz", hc.LivenessHandler())
270270
mux.Handle("/readyz", hc.ReadinessHandler())
271271

272+
// Robots.txt — prevent search engines from indexing the portal.
273+
mux.HandleFunc("GET /robots.txt", func(w http.ResponseWriter, _ *http.Request) {
274+
w.Header().Set("Content-Type", "text/plain")
275+
_, _ = fmt.Fprint(w, "User-agent: *\nDisallow: /\n")
276+
})
277+
272278
// Mount OAuth server if enabled
273279
if p != nil && p.OAuthServer() != nil {
274280
registerOAuthRoutes(mux, p.OAuthServer())

cmd/mcp-data-platform/main_test.go

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package main
22

33
import (
44
"context"
5+
"fmt"
56
"log/slog"
67
"net/http"
78
"net/http/httptest"
@@ -496,6 +497,28 @@ func TestHealthEndpointsRegistered(t *testing.T) {
496497
}
497498
}
498499

500+
func TestRobotsTxt(t *testing.T) {
501+
mux := http.NewServeMux()
502+
mux.HandleFunc("GET /robots.txt", func(w http.ResponseWriter, _ *http.Request) {
503+
w.Header().Set("Content-Type", "text/plain")
504+
_, _ = fmt.Fprint(w, "User-agent: *\nDisallow: /\n")
505+
})
506+
507+
w := httptest.NewRecorder()
508+
mux.ServeHTTP(w, httptest.NewRequestWithContext(context.Background(), "GET", "/robots.txt", http.NoBody))
509+
510+
if w.Code != http.StatusOK {
511+
t.Fatalf("/robots.txt status = %d, want %d", w.Code, http.StatusOK)
512+
}
513+
if ct := w.Header().Get("Content-Type"); ct != "text/plain" {
514+
t.Errorf("Content-Type = %q, want %q", ct, "text/plain")
515+
}
516+
body := w.Body.String()
517+
if !strings.Contains(body, "Disallow: /") {
518+
t.Errorf("body missing Disallow directive: %q", body)
519+
}
520+
}
521+
499522
func TestAwareHandlerWiring(t *testing.T) {
500523
t.Run("not wired when stateless is false", func(t *testing.T) {
501524
p := newTestPlatform(t, &platform.Config{

docs/llms-full.txt

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -352,6 +352,9 @@ When `workflow.require_discovery_before_query` is disabled (default), the platfo
352352
| `portal.s3_prefix` | string | `""` | Key prefix within the bucket |
353353
| `portal.public_base_url` | string | `""` | Base URL for portal links in save_artifact responses |
354354
| `portal.max_content_size` | int | `10485760` | Maximum artifact size in bytes (10 MB) |
355+
| `portal.implementor.name` | string | `""` | Implementor display name shown in the left zone of the public viewer header |
356+
| `portal.implementor.logo` | string | `""` | URL to implementor SVG logo (fetched once at startup, max 1 MB) |
357+
| `portal.implementor.url` | string | `""` | Clickable link wrapping the implementor name and logo |
355358

356359
Portal requires `database.dsn` for metadata storage and at least one S3 toolkit instance for artifact content.
357360

@@ -372,8 +375,13 @@ Branding fields have moved to the `portal:` section:
372375
| `portal.logo` | string | `""` | Logo URL (fallback for both themes) |
373376
| `portal.logo_light` | string | `""` | Logo URL for light theme |
374377
| `portal.logo_dark` | string | `""` | Logo URL for dark theme |
378+
| `portal.implementor.name` | string | `""` | Implementor display name (left zone of public viewer header) |
379+
| `portal.implementor.logo` | string | `""` | URL to implementor SVG logo (fetched at startup, max 1 MB) |
380+
| `portal.implementor.url` | string | `""` | Clickable link wrapping implementor name and logo |
375381

376-
When `portal.enabled: true`, an interactive web dashboard is served at `/portal/`. It provides audit log exploration, tool execution testing, and system monitoring. The sidebar displays a configurable logo and title. Theme-specific logos are resolved as: light theme uses `logo_light` → `logo` → built-in default; dark theme uses `logo_dark` → `logo` → built-in default. The resolved logo is also used as the browser favicon.
382+
When `portal.enabled: true`, an interactive web dashboard is served at `/portal/`. It provides audit log exploration, tool execution testing, and system monitoring. The sidebar displays a configurable logo and title. Theme-specific logos are resolved as: light theme uses `logo_light` → `logo` → built-in default; dark theme uses `logo_dark` → `logo` → built-in default. The resolved logo is also used as the browser favicon. Shared artifact links (`/portal/view/{token}`) display a two-zone header: the right zone shows the platform brand, and the optional left zone shows the implementor brand configured via `portal.implementor`.
383+
384+
The public viewer supports light/dark mode (system default with toggle, persisted to localStorage), an expiration countdown notice showing relative time until share expiry, and a configurable per-share notice text. The `notice_text` field defaults to "Proprietary & Confidential. Only share with authorized viewers." — set it to a custom string for different text, or to `""` (empty string) to hide the notice entirely. When creating shares, set `hide_expiration: true` to hide the expiration countdown from the viewer.
377385

378386
## Resource Templates Configuration
379387

docs/llms.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
- [Installation](server/installation.md): Install via go install, Homebrew, Docker, or from source
1818
- [Configuration](server/configuration.md): YAML configuration with environment variable expansion, config versioning (apiVersion field, version lifecycle, migrate-config CLI), config store options (file vs database mode), tool visibility filtering (allow/deny patterns for tools/list token reduction), tool description overrides (built-in and custom per-tool description replacement in tools/list), workflow gating (session-aware enforcement that agents call DataHub discovery before Trino queries, with escalation), prompts (three-tier: auto-registered platform-overview from description + toolkits, operator-configured with {placeholder} argument substitution, conditional workflow prompts registered when required toolkits are present; PromptDescriber interface for toolkit-registered prompts; operator override by name), resource templates, custom resources (static MCP resources from config: inline content or file path, URI/name/mime_type/description fields), progress notifications, client logging, elicitation (cost estimation, PII consent), icons (tool/resource/prompt visual metadata), admin API and portal, database, audit, session configuration, and browser session configuration (OIDC login with PKCE for portal UI, cookie-based sessions)
1919
- [Operating Modes](server/operating-modes.md): Three deployment modes — standalone (no database), full-config file + database, bootstrap + database config. Feature availability by mode, example configurations, decision guide
20-
- [Admin Portal](server/admin-portal.md): Built-in web dashboard for monitoring and managing the platform. Configurable branding (portal.title, portal.logo with light/dark theme variants, dynamic favicon). Dashboard with activity timelines, performance percentiles, error monitoring. Tools overview with connection grid and tool inventory. Interactive tool explorer with semantic enrichment display. Searchable audit log with event detail drawer. Knowledge insight governance with approve/reject workflow and changeset tracking. Local dev with MSW mock data
20+
- [Admin Portal](server/admin-portal.md): Built-in web dashboard for monitoring and managing the platform. Configurable branding (portal.title, portal.logo with light/dark theme variants, dynamic favicon). Public viewer two-zone header with optional implementor brand (portal.implementor.name, portal.implementor.logo, portal.implementor.url). Public viewer supports light/dark mode toggle (system default, localStorage persistence), expiration countdown notice, per-share configurable notice_text (default: "Proprietary & Confidential. Only share with authorized viewers.", empty string hides notice), and per-share hide_expiration option. Dashboard with activity timelines, performance percentiles, error monitoring. Tools overview with connection grid and tool inventory. Interactive tool explorer with semantic enrichment display. Searchable audit log with event detail drawer. Knowledge insight governance with approve/reject workflow and changeset tracking. Local dev with MSW mock data
2121
- [Admin API](server/admin-api.md): REST endpoints for system info, config management, personas, auth keys, audit (events, stats, metrics/overview, metrics/enrichment, metrics/discovery), knowledge. Authentication, operating mode behavior, request/response reference. Interactive Swagger UI at `/api/v1/admin/docs/`
2222
- [Tools](server/tools.md): All tools from DataHub, Trino, S3, Knowledge, and Portal toolkits
2323
- [Multi-Provider](server/multi-provider.md): Connect multiple instances of each service

docs/reference/configuration.md

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -653,6 +653,10 @@ portal:
653653
s3_prefix: "artifacts/" # Key prefix within the bucket
654654
public_base_url: "https://portal.example.com" # Base URL for portal links
655655
max_content_size: 10485760 # Max artifact size in bytes (default: 10MB)
656+
implementor: # Optional implementor brand (left zone of public viewer header)
657+
name: "ACME Corp"
658+
logo: "https://acme.com/logo.svg"
659+
url: "https://acme.com"
656660
```
657661

658662
| Option | Type | Default | Description |
@@ -667,6 +671,29 @@ portal:
667671
| `portal.s3_prefix` | string | `""` | Key prefix within the bucket (e.g., `artifacts/`) |
668672
| `portal.public_base_url` | string | `""` | Base URL for portal links returned in `save_artifact` responses |
669673
| `portal.max_content_size` | int | `10485760` | Maximum artifact size in bytes (10 MB) |
674+
| `portal.implementor.name` | string | `""` | Implementor display name shown in the left zone of the public viewer header |
675+
| `portal.implementor.logo` | string | `""` | URL to implementor SVG logo (fetched once at startup, max 1 MB) |
676+
| `portal.implementor.url` | string | `""` | Clickable link wrapping the implementor name and logo |
677+
678+
### Share Creation API
679+
680+
When creating a share via `POST /api/v1/portal/assets/{id}/shares`, the request body accepts:
681+
682+
| Field | Type | Default | Description |
683+
|-------|------|---------|-------------|
684+
| `expires_in` | string | - | Duration string (e.g., `"24h"`, `"72h"`) |
685+
| `shared_with_user_id` | string | - | Target user ID for private shares |
686+
| `shared_with_email` | string | - | Target email for private shares |
687+
| `hide_expiration` | bool | `false` | Hide the expiration countdown in the public viewer |
688+
| `notice_text` | string\|null | `"Proprietary & Confidential. Only share with authorized viewers."` | Custom notice text for the public viewer. Omit or `null` for the default. Set to `""` to hide the notice entirely. Max 500 characters. |
689+
690+
### Public Viewer
691+
692+
The public viewer (`/portal/view/{token}`) renders shared artifacts with:
693+
694+
- **Light/dark mode** — respects system `prefers-color-scheme`, toggle button persists choice to `localStorage`
695+
- **Expiration notice** — shows relative time until expiry (hidden when `hide_expiration` is true or no expiry set)
696+
- **Notice text** — configurable per-share via `notice_text`. Defaults to "Proprietary & Confidential. Only share with authorized viewers." Set to `""` at share creation to hide entirely.
670697

671698
!!! note "Prerequisites"
672699
Portal requires `database.dsn` to be configured for metadata storage, and at least one S3 toolkit instance for artifact content storage.

docs/server/admin-portal.md

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,34 @@ Customize the sidebar title and logo via `portal.title`, `portal.logo`, `portal.
3030

3131
The resolved logo is also used as the browser favicon. A built-in activity icon is used when no logo is configured. Logos should be square SVGs for best results.
3232

33+
### Public Viewer Branding
34+
35+
Shared artifact links (the public viewer at `/portal/view/{token}`) display a two-zone header. The **right zone** shows the platform brand (`portal.title` and `portal.logo`). The **left zone** is an optional implementor brand for the organization deploying the platform:
36+
37+
```yaml
38+
portal:
39+
implementor:
40+
name: "ACME Corp" # Display name (left zone of public viewer header)
41+
logo: "https://acme.com/logo.svg" # URL to SVG logo (fetched once at startup, max 1 MB)
42+
url: "https://acme.com" # Clickable link wrapping name + logo
43+
```
44+
45+
All three fields are optional. When omitted, the left zone is hidden and only the platform brand appears. The logo URL must point to an SVG file; it is fetched at server startup and inlined into the HTML.
46+
47+
### Public Viewer Features
48+
49+
The public viewer includes:
50+
51+
- **Light/dark mode** — Defaults to the system `prefers-color-scheme` setting. A toggle button in the header allows switching; the choice is persisted to `localStorage`.
52+
- **Expiration notice** — When the share has an expiration, a notice bar shows the relative time remaining (e.g., "This page expires in 6 hours"). Hidden when the share has no expiry or `hide_expiration` was set at share creation.
53+
- **Notice text** — Configurable per-share via `notice_text`. Defaults to "Proprietary & Confidential. Only share with authorized viewers." Set to `""` to hide the notice entirely.
54+
55+
The `hide_expiration` and `notice_text` fields are set per-share when creating a share via the API:
56+
57+
```json
58+
{"expires_in": "24h", "hide_expiration": true, "notice_text": "Internal use only."}
59+
```
60+
3361
## Dashboard
3462

3563
The home page provides a real-time overview of platform health across configurable time ranges (1h, 6h, 24h, 7d).

pkg/database/migrate/migrate_unit_test.go

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ import (
1515
)
1616

1717
const (
18-
migrateTestFileCount = 34
18+
migrateTestFileCount = 38
1919
migrateTestSuccess = "success"
2020
migrateTestFactoryError = "factory error"
2121
)
@@ -78,6 +78,10 @@ func TestMigrationsEmbedded(t *testing.T) {
7878
"000016_portal_share_email.down.sql",
7979
"000017_portal_assets_owner_email.up.sql",
8080
"000017_portal_assets_owner_email.down.sql",
81+
"000018_portal_share_hide_expiration.up.sql",
82+
"000018_portal_share_hide_expiration.down.sql",
83+
"000019_portal_share_notice_text.up.sql",
84+
"000019_portal_share_notice_text.down.sql",
8185
}
8286

8387
fileNames := make(map[string]bool)
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
ALTER TABLE portal_shares DROP COLUMN IF EXISTS hide_expiration;
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
ALTER TABLE portal_shares ADD COLUMN IF NOT EXISTS hide_expiration BOOLEAN NOT NULL DEFAULT FALSE;
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
ALTER TABLE portal_shares DROP COLUMN IF EXISTS notice_text;

0 commit comments

Comments
 (0)