Skip to content

Commit c66932d

Browse files
zhuangjianguoCopilot
andcommitted
feat(spec): add Portal metadata kind for external-user UI projection
Defines kind: 'portal' as a declarative projection of existing apps, views, and actions onto a route prefix scoped by profile. Portal is deliberately NOT a new permission model — data API and sharing are untouched; portal only narrows the UI surface. Key fields: - routePrefix + optional vanity domain - layout: console | minimal | embedded | custom:<plugin>[/<layout>] - theme tokens (primaryColor, logoUrl, etc.) — no raw CSS by default - authMode: authenticated | magic-link | anonymous | sso:<provider> - profiles[] (required; min 1) - anonymousEntry.routes[] with rateLimit + captcha + magic-link identity-bind for deferred attribution of anonymous mutations - navigation[] as references (view/action/dashboard/url) — no group tree - embeddable + allowedEmbedOrigins for iframe widgets - seo, locale, defaultRoute Closes design portion of #1294. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 03558b0 commit c66932d

3 files changed

Lines changed: 443 additions & 0 deletions

File tree

‎packages/spec/src/ui/index.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,3 +29,4 @@ export * from './animation.zod';
2929
export * from './notification.zod';
3030
export * from './dnd.zod';
3131
export * from './sharing.zod';
32+
export * from './portal.zod';
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import { describe, expect, it } from 'vitest';
4+
import { PortalSchema, definePortal } from './portal.zod';
5+
6+
describe('PortalSchema', () => {
7+
it('accepts a minimal authenticated portal', () => {
8+
const portal = definePortal({
9+
kind: 'portal',
10+
id: 'helpdesk_customer',
11+
label: 'Help Center',
12+
routePrefix: '/portal/helpdesk',
13+
profiles: ['helpdesk_customer_portal'],
14+
navigation: [
15+
{
16+
id: 'nav_my_tickets',
17+
type: 'view',
18+
label: 'My Tickets',
19+
viewRef: 'helpdesk_ticket.list.my_tickets',
20+
},
21+
],
22+
});
23+
expect(portal.kind).toBe('portal');
24+
expect(portal.routePrefix).toBe('/portal/helpdesk');
25+
expect(portal.layout).toBe('minimal');
26+
expect(portal.authMode).toBe('authenticated');
27+
});
28+
29+
it('accepts anonymousEntry with rate-limit and captcha', () => {
30+
const portal = definePortal({
31+
kind: 'portal',
32+
id: 'helpdesk_customer',
33+
label: 'Help Center',
34+
routePrefix: '/portal/helpdesk',
35+
authMode: 'magic-link',
36+
profiles: ['helpdesk_customer_portal'],
37+
navigation: [
38+
{ id: 'nav_my', type: 'view', label: 'My', viewRef: 'helpdesk_ticket.list.my_tickets' },
39+
],
40+
anonymousEntry: {
41+
routes: [
42+
{
43+
path: '/submit',
44+
actionRef: 'helpdesk_ticket.create',
45+
rateLimit: { rule: '5/hour/ip', scope: 'ip' },
46+
captcha: true,
47+
bindIdentityFromField: 'customer_email',
48+
},
49+
{
50+
path: '/kb',
51+
viewRef: 'helpdesk_kb_article.list.published',
52+
},
53+
],
54+
defaultRateLimit: { rule: '100/hour/ip', scope: 'ip' },
55+
},
56+
});
57+
expect(portal.anonymousEntry?.routes).toHaveLength(2);
58+
expect(portal.anonymousEntry?.routes[0].captcha).toBe(true);
59+
});
60+
61+
it('rejects an invalid routePrefix', () => {
62+
expect(() =>
63+
definePortal({
64+
kind: 'portal',
65+
id: 'bad',
66+
label: 'bad',
67+
routePrefix: 'no-leading-slash',
68+
profiles: ['x'],
69+
navigation: [{ id: 'a', type: 'view', label: 'A', viewRef: 'x.y' }],
70+
}),
71+
).toThrow();
72+
});
73+
74+
it('rejects an empty profiles array', () => {
75+
expect(() =>
76+
definePortal({
77+
kind: 'portal',
78+
id: 'bad',
79+
label: 'bad',
80+
routePrefix: '/x',
81+
profiles: [],
82+
navigation: [{ id: 'a', type: 'view', label: 'A', viewRef: 'x.y' }],
83+
}),
84+
).toThrow();
85+
});
86+
87+
it('accepts SSO and custom plugin layouts', () => {
88+
const portal = definePortal({
89+
kind: 'portal',
90+
id: 'enterprise',
91+
label: 'Enterprise',
92+
routePrefix: '/portal/enterprise',
93+
authMode: 'sso:azure-ad',
94+
layout: 'custom:my-plugin/dashboard',
95+
profiles: ['enterprise_user'],
96+
navigation: [
97+
{ id: 'home', type: 'dashboard', label: 'Home', dashboardName: 'enterprise_home' },
98+
],
99+
});
100+
expect(portal.authMode).toBe('sso:azure-ad');
101+
expect(portal.layout).toBe('custom:my-plugin/dashboard');
102+
});
103+
104+
it('rejects malformed SSO mode', () => {
105+
expect(() =>
106+
PortalSchema.parse({
107+
kind: 'portal',
108+
id: 'x',
109+
label: 'x',
110+
routePrefix: '/x',
111+
authMode: 'sso:',
112+
profiles: ['x'],
113+
navigation: [{ id: 'a', type: 'view', label: 'A', viewRef: 'x.y' }],
114+
}),
115+
).toThrow();
116+
});
117+
});

0 commit comments

Comments
 (0)