Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .changeset/setup-firstrun-nav-i18n.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
"@objectstack/platform-objects": patch
"@objectstack/service-settings": patch
"@objectstack/rest": patch
---

fix(setup): first-run admin polish — pin Company/Localization, gate dashboard widgets by `requiresService`, i18n + settings PUT envelope

Dogfooding the Setup app as a brand-new system administrator surfaced a cluster of small first-run gaps, now fixed:

- **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'`.
- **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.
- **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.
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,12 @@ export const SystemOverviewDashboard = Dashboard.create({
dataset: 'sys_organization_metrics', values: ['org_count'],
title: 'Organizations',
type: 'metric',
// Organizations only exist under multi-tenant org-scoping. In a
// single-tenant runtime the count is always 0 and the matching
// nav entries (nav_organizations / nav_invitations) are hidden via
// `requiresService: 'org-scoping'` — gate this KPI the same way so the
// overview doesn't dangle a metric the admin can't act on.
requiresService: 'org-scoping',
layout: { x: 3, y: 0, w: 3, h: 2 },
colorVariant: 'orange',
description: 'Total organizations on the platform',
Expand Down
10 changes: 10 additions & 0 deletions packages/platform-objects/src/apps/setup-nav.contributions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,16 @@ export const SETUP_NAV_CONTRIBUTIONS: NavigationContribution[] = [
priority: BASE_PRIORITY,
items: [
{ id: 'nav_settings_hub', type: 'url', label: 'All Settings', url: '/apps/setup/system/settings', icon: 'settings-2', requiredPermissions: ['manage_platform_settings'] },
// Workspace identity first — Localization (order 2) and Company (order 3)
// are the lowest-`order` settings manifests and the first thing a new
// company admin configures. They ship as `service-settings` manifests
// (tenant scope, read=`setup.access`) but were never pinned here, so they
// were reachable only by drilling into the "All Settings" hub. Mainstream
// admin consoles (Salesforce "Company Information", ServiceNow) surface
// both directly. No `requiredPermissions` — matches Branding (read perm is
// the app's base `setup.access`).
{ id: 'nav_settings_localization', type: 'url', label: 'Localization', url: '/apps/setup/system/settings/localization', icon: 'globe' },
{ id: 'nav_settings_company', type: 'url', label: 'Company', url: '/apps/setup/system/settings/company', icon: 'building-2' },
{ id: 'nav_settings_branding', type: 'url', label: 'Branding', url: '/apps/setup/system/settings/branding', icon: 'palette' },
{ id: 'nav_settings_auth', type: 'url', label: 'Authentication', url: '/apps/setup/system/settings/auth', icon: 'lock-keyhole', requiredPermissions: ['manage_platform_settings'] },
{ id: 'nav_settings_mail', type: 'url', label: 'Email', url: '/apps/setup/system/settings/mail', icon: 'mail', requiredPermissions: ['manage_platform_settings'] },
Expand Down
6 changes: 6 additions & 0 deletions packages/platform-objects/src/apps/translations/zh-CN.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ export const zhCN: TranslationData = {
group_apps: { label: '应用' },
nav_marketplace_browse: { label: '浏览应用市场' },
nav_marketplace_installed: { label: '已安装应用' },
nav_cloud_connection: { label: '云连接' },
group_people_org: { label: '人员与组织' },
group_access_control: { label: '访问控制' },
group_approvals: { label: '审批' },
Expand All @@ -51,6 +52,7 @@ export const zhCN: TranslationData = {
nav_invitations: { label: '邀请' },

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

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

nav_datasources: { label: '数据源' },

nav_oauth_apps: { label: 'OAuth 应用' },
nav_jwks: { label: '签名密钥 (JWKS)' },
nav_verifications: { label: '验证记录' },
Expand Down
61 changes: 60 additions & 1 deletion packages/rest/src/rest-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1169,6 +1169,31 @@ export class RestServer {
return { ...item, navigation: filterNav(nav) };
}

/**
* ADR-0057 D10 (dashboards): strip dashboard widgets whose `requiresService`
* capability gate names a kernel service that isn't registered — the same
* "server is the authoritative visibility gate" rule already applied to app
* nav entries (see {@link filterAppForUser}). Without this, a widget bound to
* an optional service renders a dead tile in deployments where the service is
* off (e.g. the Organizations KPI under multi-tenant `org-scoping`, which is
* absent in a single-tenant runtime while its nav entry is correctly hidden).
*
* Fail-open when the gate can't be probed (serviceGate undefined). Never
* mutates the original — returns a shallow copy only when a widget is dropped.
*/
private filterDashboardForUser(item: any, serviceGate?: (name: string) => boolean): any {
if (!item || typeof item !== 'object' || !serviceGate) return item;
if (isMetaEnvelope(item)) {
const body = this.filterDashboardForUser((item as any).item, serviceGate);
return body === (item as any).item ? item : { ...(item as any), item: body };
}
if (!Array.isArray(item.widgets)) return item;
const widgets = item.widgets.filter(
(w: any) => !(w && typeof w.requiresService === 'string' && serviceGate(w.requiresService) === false),
);
return widgets.length === item.widgets.length ? item : { ...item, widgets };
}

/**
* Probe which `requiresService` capability gates referenced anywhere in
* `items` are actually registered in the runtime kernel. Returns `null`
Expand All @@ -1195,7 +1220,9 @@ export class RestServer {
if (isMetaEnvelope(e)) { walk((e as any).item); return; }
if (typeof e.requiresService === 'string') wanted.add(e.requiresService);
const kids = Array.isArray(e.navigation) ? e.navigation
: Array.isArray(e.children) ? e.children : null;
: Array.isArray(e.children) ? e.children
// Dashboard widgets carry their own `requiresService` gate.
: Array.isArray(e.widgets) ? e.widgets : null;
if (kids) for (const k of kids) walk(k);
};
for (const it of items) walk(it);
Expand Down Expand Up @@ -2002,6 +2029,28 @@ export class RestServer {
}
}

// ADR-0057 D10: gate dashboard widgets by `requiresService`
// the same way app nav entries are gated above.
if (req.params.type === 'dashboard') {
const raw = visible as unknown;
const list: any[] | null = Array.isArray(raw)
? (raw as any[])
: (raw && typeof raw === 'object' && Array.isArray((raw as any).items))
? ((raw as any).items as any[])
: null;
if (list) {
const ctx = await this.resolveExecCtx(environmentId, req).catch(() => undefined);
const registered = await this.resolveRegisteredServices((ctx as any)?.__kernel, list);
const serviceGate = registered ? (n: string) => registered.has(n) : undefined;
if (serviceGate) {
const filtered = list.map((it: any) => this.filterDashboardForUser(it, serviceGate));
visible = Array.isArray(raw)
? filtered
: { ...(raw as any), items: filtered };
}
}
}

// View switcher query: GET /meta/view?object=<object>
// returns ONLY the independent ViewItems bound to that
// object (the `package` layer of "Object has-many
Expand Down Expand Up @@ -2326,6 +2375,16 @@ export class RestServer {
}
}

// ADR-0057 D10: gate dashboard widgets by `requiresService`
// (mirrors the app-nav gate above) so the console never
// renders a tile bound to an absent optional service.
if (req.params.type === 'dashboard' && visible) {
const ctx = await this.resolveExecCtx(environmentId, req).catch(() => undefined);
const registered = await this.resolveRegisteredServices((ctx as any)?.__kernel, [visible]);
const serviceGate = registered ? (n: string) => registered.has(n) : undefined;
if (serviceGate) visible = this.filterDashboardForUser(visible, serviceGate);
}

// ADR-0046 i18n: collapse the doc to the request
// locale (label/description/content) and drop the
// `translations` map so consumers get one body.
Expand Down
56 changes: 56 additions & 0 deletions packages/rest/src/rest.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1944,6 +1944,62 @@ describe('filterAppForUser — ADR-0057 D10 requiresService gate', () => {
});
});

// ---------------------------------------------------------------------------
// ADR-0057 D10 — requiresService capability gate for DASHBOARD WIDGETS
// (filterDashboardForUser) — server is the authoritative visibility gate.
// ---------------------------------------------------------------------------

describe('filterDashboardForUser — ADR-0057 D10 widget requiresService gate', () => {
const make = () => new RestServer(createMockServer() as any, createMockProtocol() as any);
const dash = () => ({
name: 'system_overview',
widgets: [
{ id: 'widget_total_users' },
{ id: 'widget_packages_installed', requiresObject: 'sys_package_installation' },
{ id: 'widget_organizations', requiresService: 'org-scoping' },
],
});
const ids = (d: any): string[] => (d?.widgets ?? []).map((w: any) => w.id);

it('drops widgets whose requiresService gate reports the service absent', () => {
const rest: any = make();
const out = rest.filterDashboardForUser(dash(), (n: string) => n !== 'org-scoping');
expect(ids(out)).toEqual(['widget_total_users', 'widget_packages_installed']);
});

it('keeps requiresService widgets when the service is present', () => {
const rest: any = make();
expect(ids(rest.filterDashboardForUser(dash(), () => true))).toContain('widget_organizations');
});

it('fail-open: with no service gate, widgets are untouched', () => {
const rest: any = make();
expect(ids(rest.filterDashboardForUser(dash(), undefined))).toContain('widget_organizations');
});

it('does not touch requiresObject widgets (client-side concern)', () => {
const rest: any = make();
const out = rest.filterDashboardForUser(dash(), () => false);
expect(ids(out)).toContain('widget_packages_installed');
expect(ids(out)).not.toContain('widget_organizations');
});

it('unwraps the getMetaItem envelope and gates the inner dashboard', () => {
const rest: any = make();
const envelope = { type: 'dashboard', name: 'system_overview', item: dash() };
const out = rest.filterDashboardForUser(envelope, (n: string) => n !== 'org-scoping');
expect(out.type).toBe('dashboard');
expect(ids(out.item)).not.toContain('widget_organizations');
});

it('resolveRegisteredServices discovers requiresService declared on widgets', async () => {
const rest: any = make();
const kernel = { getServiceAsync: async (n: string) => { if (n === 'org-scoping') return {}; throw new Error('absent'); } };
const reg = await rest.resolveRegisteredServices(kernel, [dash()]);
expect(reg.has('org-scoping')).toBe(true);
});
});

// ---------------------------------------------------------------------------
// Object API exposure — enable.apiEnabled / enable.apiMethods (ADR-0049 #1889)
// ---------------------------------------------------------------------------
Expand Down
31 changes: 31 additions & 0 deletions packages/services/service-settings/src/settings-routes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,37 @@ describe('settings-routes', () => {
expect(state.status).toBe(404);
});

it('PUT accepts the {values:{...}} envelope (flat inner) symmetrically with GET', async () => {
const http = new MockHttp();
const svc = new SettingsService({ env: {} });
svc.registerManifest(brandingSettingsManifest);
registerSettingsRoutes(http, svc);

const h = http.routes.get('PUT /api/settings/:namespace')!;
const { req, res, state } = makeReqRes({ params: { namespace: 'branding' }, body: { values: { workspace_name: 'My Co' } } });
await h(req, res);
expect(state.body.error).toBeUndefined();
expect(state.body.values.workspace_name.value).toBe('My Co');
expect(state.body.values.workspace_name.source).toBe('tenant');
});

it('PUT accepts the read-shape envelope echoed back from GET', async () => {
const http = new MockHttp();
const svc = new SettingsService({ env: {} });
svc.registerManifest(brandingSettingsManifest);
registerSettingsRoutes(http, svc);

const h = http.routes.get('PUT /api/settings/:namespace')!;
// Exactly what GET returns: { values: { key: { value, source, ... } } }
const { req, res, state } = makeReqRes({
params: { namespace: 'branding' },
body: { values: { workspace_name: { value: 'Echoed', source: 'tenant', locked: false } } },
});
await h(req, res);
expect(state.body.error).toBeUndefined();
expect(state.body.values.workspace_name.value).toBe('Echoed');
});

it('POST action invokes service.runAction', async () => {
const http = new MockHttp();
const svc = new SettingsService({ env: {} });
Expand Down
22 changes: 21 additions & 1 deletion packages/services/service-settings/src/settings-routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,27 @@ export function registerSettingsRoutes(

http.put(`${base}/:namespace`, (async (req, res) => {
const ns = req.params.namespace;
const body = (req.body ?? {}) as Record<string, unknown>;
let body = (req.body ?? {}) as Record<string, unknown>;
// DX symmetry: GET returns `{ values: { key: { value, source, … } } }`.
// Accept that same envelope on PUT (sole top-level `values` object) so a
// caller can write back exactly what it read instead of tripping a
// confusing UNKNOWN_KEY('values'). Per-key, unwrap the read-shape
// `{ value, … }` wrapper to the bare value; flat `{ key: value }` bodies
// (and a manifest that genuinely declares a `values` key alongside others)
// are untouched.
if (
Object.keys(body).length === 1 &&
body.values && typeof body.values === 'object' && !Array.isArray(body.values)
) {
const inner = body.values as Record<string, unknown>;
body = Object.fromEntries(
Object.entries(inner).map(([k, v]) =>
v && typeof v === 'object' && !Array.isArray(v) && 'value' in (v as object)
? [k, (v as { value: unknown }).value]
: [k, v],
),
);
}
try {
const ctx = ctxOf(req);
const result = await service.setMany(ns, body, ctx);
Expand Down
8 changes: 4 additions & 4 deletions packages/services/service-settings/src/translations/zh-CN.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,10 +107,10 @@ export const zhCN: TranslationData = {
default_country: { label: '默认国家/地区', help: 'ISO 3166-1 二位代码(如 US、GB、CN)。' },
date_format: { label: '日期格式' },
time_format: { label: '时间格式', options: { '24h': '24 小时制(14:30)', '12h': '12 小时制(2:30 PM)' } },
number_format: { label: '数字格式' },
first_day_of_week: { label: '每周起始日', options: { monday: '周一(ISO)', sunday: '周日', saturday: '周六' } },
currency: { label: '默认货币' },
fiscal_year_start: { label: '财年起始月' },
number_format: { label: '数字格式', help: '用于显示数字的千分位与小数分隔符。' },
first_day_of_week: { label: '每周起始日', help: '用作周度分析分桶与日历网格的起始基准。', options: { monday: '周一(ISO)', sunday: '周日', saturday: '周六' } },
currency: { label: '默认货币', help: '当货币字段未指定币种时套用的 ISO 4217 代码。' },
fiscal_year_start: { label: '财年起始月', help: '财年的起始月份——决定报表中的"本季度/本财年"。' },
},
},

Expand Down