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
2 changes: 1 addition & 1 deletion packages/app-shell/src/components/ManagedByBadge.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@ const VARIANTS: Record<Exclude<Bucket, 'platform'>, 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',
},
};
Expand Down
53 changes: 53 additions & 0 deletions packages/app-shell/src/utils/__tests__/managedByEmptyState.test.ts
Original file line number Diff line number Diff line change
@@ -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, unknown>): 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);
}
});
});
22 changes: 21 additions & 1 deletion packages/app-shell/src/utils/managedByEmptyState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ type TranslateFn = (key: string, options?: Record<string, unknown>) => string;
export function resolveManagedByEmptyState(
managedBy: string | undefined | null,
t: TranslateFn,
objectName?: string | null,
): ManagedByEmptyState | undefined {
switch (managedBy) {
case 'system':
Expand All @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion packages/app-shell/src/views/ObjectView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 6 additions & 1 deletion packages/i18n/src/locales/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
7 changes: 6 additions & 1 deletion packages/i18n/src/locales/zh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -284,7 +284,12 @@ const zh = {
betterAuth: {
title: '暂无身份记录',
message:
'身份记录由认证提供方管理。请使用专门的身份流程(邀请用户、重置密码……)来创建新条目。',
'这些记录由认证提供方创建——通过登录、预置和安全流程自动写入,无法在此处手动添加。',
},
betterAuthUser: {
title: '暂无用户',
message:
'用户账号由认证提供方预置,无法在此处手动创建。邀请团队成员加入你的组织,他们首次登录时会自动出现(SSO 即时预置)。应用的终端用户会在通过你的应用注册时出现。',
},
},
showAll: '显示全部',
Expand Down
Loading