Skip to content

Commit cf2176d

Browse files
xuyushun441-sysos-zhuangclaude
authored
fix(app-shell): retarget better-auth empty-state copy off unreachable identity workflows (cloud#580) (#2019)
On a fresh (single-org) env, the Users (sys_user) empty state told admins to "Use the dedicated identity workflows (Invite User, Reset Password, …)" — but neither is reachable there: the env-level `invite_user` action is deliberately multi-org-gated (hidden in single-org), and no "Reset Password" toolbar action exists. The copy advertised dead ends during onboarding. Per ADR-0024 D9 / D5.2 (Accepted): org membership + invites live at the org level (better-auth organization plugin) and env users arrive via SSO JIT; there is intentionally no env-level invite affordance in single-org. So the copy, not the missing button, is the defect — option (b) in cloud#580. - managedByEmptyState: make the better-auth empty state object-aware. sys_user gets actionable copy (invite at the org level → SSO just-in-time provisioning; app end-users self-register). The other ~17 better-auth identity tables (sessions, tokens, jwks, …) get accurate generic copy and no invite/signup CTA. Pass objectDef.name from ObjectView. - i18n (en/zh): reword list.managedBy.betterAuth.message; add betterAuthUser. - ManagedByBadge: drop the "(Invite User, Reset Password, Revoke Session, Rotate Key, …)" tail from the tooltip; keep the why-direct-edits-are-unsafe explanation. - Add managedByEmptyState unit test (locks the copy + the cloud#580 regression). Framework's invite_user multi-org gate is intentional and unchanged. Co-authored-by: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 2021b5f commit cf2176d

6 files changed

Lines changed: 88 additions & 5 deletions

File tree

packages/app-shell/src/components/ManagedByBadge.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,7 @@ const VARIANTS: Record<Exclude<Bucket, 'platform'>, Variant> = {
9292
short: 'Identity',
9393
title: 'Managed by the identity provider',
9494
body: (display) =>
95-
`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, …).`,
95+
`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.`,
9696
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',
9797
},
9898
};
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import { describe, it, expect } from 'vitest';
2+
import { resolveManagedByEmptyState } from '../managedByEmptyState';
3+
4+
// Mirror the real i18n fallback: return the English `defaultValue` baked into
5+
// the helper. The en.ts bundle mirrors these strings verbatim, so asserting on
6+
// the defaults is asserting on the copy a user actually sees.
7+
const t = (key: string, opts?: Record<string, unknown>): string =>
8+
(opts?.defaultValue as string) ?? key;
9+
10+
describe('resolveManagedByEmptyState', () => {
11+
it('returns undefined for platform / config / unknown buckets', () => {
12+
expect(resolveManagedByEmptyState('platform', t)).toBeUndefined();
13+
expect(resolveManagedByEmptyState('config', t)).toBeUndefined();
14+
expect(resolveManagedByEmptyState(undefined, t)).toBeUndefined();
15+
expect(resolveManagedByEmptyState('nope', t)).toBeUndefined();
16+
});
17+
18+
it('leaves the system / append-only buckets intact', () => {
19+
expect(resolveManagedByEmptyState('system', t)?.title).toBe('Nothing here yet');
20+
expect(resolveManagedByEmptyState('append-only', t)?.title).toBe('No events recorded');
21+
});
22+
23+
it('gives sys_user an actionable empty state (org invite + SSO JIT, end-users)', () => {
24+
const es = resolveManagedByEmptyState('better-auth', t, 'sys_user');
25+
expect(es?.title).toBe('No users yet');
26+
expect(es?.message).toMatch(/invite teammates to your organization/i);
27+
expect(es?.message).toMatch(/just-in-time/i);
28+
expect(es?.message).toMatch(/end-users/i);
29+
});
30+
31+
it('gives every other identity table a generic, accurate empty state', () => {
32+
// The single better-auth bucket is shared by ~18 identity tables; only
33+
// sys_user has a real onboarding answer. Sessions / tokens / jwks must NOT
34+
// get a "go invite someone" CTA.
35+
for (const name of ['sys_session', 'sys_api_key', 'sys_jwks', undefined]) {
36+
const es = resolveManagedByEmptyState('better-auth', t, name as string | undefined);
37+
expect(es?.title).toBe('No identity records');
38+
expect(es?.message).toMatch(/created by the authentication provider/i);
39+
expect(es?.message).not.toMatch(/invite/i);
40+
}
41+
});
42+
43+
// cloud#580 regression: the empty state must never advertise affordances that
44+
// are gated off (env-level "Invite User" is multi-org-only, hidden in
45+
// single-org) or that do not exist ("Reset Password" is not a toolbar action).
46+
it('never names the unreachable "Invite User" / "Reset Password" workflows', () => {
47+
for (const name of ['sys_user', 'sys_session', undefined]) {
48+
const es = resolveManagedByEmptyState('better-auth', t, name as string | undefined);
49+
expect(es?.message).not.toMatch(/invite user/i);
50+
expect(es?.message).not.toMatch(/reset password/i);
51+
}
52+
});
53+
});

packages/app-shell/src/utils/managedByEmptyState.ts

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ type TranslateFn = (key: string, options?: Record<string, unknown>) => string;
3737
export function resolveManagedByEmptyState(
3838
managedBy: string | undefined | null,
3939
t: TranslateFn,
40+
objectName?: string | null,
4041
): ManagedByEmptyState | undefined {
4142
switch (managedBy) {
4243
case 'system':
@@ -58,12 +59,31 @@ export function resolveManagedByEmptyState(
5859
}),
5960
};
6061
case 'better-auth':
62+
// `sys_user` is the one identity table with a concrete onboarding
63+
// answer, so give it actionable guidance: teammates arrive via an
64+
// org-level invite + SSO just-in-time provisioning (ADR-0024 D9), and
65+
// app end-users self-register. We deliberately do NOT name the env-level
66+
// "Invite User" action — it is multi-org-gated and hidden in single-org —
67+
// nor a "Reset Password" toolbar action, which does not exist (cloud#580).
68+
// Every other identity table (sessions, accounts, tokens, jwks,
69+
// verifications, …) is written purely by auth flows, so keep the generic
70+
// copy — naming an invite/signup CTA on a token list would be wrong.
71+
if (objectName === 'sys_user') {
72+
return {
73+
icon: 'ShieldAlert',
74+
title: t('list.managedBy.betterAuthUser.title', { defaultValue: 'No users yet' }),
75+
message: t('list.managedBy.betterAuthUser.message', {
76+
defaultValue:
77+
'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.',
78+
}),
79+
};
80+
}
6181
return {
6282
icon: 'ShieldAlert',
6383
title: t('list.managedBy.betterAuth.title', { defaultValue: 'No identity records' }),
6484
message: t('list.managedBy.betterAuth.message', {
6585
defaultValue:
66-
'Identity rows are managed by the authentication provider. Use the dedicated identity workflows (Invite User, Reset Password, …) to create new entries.',
86+
'These records are created by the authentication provider — through sign-in, provisioning, and security flows — not added by hand here.',
6787
}),
6888
};
6989
default:

packages/app-shell/src/views/ObjectView.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1290,7 +1290,7 @@ function ObjectViewInner({ dataSource, objects, onEdit, externalRefreshKey }: an
12901290
viewDef.name || viewDef.id || '',
12911291
viewDef.emptyState
12921292
?? listSchema.emptyState
1293-
?? resolveManagedByEmptyState((objectDef as any)?.managedBy, t),
1293+
?? resolveManagedByEmptyState((objectDef as any)?.managedBy, t, objectDef.name),
12941294
),
12951295
aria: viewDef.aria ?? listSchema.aria,
12961296
// Propagate filter/sort as default filters/sort for data flow

packages/i18n/src/locales/en.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -284,7 +284,12 @@ const en = {
284284
betterAuth: {
285285
title: 'No identity records',
286286
message:
287-
'Identity rows are managed by the authentication provider. Use the dedicated identity workflows (Invite User, Reset Password, …) to create new entries.',
287+
'These records are created by the authentication provider — through sign-in, provisioning, and security flows — not added by hand here.',
288+
},
289+
betterAuthUser: {
290+
title: 'No users yet',
291+
message:
292+
'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.',
288293
},
289294
},
290295
showAll: 'Show all',

packages/i18n/src/locales/zh.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -284,7 +284,12 @@ const zh = {
284284
betterAuth: {
285285
title: '暂无身份记录',
286286
message:
287-
'身份记录由认证提供方管理。请使用专门的身份流程(邀请用户、重置密码……)来创建新条目。',
287+
'这些记录由认证提供方创建——通过登录、预置和安全流程自动写入,无法在此处手动添加。',
288+
},
289+
betterAuthUser: {
290+
title: '暂无用户',
291+
message:
292+
'用户账号由认证提供方预置,无法在此处手动创建。邀请团队成员加入你的组织,他们首次登录时会自动出现(SSO 即时预置)。应用的终端用户会在通过你的应用注册时出现。',
288293
},
289294
},
290295
showAll: '显示全部',

0 commit comments

Comments
 (0)