Repository navigation
[P0] portal: no external-user portal mechanism — customers see the full admin Console chrome #1294
Description
Activity
- addedbugSomething isn't workingSomething isn't workingenhancementNew feature or requestNew feature or request
on May 25, 2026 xuyushun441-sys commented
on May 25, 2026 CollaboratorAuthorMore actions元数据驱动的实现方案
核心心智模型:Portal 不是新增的「应用」,而是对已有 apps / views / actions 的一个「投影 + 包装」。
同一份数据 plane,多个 UI plane。1. 元数据 Schema 草案(新
kind: 'portal')export const HelpdeskCustomerPortal: Portal = { kind: 'portal', id: 'helpdesk_customer', label: { en: 'Help Center', 'zh-CN': '帮助中心' }, // 路由 routePrefix: '/portal/helpdesk', domain: 'support.acme.com', // 可选 vanity domain // 外壳 + 主题 layout: 'minimal', // console | minimal | embedded | plugin id theme: { primaryColor: '#A855F7', logoUrl: '...', favicon: '...', fontFamily: 'system-ui' }, locale: 'auto', // 鉴权 authMode: 'authenticated', // authenticated | magic-link | anonymous | sso:<provider> profiles: ['helpdesk_customer_portal'], anonymousEntry: { routes: [ { path: '/submit', action: 'helpdesk_ticket.create', rateLimit: '5/hour/ip', captcha: true }, { path: '/kb', view: 'helpdesk_kb_article.list.public' }, ], }, // 导航 navigation: [ { label: { en: 'My Tickets', 'zh-CN': '我的工单' }, view: 'helpdesk_ticket.list.my_tickets' }, { label: { en: 'Submit', 'zh-CN': '提交工单' }, action: 'helpdesk_ticket.create' }, { label: { en: 'Knowledge Base', 'zh-CN': '帮助文档' }, view: 'helpdesk_kb_article.list.published' }, ], defaultRoute: { view: 'helpdesk_ticket.list.my_tickets' }, };
2. 架构改动 / 复用
层 改动 复用 spec 注册新 kind: 'portal',与 app/object 同级spec metadata registry Dispatcher / HonoServer 启动时枚举 portal,注册 /portal/<prefix>/*路由族 + auth scope现有路由分发器 Auth middleware 校验 profile ∈ portal.profiles 或 anonymousEntry 命中 Auth 插件、profile 系统 objectui LayoutDispatcher 顶层按 portal.layout选 shell现有 React 组件 objectui NavigationBuilder 不再「列出当前 profile 能看到的 apps」;而是渲染 portal.navigation现有 nav 组件 objectui ThemeProvider 注入 portal.theme为 CSS variables现有 ThemeProvider Data API /api/v1/data/...不动 sharing + profile 已够 关键:Data API 完全不引入 portal 概念。权限只走 profile + sharing。portal 隐藏一个 view 仅是 UI 投影;用户直接拿 URL 访问,仍由 profile/sharing 决定可见性——defense in depth,不是 source of truth。
3. 五条不可破的不变量(保「元数据驱动」纯度)
- 零业务代码:portal 元数据中不出现 React / JSX / 函数。layout 是「枚举 + plugin id」,theme 是 token,navigation 是引用。
- 数据 plane 与 UI plane 解耦:portal 不能定义对象、字段、flow、权限。
- portal ≠ 权限边界:profile 才是。portal 只决定「在这条路由下看到哪些入口」。
- 可栈叠:同一用户同一 profile 可同时被多个 portal 接纳;登录后由路由 / 选择器决定进哪个。
- portal 是模板可声明的一等公民:模板作者写
customer.portal.ts即完成「让该模板支持外部门户」,平台保证渲染。
4. 微妙设计点
- 同对象不同 portal 看不同 view:不要在 view 上加
portal字段;改由 portal 在 navigation/defaultRoute 引用,未被引用的 view 在该 portal 不出现。 - URL 不跨 portal 复用:每 portal 独立 routePrefix,同一记录在 console / portal 中是不同 URL。
- 匿名提交工单的归属:走系统用户
system.anonymous+ action metadata 声明「创建后绑定到 email」,客户通过 magic-link 找回工单。 - 多 portal 用户:登录后 0 个→
/_console;1 个→直跳;多个→portal 选择器。 - plugin 扩展:
layout: 'custom:my-plugin/layout-id'+LayoutPlugin接口注册 React 组件,保留扩展性而不破坏「核心元数据无代码」原则。
5. 三阶段切片
Phase 内容 M1(最小可用) portal kind + layout: 'minimal'+ 路由前缀 + profile 绑定 + navigation。无主题、无匿名、无 vanity domainM2(生产可用) 主题、anonymousEntry + 限流 + CAPTCHA、locale 默认、SEO 头 M3(企业级) vanity domain、SSO per portal、plugin layout、可嵌入 iframe widget
接下来在 framework spec 包定义 protocol,并在
templates/packages/helpdesk实际落一份 portal 元数据,假想运行时已实现的前提下做用户视角验证。结果回到本 issue 评论。- added a commit that references this issue
on May 25, 2026 xuyushun441-sys commented
on May 25, 2026 CollaboratorAuthorMore actions验证:假想 framework 已实现,spec 够用吗?
按 design comment 的 schema 在
templates/packages/helpdesk实际落了一份customer.portal.ts(含一个_portal-spec-shim.ts前向兼容到 spec 发版),然后站在客服/客户两个角色走 10 个场景。结论:主流程能跑,关键边角缺 8 处,下面诚实列出。✅ 能跑的场景
- 注册客户登录 → 看自己工单(profile + magic-link + defaultRoute)
- 匿名访客填表提单 → captcha + rate-limit + 邮箱 magic-link 绑定
- 客服在
/_console工作,客户在/portal/helpdesk工作,同一 ticket 对象、同一 flow、零代码重复 - 主题(颜色/logo/字体)通过 token 切换,不写一行 CSS
- 三条 navigation(My Tickets / Submit / KB)全是对已有 view/action 的引用
❌ 走不通的场景(需要 spec 补丁)
GAP-1:匿名提单后立刻看状态
匿名用户提交 → 想马上看到「工单已创建,编号 #123,状态:处理中」。
当前bindIdentityFromField只解决「以后通过邮件链接回来看」,没解决「提交完立刻给一个短期 token + 跳详情页」。需要:
anonymousRoute.onSuccess: { showView: 'helpdesk_ticket.detail', ephemeralTokenTtl: '24h' }GAP-2:匿名查询(工单号 + 邮箱)
Zendesk/Intercom 标配:未注册用户输工单号+邮箱 → 验证 → 查看。当前 spec 的
anonymousEntry.routes只支持「全公开 view」或「mutation action」,没有「带凭证校验的私有 view」。需要:新增 route 子类型
lookup:{ path, viewRef, keyFields: ['ticket_number','customer_email'], deliverOtpBy: 'email' }GAP-3:多语言 fallback 未声明
locale: 'auto'配合 Accept-Language=ja,但 helpdesk 翻译没日语,会发生啥?spec 没说。需要:
supportedLocales: ['en','zh-CN']+fallbackLocale: 'en'GAP-4:通知通道缺失
客服在 console 回复 → 客户怎么知道?email?站内?digest?这是 portal 的事不是 view 的事。
需要:
notifications: { channels: ['email','in_app'], digestPolicy: 'realtime'|'daily', emailTemplate: '<tpl_id>' }GAP-5:SSO 是租户级数据,不该写死在模板元数据
当前
authMode: 'sso:okta'把 IdP 写死在 portal 元数据里。但 IdP 配置(client_id/secret/issuer)一定是租户级运行时数据。模板作者根本不知道客户用哪家 IdP。需要拆开:
- 元数据:
authMode: 'sso'+supportedSsoProviders: ['saml','oidc'] - 运行时:租户绑定
IdentityProvider对象,portal 在登录时枚举可用 IdP
GAP-6:embed 区分整页 iframe vs widget
embeddable: boolean太粗。客户官网上 99% 的用法是悬浮聊天泡泡而不是整页 iframe。需要:
embed: { mode: 'iframe' | 'widget', widget?: { trigger: 'bubble'|'button', position, launcherIconUrl, openOnInit } }GAP-7:record-detail URL scheme 未定义
navigation 只有 list view。客户点开一条工单后 URL 长什么样?
/portal/helpdesk/ticket/<id>还是/portal/helpdesk/view/helpdesk_ticket/<id>?同事帮忙看的 share-link 怎么生成?需要:portal 级 URL 模板约定,或在 navigation 里允许
detailRoute: 'helpdesk_ticket.detail',框架按约定生成/<prefix>/<object>/<id>路由。GAP-8:action 完成后的导航
匿名用户填完表 → 跳哪?默认空白页?跳 KB?跳"已提交"页?
需要:action route 上的
onComplete: { redirectTo: viewRef | path | 'origin' },或 portal 级actionDefaults.onComplete。⚠️ 不变量复查不变量 状态 备注 零业务代码 ✅ customCss字段是后门,建议下个版本移到 plugin数据 plane 不动 ✅ portal ≠ 权限边界 ✅ 文档需强警告:navigation 隐藏 ≠ 安全;profile + sharing 才是 可栈叠 ⚠️ 缺多 portal 选择器 UI 的协议约定(用户同时被多个 portal 接纳时怎么选) 模板可声明 ✅ 结论
Spec 主干是对的,可以发 M1 落地(基础路由、profile gate、minimal layout、anonymous + captcha、navigation 引用、theme tokens)。
M2 之前必须补:GAP-1(匿名提单后状态)、GAP-2(凭证 lookup)、GAP-7(detail URL)、GAP-8(action onComplete)—— 这四个不补客户体验就没法用。
M3:GAP-3、GAP-4、GAP-5、GAP-6 —— 企业级生产必备但 MVP 可以暂缓。
验证文件:
framework/packages/spec/src/ui/portal.zod.ts+ tests(已提交)templates/packages/helpdesk/src/portals/customer.portal.ts+ 前向兼容 shim(已提交)
我会把上面 8 个 GAP 拆成独立 issue 关联回 #1294。
xuyushun441-sys commented
on May 25, 2026 CollaboratorAuthorMore actions验证发现的 8 个 spec gap 已拆为独立 issue
GAP Issue 优先级 概述 1 #1331 M2(必须) 匿名提交后 onSuccess + ephemeral token 2 #1332 M2(必须) 匿名 lookup(凭证 + OTP)route 类型 3 #1333 M2 多语言 supportedLocales + fallback + 切换器 4 #1334 M2/M3 通知通道声明 + 品牌继承 5 #1335 M3 SSO 元数据/租户运行时分层 6 #1336 M3 embed 拆 iframe / widget + launcher 7 #1337 M2(必须) record detail URL + share-link 8 #1338 M2(必须) action onComplete 导航策略 M1(最小可用) 可以基于当前 spec 直接落地:路由前缀、profile gate、minimal layout、anonymous + captcha、navigation 引用、theme tokens。
- addedpriority:p0Critical: blocker, must ship before MVPCritical: blocker, must ship before MVP
on May 25, 2026 Triage (2026-06-27, for 11.0 epic #2364): mostly done — verify runtime. Portal spec (
packages/spec/src/ui/portal.zod.ts) + metadata bootstrap/CLI shipped (commits 641c70a/c66932dc8). Not yet verified: objectuiLayoutDispatcherportal-aware routing + portal auth middleware end-to-end. Sub-gaps #1337 (detail URL/share-link), #1338 (onComplete nav) still open.
现象
平台没有「外部用户门户」机制。当同一个 app 同时被员工和外部用户(客户 / 审计员 / 供应商 / 合作方)使用时:
profile隐藏字段 / 视图 / 应用入口,但视觉层(chrome / navigation / 入口)无法换肤。helpdesk模板的customer_portalprofile 是空壳——除了限制能看哪些字段,体验上和 admin 同屋。复现
当前 workaround
只能:
helpdesk-portal),独立部署、独立端口。两种都不达标 B2B/B2C SaaS 体验。
建议范围
M1:Portal 概念
portal: { id, label, themeOverrides?, layoutKind: 'minimal' | 'embedded' | 'console' }。/portal/customer/...)。M2:Portal-aware Layout
minimal布局:去掉应用网格 / 跨应用导航 / 顶栏品牌部分可换肤。/portal/customer/helpdesk/...vs/_console/apps/helpdesk/...。M3:Portal 默认页 + 公开入口
entryView: 'helpdesk_ticket.list'与entryAction: 'create_ticket'。验收
helpdesk模板新增customer_portalportal,路由/portal/helpdesk-customer/...,左导航只显示「我的工单 / 提交工单 / 知识库」三项compliance模板新增auditor_portal:外审人员只能看到只读证据关联