diff --git a/packages/app-shell/src/components/ManagedByBadge.tsx b/packages/app-shell/src/components/ManagedByBadge.tsx index d4d7d4c9f..9ac5d140a 100644 --- a/packages/app-shell/src/components/ManagedByBadge.tsx +++ b/packages/app-shell/src/components/ManagedByBadge.tsx @@ -92,7 +92,7 @@ const VARIANTS: Record, Variant> = { short: 'Identity', title: 'Managed by the identity provider', body: (display) => - `This object's schema is owned by ${display}. Direct edits bypass password hashing, session validation, two-factor checks, and audit hooks. Use the dedicated identity workflows instead (Invite User, Reset Password, Revoke Session, Rotate Key, …).`, + `This object's schema is owned by ${display}. Direct edits bypass password hashing, session validation, two-factor checks, and audit hooks. Manage these records through your authentication provider's sign-in, invitation, and security flows instead.`, tone: 'border-amber-300/60 bg-amber-50 text-amber-900 hover:bg-amber-100 dark:border-amber-500/40 dark:bg-amber-950/40 dark:text-amber-100', }, }; diff --git a/packages/app-shell/src/utils/__tests__/managedByEmptyState.test.ts b/packages/app-shell/src/utils/__tests__/managedByEmptyState.test.ts new file mode 100644 index 000000000..0abb39a7e --- /dev/null +++ b/packages/app-shell/src/utils/__tests__/managedByEmptyState.test.ts @@ -0,0 +1,53 @@ +import { describe, it, expect } from 'vitest'; +import { resolveManagedByEmptyState } from '../managedByEmptyState'; + +// Mirror the real i18n fallback: return the English `defaultValue` baked into +// the helper. The en.ts bundle mirrors these strings verbatim, so asserting on +// the defaults is asserting on the copy a user actually sees. +const t = (key: string, opts?: Record): string => + (opts?.defaultValue as string) ?? key; + +describe('resolveManagedByEmptyState', () => { + it('returns undefined for platform / config / unknown buckets', () => { + expect(resolveManagedByEmptyState('platform', t)).toBeUndefined(); + expect(resolveManagedByEmptyState('config', t)).toBeUndefined(); + expect(resolveManagedByEmptyState(undefined, t)).toBeUndefined(); + expect(resolveManagedByEmptyState('nope', t)).toBeUndefined(); + }); + + it('leaves the system / append-only buckets intact', () => { + expect(resolveManagedByEmptyState('system', t)?.title).toBe('Nothing here yet'); + expect(resolveManagedByEmptyState('append-only', t)?.title).toBe('No events recorded'); + }); + + it('gives sys_user an actionable empty state (org invite + SSO JIT, end-users)', () => { + const es = resolveManagedByEmptyState('better-auth', t, 'sys_user'); + expect(es?.title).toBe('No users yet'); + expect(es?.message).toMatch(/invite teammates to your organization/i); + expect(es?.message).toMatch(/just-in-time/i); + expect(es?.message).toMatch(/end-users/i); + }); + + it('gives every other identity table a generic, accurate empty state', () => { + // The single better-auth bucket is shared by ~18 identity tables; only + // sys_user has a real onboarding answer. Sessions / tokens / jwks must NOT + // get a "go invite someone" CTA. + for (const name of ['sys_session', 'sys_api_key', 'sys_jwks', undefined]) { + const es = resolveManagedByEmptyState('better-auth', t, name as string | undefined); + expect(es?.title).toBe('No identity records'); + expect(es?.message).toMatch(/created by the authentication provider/i); + expect(es?.message).not.toMatch(/invite/i); + } + }); + + // cloud#580 regression: the empty state must never advertise affordances that + // are gated off (env-level "Invite User" is multi-org-only, hidden in + // single-org) or that do not exist ("Reset Password" is not a toolbar action). + it('never names the unreachable "Invite User" / "Reset Password" workflows', () => { + for (const name of ['sys_user', 'sys_session', undefined]) { + const es = resolveManagedByEmptyState('better-auth', t, name as string | undefined); + expect(es?.message).not.toMatch(/invite user/i); + expect(es?.message).not.toMatch(/reset password/i); + } + }); +}); diff --git a/packages/app-shell/src/utils/managedByEmptyState.ts b/packages/app-shell/src/utils/managedByEmptyState.ts index cd4226c31..d7a145298 100644 --- a/packages/app-shell/src/utils/managedByEmptyState.ts +++ b/packages/app-shell/src/utils/managedByEmptyState.ts @@ -37,6 +37,7 @@ type TranslateFn = (key: string, options?: Record) => string; export function resolveManagedByEmptyState( managedBy: string | undefined | null, t: TranslateFn, + objectName?: string | null, ): ManagedByEmptyState | undefined { switch (managedBy) { case 'system': @@ -58,12 +59,31 @@ export function resolveManagedByEmptyState( }), }; case 'better-auth': + // `sys_user` is the one identity table with a concrete onboarding + // answer, so give it actionable guidance: teammates arrive via an + // org-level invite + SSO just-in-time provisioning (ADR-0024 D9), and + // app end-users self-register. We deliberately do NOT name the env-level + // "Invite User" action — it is multi-org-gated and hidden in single-org — + // nor a "Reset Password" toolbar action, which does not exist (cloud#580). + // Every other identity table (sessions, accounts, tokens, jwks, + // verifications, …) is written purely by auth flows, so keep the generic + // copy — naming an invite/signup CTA on a token list would be wrong. + if (objectName === 'sys_user') { + return { + icon: 'ShieldAlert', + title: t('list.managedBy.betterAuthUser.title', { defaultValue: 'No users yet' }), + message: t('list.managedBy.betterAuthUser.message', { + defaultValue: + 'User accounts are provisioned by the authentication provider, not created here. Invite teammates to your organization and they appear automatically on first sign-in (SSO just-in-time provisioning). App end-users arrive when they sign up through your app.', + }), + }; + } return { icon: 'ShieldAlert', title: t('list.managedBy.betterAuth.title', { defaultValue: 'No identity records' }), message: t('list.managedBy.betterAuth.message', { defaultValue: - 'Identity rows are managed by the authentication provider. Use the dedicated identity workflows (Invite User, Reset Password, …) to create new entries.', + 'These records are created by the authentication provider — through sign-in, provisioning, and security flows — not added by hand here.', }), }; default: diff --git a/packages/app-shell/src/views/ObjectView.tsx b/packages/app-shell/src/views/ObjectView.tsx index 95dd93401..27103b093 100644 --- a/packages/app-shell/src/views/ObjectView.tsx +++ b/packages/app-shell/src/views/ObjectView.tsx @@ -1290,7 +1290,7 @@ function ObjectViewInner({ dataSource, objects, onEdit, externalRefreshKey }: an viewDef.name || viewDef.id || '', viewDef.emptyState ?? listSchema.emptyState - ?? resolveManagedByEmptyState((objectDef as any)?.managedBy, t), + ?? resolveManagedByEmptyState((objectDef as any)?.managedBy, t, objectDef.name), ), aria: viewDef.aria ?? listSchema.aria, // Propagate filter/sort as default filters/sort for data flow diff --git a/packages/i18n/src/locales/en.ts b/packages/i18n/src/locales/en.ts index 7c9daadb0..1e9c89e47 100644 --- a/packages/i18n/src/locales/en.ts +++ b/packages/i18n/src/locales/en.ts @@ -284,7 +284,12 @@ const en = { betterAuth: { title: 'No identity records', message: - 'Identity rows are managed by the authentication provider. Use the dedicated identity workflows (Invite User, Reset Password, …) to create new entries.', + 'These records are created by the authentication provider — through sign-in, provisioning, and security flows — not added by hand here.', + }, + betterAuthUser: { + title: 'No users yet', + message: + 'User accounts are provisioned by the authentication provider, not created here. Invite teammates to your organization and they appear automatically on first sign-in (SSO just-in-time provisioning). App end-users arrive when they sign up through your app.', }, }, showAll: 'Show all', diff --git a/packages/i18n/src/locales/zh.ts b/packages/i18n/src/locales/zh.ts index 672b30407..cc245d7c4 100644 --- a/packages/i18n/src/locales/zh.ts +++ b/packages/i18n/src/locales/zh.ts @@ -284,7 +284,12 @@ const zh = { betterAuth: { title: '暂无身份记录', message: - '身份记录由认证提供方管理。请使用专门的身份流程(邀请用户、重置密码……)来创建新条目。', + '这些记录由认证提供方创建——通过登录、预置和安全流程自动写入,无法在此处手动添加。', + }, + betterAuthUser: { + title: '暂无用户', + message: + '用户账号由认证提供方预置,无法在此处手动创建。邀请团队成员加入你的组织,他们首次登录时会自动出现(SSO 即时预置)。应用的终端用户会在通过你的应用注册时出现。', }, }, showAll: '显示全部',