Skip to content

Commit 6696295

Browse files
committed
feat(identity): open standard Edit affordance on sys_user for profile fields (ADR-0092 D4)
sys_user gains userActions:{edit:true} (create/import/delete stay off). name/image are editable; email, email_verified, two_factor_enabled, role, banned/ban_reason/ban_expires, ai_access, manager_id, primary_business_unit_id and every system-managed stamp are marked readonly so the standard edit form renders them non-editable. Safe because the server boundary is the identity write guard (ADR-0092 D2, #2828): a user-context sys_user update may only touch {name, image} regardless of what a form submits. The readonly flags are UX only. Verified in the running Console: the object metadata sent to the client carries userActions.edit=true and the correct per-field readonly flags; the row 'Edit' action appears; the edit form surfaces only name/image; and — the risk this issue flagged — the create_user/invite_user dialogs keep email/role editable (action params do not inherit field readonly). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YGAN5VvvBi4YRGwpNT6e21
1 parent a348394 commit 6696295

2 files changed

Lines changed: 49 additions & 0 deletions

File tree

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
---
2+
"@objectstack/platform-objects": minor
3+
---
4+
5+
feat(identity): open the standard Edit affordance on sys_user for profile fields (ADR-0092 D4)
6+
7+
`sys_user` now sets `userActions: { edit: true }`, so the generic row-edit
8+
form is available (create / import / delete stay off). The two profile fields
9+
(`name`, `image`) are editable; every other column — `email`, `role`, ban
10+
state, phone, and all system-managed stamps — is marked `readonly` so the
11+
standard edit form renders it non-editable.
12+
13+
This is safe because the server boundary is the identity write guard shipped
14+
in the previous change (ADR-0092 D2): a user-context update to `sys_user` may
15+
only touch `{name, image}` regardless of what any form submits; everything
16+
else is stripped or rejected. The `readonly` flags here are UX only.
17+
18+
The dedicated action dialogs are unaffected — `create_user` / `invite_user` /
19+
`set_user_role` reference `email` and `role` as action **params** (their own
20+
inputs), which do not inherit the field-level `readonly` and stay editable
21+
(verified in the running Console).
22+
23+
Note: the Console's record-form renderer must honor `userActions.edit` +
24+
per-field `readonly` on `managedBy:'better-auth'` objects for the edit form to
25+
be functional; that is an objectui-side change vendored via `objectui:refresh`
26+
and tracked separately.

packages/platform-objects/src/identity/sys-user.object.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,15 @@ export const SysUser = ObjectSchema.create({
2020
icon: 'user',
2121
isSystem: true,
2222
managedBy: 'better-auth',
23+
// ADR-0092 D4 — the ONE generic affordance opened on an identity table:
24+
// standard row editing. Safe because the plugin-auth identity write guard
25+
// (ADR-0092 D2) enforces the profile whitelist server-side — a user-context
26+
// update may only touch SYS_USER_PROFILE_EDIT_FIELDS ({name, image});
27+
// everything else is stripped/rejected regardless of what a form submits.
28+
// The permission layer still decides WHO may edit (platform admins only by
29+
// default; member/org-admin sets keep allowEdit: false). create / import /
30+
// delete stay bucket-default (off).
31+
userActions: { edit: true },
2332
// ADR-0010 §3.7 — identity table is managed by better-auth; schema must not drift.
2433
protection: {
2534
lock: 'full',
@@ -462,22 +471,29 @@ export const SysUser = ObjectSchema.create({
462471
group: 'Identity',
463472
}),
464473

474+
// ADR-0092 D4 — with the generic edit affordance open, every non-profile
475+
// field is readonly so the standard edit form renders it non-editable.
476+
// This is UX only; the server boundary is the plugin-auth identity write
477+
// guard (ADR-0092 D2), which strips/rejects these regardless.
465478
email: Field.email({
466479
label: 'Email',
467480
required: true,
481+
readonly: true, // login identity — change flows through better-auth change-email verification
468482
searchable: true,
469483
group: 'Identity',
470484
}),
471485

472486
email_verified: Field.boolean({
473487
label: 'Email Verified',
474488
defaultValue: false,
489+
readonly: true,
475490
group: 'Identity',
476491
}),
477492

478493
two_factor_enabled: Field.boolean({
479494
label: 'Two-Factor Enabled',
480495
defaultValue: false,
496+
readonly: true,
481497
group: 'Identity',
482498
description: 'Whether two-factor authentication is enabled for this user. Maintained by the better-auth `twoFactor` plugin.',
483499
}),
@@ -486,6 +502,7 @@ export const SysUser = ObjectSchema.create({
486502
role: Field.text({
487503
label: 'Platform Role',
488504
required: false,
505+
readonly: true, // ADR-0092 — set via the Set Platform Role action, never the edit form
489506
maxLength: 64,
490507
group: 'Admin',
491508
description: 'Platform-level role (admin, user, …). Set via the Set Platform Role action.',
@@ -494,20 +511,23 @@ export const SysUser = ObjectSchema.create({
494511
banned: Field.boolean({
495512
label: 'Banned',
496513
defaultValue: false,
514+
readonly: true, // ADR-0092 — toggled via Ban/Unban actions (session side effects)
497515
group: 'Admin',
498516
description: 'When true, the user cannot sign in. Toggle via Ban User / Unban User actions.',
499517
}),
500518

501519
ban_reason: Field.text({
502520
label: 'Ban Reason',
503521
required: false,
522+
readonly: true, // ADR-0092 — written by the Ban User action
504523
maxLength: 255,
505524
group: 'Admin',
506525
}),
507526

508527
ban_expires: Field.datetime({
509528
label: 'Ban Expires',
510529
required: false,
530+
readonly: true, // ADR-0092 — written by the Ban User action
511531
group: 'Admin',
512532
description: 'When set, the ban auto-clears at this time.',
513533
}),
@@ -618,6 +638,7 @@ export const SysUser = ObjectSchema.create({
618638
ai_access: Field.boolean({
619639
label: 'AI Access',
620640
defaultValue: false,
641+
readonly: true, // ADR-0092 — a licensed-seat grant; flows through the AiSeatPlugin enforcement path
621642
group: 'Admin',
622643
description:
623644
'Whether this user holds an AI seat — grants access to the in-UI AI ' +
@@ -639,13 +660,15 @@ export const SysUser = ObjectSchema.create({
639660
manager_id: Field.lookup('sys_user', {
640661
label: 'Manager',
641662
required: false,
663+
readonly: true, // ADR-0092 — drives own_and_reports RLS scope; org-structure maintenance is its own surface
642664
group: 'Organization',
643665
description: "This user's direct manager. Forms the reporting chain the `own_and_reports` hierarchy scope walks (ADR-0057 / @objectstack/security-enterprise).",
644666
}),
645667

646668
primary_business_unit_id: Field.lookup('sys_business_unit', {
647669
label: 'Primary Business Unit',
648670
required: false,
671+
readonly: true, // ADR-0092 — denormalised projection maintained by plugin-sharing; never hand-edited
649672
group: 'Organization',
650673
description: "The user's primary business unit — a denormalised projection of sys_business_unit_member.is_primary, maintained by plugin-sharing (ADR-0057 addendum D12). Lets a user-lookup filter candidates by business unit without traversing the membership junction. Do not edit directly; set it via business-unit membership.",
651674
}),

0 commit comments

Comments
 (0)