Skip to content

Commit a619a3a

Browse files
os-zhuangclaude
andauthored
fix(setup): first-run admin polish — nav pin, widget service-gate, i18n, settings PUT envelope (#2249)
Dogfooding the Setup app as a brand-new system administrator surfaced a cluster of small first-run gaps. This fixes the bounded ones (larger gaps tracked in #2246): - Pin Localization + Company in the Setup sidebar (were reachable only via the All Settings hub despite being the two lowest-order Workspace settings). - Extend the ADR-0057 D10 server-side visibility gate to dashboard widgets: rest-server strips widgets whose requiresService names an unregistered kernel service. Removes the orphan "Organizations" KPI in single-tenant runtimes, matching the already-hidden org nav entries. - i18n: translate nav labels Cloud Connection (云连接), Datasources (数据源), Capabilities (能力); add missing zh help for the Localization settings fields. - settings: PUT /api/settings/:ns now also accepts the { values: {...} } envelope that GET returns (symmetry). Tests: service-settings 128 green (+2), rest 133 green (+6). All fixes browser-verified on the showcase example. Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 6fbe91f commit a619a3a

9 files changed

Lines changed: 207 additions & 6 deletions

File tree

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
"@objectstack/platform-objects": patch
3+
"@objectstack/service-settings": patch
4+
"@objectstack/rest": patch
5+
---
6+
7+
fix(setup): first-run admin polish — pin Company/Localization, gate dashboard widgets by `requiresService`, i18n + settings PUT envelope
8+
9+
Dogfooding the Setup app as a brand-new system administrator surfaced a cluster of small first-run gaps, now fixed:
10+
11+
- **platform-objects**: pin **Localization** and **Company** in the Setup sidebar's Configuration group — both are registered `service-settings` manifests (the two lowest-`order` Workspace settings) but were reachable only via the "All Settings" hub. Translate the previously-English nav labels Cloud Connection (云连接), Datasources (数据源) and Capabilities (能力). Tag the System Overview `widget_organizations` KPI with `requiresService: 'org-scoping'`.
12+
- **rest**: extend the ADR-0057 D10 server-side visibility gate to **dashboard widgets** — strip widgets whose `requiresService` names an unregistered kernel service (mirrors the existing app-nav gate; `resolveRegisteredServices` now also discovers gates declared on widgets). In a single-tenant runtime this removes the orphan "Organizations" KPI, matching the already-hidden org nav entries.
13+
- **service-settings**: add the missing zh `help` strings for the Localization manifest (number/currency/first-day-of-week/fiscal-year fields), and accept the `{ values: { … } }` envelope on `PUT /api/settings/:ns` symmetrically with what `GET` returns.

packages/platform-objects/src/apps/dashboards/system_overview.dashboard.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,12 @@ export const SystemOverviewDashboard = Dashboard.create({
4242
dataset: 'sys_organization_metrics', values: ['org_count'],
4343
title: 'Organizations',
4444
type: 'metric',
45+
// Organizations only exist under multi-tenant org-scoping. In a
46+
// single-tenant runtime the count is always 0 and the matching
47+
// nav entries (nav_organizations / nav_invitations) are hidden via
48+
// `requiresService: 'org-scoping'` — gate this KPI the same way so the
49+
// overview doesn't dangle a metric the admin can't act on.
50+
requiresService: 'org-scoping',
4551
layout: { x: 3, y: 0, w: 3, h: 2 },
4652
colorVariant: 'orange',
4753
description: 'Total organizations on the platform',

packages/platform-objects/src/apps/setup-nav.contributions.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,16 @@ export const SETUP_NAV_CONTRIBUTIONS: NavigationContribution[] = [
7676
priority: BASE_PRIORITY,
7777
items: [
7878
{ id: 'nav_settings_hub', type: 'url', label: 'All Settings', url: '/apps/setup/system/settings', icon: 'settings-2', requiredPermissions: ['manage_platform_settings'] },
79+
// Workspace identity first — Localization (order 2) and Company (order 3)
80+
// are the lowest-`order` settings manifests and the first thing a new
81+
// company admin configures. They ship as `service-settings` manifests
82+
// (tenant scope, read=`setup.access`) but were never pinned here, so they
83+
// were reachable only by drilling into the "All Settings" hub. Mainstream
84+
// admin consoles (Salesforce "Company Information", ServiceNow) surface
85+
// both directly. No `requiredPermissions` — matches Branding (read perm is
86+
// the app's base `setup.access`).
87+
{ id: 'nav_settings_localization', type: 'url', label: 'Localization', url: '/apps/setup/system/settings/localization', icon: 'globe' },
88+
{ id: 'nav_settings_company', type: 'url', label: 'Company', url: '/apps/setup/system/settings/company', icon: 'building-2' },
7989
{ id: 'nav_settings_branding', type: 'url', label: 'Branding', url: '/apps/setup/system/settings/branding', icon: 'palette' },
8090
{ id: 'nav_settings_auth', type: 'url', label: 'Authentication', url: '/apps/setup/system/settings/auth', icon: 'lock-keyhole', requiredPermissions: ['manage_platform_settings'] },
8191
{ id: 'nav_settings_mail', type: 'url', label: 'Email', url: '/apps/setup/system/settings/mail', icon: 'mail', requiredPermissions: ['manage_platform_settings'] },

packages/platform-objects/src/apps/translations/zh-CN.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ export const zhCN: TranslationData = {
3434
group_apps: { label: '应用' },
3535
nav_marketplace_browse: { label: '浏览应用市场' },
3636
nav_marketplace_installed: { label: '已安装应用' },
37+
nav_cloud_connection: { label: '云连接' },
3738
group_people_org: { label: '人员与组织' },
3839
group_access_control: { label: '访问控制' },
3940
group_approvals: { label: '审批' },
@@ -51,6 +52,7 @@ export const zhCN: TranslationData = {
5152
nav_invitations: { label: '邀请' },
5253

5354
nav_roles: { label: '角色' },
55+
nav_capabilities: { label: '能力' },
5456
nav_permission_sets: { label: '权限集' },
5557
nav_sharing_rules: { label: '共享规则' },
5658
nav_record_shares: { label: '记录共享' },
@@ -61,6 +63,8 @@ export const zhCN: TranslationData = {
6163
nav_approval_actions: { label: '审批历史' },
6264

6365
nav_settings_hub: { label: '全部设置' },
66+
nav_settings_localization: { label: '本地化' },
67+
nav_settings_company: { label: '公司信息' },
6468
nav_settings_mail: { label: '邮件' },
6569
nav_settings_branding: { label: '品牌' },
6670
nav_settings_auth: { label: '认证' },
@@ -76,6 +80,8 @@ export const zhCN: TranslationData = {
7680
nav_audit_logs: { label: '审计日志' },
7781
nav_notifications: { label: '通知' },
7882

83+
nav_datasources: { label: '数据源' },
84+
7985
nav_oauth_apps: { label: 'OAuth 应用' },
8086
nav_jwks: { label: '签名密钥 (JWKS)' },
8187
nav_verifications: { label: '验证记录' },

packages/rest/src/rest-server.ts

Lines changed: 60 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1169,6 +1169,31 @@ export class RestServer {
11691169
return { ...item, navigation: filterNav(nav) };
11701170
}
11711171

1172+
/**
1173+
* ADR-0057 D10 (dashboards): strip dashboard widgets whose `requiresService`
1174+
* capability gate names a kernel service that isn't registered — the same
1175+
* "server is the authoritative visibility gate" rule already applied to app
1176+
* nav entries (see {@link filterAppForUser}). Without this, a widget bound to
1177+
* an optional service renders a dead tile in deployments where the service is
1178+
* off (e.g. the Organizations KPI under multi-tenant `org-scoping`, which is
1179+
* absent in a single-tenant runtime while its nav entry is correctly hidden).
1180+
*
1181+
* Fail-open when the gate can't be probed (serviceGate undefined). Never
1182+
* mutates the original — returns a shallow copy only when a widget is dropped.
1183+
*/
1184+
private filterDashboardForUser(item: any, serviceGate?: (name: string) => boolean): any {
1185+
if (!item || typeof item !== 'object' || !serviceGate) return item;
1186+
if (isMetaEnvelope(item)) {
1187+
const body = this.filterDashboardForUser((item as any).item, serviceGate);
1188+
return body === (item as any).item ? item : { ...(item as any), item: body };
1189+
}
1190+
if (!Array.isArray(item.widgets)) return item;
1191+
const widgets = item.widgets.filter(
1192+
(w: any) => !(w && typeof w.requiresService === 'string' && serviceGate(w.requiresService) === false),
1193+
);
1194+
return widgets.length === item.widgets.length ? item : { ...item, widgets };
1195+
}
1196+
11721197
/**
11731198
* Probe which `requiresService` capability gates referenced anywhere in
11741199
* `items` are actually registered in the runtime kernel. Returns `null`
@@ -1195,7 +1220,9 @@ export class RestServer {
11951220
if (isMetaEnvelope(e)) { walk((e as any).item); return; }
11961221
if (typeof e.requiresService === 'string') wanted.add(e.requiresService);
11971222
const kids = Array.isArray(e.navigation) ? e.navigation
1198-
: Array.isArray(e.children) ? e.children : null;
1223+
: Array.isArray(e.children) ? e.children
1224+
// Dashboard widgets carry their own `requiresService` gate.
1225+
: Array.isArray(e.widgets) ? e.widgets : null;
11991226
if (kids) for (const k of kids) walk(k);
12001227
};
12011228
for (const it of items) walk(it);
@@ -2002,6 +2029,28 @@ export class RestServer {
20022029
}
20032030
}
20042031

2032+
// ADR-0057 D10: gate dashboard widgets by `requiresService`
2033+
// the same way app nav entries are gated above.
2034+
if (req.params.type === 'dashboard') {
2035+
const raw = visible as unknown;
2036+
const list: any[] | null = Array.isArray(raw)
2037+
? (raw as any[])
2038+
: (raw && typeof raw === 'object' && Array.isArray((raw as any).items))
2039+
? ((raw as any).items as any[])
2040+
: null;
2041+
if (list) {
2042+
const ctx = await this.resolveExecCtx(environmentId, req).catch(() => undefined);
2043+
const registered = await this.resolveRegisteredServices((ctx as any)?.__kernel, list);
2044+
const serviceGate = registered ? (n: string) => registered.has(n) : undefined;
2045+
if (serviceGate) {
2046+
const filtered = list.map((it: any) => this.filterDashboardForUser(it, serviceGate));
2047+
visible = Array.isArray(raw)
2048+
? filtered
2049+
: { ...(raw as any), items: filtered };
2050+
}
2051+
}
2052+
}
2053+
20052054
// View switcher query: GET /meta/view?object=<object>
20062055
// returns ONLY the independent ViewItems bound to that
20072056
// object (the `package` layer of "Object has-many
@@ -2326,6 +2375,16 @@ export class RestServer {
23262375
}
23272376
}
23282377

2378+
// ADR-0057 D10: gate dashboard widgets by `requiresService`
2379+
// (mirrors the app-nav gate above) so the console never
2380+
// renders a tile bound to an absent optional service.
2381+
if (req.params.type === 'dashboard' && visible) {
2382+
const ctx = await this.resolveExecCtx(environmentId, req).catch(() => undefined);
2383+
const registered = await this.resolveRegisteredServices((ctx as any)?.__kernel, [visible]);
2384+
const serviceGate = registered ? (n: string) => registered.has(n) : undefined;
2385+
if (serviceGate) visible = this.filterDashboardForUser(visible, serviceGate);
2386+
}
2387+
23292388
// ADR-0046 i18n: collapse the doc to the request
23302389
// locale (label/description/content) and drop the
23312390
// `translations` map so consumers get one body.

packages/rest/src/rest.test.ts

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1944,6 +1944,62 @@ describe('filterAppForUser — ADR-0057 D10 requiresService gate', () => {
19441944
});
19451945
});
19461946

1947+
// ---------------------------------------------------------------------------
1948+
// ADR-0057 D10 — requiresService capability gate for DASHBOARD WIDGETS
1949+
// (filterDashboardForUser) — server is the authoritative visibility gate.
1950+
// ---------------------------------------------------------------------------
1951+
1952+
describe('filterDashboardForUser — ADR-0057 D10 widget requiresService gate', () => {
1953+
const make = () => new RestServer(createMockServer() as any, createMockProtocol() as any);
1954+
const dash = () => ({
1955+
name: 'system_overview',
1956+
widgets: [
1957+
{ id: 'widget_total_users' },
1958+
{ id: 'widget_packages_installed', requiresObject: 'sys_package_installation' },
1959+
{ id: 'widget_organizations', requiresService: 'org-scoping' },
1960+
],
1961+
});
1962+
const ids = (d: any): string[] => (d?.widgets ?? []).map((w: any) => w.id);
1963+
1964+
it('drops widgets whose requiresService gate reports the service absent', () => {
1965+
const rest: any = make();
1966+
const out = rest.filterDashboardForUser(dash(), (n: string) => n !== 'org-scoping');
1967+
expect(ids(out)).toEqual(['widget_total_users', 'widget_packages_installed']);
1968+
});
1969+
1970+
it('keeps requiresService widgets when the service is present', () => {
1971+
const rest: any = make();
1972+
expect(ids(rest.filterDashboardForUser(dash(), () => true))).toContain('widget_organizations');
1973+
});
1974+
1975+
it('fail-open: with no service gate, widgets are untouched', () => {
1976+
const rest: any = make();
1977+
expect(ids(rest.filterDashboardForUser(dash(), undefined))).toContain('widget_organizations');
1978+
});
1979+
1980+
it('does not touch requiresObject widgets (client-side concern)', () => {
1981+
const rest: any = make();
1982+
const out = rest.filterDashboardForUser(dash(), () => false);
1983+
expect(ids(out)).toContain('widget_packages_installed');
1984+
expect(ids(out)).not.toContain('widget_organizations');
1985+
});
1986+
1987+
it('unwraps the getMetaItem envelope and gates the inner dashboard', () => {
1988+
const rest: any = make();
1989+
const envelope = { type: 'dashboard', name: 'system_overview', item: dash() };
1990+
const out = rest.filterDashboardForUser(envelope, (n: string) => n !== 'org-scoping');
1991+
expect(out.type).toBe('dashboard');
1992+
expect(ids(out.item)).not.toContain('widget_organizations');
1993+
});
1994+
1995+
it('resolveRegisteredServices discovers requiresService declared on widgets', async () => {
1996+
const rest: any = make();
1997+
const kernel = { getServiceAsync: async (n: string) => { if (n === 'org-scoping') return {}; throw new Error('absent'); } };
1998+
const reg = await rest.resolveRegisteredServices(kernel, [dash()]);
1999+
expect(reg.has('org-scoping')).toBe(true);
2000+
});
2001+
});
2002+
19472003
// ---------------------------------------------------------------------------
19482004
// Object API exposure — enable.apiEnabled / enable.apiMethods (ADR-0049 #1889)
19492005
// ---------------------------------------------------------------------------

packages/services/service-settings/src/settings-routes.test.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,37 @@ describe('settings-routes', () => {
9090
expect(state.status).toBe(404);
9191
});
9292

93+
it('PUT accepts the {values:{...}} envelope (flat inner) symmetrically with GET', async () => {
94+
const http = new MockHttp();
95+
const svc = new SettingsService({ env: {} });
96+
svc.registerManifest(brandingSettingsManifest);
97+
registerSettingsRoutes(http, svc);
98+
99+
const h = http.routes.get('PUT /api/settings/:namespace')!;
100+
const { req, res, state } = makeReqRes({ params: { namespace: 'branding' }, body: { values: { workspace_name: 'My Co' } } });
101+
await h(req, res);
102+
expect(state.body.error).toBeUndefined();
103+
expect(state.body.values.workspace_name.value).toBe('My Co');
104+
expect(state.body.values.workspace_name.source).toBe('tenant');
105+
});
106+
107+
it('PUT accepts the read-shape envelope echoed back from GET', async () => {
108+
const http = new MockHttp();
109+
const svc = new SettingsService({ env: {} });
110+
svc.registerManifest(brandingSettingsManifest);
111+
registerSettingsRoutes(http, svc);
112+
113+
const h = http.routes.get('PUT /api/settings/:namespace')!;
114+
// Exactly what GET returns: { values: { key: { value, source, ... } } }
115+
const { req, res, state } = makeReqRes({
116+
params: { namespace: 'branding' },
117+
body: { values: { workspace_name: { value: 'Echoed', source: 'tenant', locked: false } } },
118+
});
119+
await h(req, res);
120+
expect(state.body.error).toBeUndefined();
121+
expect(state.body.values.workspace_name.value).toBe('Echoed');
122+
});
123+
93124
it('POST action invokes service.runAction', async () => {
94125
const http = new MockHttp();
95126
const svc = new SettingsService({ env: {} });

packages/services/service-settings/src/settings-routes.ts

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,27 @@ export function registerSettingsRoutes(
8888

8989
http.put(`${base}/:namespace`, (async (req, res) => {
9090
const ns = req.params.namespace;
91-
const body = (req.body ?? {}) as Record<string, unknown>;
91+
let body = (req.body ?? {}) as Record<string, unknown>;
92+
// DX symmetry: GET returns `{ values: { key: { value, source, … } } }`.
93+
// Accept that same envelope on PUT (sole top-level `values` object) so a
94+
// caller can write back exactly what it read instead of tripping a
95+
// confusing UNKNOWN_KEY('values'). Per-key, unwrap the read-shape
96+
// `{ value, … }` wrapper to the bare value; flat `{ key: value }` bodies
97+
// (and a manifest that genuinely declares a `values` key alongside others)
98+
// are untouched.
99+
if (
100+
Object.keys(body).length === 1 &&
101+
body.values && typeof body.values === 'object' && !Array.isArray(body.values)
102+
) {
103+
const inner = body.values as Record<string, unknown>;
104+
body = Object.fromEntries(
105+
Object.entries(inner).map(([k, v]) =>
106+
v && typeof v === 'object' && !Array.isArray(v) && 'value' in (v as object)
107+
? [k, (v as { value: unknown }).value]
108+
: [k, v],
109+
),
110+
);
111+
}
92112
try {
93113
const ctx = ctxOf(req);
94114
const result = await service.setMany(ns, body, ctx);

packages/services/service-settings/src/translations/zh-CN.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -107,10 +107,10 @@ export const zhCN: TranslationData = {
107107
default_country: { label: '默认国家/地区', help: 'ISO 3166-1 二位代码(如 US、GB、CN)。' },
108108
date_format: { label: '日期格式' },
109109
time_format: { label: '时间格式', options: { '24h': '24 小时制(14:30)', '12h': '12 小时制(2:30 PM)' } },
110-
number_format: { label: '数字格式' },
111-
first_day_of_week: { label: '每周起始日', options: { monday: '周一(ISO)', sunday: '周日', saturday: '周六' } },
112-
currency: { label: '默认货币' },
113-
fiscal_year_start: { label: '财年起始月' },
110+
number_format: { label: '数字格式', help: '用于显示数字的千分位与小数分隔符。' },
111+
first_day_of_week: { label: '每周起始日', help: '用作周度分析分桶与日历网格的起始基准。', options: { monday: '周一(ISO)', sunday: '周日', saturday: '周六' } },
112+
currency: { label: '默认货币', help: '当货币字段未指定币种时套用的 ISO 4217 代码。' },
113+
fiscal_year_start: { label: '财年起始月', help: '财年的起始月份——决定报表中的"本季度/本财年"。' },
114114
},
115115
},
116116

0 commit comments

Comments
 (0)