diff --git a/.changeset/26587-consent-metadata-write.md b/.changeset/26587-consent-metadata-write.md new file mode 100644 index 00000000000..bbbdc8be705 --- /dev/null +++ b/.changeset/26587-consent-metadata-write.md @@ -0,0 +1,12 @@ +--- +'@object-ui/i18n': patch +'@object-ui/console': patch +--- + +fix(console,i18n): the OAuth consent screen explains the `metadata:write` scope and never shows an unknown scope as its bare spelling + +Clause-②: yes +Metadata-protocol: none + +- **`@object-ui/console`.** The consent screen at `/oauth/consent` names every MCP scope in a sentence. The sentences come from `@objectstack/spec`'s `MCP_OAUTH_SCOPE_CONSENT_COPY`, the copy the spec declares beside each scope, through a switch that is exhaustive over `McpOauthScope`: a scope added to the spec without a case here fails to compile. `metadata:write` used to appear as that raw string beside three plain sentences. It now reads "Build and change this app's metadata as drafts, within your own permissions — a person publishes them". A scope the page cannot name renders as "Unrecognised permission: SCOPE" instead of the bare scope string, which could read as a description of the grant. +- **`@object-ui/i18n`.** Every locale pack gains `oauth.consent.scope.metadataWrite` and `oauth.consent.scope.unrecognised` (the latter takes a `{{scope}}` placeholder). `TranslationKeys` widens by these two keys. diff --git a/docs/qa/platform-checklist/areas/ai.json b/docs/qa/platform-checklist/areas/ai.json index 2b9622ad166..6c4a6d4fbd9 100644 --- a/docs/qa/platform-checklist/areas/ai.json +++ b/docs/qa/platform-checklist/areas/ai.json @@ -127,7 +127,7 @@ "title": "MCP HTTP transport (/mcp) and the public /mcp/skill endpoint gate exactly as documented: 404 when opted out, 501 when unimplemented, 401 anonymous, 403 scopeless-OAuth, full tool list when keyed", "since": "v15", "status": "active", - "revision": 5, + "revision": 6, "priority": "P1", "surface": "api", "fixtures": { @@ -140,7 +140,8 @@ "variants": [ "OAuth scope data:read → read-family tools: list_objects, describe_object, validate_expression, query_records, get_record, and aggregate_records where the bridge implements aggregate (packages/mcp/src/mcp-http-tools.ts canRead block; packages/spec/src/ai/mcp.zod.ts MCP_OAUTH_SCOPE_DATA_READ)", "OAuth scope data:write → write-family tools: create_record, update_record, delete_record", - "OAuth scope actions:execute → list_actions, run_action" + "OAuth scope actions:execute → list_actions, run_action", + "OAuth scope metadata:write → the authoring tools the host injects (packages/mcp/src/mcp-server-runtime.ts registerAuthoringTools; packages/spec/src/ai/mcp.zod.ts MCP_OAUTH_SCOPE_METADATA_WRITE), served only to an OAuth grant naming metadata:write — a key or a session is served none" ], "steps": [ "boot with OS_MCP_SERVER_ENABLED=false; GET /api/v1/mcp/skill and POST /api/v1/mcp; record status + body of both", @@ -148,7 +149,7 @@ "POST /api/v1/mcp/skill (wrong method); capture the 405 and its Allow header", "POST /api/v1/mcp with NO credentials (an MCP initialize body); capture the 401 body and any WWW-Authenticate header", "open an MCP Streamable HTTP session against /api/v1/mcp with the osk_ key: initialize, then tools/list; record the full tool-name set", - "if the OAuth track is live in the environment, request /mcp with an OAuth token (to mint one locally, register the client with application_type: 'native' — dynamic client registration refuses a web client whose redirect is http or a loopback host, 'web clients require https redirect URIs on non-loopback hosts', @better-auth/oauth-provider) carrying NONE of the three MCP scopes; capture the 403 + WWW-Authenticate error=\"insufficient_scope\"", + "if the OAuth track is live in the environment, request /mcp with an OAuth token (to mint one locally, register the client with application_type: 'native' — dynamic client registration refuses a web client whose redirect is http or a loopback host, 'web clients require https redirect URIs on non-loopback hosts', @better-auth/oauth-provider) carrying NONE of the four MCP scopes; capture the 403 + WWW-Authenticate error=\"insufficient_scope\"", "capture the dispatcher-vs-hono distinction: the disabled boot's 404 comes from the gate (surface not advertised), never a hang or 500" ], "acceptance": [ @@ -183,9 +184,9 @@ "evidence": "the session trace + tool-name list" }, { - "clause": "an OAuth token granting none of the MCP scopes is refused 403 up front with insufficient_scope naming all three scopes (data:read, data:write, actions:execute); a partial grant narrows the tool list per the variants matrix (#2698, ADR-0090 D10)", + "clause": "an OAuth token granting none of the MCP scopes is refused 403 up front with insufficient_scope naming all four scopes (data:read, data:write, actions:execute, metadata:write); a partial grant narrows the tool list per the variants matrix (#2698, ADR-0090 D10)", "oracle": "api", - "verify": "403 body 'Forbidden: the access token grants none of the MCP scopes …' + WWW-Authenticate error=\"insufficient_scope\" scope=\"data:read data:write actions:execute\"; for a data:read-only token, tools/list carries the read family only", + "verify": "403 body 'Forbidden: the access token grants none of the MCP scopes …' + WWW-Authenticate error=\"insufficient_scope\" scope=\"data:read data:write actions:execute metadata:write\"; for a data:read-only token, tools/list carries the read family only", "evidence": "the 403 + the narrowed tools/list" } ], @@ -209,7 +210,8 @@ { "revision": 2, "date": "2026-08-07", "change": "expanded to deep-test contract: concrete steps, multi-clause acceptance, negatives, variants", "ref": "claude/platform-test-checklist-ocwugl" }, { "revision": 3, "date": "2026-10-16", "change": "clause 5's tool list omitted resume_run (#23878 checklist-accuracy finding). Re-measured on main c91af8ed6 with an admin osk_ key: tools/list returns twelve tools, resume_run among them; mcp-http-tools.ts registers it only when the bridge exposes resumeRun, the same optional shape as aggregate_records, so the verify now names that degradation too", "ref": "#24036" }, { "revision": 4, "date": "2026-10-19", "change": "variants[0] and steps[5] corrected — checklist-accuracy findings of the 18.0 QA sweep (QA run #24540, ai.mcp-http-surface; wave anchor #24579 §3). The data:read family also serves aggregate_records and validate_expression (both read-only), and a local OAuth leg needs a native client. Re-derived at base 9a7838edf5 (mcp-http-tools.ts registers validate_expression and, when the bridge exposes aggregate, aggregate_records inside the canRead gate; the oauth-provider DCR rule in the installed @better-auth/oauth-provider 1.7.3). No clause changed", "ref": "#24649" }, - { "revision": 5, "date": "2026-10-26", "change": "automated.ref extended and annotated per clause — checklist-accuracy findings of the 18.2.0 sweep, Tier 1 record #25815 at 6679644a (wave anchor #25807): the one-file ref left acceptance[0]–[2] and [5] with no named pin, and the pin asserts acceptance[3]'s status only and acceptance[4] with a session bearer, no initialize and two tool names. Re-read on main at b4199c96: the record's 'no pin at any layer' for acceptance[0]–[2] and [5] no longer holds, since dispatcher-unit and Hono-integration pins in packages/runtime and the scope-narrowing unit in packages/mcp assert parts of them; they join the ref with the layer each reaches and what each leaves unasserted. No clause changed, no status changed", "ref": "#25836" } + { "revision": 5, "date": "2026-10-26", "change": "automated.ref extended and annotated per clause — checklist-accuracy findings of the 18.2.0 sweep, Tier 1 record #25815 at 6679644a (wave anchor #25807): the one-file ref left acceptance[0]–[2] and [5] with no named pin, and the pin asserts acceptance[3]'s status only and acceptance[4] with a session bearer, no initialize and two tool names. Re-read on main at b4199c96: the record's 'no pin at any layer' for acceptance[0]–[2] and [5] no longer holds, since dispatcher-unit and Hono-integration pins in packages/runtime and the scope-narrowing unit in packages/mcp assert parts of them; they join the ref with the layer each reaches and what each leaves unasserted. No clause changed, no status changed", "ref": "#25836" }, + { "revision": 6, "date": "2026-10-27", "change": "acceptance[5] clause and verify, steps[5] and variants follow the fourth MCP scope, metadata:write: the scopeless 403 names all four scopes in its body and in its WWW-Authenticate scope list (packages/runtime/src/domains/mcp.ts joins MCP_OAUTH_SCOPES), and the variants matrix gains the metadata:write row (the host-injected authoring tools, served only to an OAuth grant naming the scope). Re-read on main at c739de75. No status changed", "ref": "#26587" } ] }, { diff --git a/ui/apps/console/src/pages/auth/OAuthConsentPage.tsx b/ui/apps/console/src/pages/auth/OAuthConsentPage.tsx index b7f6c1c5ba4..935b2c4ded2 100644 --- a/ui/apps/console/src/pages/auth/OAuthConsentPage.tsx +++ b/ui/apps/console/src/pages/auth/OAuthConsentPage.tsx @@ -34,6 +34,15 @@ import { useNavigate } from 'react-router-dom'; import { Check, KeyRound, ShieldAlert, X } from 'lucide-react'; import { toast } from 'sonner'; import type { OAuthApplicationPublic } from '@objectstack/client'; +import { + MCP_OAUTH_SCOPE_ACTIONS, + MCP_OAUTH_SCOPE_CONSENT_COPY, + MCP_OAUTH_SCOPE_DATA_READ, + MCP_OAUTH_SCOPE_DATA_WRITE, + MCP_OAUTH_SCOPE_METADATA_WRITE, + MCP_OAUTH_SCOPES, + type McpOauthScope, +} from '@objectstack/spec/ai'; import { useAuth } from '@object-ui/auth'; import { useObjectTranslation } from '@object-ui/i18n'; import { @@ -271,10 +280,14 @@ export function OAuthConsentPage() { ); } -function describeScope( - scope: string, - t: ReturnType['t'], -): string { +type Translate = ReturnType['t']; + +function isMcpOauthScope(scope: string): scope is McpOauthScope { + return (MCP_OAUTH_SCOPES as readonly string[]).includes(scope); +} + +function describeScope(scope: string, t: Translate): string { + if (isMcpOauthScope(scope)) return describeAgentScope(scope, t); switch (scope) { case 'openid': return t('oauth.consent.scope.openid', { @@ -292,23 +305,37 @@ function describeScope( return t('oauth.consent.scope.offlineAccess', { defaultValue: 'Stay signed in (refresh access)', }); - // The three agent scopes, as the server grants them: the agent's tools and - // data ceiling follow the scope, and every call still runs under the - // signed-in user's own permissions, so each sentence carries that bound. - case 'data:read': - return t('oauth.consent.scope.dataRead', { - defaultValue: 'Read records, within your own permissions', - }); - case 'data:write': - return t('oauth.consent.scope.dataWrite', { - defaultValue: 'Create, change and delete records, within your own permissions', - }); - case 'actions:execute': - return t('oauth.consent.scope.actionsExecute', { - defaultValue: 'Run actions on your behalf, within your own permissions', - }); + // A scope the page cannot name is still a grant the user is asked to + // make, so it is announced as unrecognised, never shown as its bare + // spelling: a raw string reads like a description of what is granted. default: - return scope; + return t('oauth.consent.scope.unrecognised', { + scope, + defaultValue: 'Unrecognised permission: {{scope}}', + }); + } +} + +/** + * The MCP agent scopes, as the server grants them: the agent's tools and data + * ceiling follow the scope, and every call still runs under the signed-in + * user's own permissions, so each sentence carries that bound. The sentence is + * the spec's `MCP_OAUTH_SCOPE_CONSENT_COPY`, the one the server side declares + * beside the scope; the pack translates it. The switch is exhaustive over + * `McpOauthScope`, so a scope added to the spec without a case here fails to + * compile instead of reaching the user as "unrecognised". + */ +function describeAgentScope(scope: McpOauthScope, t: Translate): string { + const defaultValue = MCP_OAUTH_SCOPE_CONSENT_COPY[scope]; + switch (scope) { + case MCP_OAUTH_SCOPE_DATA_READ: + return t('oauth.consent.scope.dataRead', { defaultValue }); + case MCP_OAUTH_SCOPE_DATA_WRITE: + return t('oauth.consent.scope.dataWrite', { defaultValue }); + case MCP_OAUTH_SCOPE_ACTIONS: + return t('oauth.consent.scope.actionsExecute', { defaultValue }); + case MCP_OAUTH_SCOPE_METADATA_WRITE: + return t('oauth.consent.scope.metadataWrite', { defaultValue }); } } diff --git a/ui/apps/console/src/pages/auth/__tests__/OAuthConsentPage.agent-scopes-12853.test.tsx b/ui/apps/console/src/pages/auth/__tests__/OAuthConsentPage.agent-scopes-12853.test.tsx index c62f9c9bfc6..10c9e34c09e 100644 --- a/ui/apps/console/src/pages/auth/__tests__/OAuthConsentPage.agent-scopes-12853.test.tsx +++ b/ui/apps/console/src/pages/auth/__tests__/OAuthConsentPage.agent-scopes-12853.test.tsx @@ -15,8 +15,10 @@ * sentence, carrying the bound the server enforces: the agent acts within the * user's own permissions. * - * Any scope the page does not map still renders as its raw string — the - * `CONTROL` case — so no sentence is guessed from a scope's spelling. + * Any scope the page does not map renders as an explicit "unrecognised + * permission" line naming it — the `CONTROL` case — so no sentence is guessed + * from a scope's spelling, and the bare spelling never reads as a description + * of the grant (objectstack#26587, which also walks every MCP scope). * * The catalogue reaches the page through the global instance `createI18n` * registers, the same harness as `FormPage.chromeI18n-11071.test.tsx`. @@ -106,12 +108,12 @@ describe('OAuthConsentPage — the agent scopes in words (objectui#12853)', () = ]); }); - it('CONTROL: a scope the page does not map still renders as its raw string', async () => { + it('CONTROL: a scope the page does not map renders as unrecognised, never as its raw string', async () => { renderConsent('data:read reports:export', 'en'); expect(await scopeRows()).toEqual([ 'Read records, within your own permissions', - 'reports:export', + 'Unrecognised permission: reports:export', ]); }); }); diff --git a/ui/apps/console/src/pages/auth/__tests__/OAuthConsentPage.mcp-scopes-26587.test.tsx b/ui/apps/console/src/pages/auth/__tests__/OAuthConsentPage.mcp-scopes-26587.test.tsx new file mode 100644 index 00000000000..f3b778fc582 --- /dev/null +++ b/ui/apps/console/src/pages/auth/__tests__/OAuthConsentPage.mcp-scopes-26587.test.tsx @@ -0,0 +1,132 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * OAuthConsentPage names every MCP scope the spec declares, in every built-in + * language, and announces any other scope as unrecognised (objectstack#26587). + * + * `metadata:write` reached the consent screen as its raw spelling: the page + * mapped the three older agent scopes by hand and had no case for the fourth, + * so the one scope that lets an agent build in the user's environment was the + * only one left unexplained. The walk below takes its subjects from + * `MCP_OAUTH_SCOPES`, so a scope the spec adds is a subject here the moment it + * lands, and its English is pinned to `MCP_OAUTH_SCOPE_CONSENT_COPY`, the + * sentence the spec declares beside the scope. + * + * Each language's catalogue is loaded BEFORE the instance is created, so the + * rows are read from that language's pack, never from the `en` fallback a + * missing key would render through. + */ + +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { render, screen, cleanup } from '@testing-library/react'; +import { MemoryRouter, Route, Routes } from 'react-router-dom'; +import { MCP_OAUTH_SCOPE_CONSENT_COPY, MCP_OAUTH_SCOPES } from '@objectstack/spec/ai'; +import { AuthProvider } from '@object-ui/auth'; +import type { AuthClient } from '@object-ui/auth'; +import { BUILT_IN_LANGUAGE_CODES, createI18n, en, loadBuiltInLocale } from '@object-ui/i18n'; +import { OAuthConsentPage } from '../OAuthConsentPage'; + +const CLIENT_ID = 'mcp-client'; + +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); +}); + +function authClient(): AuthClient { + return { + getSession: vi + .fn() + .mockResolvedValue({ user: { id: 'u1', email: 'me@example.com' }, session: {} }), + getConfig: vi.fn().mockResolvedValue({ features: {} }), + } as unknown as AuthClient; +} + +function stubPublicClient() { + vi.stubGlobal( + 'fetch', + vi.fn(() => + Promise.resolve({ + ok: true, + status: 200, + json: async () => ({ client_id: CLIENT_ID, client_name: 'Claude Code' }), + }), + ), + ); +} + +async function renderConsent(scope: string, language: string) { + await loadBuiltInLocale(language); + createI18n({ defaultLanguage: language, detectBrowserLanguage: false }); + stubPublicClient(); + const search = `?client_id=${CLIENT_ID}&scope=${encodeURIComponent(scope)}&sig=s1`; + window.history.replaceState({}, '', `/oauth/consent${search}`); + return render( + + + + } /> + + + , + ); +} + +/** The consent list's rows, in the order the page draws them. */ +async function scopeRows(): Promise { + const list = await screen.findByRole('list'); + return Array.from(list.querySelectorAll('li')).map((li) => li.textContent ?? ''); +} + +const UNKNOWN_SCOPE = 'reports:export'; +const UNRECOGNISED_EN = en.oauth.consent.scope.unrecognised; + +describe('OAuthConsentPage — every MCP scope in words (objectstack#26587)', () => { + it('names each MCP scope in en with the sentence the spec declares for it', async () => { + await renderConsent(MCP_OAUTH_SCOPES.join(' '), 'en'); + + expect(await scopeRows()).toEqual( + MCP_OAUTH_SCOPES.map((scope) => MCP_OAUTH_SCOPE_CONSENT_COPY[scope]), + ); + }); + + it.each(BUILT_IN_LANGUAGE_CODES.filter((language) => language !== 'en'))( + 'names each MCP scope from the %s pack, never as its raw spelling', + async (language) => { + await renderConsent(MCP_OAUTH_SCOPES.join(' '), language); + const rows = await scopeRows(); + + expect(rows).toHaveLength(MCP_OAUTH_SCOPES.length); + MCP_OAUTH_SCOPES.forEach((scope, index) => { + expect(rows[index]).not.toContain(scope); + // The `en` sentence here would mean the key is missing from this pack + // and the row fell back to English. + expect(rows[index]).not.toBe(MCP_OAUTH_SCOPE_CONSENT_COPY[scope]); + }); + }, + ); + + it('announces a scope the page does not name as unrecognised, never as the bare string', async () => { + await renderConsent(`data:read ${UNKNOWN_SCOPE}`, 'en'); + + expect(await scopeRows()).toEqual([ + MCP_OAUTH_SCOPE_CONSENT_COPY['data:read'], + UNRECOGNISED_EN.replace('{{scope}}', UNKNOWN_SCOPE), + ]); + }); + + it('announces an unrecognised scope in the user language too', async () => { + await renderConsent(UNKNOWN_SCOPE, 'zh'); + const [row] = await scopeRows(); + + expect(row).not.toBe(UNKNOWN_SCOPE); + expect(row).toContain(UNKNOWN_SCOPE); + expect(row).not.toBe(UNRECOGNISED_EN.replace('{{scope}}', UNKNOWN_SCOPE)); + }); +}); diff --git a/ui/packages/i18n/src/locales/ar.ts b/ui/packages/i18n/src/locales/ar.ts index a8b6d0c0f82..d2e7278beba 100644 --- a/ui/packages/i18n/src/locales/ar.ts +++ b/ui/packages/i18n/src/locales/ar.ts @@ -2960,6 +2960,8 @@ const ar = { dataRead: "قراءة السجلات (ضمن صلاحياتك الخاصة)", dataWrite: "إنشاء السجلات وتعديلها وحذفها (ضمن صلاحياتك الخاصة)", actionsExecute: "تنفيذ الإجراءات نيابةً عنك (ضمن صلاحياتك الخاصة)", + metadataWrite: "إنشاء بيانات التعريف لهذا التطبيق وتعديلها كمسودات (ضمن صلاحياتك الخاصة)، ويتولى شخص نشرها", + unrecognised: "إذن غير معروف: {{scope}}", }, deny: "رفض", authorize: "تفويض", diff --git a/ui/packages/i18n/src/locales/de.ts b/ui/packages/i18n/src/locales/de.ts index aa00dea2d9a..6fcdf07c123 100644 --- a/ui/packages/i18n/src/locales/de.ts +++ b/ui/packages/i18n/src/locales/de.ts @@ -2750,6 +2750,8 @@ const de = { dataRead: "Datensätze lesen (im Rahmen Ihrer eigenen Berechtigungen)", dataWrite: "Datensätze erstellen, ändern und löschen (im Rahmen Ihrer eigenen Berechtigungen)", actionsExecute: "Aktionen in Ihrem Namen ausführen (im Rahmen Ihrer eigenen Berechtigungen)", + metadataWrite: "Die Metadaten dieser App als Entwürfe erstellen und ändern (im Rahmen Ihrer eigenen Berechtigungen) – veröffentlicht werden sie von einer Person", + unrecognised: "Unbekannte Berechtigung: {{scope}}", }, deny: "Ablehnen", authorize: "Autorisieren", diff --git a/ui/packages/i18n/src/locales/en.ts b/ui/packages/i18n/src/locales/en.ts index 6af1bd041e2..206d33a3efd 100644 --- a/ui/packages/i18n/src/locales/en.ts +++ b/ui/packages/i18n/src/locales/en.ts @@ -3290,8 +3290,10 @@ const en = { unidentified: 'This application could not be identified.', clientId: 'Client ID', willAllow: 'This app will be able to:', - // Only the seven scopes the page maps by name; anything else renders the - // raw scope string, which is why there is no generic entry here. + // One sentence per scope the page names. The MCP agent scopes take their + // English from `@objectstack/spec`'s `MCP_OAUTH_SCOPE_CONSENT_COPY` (the + // page passes it as the default) and must read the same here. Any other + // scope renders as `unrecognised`, never as its raw spelling. scope: { openid: 'Confirm your identity', profile: 'Read your basic profile (name, picture)', @@ -3300,6 +3302,8 @@ const en = { dataRead: 'Read records, within your own permissions', dataWrite: 'Create, change and delete records, within your own permissions', actionsExecute: 'Run actions on your behalf, within your own permissions', + metadataWrite: "Build and change this app's metadata as drafts, within your own permissions — a person publishes them", + unrecognised: 'Unrecognised permission: {{scope}}', }, deny: 'Deny', authorize: 'Authorize', diff --git a/ui/packages/i18n/src/locales/es.ts b/ui/packages/i18n/src/locales/es.ts index a136a999dac..acc1d326335 100644 --- a/ui/packages/i18n/src/locales/es.ts +++ b/ui/packages/i18n/src/locales/es.ts @@ -2804,6 +2804,8 @@ const es = { dataRead: "Leer registros (dentro de sus propios permisos)", dataWrite: "Crear, modificar y eliminar registros (dentro de sus propios permisos)", actionsExecute: "Ejecutar acciones en su nombre (dentro de sus propios permisos)", + metadataWrite: "Crear y modificar los metadatos de esta aplicación como borradores (dentro de sus propios permisos); una persona los publica", + unrecognised: "Permiso no reconocido: {{scope}}", }, deny: "Denegar", authorize: "Autorizar", diff --git a/ui/packages/i18n/src/locales/fr.ts b/ui/packages/i18n/src/locales/fr.ts index 8e3480412db..92abf473040 100644 --- a/ui/packages/i18n/src/locales/fr.ts +++ b/ui/packages/i18n/src/locales/fr.ts @@ -2800,6 +2800,8 @@ const fr = { dataRead: "Lire des enregistrements (dans la limite de vos propres autorisations)", dataWrite: "Créer, modifier et supprimer des enregistrements (dans la limite de vos propres autorisations)", actionsExecute: "Exécuter des actions en votre nom (dans la limite de vos propres autorisations)", + metadataWrite: "Créer et modifier les métadonnées de cette application sous forme de brouillons (dans la limite de vos propres autorisations) — une personne les publie", + unrecognised: "Autorisation non reconnue : {{scope}}", }, deny: "Refuser", authorize: "Autoriser", diff --git a/ui/packages/i18n/src/locales/ja.ts b/ui/packages/i18n/src/locales/ja.ts index 4ce1cb7be47..7c8504b8179 100644 --- a/ui/packages/i18n/src/locales/ja.ts +++ b/ui/packages/i18n/src/locales/ja.ts @@ -2752,6 +2752,8 @@ const ja = { dataRead: "レコードを読み取る(ご自身の権限の範囲内)", dataWrite: "レコードを作成・変更・削除する(ご自身の権限の範囲内)", actionsExecute: "あなたに代わってアクションを実行する(ご自身の権限の範囲内)", + metadataWrite: "このアプリのメタデータを下書きとして作成・変更する(ご自身の権限の範囲内)。公開は人が行います", + unrecognised: "認識できない権限:{{scope}}", }, deny: "拒否", authorize: "許可", diff --git a/ui/packages/i18n/src/locales/ko.ts b/ui/packages/i18n/src/locales/ko.ts index 55e59cc757d..44761253066 100644 --- a/ui/packages/i18n/src/locales/ko.ts +++ b/ui/packages/i18n/src/locales/ko.ts @@ -2750,6 +2750,8 @@ const ko = { dataRead: "레코드 읽기(본인 권한 범위 내)", dataWrite: "레코드 생성, 변경 및 삭제(본인 권한 범위 내)", actionsExecute: "사용자를 대신하여 작업 실행(본인 권한 범위 내)", + metadataWrite: "이 앱의 메타데이터를 초안으로 작성 및 변경(본인 권한 범위 내), 게시는 사람이 합니다", + unrecognised: "인식할 수 없는 권한: {{scope}}", }, deny: "거부", authorize: "허용", diff --git a/ui/packages/i18n/src/locales/pt.ts b/ui/packages/i18n/src/locales/pt.ts index 666f477de81..11b22b14ec4 100644 --- a/ui/packages/i18n/src/locales/pt.ts +++ b/ui/packages/i18n/src/locales/pt.ts @@ -2799,6 +2799,8 @@ const pt = { dataRead: "Ler registros (dentro das suas próprias permissões)", dataWrite: "Criar, alterar e excluir registros (dentro das suas próprias permissões)", actionsExecute: "Executar ações em seu nome (dentro das suas próprias permissões)", + metadataWrite: "Criar e alterar os metadados deste aplicativo como rascunhos (dentro das suas próprias permissões) — uma pessoa os publica", + unrecognised: "Permissão não reconhecida: {{scope}}", }, deny: "Negar", authorize: "Autorizar", diff --git a/ui/packages/i18n/src/locales/ru.ts b/ui/packages/i18n/src/locales/ru.ts index a1e4ddc45c4..2edde10b352 100644 --- a/ui/packages/i18n/src/locales/ru.ts +++ b/ui/packages/i18n/src/locales/ru.ts @@ -2863,6 +2863,8 @@ const ru = { dataRead: "Читать записи (в пределах ваших собственных прав)", dataWrite: "Создавать, изменять и удалять записи (в пределах ваших собственных прав)", actionsExecute: "Выполнять действия от вашего имени (в пределах ваших собственных прав)", + metadataWrite: "Создавать и изменять метаданные этого приложения в виде черновиков (в пределах ваших собственных прав) — публикует их человек", + unrecognised: "Нераспознанное разрешение: {{scope}}", }, deny: "Отклонить", authorize: "Разрешить", diff --git a/ui/packages/i18n/src/locales/zh.ts b/ui/packages/i18n/src/locales/zh.ts index 681cea9a2e2..4a8ff35cd28 100644 --- a/ui/packages/i18n/src/locales/zh.ts +++ b/ui/packages/i18n/src/locales/zh.ts @@ -2819,6 +2819,8 @@ const zh = { dataRead: '读取记录(不超出您自己的权限)', dataWrite: '创建、修改和删除记录(不超出您自己的权限)', actionsExecute: '代表您运行操作(不超出您自己的权限)', + metadataWrite: '以草稿形式构建和修改此应用的元数据(不超出您自己的权限),由人来发布', + unrecognised: '无法识别的权限:{{scope}}', }, deny: '拒绝', authorize: '授权',