diff --git a/.changeset/24271-org-rename-door.md b/.changeset/24271-org-rename-door.md new file mode 100644 index 00000000000..6ec2706359a --- /dev/null +++ b/.changeset/24271-org-rename-door.md @@ -0,0 +1,12 @@ +--- +'@objectstack/platform-objects': minor +'@objectstack/spec': patch +--- + +`sys_organization` now declares a `rename_organization` action ("Rename Organization") at `record_header` and `list_item`, so an organization record page can rename the organization. It collects `name` (required) and `logo`, both pre-filled from the record, and no `slug`. It calls better-auth's `/api/v1/auth/organization/update` exactly as `update_organization` does (the record's id as `organizationId`, the fields under `data`), and it is gated on `multiOrgEnabled` like the other organization actions. Because the request carries no slug, the organization keeps its slug. It is declared before `change_slug`, so on the record header it takes the third inline slot and `change_slug` moves into the `⋯` overflow menu. + +`update_organization` (name, slug and logo, list row only) and `change_slug` are unchanged. + +`@objectstack/spec` books the new action in `PUBLIC_AUTH_FEATURES.multiOrgEnabled.gatedInputs`, beside `update_organization`. + +Clause-②: yes 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 b500f35efd5..7a0942cb959 100644 --- a/packages/platform-objects/src/apps/translations/en.objects.generated.ts +++ b/packages/platform-objects/src/apps/translations/en.objects.generated.ts @@ -601,6 +601,10 @@ export const enObjects: NonNullable = { label: "Set Active", successMessage: "Active organization switched" }, + rename_organization: { + label: "Rename Organization", + successMessage: "Organization renamed" + }, change_slug: { label: "Change Slug", description: "Renaming the slug rewrites every platform subdomain for this org and parks the old slug for 90 days. Continue?", 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 eb8a27af706..bc65351b76c 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 @@ -601,6 +601,10 @@ export const esESObjects: NonNullable = { label: "Establecer como activa", successMessage: "Organización activa cambiada" }, + rename_organization: { + label: "Renombrar organización", + successMessage: "Organización renombrada" + }, change_slug: { label: "Cambiar slug", description: "Renombrar el slug reescribe todos los subdominios de la plataforma para esta organización y reserva el slug anterior durante 90 días. ¿Continuar?", 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 efc43001ebe..f41fb422c2b 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 @@ -601,6 +601,10 @@ export const jaJPObjects: NonNullable = { label: "アクティブに設定", successMessage: "アクティブ組織を切り替えました" }, + rename_organization: { + label: "組織名を変更", + successMessage: "組織名を変更しました" + }, change_slug: { label: "スラッグを変更", description: "スラッグを変更すると、この組織のすべてのプラットフォームサブドメインが書き換えられ、旧スラッグは 90 日間確保されます。続行しますか?", 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 2ddcbc54f2e..2ee763c06f0 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 @@ -601,6 +601,10 @@ export const zhCNObjects: NonNullable = { label: "设为当前", successMessage: "当前组织已切换" }, + rename_organization: { + label: "重命名组织", + successMessage: "组织已重命名" + }, change_slug: { label: "修改标识符", description: "重命名标识符会重写该组织所有平台子域名,旧标识符将保留占用 90 天。继续吗?", diff --git a/packages/platform-objects/src/identity/sys-organization-rename-action.test.ts b/packages/platform-objects/src/identity/sys-organization-rename-action.test.ts new file mode 100644 index 00000000000..f451d15402f --- /dev/null +++ b/packages/platform-objects/src/identity/sys-organization-rename-action.test.ts @@ -0,0 +1,143 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// sys_organization's rename door: a name/logo-only action a record page can +// offer. +// +// `update_organization` renames too, but it is declared at `list_item` only (a +// record page's header never offers it) and it bundles `slug`, which the cloud +// orchestrator's `change_slug` governs where it exists. So the record page +// needs its own door for `name` and `logo`, one that sends no slug. +// +// What this file pins, from source: +// - the new action's declaration: its params are exactly `name` and `logo`, +// it sits at `record_header` and `list_item`, it targets better-auth's +// `organization/update` exactly as `update_organization` does, and it is +// gated on `multiOrgEnabled` like every sibling organization action; +// - one door per field on the record header: no `record_header` action that +// targets `organization/update` carries a `slug` param; +// - the header order: the rename takes an inline slot and `change_slug` folds +// into the overflow (the ordering model is described in +// `sys-organization-invite-action.test.ts`); +// - controls: `update_organization` and `change_slug` are unchanged. +// +// The runtime half, a rename through better-auth that reads the slug back +// unchanged, is `organization-rename-door.dogfood.test.ts` in +// `packages/qa/dogfood`. +import { describe, expect, it } from 'vitest'; +import { featureGatePredicate } from '@objectstack/spec/kernel'; +import { SysOrganization } from './sys-organization.object.js'; + +interface DeclaredParam { + field?: string; + name?: string; + required?: boolean; + defaultFromRow?: boolean; +} + +interface DeclaredAction { + name?: string; + type?: string; + target?: string; + method?: string; + recordIdParam?: string; + bodyShape?: unknown; + order?: number; + locations?: string[]; + visible?: { source?: string }; + params?: DeclaredParam[]; +} + +const UPDATE_TARGET = '/api/v1/auth/organization/update'; + +/** objectui page:header's default desktop inline budget. */ +const MAX_VISIBLE = 3; + +const actions = (): DeclaredAction[] => + ((SysOrganization as { actions?: DeclaredAction[] }).actions) ?? []; + +function action(name: string): DeclaredAction { + const found = actions().find((a) => a.name === name); + expect(found, `${name} is declared`).toBeDefined(); + return found as DeclaredAction; +} + +const paramKeys = (a: DeclaredAction): string[] => (a.params ?? []).map((p) => (p.field ?? p.name) as string); + +/** The object's `record_header` actions, in the order the stable `order` sort gives them. */ +function headerOrder(): string[] { + return actions() + .filter((a) => (a.locations ?? []).includes('record_header')) + .map((a, i) => ({ a, i })) + .sort((x, y) => (x.a.order ?? 0) - (y.a.order ?? 0) || x.i - y.i) + .map(({ a }) => a.name as string); +} + +describe('sys_organization.rename_organization: the declaration', () => { + it('collects exactly `name` (required) and `logo`, both pre-filled from the row, and no `slug`', () => { + const rename = action('rename_organization'); + expect(paramKeys(rename)).toEqual(['name', 'logo']); + const [name, logo] = rename.params ?? []; + expect(name).toMatchObject({ field: 'name', required: true, defaultFromRow: true }); + expect(logo).toMatchObject({ field: 'logo', defaultFromRow: true }); + expect(logo?.required).not.toBe(true); + }); + + it('is offered at the record header and on the list row', () => { + expect(action('rename_organization').locations).toEqual(['record_header', 'list_item']); + }); + + it("targets better-auth's organization/update exactly as update_organization does", () => { + const rename = action('rename_organization'); + const update = action('update_organization'); + expect(rename.type).toBe('api'); + expect(rename.target).toBe(UPDATE_TARGET); + expect(rename.recordIdParam).toBe('organizationId'); + expect(rename.bodyShape).toEqual({ wrap: 'data' }); + expect({ type: rename.type, target: rename.target, method: rename.method, recordIdParam: rename.recordIdParam, bodyShape: rename.bodyShape }) + .toEqual({ type: update.type, target: update.target, method: update.method, recordIdParam: update.recordIdParam, bodyShape: update.bodyShape }); + }); + + it('is gated on multiOrgEnabled, the same lowered predicate as update_organization', () => { + const gate = featureGatePredicate('multiOrgEnabled'); + expect(action('rename_organization').visible?.source).toBe(gate); + expect(action('update_organization').visible?.source).toBe(gate); + }); +}); + +describe('sys_organization: one door per field on the record header', () => { + it('no record_header action that writes through organization/update collects a slug', () => { + const headerWriters = actions() + .filter((a) => (a.locations ?? []).includes('record_header')) + .filter((a) => a.target === UPDATE_TARGET); + expect(headerWriters.map((a) => a.name)).toEqual(['rename_organization']); + for (const a of headerWriters) { + expect(paramKeys(a), `${a.name} sends a slug to better-auth`).not.toContain('slug'); + } + }); + + it('the rename takes an inline header slot and change_slug folds into the overflow', () => { + const order = headerOrder(); + const names = actions().map((a) => a.name); + expect(names.indexOf('rename_organization')).toBeLessThan(names.indexOf('change_slug')); + expect(order.indexOf('rename_organization')).toBeGreaterThanOrEqual(0); + expect(order.indexOf('rename_organization')).toBeLessThan(MAX_VISIBLE); + expect(order.indexOf('change_slug')).toBeGreaterThanOrEqual(MAX_VISIBLE); + expect(order[order.length - 1]).toBe('leave_organization'); + }); +}); + +describe('sys_organization: the slug doors are unchanged (controls)', () => { + it('update_organization keeps name / slug / logo, its target and list_item alone', () => { + const update = action('update_organization'); + expect(update.locations).toEqual(['list_item']); + expect(update.target).toBe(UPDATE_TARGET); + expect(paramKeys(update)).toEqual(['name', 'slug', 'logo']); + }); + + it("change_slug keeps its cloud orchestrator target, record_header alone and its one slug param", () => { + const change = action('change_slug'); + expect(change.locations).toEqual(['record_header']); + expect(change.target).toBe('/api/v1/cloud/organizations/{id}/change-slug'); + expect(paramKeys(change)).toEqual(['slug']); + }); +}); diff --git a/packages/platform-objects/src/identity/sys-organization.object.ts b/packages/platform-objects/src/identity/sys-organization.object.ts index 919503b9313..16a3dce6579 100644 --- a/packages/platform-objects/src/identity/sys-organization.object.ts +++ b/packages/platform-objects/src/identity/sys-organization.object.ts @@ -55,7 +55,9 @@ export const SysOrganization = ObjectSchema.create({ // Custom actions — generic create / delete are suppressed (better-auth- // managed), and better-auth's own columns (`name`, `slug`, `logo`) are - // edited ONLY through `update_organization` below. The generic `update` the + // edited ONLY through better-auth's `organization/update`: the + // `update_organization` row action (all three) and the name/logo-only + // `rename_organization` (record header and row) below. The generic `update` the // data door admits since #15873 reaches the platform-owned extension columns // alone (see `userActions` above and `enable.apiMethods` at the bottom); // admins still need to create new orgs from the Setup app. @@ -194,10 +196,11 @@ export const SysOrganization = ObjectSchema.create({ variant: 'danger', // Higher than every sibling (unset = 0), so it sorts LAST: with // `invite_user` leading, the header's three inline slots go to the - // invite, `set_active_organization` and `change_slug`, and this one - // folds into the `⋯` overflow's destructive band (objectui's - // page:header split, default `maxVisible` 3). Without it, registration - // order kept it inline as the third button. + // invite, `set_active_organization` and `rename_organization`, and this + // one folds into the `⋯` overflow's destructive band (objectui's + // page:header split, default `maxVisible` 3), with `change_slug` in the + // overflow above it. Without it, registration order kept it inline as + // the third button. order: 1, mode: 'custom', locations: ['list_item', 'record_header'], @@ -210,6 +213,42 @@ export const SysOrganization = ObjectSchema.create({ successMessage: 'You have left the organization', refreshAfter: true, }, + { + // Rename the organization: its `name` and `logo`, never its `slug`. + // The same better-auth door as `update_organization` above + // (`organization/update`, fields nested under `data`), with no `slug` + // param, so the body carries no slug and the stored one is kept: + // better-auth's slug-taken check and plugin-auth's + // `beforeUpdateOrganization` slug guard both run only on a sent slug. + // + // One door per field. A record page reaches no `list_item` action, so + // this one is declared at `record_header` too; where the cloud + // orchestrator governs the slug, that page offers this action and + // `change_slug`, and nothing on it sends a slug to better-auth. + // `update_organization` stays the slug door of a composition without the + // orchestrator, because `change_slug`'s target exists only in cloud. + // + // Declared BEFORE `change_slug`: both sort at `order` 0, so declaration + // order breaks the tie, the rename takes the header's third inline slot, + // and `change_slug` folds into the `⋯` overflow. + name: 'rename_organization', + label: 'Rename Organization', + icon: 'pencil', + variant: 'secondary', + mode: 'edit', + locations: ['record_header', 'list_item'], + type: 'api', + target: '/api/v1/auth/organization/update', + recordIdParam: 'organizationId', + bodyShape: { wrap: 'data' }, + requiresFeature: 'multiOrgEnabled', + successMessage: 'Organization renamed', + refreshAfter: true, + params: [ + { field: 'name', required: true, defaultFromRow: true }, + { field: 'logo', defaultFromRow: true }, + ], + }, { // Rename the organization slug (URL prefix). Backed by the cloud // orchestrator at /api/v1/cloud/organizations/{id}/change-slug, diff --git a/packages/platform-objects/src/platform-objects.test.ts b/packages/platform-objects/src/platform-objects.test.ts index fc8c7de731e..1bfff092e6b 100644 --- a/packages/platform-objects/src/platform-objects.test.ts +++ b/packages/platform-objects/src/platform-objects.test.ts @@ -586,6 +586,7 @@ describe('feature-gate lowering matrix (#2874)', () => { const rows: Array<[string, { actions?: readonly { name?: string; visible?: unknown; params?: readonly unknown[] }[] }, string, string]> = [ ['SysOrganization', SysOrganization, 'create_organization', MULTI_ORG], ['SysOrganization', SysOrganization, 'update_organization', MULTI_ORG], + ['SysOrganization', SysOrganization, 'rename_organization', MULTI_ORG], ['SysOrganization', SysOrganization, 'delete_organization', MULTI_ORG], ['SysOrganization', SysOrganization, 'set_active_organization', MULTI_ORG], ['SysOrganization', SysOrganization, 'leave_organization', MULTI_ORG], diff --git a/packages/qa/dogfood/test/organization-rename-door.dogfood.test.ts b/packages/qa/dogfood/test/organization-rename-door.dogfood.test.ts new file mode 100644 index 00000000000..a450f9f0523 --- /dev/null +++ b/packages/qa/dogfood/test/organization-rename-door.dogfood.test.ts @@ -0,0 +1,118 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * `sys_organization.rename_organization` through the door it targets, on a + * booted showcase. + * + * The record page's rename door is a name/logo-only action on better-auth's + * `organization/update`, the endpoint `update_organization` uses, declared + * without a `slug` param. Its whole claim is that a rename through it keeps the + * stored slug: better-auth writes only the keys it is sent, its slug-taken + * check runs only on a sent slug, and plugin-auth's `beforeUpdateOrganization` + * slug guard returns at once when no slug is sent. This file proves that claim + * against the real stack instead of a source reading. + * + * The request is built from the REGISTERED action, the way the console's api + * action runner builds it (objectui `useConsoleActionRuntime`): the params go + * under `bodyShape.wrap`, the record id under `recordIdParam`, and the target is + * the declared one. So a slug param added to the declaration would reach the + * body here, and the slug assertion below would see what a user would get. + * + * The organization is minted the way `organization-update-door.dogfood.test.ts` + * mints it: `multiTenant: 'posture-only'` opens `organization/create` on the + * plain showcase (it activates the posture, never the wall), and the creator is + * the owner, a grade better-auth grants `organization: ['update']`. + * + * The declaration half (params, locations, gate, header order, and the two + * slug doors unchanged) is `sys-organization-rename-action.test.ts` in + * `@objectstack/platform-objects`. + */ + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { type VerifyStack } from '@objectstack/verify'; +import { bootShowcase } from './showcase-boot.js'; + +interface RegisteredAction { + name?: string; + type?: string; + target?: string; + recordIdParam?: string; + bodyShape?: { wrap?: string }; + params?: Array<{ field?: string; name?: string }>; +} + +const SLUG = 'rename-door-org'; + +describe('sys_organization.rename_organization: a rename through better-auth keeps the slug', () => { + let stack: VerifyStack; + let token: string; + let orgId: string; + let rename: RegisteredAction; + + /** The organization row as the data door serves it back. */ + const readOrg = async (id: string): Promise> => { + const res = await stack.apiAs(token, 'GET', `/data/sys_organization/${id}`); + expect(res.status).toBe(200); + const body: any = await res.json(); + return (body.record ?? body.data ?? {}) as Record; + }; + + /** Run the registered action the way the console's api runner does. */ + const runRename = async (values: Record) => { + const keys = (rename.params ?? []).map((p) => (p.field ?? p.name) as string); + const collected = Object.fromEntries(keys.filter((k) => k in values).map((k) => [k, values[k]])); + const wrap = rename.bodyShape?.wrap; + const body: Record = wrap ? { [wrap]: collected } : { ...collected }; + body[rename.recordIdParam as string] = orgId; + // `apiAs` paths are relative to the REST prefix the target names. + const path = String(rename.target).replace(/^\/api\/v1/, ''); + return stack.apiAs(token, 'POST', path, body); + }; + + beforeAll(async () => { + stack = await bootShowcase({ multiTenant: 'posture-only' }); + token = await stack.signIn(); + + const created = await stack.apiAs(token, 'POST', '/auth/organization/create', { + name: 'Rename Door Org', + slug: SLUG, + }); + expect(created.status, `organization/create returned ${created.status}: ${await created.clone().text()}`).toBe(200); + orgId = String(((await created.json()) as { id?: string }).id); + expect(orgId).toBeTruthy(); + + const engine = await stack.kernel.getServiceAsync('objectql'); + const schema = engine?.getSchema?.('sys_organization'); + rename = ((schema?.actions ?? []) as RegisteredAction[]).find((a) => a?.name === 'rename_organization') as RegisteredAction; + expect(rename, 'rename_organization is registered').toBeTruthy(); + }, 120_000); + + afterAll(async () => { await stack?.stop?.(); }); + + it('the registered action is the declared one: organization/update, name and logo under data, the row id as organizationId', () => { + expect(rename.type).toBe('api'); + expect(rename.target).toBe('/api/v1/auth/organization/update'); + expect(rename.bodyShape).toEqual({ wrap: 'data' }); + expect(rename.recordIdParam).toBe('organizationId'); + expect((rename.params ?? []).map((p) => p.field ?? p.name)).toEqual(['name', 'logo']); + }); + + it('renames the organization and sets its logo, and the slug reads back unchanged', async () => { + const before = await readOrg(orgId); + expect(before.slug).toBe(SLUG); + + // A new slug is among the values on purpose. The param dialog collects + // declared params alone, so with no `slug` param it never reaches the + // body, and the stored slug must survive the rename. + const res = await runRename({ name: 'Renamed Door Org', logo: 'https://example.com/rename-door.png', slug: 'smuggled-slug' }); + expect(res.status, `organization/update returned ${res.status}: ${await res.clone().text()}`).toBe(200); + const answered: any = await res.json(); + expect(answered.name).toBe('Renamed Door Org'); + expect(answered.slug).toBe(SLUG); + + const after = await readOrg(orgId); + expect(after.name).toBe('Renamed Door Org'); + expect(after.logo).toBe('https://example.com/rename-door.png'); + expect(after.slug).toBe(SLUG); + }); +}); diff --git a/packages/spec/src/kernel/public-auth-features.ts b/packages/spec/src/kernel/public-auth-features.ts index f99b1fb90ce..05310db88ca 100644 --- a/packages/spec/src/kernel/public-auth-features.ts +++ b/packages/spec/src/kernel/public-auth-features.ts @@ -134,6 +134,9 @@ export const PUBLIC_AUTH_FEATURES = { gatedInputs: [ 'sys_organization.actions.create_organization', 'sys_organization.actions.update_organization', + // The name/logo-only rename door, at the record header and the row: + // `update_organization`'s endpoint and gate, without its `slug` param. + 'sys_organization.actions.rename_organization', 'sys_organization.actions.delete_organization', 'sys_organization.actions.set_active_organization', 'sys_organization.actions.leave_organization',