diff --git a/.changeset/21849-retire-sys-account-link-social.md b/.changeset/21849-retire-sys-account-link-social.md new file mode 100644 index 00000000000..bfceb82e8a9 --- /dev/null +++ b/.changeset/21849-retire-sys-account-link-social.md @@ -0,0 +1,15 @@ +--- +'@objectstack/platform-objects': minor +--- + +fix(platform-objects)!: retire the `sys_account` `link_social` action, which was dead on every boot; `unlink_account` stays + +Clause-②: no (narrowing) + + + +**BREAKING**: `sys_account` no longer declares the `link_social` action, so the "Link Social Account" toolbar button is gone from the Account app's Linked Accounts list and from Setup's Identity Links. It never completed a link on any boot: it navigated to a `GET` of the social sign-in route, which is served as `POST` only, and it offered a fixed list of seven providers whatever the boot had configured. It is retired under ADR-0049 (enforce or remove) and ships as `minor` under the launch-window convention for narrowings. + +**What stays.** `unlink_account` is unchanged: the same type, target, placement and row-id parameter. Its confirm question no longer says the user can re-link "from their account settings", because no console surface offers that now. The `sys_account._actions.link_social` leaves are gone from the `en`, `zh-CN`, `ja-JP` and `es-ES` bundles. + +**What to do after upgrading.** Linking a social or OIDC identity stays available through the signed-in `POST /api/v1/auth/link-social`, which is `auth.accounts.linkSocial({ provider, callbackURL })` in `@objectstack/client`: call it and navigate to the `url` it answers. diff --git a/content/docs/protocol/objectui/actions.mdx b/content/docs/protocol/objectui/actions.mdx index 0680a64e258..a3184f31de4 100644 --- a/content/docs/protocol/objectui/actions.mdx +++ b/content/docs/protocol/objectui/actions.mdx @@ -94,10 +94,15 @@ label: View Dashboard type: url target: /_console/dashboards/sales_overview -name: link_social -label: Connect Account +name: open_in_maps +label: Open in Maps type: url -target: '/api/v1/auth/sign-in/social?provider=${param.provider}&callbackURL=${ctx.origin}/_console/apps/account/sys_account' +target: 'https://www.google.com/maps/search/?api=1&query=${param.address}' +params: + - name: address + label: Address + type: text + required: true ``` For a **static** url, `openIn: 'new-tab'` opens `target` in a new tab and `openIn: 'self'` navigates in place; omitted, absolute URLs open in a new tab and relative ones navigate in place. For an **async handler that redirects**, set `opensInNewTab: true` instead (the renderer pre-opens the tab synchronously so popup blockers don't fire); `newTabUrl` provides a zero-roundtrip new-tab target template supporting the `{recordId}` placeholder. diff --git a/packages/platform-objects/src/apps/account.app.ts b/packages/platform-objects/src/apps/account.app.ts index b705aa57b63..87334cef729 100644 --- a/packages/platform-objects/src/apps/account.app.ts +++ b/packages/platform-objects/src/apps/account.app.ts @@ -19,7 +19,6 @@ * make the experience equivalent to the old account SPA for supported * surfaces: * - `sys_oauth_application.create` — one-time client_secret reveal - * - `sys_account.link_social` — OAuth redirect URL * * The same objects also appear (admin-only) in `setup.app.ts`'s * Advanced group, gated by `manage_platform_settings`, for tenant-wide diff --git a/packages/platform-objects/src/apps/translations/en.objects.generated.ts b/packages/platform-objects/src/apps/translations/en.objects.generated.ts index d56a0f174e9..0d214ad760d 100644 --- a/packages/platform-objects/src/apps/translations/en.objects.generated.ts +++ b/packages/platform-objects/src/apps/translations/en.objects.generated.ts @@ -471,26 +471,9 @@ export const enObjects: NonNullable = { } }, _actions: { - link_social: { - label: "Link Social Account", - params: { - provider: { - label: "Provider", - options: { - google: "Google", - github: "GitHub", - microsoft: "Microsoft", - apple: "Apple", - facebook: "Facebook", - gitlab: "GitLab", - discord: "Discord" - } - } - } - }, unlink_account: { label: "Unlink Account", - description: "Unlink this identity link? The user will no longer be able to sign in with this provider until they re-link it from their account settings.", + description: "Unlink this identity link? The user will no longer be able to sign in with this provider until they re-link it.", successMessage: "Identity link removed" } } diff --git a/packages/platform-objects/src/apps/translations/es-ES.objects.generated.ts b/packages/platform-objects/src/apps/translations/es-ES.objects.generated.ts index d2a55d78fd1..c5e6f49414d 100644 --- a/packages/platform-objects/src/apps/translations/es-ES.objects.generated.ts +++ b/packages/platform-objects/src/apps/translations/es-ES.objects.generated.ts @@ -471,26 +471,9 @@ export const esESObjects: NonNullable = { } }, _actions: { - link_social: { - label: "Vincular cuenta social", - params: { - provider: { - label: "Proveedor", - options: { - google: "Google", - github: "GitHub", - microsoft: "Microsoft", - apple: "Apple", - facebook: "Facebook", - gitlab: "GitLab", - discord: "Discord" - } - } - } - }, unlink_account: { label: "Desvincular cuenta", - description: "¿Desvincular este vínculo de identidad? El usuario ya no podrá iniciar sesión con este proveedor hasta que lo vuelva a vincular desde la configuración de su cuenta.", + description: "¿Desvincular este vínculo de identidad? El usuario ya no podrá iniciar sesión con este proveedor hasta que lo vuelva a vincular.", successMessage: "Vínculo de identidad eliminado" } } diff --git a/packages/platform-objects/src/apps/translations/es-ES.source-hashes.generated.ts b/packages/platform-objects/src/apps/translations/es-ES.source-hashes.generated.ts index 2decb02bf6f..bf49df2e212 100644 --- a/packages/platform-objects/src/apps/translations/es-ES.source-hashes.generated.ts +++ b/packages/platform-objects/src/apps/translations/es-ES.source-hashes.generated.ts @@ -18,13 +18,6 @@ */ export const esESGeneratedSourceHashes: Readonly> = { - "objects.sys_account._actions.link_social.params.provider.options.apple": "cfdc41e15ed6699b", - "objects.sys_account._actions.link_social.params.provider.options.discord": "12f931cc062e76ae", - "objects.sys_account._actions.link_social.params.provider.options.facebook": "7eea009178f5b807", - "objects.sys_account._actions.link_social.params.provider.options.github": "2971d247eb4e6abc", - "objects.sys_account._actions.link_social.params.provider.options.gitlab": "bc3dbbf4b650e600", - "objects.sys_account._actions.link_social.params.provider.options.google": "6fadcd05bb8da367", - "objects.sys_account._actions.link_social.params.provider.options.microsoft": "17309efbdb1ec122", "objects.sys_email.fields.cc_addresses.label": "02946d952cf15623", "objects.sys_email.fields.error.label": "786fed84bd8d5a32", "objects.sys_email.fields.message_id.label": "14cd089a4062f4b1", diff --git a/packages/platform-objects/src/apps/translations/ja-JP.objects.generated.ts b/packages/platform-objects/src/apps/translations/ja-JP.objects.generated.ts index 12466d2e907..0022b513d0a 100644 --- a/packages/platform-objects/src/apps/translations/ja-JP.objects.generated.ts +++ b/packages/platform-objects/src/apps/translations/ja-JP.objects.generated.ts @@ -471,26 +471,9 @@ export const jaJPObjects: NonNullable = { } }, _actions: { - link_social: { - label: "ソーシャルアカウント連携", - params: { - provider: { - label: "プロバイダー", - options: { - google: "Google", - github: "GitHub", - microsoft: "Microsoft", - apple: "Apple", - facebook: "Facebook", - gitlab: "GitLab", - discord: "Discord" - } - } - } - }, unlink_account: { label: "連携解除", - description: "このID連携を解除しますか?ユーザーがアカウント設定から再度連携するまで、このプロバイダーでサインインできなくなります。", + description: "このID連携を解除しますか?ユーザーが再度連携するまで、このプロバイダーでサインインできなくなります。", successMessage: "アイデンティティ連携を解除しました" } } diff --git a/packages/platform-objects/src/apps/translations/ja-JP.source-hashes.generated.ts b/packages/platform-objects/src/apps/translations/ja-JP.source-hashes.generated.ts index a96e8eb503d..1ecb3cd524f 100644 --- a/packages/platform-objects/src/apps/translations/ja-JP.source-hashes.generated.ts +++ b/packages/platform-objects/src/apps/translations/ja-JP.source-hashes.generated.ts @@ -18,13 +18,6 @@ */ export const jaJPGeneratedSourceHashes: Readonly> = { - "objects.sys_account._actions.link_social.params.provider.options.apple": "cfdc41e15ed6699b", - "objects.sys_account._actions.link_social.params.provider.options.discord": "12f931cc062e76ae", - "objects.sys_account._actions.link_social.params.provider.options.facebook": "7eea009178f5b807", - "objects.sys_account._actions.link_social.params.provider.options.github": "2971d247eb4e6abc", - "objects.sys_account._actions.link_social.params.provider.options.gitlab": "bc3dbbf4b650e600", - "objects.sys_account._actions.link_social.params.provider.options.google": "6fadcd05bb8da367", - "objects.sys_account._actions.link_social.params.provider.options.microsoft": "17309efbdb1ec122", "objects.sys_email.fields.bcc_addresses.label": "8674899b7b6d5126", "objects.sys_email.fields.cc_addresses.label": "02946d952cf15623", "objects.sys_email.fields.message_id.label": "14cd089a4062f4b1", diff --git a/packages/platform-objects/src/apps/translations/objects-es-es-echo-decisions.test.ts b/packages/platform-objects/src/apps/translations/objects-es-es-echo-decisions.test.ts index 7b1377fc36a..b696d5865e9 100644 --- a/packages/platform-objects/src/apps/translations/objects-es-es-echo-decisions.test.ts +++ b/packages/platform-objects/src/apps/translations/objects-es-es-echo-decisions.test.ts @@ -48,6 +48,11 @@ // table holds nothing BEYOND the echoes is not asserted, for the reason the // next section gives. // +// Since that walk, the seven `sys_account._actions.link_social` provider-brand +// rows (Google through Discord) left this ledger together with the action, +// retired under ADR-0049 enforce-or-remove (#21849): the bundle no longer +// carries those leaves, so the ledger below holds 47 echoes. +// // ## What this file deliberately does NOT assert // // That no OTHER es-ES objects leaf reads its `en` source. A new field's label @@ -89,8 +94,6 @@ interface Decision { departure?: string; } -const BRAND = - 'A sign-in provider brand, shown as the name a user recognises on the link button. This bundle renders no brand name: the authored SSO empty state keeps Okta, Entra and Auth0 verbatim.'; const BARE_ID = 'The bare initialism ID. This bundle keeps ID verbatim inside every label that carries it (ID de usuario, ID de organización, ID de cliente); a bare en ID has no noun to render, and adding one would invent content.'; const JWKS = @@ -151,15 +154,7 @@ const DECISIONS: readonly Decision[] = [ reason: 'The team record page field zh-CN reproduced as MEMBER COUNT: an ordinary field label an admin reads, an unauthored fill.', }, - // ── Kept as written by design (54) ─────────────────────────────────────── - echo('sys_account._actions.link_social.params.provider.options.google', 'Google', BRAND), - echo('sys_account._actions.link_social.params.provider.options.github', 'GitHub', BRAND), - echo('sys_account._actions.link_social.params.provider.options.microsoft', 'Microsoft', BRAND), - echo('sys_account._actions.link_social.params.provider.options.apple', 'Apple', BRAND), - echo('sys_account._actions.link_social.params.provider.options.facebook', 'Facebook', BRAND), - echo('sys_account._actions.link_social.params.provider.options.gitlab', 'GitLab', BRAND), - echo('sys_account._actions.link_social.params.provider.options.discord', 'Discord', BRAND), - + // ── Kept as written by design (47) ─────────────────────────────────────── echo('sys_oauth_application.fields.jwks.label', 'JWKS', JWKS), echo('sys_oauth_application.fields.jwks_uri.label', 'JWKS URI', JWKS), echo('sys_oauth_application._actions.create_oauth_application.params.type.options.web', 'Web', WEB_CLIENT_TYPE), @@ -242,10 +237,10 @@ function undeclaredEchoes(rows: readonly Decision[]): string[] { } describe('#20493 es-ES — the ledger itself (controls before verdicts)', () => { - it('is the size it claims: 3 pinned translations and 54 declared echoes, no path twice', () => { - expect(DECISIONS.length).toBe(57); + it('is the size it claims: 3 pinned translations and 47 declared echoes, no path twice', () => { + expect(DECISIONS.length).toBe(50); expect(DECISIONS.filter((d) => d.verdict === 'translate').length).toBe(3); - expect(DECISIONS.filter((d) => d.verdict === 'echo').length).toBe(54); + expect(DECISIONS.filter((d) => d.verdict === 'echo').length).toBe(47); expect(new Set(DECISIONS.map((d) => d.path)).size).toBe(DECISIONS.length); }); diff --git a/packages/platform-objects/src/apps/translations/objects-ja-jp-echo-decisions.test.ts b/packages/platform-objects/src/apps/translations/objects-ja-jp-echo-decisions.test.ts index 6aa17e0307b..303d7773d66 100644 --- a/packages/platform-objects/src/apps/translations/objects-ja-jp-echo-decisions.test.ts +++ b/packages/platform-objects/src/apps/translations/objects-ja-jp-echo-decisions.test.ts @@ -45,6 +45,11 @@ // table holds nothing BEYOND the echoes is not asserted, for the reason the // next section gives. // +// Since that walk, the seven `sys_account._actions.link_social` provider-brand +// rows (Google through Discord) left this ledger together with the action, +// retired under ADR-0049 enforce-or-remove (#21849): the bundle no longer +// carries those leaves, so the ledger below holds 36 echoes. +// // ## What this file deliberately does NOT assert // // That no OTHER ja-JP objects leaf reads its `en` source. A new field's label @@ -89,8 +94,6 @@ interface Decision { departure?: string; } -const BRAND = - 'A sign-in provider brand, shown as the name a user recognises on the link button. This bundle renders no brand name in kana: the authored SSO empty state keeps Okta, Entra and Auth0 verbatim.'; const BARE_ID = 'The bare initialism ID. This bundle keeps ID verbatim inside every label that carries it (ユーザー ID, 組織 ID, クライアント ID); a bare en ID has no noun to render, and adding one would invent content.'; const JWKS = @@ -143,15 +146,7 @@ const DECISIONS: readonly Decision[] = [ reason: 'The team record page field zh-CN reproduced as MEMBER COUNT: an ordinary field label an admin reads, an unauthored fill.', }, - // ── Kept in English by design (43) ─────────────────────────────────────── - echo('sys_account._actions.link_social.params.provider.options.google', 'Google', BRAND), - echo('sys_account._actions.link_social.params.provider.options.github', 'GitHub', BRAND), - echo('sys_account._actions.link_social.params.provider.options.microsoft', 'Microsoft', BRAND), - echo('sys_account._actions.link_social.params.provider.options.apple', 'Apple', BRAND), - echo('sys_account._actions.link_social.params.provider.options.facebook', 'Facebook', BRAND), - echo('sys_account._actions.link_social.params.provider.options.gitlab', 'GitLab', BRAND), - echo('sys_account._actions.link_social.params.provider.options.discord', 'Discord', BRAND), - + // ── Kept in English by design (36) ─────────────────────────────────────── echo('sys_oauth_application.fields.jwks.label', 'JWKS', JWKS), echo('sys_oauth_application.fields.jwks_uri.label', 'JWKS URI', JWKS), echo('sys_oauth_application._actions.create_oauth_application.params.type.options.web', 'Web', WEB_CLIENT_TYPE), @@ -221,10 +216,10 @@ function undeclaredEchoes(rows: readonly Decision[]): string[] { } describe('#20493 ja-JP — the ledger itself (controls before verdicts)', () => { - it('is the size it claims: 3 pinned translations and 43 declared echoes, no path twice', () => { - expect(DECISIONS.length).toBe(46); + it('is the size it claims: 3 pinned translations and 36 declared echoes, no path twice', () => { + expect(DECISIONS.length).toBe(39); expect(DECISIONS.filter((d) => d.verdict === 'translate').length).toBe(3); - expect(DECISIONS.filter((d) => d.verdict === 'echo').length).toBe(43); + expect(DECISIONS.filter((d) => d.verdict === 'echo').length).toBe(36); expect(new Set(DECISIONS.map((d) => d.path)).size).toBe(DECISIONS.length); }); diff --git a/packages/platform-objects/src/apps/translations/objects-zh-cn-echo-decisions.test.ts b/packages/platform-objects/src/apps/translations/objects-zh-cn-echo-decisions.test.ts index 5f9d83ea888..205c82c2ec4 100644 --- a/packages/platform-objects/src/apps/translations/objects-zh-cn-echo-decisions.test.ts +++ b/packages/platform-objects/src/apps/translations/objects-zh-cn-echo-decisions.test.ts @@ -36,6 +36,11 @@ // table holds nothing BEYOND the echoes is not asserted, for the reason the // next section gives. // +// Since that walk, the seven `sys_account._actions.link_social` provider-brand +// rows (Google through Discord) left this ledger together with the action, +// retired under ADR-0049 enforce-or-remove (#21849): the bundle no longer +// carries those leaves, so the ledger below holds 35 echoes. +// // ## What this file deliberately does NOT assert // // That no OTHER zh-CN objects leaf reads its `en` source. A new field's label @@ -80,8 +85,6 @@ interface Decision { departure?: string; } -const BRAND = - 'A sign-in provider brand, shown as the name a user recognises on the link button. The bundle renders no brand name: the authored SSO empty state keeps Okta, Entra and Auth0 verbatim.'; const BARE_ID = 'The bare initialism ID. This bundle keeps ID verbatim inside every label that carries it (用户 ID, 团队 ID, 客户端 ID); a bare en ID has no noun to render, and adding one would invent content.'; const JWKS = @@ -132,15 +135,7 @@ const DECISIONS: readonly Decision[] = [ reason: 'Reproduced on the team record page as MEMBER COUNT: an ordinary field label an admin reads, an unauthored fill.', }, - // ── Kept in English by design (42) ─────────────────────────────────────── - echo('sys_account._actions.link_social.params.provider.options.google', 'Google', BRAND), - echo('sys_account._actions.link_social.params.provider.options.github', 'GitHub', BRAND), - echo('sys_account._actions.link_social.params.provider.options.microsoft', 'Microsoft', BRAND), - echo('sys_account._actions.link_social.params.provider.options.apple', 'Apple', BRAND), - echo('sys_account._actions.link_social.params.provider.options.facebook', 'Facebook', BRAND), - echo('sys_account._actions.link_social.params.provider.options.gitlab', 'GitLab', BRAND), - echo('sys_account._actions.link_social.params.provider.options.discord', 'Discord', BRAND), - + // ── Kept in English by design (35) ─────────────────────────────────────── echo('sys_oauth_application.fields.jwks.label', 'JWKS', JWKS), echo('sys_oauth_application.fields.jwks_uri.label', 'JWKS URI', JWKS), echo( @@ -217,10 +212,10 @@ function undeclaredEchoes(rows: readonly Decision[]): string[] { } describe('#20462 — the ledger itself (controls before verdicts)', () => { - it('is the size it claims: 3 pinned translations and 42 declared echoes, no path twice', () => { - expect(DECISIONS.length).toBe(45); + it('is the size it claims: 3 pinned translations and 35 declared echoes, no path twice', () => { + expect(DECISIONS.length).toBe(38); expect(DECISIONS.filter((d) => d.verdict === 'translate').length).toBe(3); - expect(DECISIONS.filter((d) => d.verdict === 'echo').length).toBe(42); + expect(DECISIONS.filter((d) => d.verdict === 'echo').length).toBe(35); expect(new Set(DECISIONS.map((d) => d.path)).size).toBe(DECISIONS.length); }); diff --git a/packages/platform-objects/src/apps/translations/zh-CN.objects.generated.ts b/packages/platform-objects/src/apps/translations/zh-CN.objects.generated.ts index 74f069cefb5..6dbf1a1f3fe 100644 --- a/packages/platform-objects/src/apps/translations/zh-CN.objects.generated.ts +++ b/packages/platform-objects/src/apps/translations/zh-CN.objects.generated.ts @@ -471,26 +471,9 @@ export const zhCNObjects: NonNullable = { } }, _actions: { - link_social: { - label: "关联社交账号", - params: { - provider: { - label: "服务提供商", - options: { - google: "Google", - github: "GitHub", - microsoft: "Microsoft", - apple: "Apple", - facebook: "Facebook", - gitlab: "GitLab", - discord: "Discord" - } - } - } - }, unlink_account: { label: "解除关联", - description: "确定要解除此身份关联吗?在用户从账户设置中重新关联之前,将无法再使用此提供方登录。", + description: "确定要解除此身份关联吗?在用户重新关联之前,将无法再使用此提供方登录。", successMessage: "已解除身份关联" } } diff --git a/packages/platform-objects/src/apps/translations/zh-CN.source-hashes.generated.ts b/packages/platform-objects/src/apps/translations/zh-CN.source-hashes.generated.ts index 31882e433b8..73ffa865234 100644 --- a/packages/platform-objects/src/apps/translations/zh-CN.source-hashes.generated.ts +++ b/packages/platform-objects/src/apps/translations/zh-CN.source-hashes.generated.ts @@ -18,13 +18,6 @@ */ export const zhCNGeneratedSourceHashes: Readonly> = { - "objects.sys_account._actions.link_social.params.provider.options.apple": "cfdc41e15ed6699b", - "objects.sys_account._actions.link_social.params.provider.options.discord": "12f931cc062e76ae", - "objects.sys_account._actions.link_social.params.provider.options.facebook": "7eea009178f5b807", - "objects.sys_account._actions.link_social.params.provider.options.github": "2971d247eb4e6abc", - "objects.sys_account._actions.link_social.params.provider.options.gitlab": "bc3dbbf4b650e600", - "objects.sys_account._actions.link_social.params.provider.options.google": "6fadcd05bb8da367", - "objects.sys_account._actions.link_social.params.provider.options.microsoft": "17309efbdb1ec122", "objects.sys_email.fields.message_id.label": "14cd089a4062f4b1", "objects.sys_email_template.fields.id.label": "00b0385c9c152888", "objects.sys_metadata.fields.id.label": "00b0385c9c152888", diff --git a/packages/platform-objects/src/identity/sys-account-link-social-retired.test.ts b/packages/platform-objects/src/identity/sys-account-link-social-retired.test.ts new file mode 100644 index 00000000000..c57f04d182a --- /dev/null +++ b/packages/platform-objects/src/identity/sys-account-link-social-retired.test.ts @@ -0,0 +1,113 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #21849 — `sys_account` declares no link action; `unlink_account` stays. + * + * `link_social` was a `type: 'url'` toolbar action that navigated the browser to + * a GET of better-auth's social sign-in route, which better-auth serves as POST + * only, and it offered a fixed list of seven providers whatever the boot had + * configured. It was dead on every boot: a provider-less boot offered seven + * providers that 404, and a configured boot still could not link. It was + * retired under ADR-0049 enforce-or-remove rather than repaired, because the + * action contract has no way to say "options from the configured providers" + * or "hidden when none is configured" (the maintainer-ratified ruling on the + * card). Linking stays reachable through the signed-in + * `POST /api/v1/auth/link-social` (SDK `auth.accounts.linkSocial`). + * + * What is pinned is the absence of a LINK AFFORDANCE, not only of one name: an + * action that targets a social sign-in or link door under any other name is + * the same dead shape returning. The unlink half is pinned on the fields that + * make it work against better-auth, so the retirement cannot have taken it + * along. The four shipped translation bundles and the three provenance tables + * are pinned too: a leaf left behind for a retired action is a catalog entry + * nothing renders, and a provenance row for it keeps a stale echo decision + * alive. + */ + +import { describe, expect, it } from 'vitest'; + +import { SysAccount } from './sys-account.object.js'; +import { enObjects } from '../apps/translations/en.objects.generated.js'; +import { zhCNObjects } from '../apps/translations/zh-CN.objects.generated.js'; +import { jaJPObjects } from '../apps/translations/ja-JP.objects.generated.js'; +import { esESObjects } from '../apps/translations/es-ES.objects.generated.js'; +import { zhCNGeneratedSourceHashes } from '../apps/translations/zh-CN.source-hashes.generated.js'; +import { jaJPGeneratedSourceHashes } from '../apps/translations/ja-JP.source-hashes.generated.js'; +import { esESGeneratedSourceHashes } from '../apps/translations/es-ES.source-hashes.generated.js'; + +type ActionLike = { name?: string; type?: string; target?: string; [key: string]: unknown }; + +const actions = (): ActionLike[] => ((SysAccount as { actions?: ActionLike[] }).actions ?? []); + +/** A target that starts a social/OIDC sign-in or link round-trip. */ +const LINK_DOOR = /\/auth\/(sign-in\/(social|oauth2)|link-social)\b/; + +describe('#21849 — sys_account declares no link action', () => { + it('declares exactly one action, unlink_account — no link_social', () => { + const names = actions().map((a) => a.name); + expect(names).not.toContain('link_social'); + expect(names).toEqual(['unlink_account']); + }); + + it('no action targets a social sign-in or link door, under any name', () => { + const doors = actions() + .filter((a) => typeof a.target === 'string' && LINK_DOOR.test(a.target)) + .map((a) => `${a.name} -> ${a.target}`); + expect(doors).toEqual([]); + }); + + it('the link-door predicate can say "yes" — fed the retired target and the link door', () => { + // Without this, a predicate that matches nothing would green the test above. + expect(LINK_DOOR.test('/api/v1/auth/sign-in/social?provider=google')).toBe(true); + expect(LINK_DOOR.test('/api/v1/auth/link-social')).toBe(true); + expect(LINK_DOOR.test('/api/v1/auth/unlink-account')).toBe(false); + }); +}); + +describe('#21849 — unlink_account is unchanged by the retirement', () => { + it('keeps its name, type, target, placement and row-id param', () => { + const unlink = actions().find((a) => a.name === 'unlink_account'); + expect(unlink, 'unlink_account is missing from sys_account').toBeDefined(); + expect(unlink?.type).toBe('api'); + expect(unlink?.target).toBe('/api/v1/auth/unlink-account'); + expect(unlink?.mode).toBe('delete'); + expect(unlink?.locations).toEqual(['list_item', 'record_header']); + expect(unlink?.params).toEqual([ + { name: 'accountId', field: 'id', defaultFromRow: true, required: true }, + ]); + }); +}); + +describe('#21849 — the shipped translation bundles carry no link_social leaf', () => { + const BUNDLES = [ + ['en', enObjects], + ['zh-CN', zhCNObjects], + ['ja-JP', jaJPObjects], + ['es-ES', esESObjects], + ] as const; + + for (const [locale, bundle] of BUNDLES) { + it(`${locale}: sys_account._actions holds unlink_account and no link_social`, () => { + const acts = (bundle as Record }>).sys_account?._actions; + // Positive control first: an absent `_actions` would make the absence + // check below vacuous. + expect(acts, `${locale} bundle has no sys_account._actions`).toBeDefined(); + expect(Object.keys(acts ?? {})).toEqual(['unlink_account']); + expect(acts).not.toHaveProperty('link_social'); + }); + } + + const TABLES = [ + ['zh-CN', zhCNGeneratedSourceHashes], + ['ja-JP', jaJPGeneratedSourceHashes], + ['es-ES', esESGeneratedSourceHashes], + ] as const; + + for (const [locale, table] of TABLES) { + it(`${locale}: the provenance table keeps no row for the retired action`, () => { + const keys = Object.keys(table); + expect(keys.length, `${locale} provenance table is empty`).toBeGreaterThan(0); + expect(keys.filter((k) => k.startsWith('objects.sys_account._actions.link_social'))).toEqual([]); + }); + } +}); diff --git a/packages/platform-objects/src/identity/sys-account.object.ts b/packages/platform-objects/src/identity/sys-account.object.ts index e5196ab7b57..55baacb20f1 100644 --- a/packages/platform-objects/src/identity/sys-account.object.ts +++ b/packages/platform-objects/src/identity/sys-account.object.ts @@ -48,40 +48,14 @@ export const SysAccount = ObjectSchema.create({ // `accountId`). The form is locked to the row's values so it acts // as a one-click confirmation rather than a free-form edit. // - // `link_social` is the self-service counterpart — a toolbar action - // that redirects the browser to better-auth's social sign-in endpoint - // with a callbackURL pointing back to the linked-accounts view. The - // endpoint sets the link cookie and OAuth-dances through the provider, - // which is why it's `type: 'url'` (full page navigation) rather than - // `type: 'api'` (XHR — would block on CORS / 302). + // There is deliberately no LINK action. `link_social` was retired under + // ADR-0049 enforce-or-remove: it navigated to a GET of the POST-only + // social sign-in route and offered a fixed provider list, so it was dead + // on every boot. Linking is the signed-in `POST /api/v1/auth/link-social` + // (SDK `auth.accounts.linkSocial`), which answers the provider URL; a + // console surface for it reads the configured providers from + // `/auth/config`, never a static option list. actions: [ - { - name: 'link_social', - label: 'Link Social Account', - icon: 'link-2', - variant: 'primary', - mode: 'create', - locations: ['list_toolbar'], - type: 'url', - target: '/api/v1/auth/sign-in/social?provider=${param.provider}&callbackURL=${ctx.origin}/_console/apps/account/sys_account', - params: [ - { - name: 'provider', - label: 'Provider', - type: 'select', - required: true, - options: [ - { label: 'Google', value: 'google' }, - { label: 'GitHub', value: 'github' }, - { label: 'Microsoft', value: 'microsoft' }, - { label: 'Apple', value: 'apple' }, - { label: 'Facebook', value: 'facebook' }, - { label: 'GitLab', value: 'gitlab' }, - { label: 'Discord', value: 'discord' }, - ], - }, - ], - }, { name: 'unlink_account', label: 'Unlink Account', @@ -94,7 +68,7 @@ export const SysAccount = ObjectSchema.create({ // Confirm question on `description`, not `confirmText`: this action collects // params, and pairing the two keys opens two dialogs for one decision // (#7278 ruling 2026-08-10, swept by #7309). - description: 'Unlink this identity link? The user will no longer be able to sign in with this provider until they re-link it from their account settings.', + description: 'Unlink this identity link? The user will no longer be able to sign in with this provider until they re-link it.', successMessage: 'Identity link removed', refreshAfter: true, params: [ diff --git a/packages/platform-objects/src/identity/sys-member.object.ts b/packages/platform-objects/src/identity/sys-member.object.ts index 4d97d3afa11..0c96e3905d0 100644 --- a/packages/platform-objects/src/identity/sys-member.object.ts +++ b/packages/platform-objects/src/identity/sys-member.object.ts @@ -134,8 +134,8 @@ export const SysMember = ObjectSchema.create({ // and `ghost` are indistinguishable HERE; the choice is `secondary` // because it is what the button means, not what this surface draws.) // - `icon: 'link-2'` — "attach an EXISTING record", the same icon - // sys_account's `link_social` uses for attaching an existing external - // identity. `user-plus` is reserved for the flows that bring a NEW + // the Account app's Linked Accounts entry uses for existing external + // identities. `user-plus` is reserved for the flows that bring a NEW // person in. // The LABEL is deliberately left alone: "Add Member" and "Invite User" // already differ, and the four translation bundles' hand-written values diff --git a/packages/spec/src/ui/action.zod.ts b/packages/spec/src/ui/action.zod.ts index 38986cbb574..5952b1f69f3 100644 --- a/packages/spec/src/ui/action.zod.ts +++ b/packages/spec/src/ui/action.zod.ts @@ -1032,8 +1032,6 @@ const actionObject = () => strictObject({ * - `${param.X}` — value collected from the action's params dialog. * - `${ctx.X}` — values from the action context: `ctx.origin` * (window.origin), `ctx.recordId`, `ctx.user.id`, `ctx.org.id`, etc. - * Used by redirect-style actions like `link_social`, where the target is - * e.g. `/api/v1/auth/sign-in/social?provider=${param.provider}&callbackURL=${ctx.origin}/_console/apps/account/sys_account`. * Renderers MUST `encodeURIComponent` interpolated values before * substituting them into URL query positions. */