Skip to content

[P0] portal: no external-user portal mechanism — customers see the full admin Console chrome #1294

Description

@xuyushun441-sys

来源:docs/PLATFORM_GAPS_FROM_TEMPLATES.md 第 27 条(P0)。
实证发现自 helpdesk(最尖锐)、compliance(外审)、contracts(甲乙双方)、procurement(供应商 portal)。

现象

平台没有「外部用户门户」机制。当同一个 app 同时被员工和外部用户(客户 / 审计员 / 供应商 / 合作方)使用时:

  • 外部用户登录后看到的是和员工一样的 Console:同样的顶栏、同样的左侧导航、同样的应用网格、同样的搜索框。
  • 权限只能靠 profile 隐藏字段 / 视图 / 应用入口,但视觉层(chrome / navigation / 入口)无法换肤。
  • 没法给客户做一个干净的「我的工单 + 提交工单 + 浏览知识库」自助页面。

helpdesk 模板的 customer_portal profile 是空壳——除了限制能看哪些字段,体验上和 admin 同屋。

复现

cd packages/helpdesk && pnpm dev
# 创建一个 profile=customer_portal 的账号登录
# 看到的还是 /_console/...,左侧有 Tickets / Messages / Customers / KB / Teams / SLA 全套导航
# 没有「外部用户专属布局」可选

当前 workaround

只能:

  1. 起一个第二份模板包(如 helpdesk-portal),独立部署、独立端口。
    • 代价:双倍部署、数据要么共享 DB 要么走 API 同步、SSO 状态分裂。
  2. 或者在 profile 里关掉绝大部分入口,让外部用户看到一片空白 + 一个对象——但仍然顶着 Console 的 ObjectStack chrome,UX 像「内部系统给你开了一道缝」。

两种都不达标 B2B/B2C SaaS 体验。

建议范围

M1:Portal 概念

  • 在 spec 引入 portal: { id, label, themeOverrides?, layoutKind: 'minimal' | 'embedded' | 'console' }。
  • 同一份 app metadata 能挂多个 portal,每个 portal 有独立路由前缀(如 /portal/customer/...)。
  • 每个 portal 绑定一组 profiles。

M2:Portal-aware Layout

  • minimal 布局:去掉应用网格 / 跨应用导航 / 顶栏品牌部分可换肤。
  • 路由根据 portal id 切换:/portal/customer/helpdesk/... vs /_console/apps/helpdesk/...。
  • 同一对象的同一视图能在两个 portal 用不同的 view 名称引用。

M3:Portal 默认页 + 公开入口

验收

  • helpdesk 模板新增 customer_portal portal,路由 /portal/helpdesk-customer/...,左导航只显示「我的工单 / 提交工单 / 知识库」三项
  • 同一登录态下可在 customer portal 与 console 之间用 URL 切换(不再共用 chrome)
  • compliance 模板新增 auditor_portal:外审人员只能看到只读证据
  • 缺陷库 gap chore(deps)(deps): bump zod from 3.25.76 to 4.3.5 #27 标记为已解决

关联

Activity

  1. xuyushun441-sys commented on May 25, 2026

    @xuyushun441-sys
    CollaboratorAuthor

    元数据驱动的实现方案

    核心心智模型: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. 五条不可破的不变量(保「元数据驱动」纯度)

    1. 零业务代码:portal 元数据中不出现 React / JSX / 函数。layout 是「枚举 + plugin id」,theme 是 token,navigation 是引用。
    2. 数据 plane 与 UI plane 解耦:portal 不能定义对象、字段、flow、权限。
    3. portal ≠ 权限边界:profile 才是。portal 只决定「在这条路由下看到哪些入口」。
    4. 可栈叠:同一用户同一 profile 可同时被多个 portal 接纳;登录后由路由 / 选择器决定进哪个。
    5. 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 domain
    M2(生产可用) 主题、anonymousEntry + 限流 + CAPTCHA、locale 默认、SEO 头
    M3(企业级) vanity domain、SSO per portal、plugin layout、可嵌入 iframe widget

    接下来在 framework spec 包定义 protocol,并在 templates/packages/helpdesk 实际落一份 portal 元数据,假想运行时已实现的前提下做用户视角验证。结果回到本 issue 评论。

  2. xuyushun441-sys commented on May 25, 2026

    @xuyushun441-sys
    CollaboratorAuthor

    验证:假想 framework 已实现,spec 够用吗?

    按 design comment 的 schema 在 templates/packages/helpdesk 实际落了一份 customer.portal.ts(含一个 _portal-spec-shim.ts 前向兼容到 spec 发版),然后站在客服/客户两个角色走 10 个场景。结论:主流程能跑,关键边角缺 8 处,下面诚实列出。

    ✅ 能跑的场景

    1. 注册客户登录 → 看自己工单(profile + magic-link + defaultRoute)
    2. 匿名访客填表提单 → captcha + rate-limit + 邮箱 magic-link 绑定
    3. 客服在 /_console 工作,客户在 /portal/helpdesk 工作,同一 ticket 对象、同一 flow、零代码重复
    4. 主题(颜色/logo/字体)通过 token 切换,不写一行 CSS
    5. 三条 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。

  3. xuyushun441-sys commented on May 25, 2026

    @xuyushun441-sys
    CollaboratorAuthor

    验证发现的 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。

    M2 必须补:#1331 #1332 #1337 #1338(不补则客户基本流程走不通)。

  4. os-zhuang commented on Jun 27, 2026

    @os-zhuang
    Contributor

    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: objectui LayoutDispatcher portal-aware routing + portal auth middleware end-to-end. Sub-gaps #1337 (detail URL/share-link), #1338 (onComplete nav) still open.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingenhancementNew feature or requestpriority:p0Critical: blocker, must ship before MVP

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions