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
15 changes: 15 additions & 0 deletions .changeset/27858-sys-user-setup-rows.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
'@objectstack/platform-objects': patch
---

fix(platform-objects): Setup's user screens show a user's real state, and the copy-password line only when a password was generated

Clause-②: no
Metadata-protocol: none

Four rows of the Setup → Users pass, all on the platform's own `sys_user` metadata:

- **Create User / Set Password result.** The dialog no longer carries the sentence "If a temporary password was generated, copy it now — it is shown only once and never stored." on every result. A dialog-level description shows on both outcomes, and a typed password returns no temporary password, so the line asked the administrator to copy something that was not there. The instruction is now the label of the Temporary password field itself ("Temporary password: copy it now, it is shown only once and never stored"), which the dialog draws only when the server generated one. The zh-CN, ja-JP and es-ES packs carry the same change.
- **User record, Details tab.** Details now shows the saved Locale (in Identity) and an Account Status section with Banned, Ban Reason and Ban Expires. These fields were hidden or in no section, and the details grid draws only its authored sections, so a banned user read as active.
- **Users list.** The default `all_users` view gains Banned and Platform Role columns, so a banned user or an admin is spotted without opening each record.
- **Locale help.** The help now says the setting is the language the user sees the interface in as well as their notifications. It used to say notifications only, while the console applies it to the whole interface. All four shipped packs carry it.
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ export const enObjects: NonNullable<TranslationData['objects']> = {
},
locale: {
label: "Locale",
help: "Preferred language for this user's notifications, as a language tag such as zh-CN or ja-JP. When unset, the deployment's default language applies."
help: "The language this user sees the interface in and receives notifications in, as a language tag such as zh-CN or ja-JP. When unset, the deployment's default language applies."
},
manager_id: {
label: "Manager",
Expand Down Expand Up @@ -195,13 +195,12 @@ export const enObjects: NonNullable<TranslationData['objects']> = {
},
resultDialog: {
title: "User Created",
description: "If a temporary password was generated, copy it now — it is shown only once and never stored.",
acknowledge: "Done",
fields: {
"user.email": "Email",
"user.phoneNumber": "Phone Number",
permissionSet: "Permission Set",
temporaryPassword: "Temporary Password"
temporaryPassword: "Temporary password: copy it now, it is shown only once and never stored"
}
}
},
Expand All @@ -222,10 +221,9 @@ export const enObjects: NonNullable<TranslationData['objects']> = {
},
resultDialog: {
title: "Password Updated",
description: "If a temporary password was generated, copy it now — it is shown only once and never stored.",
acknowledge: "Done",
fields: {
temporaryPassword: "Temporary Password"
temporaryPassword: "Temporary password: copy it now, it is shown only once and never stored"
}
}
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ export const esESObjects: NonNullable<TranslationData['objects']> = {
},
locale: {
label: "Idioma",
help: "Idioma preferido para las notificaciones de este usuario, como una etiqueta de idioma, por ejemplo zh-CN o ja-JP. Si no se define, se aplica el idioma predeterminado del despliegue."
help: "Idioma en el que este usuario ve la interfaz y recibe las notificaciones, como una etiqueta de idioma, por ejemplo zh-CN o ja-JP. Si no se define, se aplica el idioma predeterminado del despliegue."
},
manager_id: {
label: "Gerente",
Expand Down Expand Up @@ -195,13 +195,12 @@ export const esESObjects: NonNullable<TranslationData['objects']> = {
},
resultDialog: {
title: "Usuario creado",
description: "Si se generó una contraseña temporal, cópiela ahora: se muestra una sola vez y no se almacena.",
acknowledge: "Hecho",
fields: {
"user.email": "Correo electrónico",
"user.phoneNumber": "Número de teléfono",
permissionSet: "Conjunto de permisos",
temporaryPassword: "Contraseña temporal"
temporaryPassword: "Contraseña temporal: cópiela ahora, se muestra una sola vez y no se almacena"
}
}
},
Expand All @@ -222,10 +221,9 @@ export const esESObjects: NonNullable<TranslationData['objects']> = {
},
resultDialog: {
title: "Contraseña actualizada",
description: "Si se generó una contraseña temporal, cópiela ahora: se muestra una sola vez y no se almacena.",
acknowledge: "Hecho",
fields: {
temporaryPassword: "Contraseña temporal"
temporaryPassword: "Contraseña temporal: cópiela ahora, se muestra una sola vez y no se almacena"
}
}
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ export const jaJPObjects: NonNullable<TranslationData['objects']> = {
},
locale: {
label: "言語",
help: "このユーザーへの通知に使用する優先言語。zh-CN や ja-JP のような言語タグで指定します。未設定の場合はデプロイのデフォルト言語が適用されます。"
help: "このユーザーの画面表示と通知に使用する言語。zh-CN や ja-JP のような言語タグで指定します。未設定の場合はデプロイのデフォルト言語が適用されます。"
},
manager_id: {
label: "マネージャー",
Expand Down Expand Up @@ -195,13 +195,12 @@ export const jaJPObjects: NonNullable<TranslationData['objects']> = {
},
resultDialog: {
title: "ユーザーを作成しました",
description: "一時パスワードが生成された場合は今すぐコピーしてください。表示は一度きりで、保存されません。",
acknowledge: "完了",
fields: {
"user.email": "メールアドレス",
"user.phoneNumber": "電話番号",
permissionSet: "権限セット",
temporaryPassword: "一時パスワード"
temporaryPassword: "一時パスワード:今すぐコピーしてください。表示は一度きりで、保存されません"
}
}
},
Expand All @@ -222,10 +221,9 @@ export const jaJPObjects: NonNullable<TranslationData['objects']> = {
},
resultDialog: {
title: "パスワードを更新しました",
description: "一時パスワードが生成された場合は今すぐコピーしてください。表示は一度きりで、保存されません。",
acknowledge: "完了",
fields: {
temporaryPassword: "一時パスワード"
temporaryPassword: "一時パスワード:今すぐコピーしてください。表示は一度きりで、保存されません"
}
}
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ export const zhCNObjects: NonNullable<TranslationData['objects']> = {
},
locale: {
label: "语言",
help: "该用户接收通知时使用的首选语言,填写语言标签,如 zh-CN 或 ja-JP。未设置时采用部署的默认语言。"
help: "该用户界面显示和接收通知所用的语言,填写语言标签,如 zh-CN 或 ja-JP。未设置时采用部署的默认语言。"
},
manager_id: {
label: "经理",
Expand Down Expand Up @@ -195,13 +195,12 @@ export const zhCNObjects: NonNullable<TranslationData['objects']> = {
},
resultDialog: {
title: "用户已创建",
description: "如果生成了临时密码,请立即复制——它只显示一次,不会被保存。",
acknowledge: "完成",
fields: {
"user.email": "邮箱",
"user.phoneNumber": "手机号",
permissionSet: "权限集",
temporaryPassword: "临时密码"
temporaryPassword: "临时密码:请立即复制,它只显示一次,不会被保存"
}
}
},
Expand All @@ -222,10 +221,9 @@ export const zhCNObjects: NonNullable<TranslationData['objects']> = {
},
resultDialog: {
title: "密码已更新",
description: "如果生成了临时密码,请立即复制——它只显示一次,不会被保存。",
acknowledge: "完成",
fields: {
temporaryPassword: "临时密码"
temporaryPassword: "临时密码:请立即复制,它只显示一次,不会被保存"
}
}
},
Expand Down
Original file line number Diff line number Diff line change
@@ -1,17 +1,17 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* `sys_user.create_user` — the result dialog reads true whether the admin
* typed the password or left it empty to generate one.
* `sys_user.create_user` and `set_user_password` — the result dialog says
* "copy it now" only when there is a temporary password to copy.
*
* The endpoint (`plugin-auth`, `admin-user-endpoints.ts`) returns
* `temporaryPassword` only when it minted one, and the dialog skips a field
* whose path is absent from the response. So after a typed password the
* dialog shows the email alone; a sentence ordering the admin to "copy the
* temporary password now" beside an "I have saved this password" button then
* describes a password that was never shown. `ActionSchema.resultDialog` has
* no per-outcome copy, so one sentence must hold for both outcomes — the
* hedged shape `set_user_password` in the same file already uses.
* whose path is absent from the response. `ActionSchema.resultDialog` has no
* per-outcome copy, so a dialog-level `description` shows on both outcomes:
* the hedged "If a temporary password was generated, copy it now" sentence
* sat above a typed password's result, where nothing was generated and
* nothing could be copied. The instruction now rides the `temporaryPassword`
* field's own label, which is drawn only beside the password itself.
*
* Pinned in the source and in all four shipped locales: the translated leaves
* are hand-written and carry no source-hash entry, so a stale translation of
Expand All @@ -35,14 +35,25 @@ const LOCALES: readonly (readonly [string, any])[] = [
['es-ES', esESObjects],
];

describe('create_user — the result dialog holds for a typed and a generated password', () => {
it('the source sentence is conditional on a password having been generated', () => {
const rd = action('create_user')?.resultDialog;
expect(rd, 'create_user declares a resultDialog').toBeDefined();
expect(rd?.description).toBe(
'If a temporary password was generated, copy it now — it is shown only once and never stored.',
);
});
describe('create_user / set_user_password — the copy line shows only beside a generated password', () => {
for (const name of ['create_user', 'set_user_password']) {
it(`${name}: no dialog-level description, which would show on both outcomes`, () => {
const rd = action(name)?.resultDialog;
expect(rd, `${name} declares a resultDialog`).toBeDefined();
expect(rd).not.toHaveProperty('description');
});

it(`${name}: the copy instruction is the temporaryPassword field's own label`, () => {
const field = action(name)?.resultDialog?.fields?.find((f) => f.path === 'temporaryPassword');
expect(field?.format).toBe('secret');
expect(field?.label).toMatch(/^Temporary password: copy it now/);
});

it(`${name}: no other field label asks the admin to copy a password`, () => {
const others = (action(name)?.resultDialog?.fields ?? []).filter((f) => f.path !== 'temporaryPassword');
for (const f of others) expect(String(f.label ?? ''), f.path).not.toMatch(/copy|password/i);
});
}

it('the button claims no saved password, since a typed one shows none', () => {
const ack = action('create_user')?.resultDialog?.acknowledge ?? '';
Expand All @@ -60,12 +71,20 @@ describe('create_user — the result dialog holds for a typed and a generated pa
]);
});

it('every locale says the same thing as set_user_password, whose dialog has the same two outcomes', () => {
it('every locale drops the description and translates the field label, the same for both actions', () => {
const source = action('create_user')?.resultDialog?.fields?.find((f) => f.path === 'temporaryPassword')?.label;
for (const [locale, bundle] of LOCALES) {
const created = dialog(bundle, 'create_user');
const updated = dialog(bundle, 'set_user_password');
expect(created?.description, `${locale} create_user description`).toBeTruthy();
expect(created.description, `${locale} description`).toBe(updated?.description);
for (const [name, d] of [['create_user', created], ['set_user_password', updated]] as const) {
expect(d, `${locale} ${name} resultDialog`).toBeDefined();
expect(d, `${locale} ${name} description`).not.toHaveProperty('description');
}
const label = created?.fields?.temporaryPassword;
expect(label, `${locale} temporaryPassword label`).toBeTruthy();
expect(updated?.fields?.temporaryPassword, `${locale} same label on both`).toBe(label);
if (locale === 'en') expect(label).toBe(source);
else expect(label, `${locale} temporaryPassword label is the English source`).not.toBe(source);
expect(created.acknowledge, `${locale} acknowledge`).toBe(updated?.acknowledge);
}
});
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* Setup → Users: the default list shows whether a user is banned and what role
* they hold, so a banned user or an admin is spotted without opening each
* record (#27863).
*
* `all_users` is the view Setup's Users entry opens (`viewName: 'all_users'`,
* pinned in `setup-users-nav-view.test.ts`). Its columns used to be name,
* email, phone, email verified, identity source and created: nothing on the
* list said a user was banned. The `banned` view keeps its own filter; this
* pin is about the default list.
*/

import { describe, expect, it } from 'vitest';
import { SysUser } from './sys-user.object.js';

type ListView = { columns?: unknown[] };

const view = (name: string): ListView =>
((SysUser as { listViews?: Record<string, ListView> }).listViews ?? {})[name] ?? {};

describe('sys_user all_users — Status and Role columns (#27863)', () => {
it('pins the default list columns', () => {
expect(view('all_users').columns).toEqual([
'name', 'email', 'phone_number', 'email_verified', 'banned', 'role', 'source', 'created_at',
]);
});

it('every column is a declared sys_user field', () => {
const fields = Object.keys(SysUser.fields ?? {});
for (const c of view('all_users').columns ?? []) expect(fields, String(c)).toContain(c);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* `sys_user.locale` help says what the setting does: it sets the language the
* user sees the interface in, as well as their notifications (#27869).
*
* The console applies the signed-in user's `sys_user.locale` to the whole UI
* (`useUserLocale`, and the globe switcher writes the same column), while the
* help said "Preferred language for this user's notifications", so a user
* reading it did not expect the interface to switch. Pinned in the source and
* in every shipped pack, because a translated leaf is hand-written and a stale
* one is not flagged anywhere else.
*/

import { describe, expect, it } from 'vitest';
import { SysUser } from './sys-user.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';

const help = (bundle: any): string => bundle?.sys_user?.fields?.locale?.help ?? '';

/** Per pack: the word for the interface, and the old notifications-only opening that must be gone. */
const PACKS: readonly (readonly [string, any, RegExp, RegExp])[] = [
['en', enObjects, /interface/, /^Preferred language for this user's notifications/],
['zh-CN', zhCNObjects, /界面/, /^该用户接收通知时使用的首选语言/],
['ja-JP', jaJPObjects, /画面/, /^このユーザーへの通知に使用する優先言語/],
['es-ES', esESObjects, /interfaz/, /^Idioma preferido para las notificaciones/],
];

describe('sys_user.locale help — it sets the interface language (#27869)', () => {
it('the source description names the interface', () => {
const d = String(SysUser.fields?.locale?.description ?? '');
expect(d).toMatch(/^The language this user sees the interface in and receives notifications in/);
});

for (const [locale, bundle, names, old] of PACKS) {
it(`${locale} pack says it sets the interface language`, () => {
expect(help(bundle)).toMatch(names);
expect(help(bundle)).not.toMatch(old);
});
}
});
Loading
Loading