Repository navigation
Edit roles in a visual editor, with JSON as the fallback - #1893
dawsontoth merged 7 commits into
Conversation
There was a problem hiding this comment.
Code Review
This pull request introduces a comprehensive visual role editor for HarperDB, allowing users to configure role types, structure user settings, operations allowlists, and granular table and attribute permissions through an interactive GUI alongside the existing JSON editor. It updates the role creation and editing modals to use this new unified editor. The review feedback highlights several React anti-patterns in the newly added components, specifically recommending against setting state during the render phase in RolePermissionEditor and mutating refs directly during render in RoleVisualEditor, suggesting instead to derive state or defer ref mutations to useLayoutEffect.
| // Undecided until the schema loads; then the view opens on the visual editor when it can. | ||
| const [preferJson, setPreferJson] = useState<boolean>(); | ||
| // The JSON view re-checks on every keystroke; deferring keeps typing responsive on a large role. | ||
| const deferredPermission = useDeferredValue(permission); | ||
| const analysed = preferJson === true ? deferredPermission : permission; | ||
| const blockers = useMemo(() => { | ||
| if (!instanceDatabaseMap || !version) { | ||
| return []; | ||
| } | ||
| return analysed | ||
| ? getVisualEditorBlockers(analysed, instanceDatabaseMap, version) | ||
| : ['The JSON is not a valid object.']; | ||
| }, [analysed, instanceDatabaseMap, version]); | ||
| const visualAvailable = schemaReady && !!analysed && blockers.length === 0; | ||
| if (preferJson === undefined && schemaReady) { | ||
| setPreferJson(!visualAvailable); | ||
| } |
There was a problem hiding this comment.
Setting state during the render phase (setPreferJson(!visualAvailable)) is a React anti-pattern that can lead to extra re-renders or infinite loops. Instead, we can derive preferJson directly from the state and the visual editor's availability. This completely avoids setting state during render and simplifies the component's render logic.
Note: If you apply this suggestion, please also update the state setters on lines 112 and 159 from setPreferJson(...) to setUserPreferJson(...).
// Undecided until the schema loads; then the view opens on the visual editor when it can.
const [userPreferJson, setUserPreferJson] = useState<boolean | null>(null);
// The JSON view re-checks on every keystroke; deferring keeps typing responsive on a large role.
const deferredPermission = useDeferredValue(permission);
const visualBlockers = useMemo(() => {
if (!instanceDatabaseMap || !version || !permission) {
return ['The JSON is not a valid object.'];
}
return getVisualEditorBlockers(permission, instanceDatabaseMap, version);
}, [permission, instanceDatabaseMap, version]);
const visualAvailable = schemaReady && visualBlockers.length === 0;
const preferJson = userPreferJson ?? !visualAvailable;
const analysed = preferJson ? deferredPermission : permission;
const blockers = useMemo(() => {
if (!instanceDatabaseMap || !version) {
return [];
}
return analysed
? getVisualEditorBlockers(analysed, instanceDatabaseMap, version)
: ['The JSON is not a valid object.'];
}, [analysed, instanceDatabaseMap, version]);
There was a problem hiding this comment.
Declining this one, on evidence.
The setPreferJson call is React's supported storing information from previous renders pattern: a guarded update to the component's own state during render. preferJson === undefined is true only until the first render that has the schema, so it fires once per mount, and React re-runs that render before committing anything. The guard is what prevents a loop: with it removed, 22 modal tests fail with "Too many re-renders". Under StrictMode's double render both passes compute the same value, and both modal suites now run under StrictMode (8722738).
The suggested derivation changes behavior. userPreferJson ?? !visualAvailable keeps following availability until the author clicks, so a role that opened in the JSON view would jump to the visual editor as soon as the JSON became representable, unmounting Monaco mid-edit. It also runs getVisualEditorBlockers a second time on the non-deferred permission on every keystroke, which undoes the useDeferredValue. A new test pins the first point, and it fails with the suggestion applied.
🤖 Generated by Claude Code (Claude Opus); posted via @dawsontoth.
…as the fallback Add and Edit Role now open on a visual editor: role type, structure user (none / all databases / selected databases), the existing operations allowlist editor, a per-database table grid for read/insert/update/delete, and per-table attribute scoping. "Edit as JSON" switches to the Monaco view of the same draft, and the visual view comes back only while the JSON holds a document it can represent; otherwise the JSON view lists why. The JSON text stays the single source of truth. Every visual edit is a copy-on-write patch of the keys it touches, so content the GUI does not model is carried through unchanged and listed as kept. The editor no longer seeds the JSON from calculateDefaultPermissions: its global Pick Attributes flag rewrote every table's attribute list at once, and expanding a list wrote the primary key as an explicit false row, which revokes the key Harper otherwise derives from the other attributes. Add Role creates the role with its full permission in one step, through preparePermissionForSave. That also fixes creating a super user role: the old form always sent structure_user beside super_user, which Harper's validateNoSUPerms rejects. preparePermissionForSave now drops structure_user beside super_user/cluster_user for the same reason. Refs #1634 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ewer version From the first cross-model review round of the visual role editor: - The edit modal is keyed on the role id. Its draft is seeded once, so moving straight from one role to another reused the first draft and Save sent it to the second role. - A newer stored version (polled __updatedtime__) now blocks Save until the author loads it or chooses to keep theirs; alter_role replaces the whole permission with no revision check. - The derived primary key counts every listed attribute, including ones describe_all does not show, as Harper's permission translator does. - Only one database section opens by default, so a broad role on a large instance does not mount every table grid up front. - The cluster user option says what it means per version: on Harper 4 a user with that role has its password stored reversibly for clustering; Harper 5 does not use the flag. - A table entry without attribute_permissions gets a blocker that says how to fix it, and schema tables without an attributes array no longer throw. Refs #1634 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… version is unknown If registration_info fails, the allowlist floor is unknown, and Save stayed enabled for an operations value that isn't a list of names. For a structure_user: true role, preparePermissionForSave drops that value and widens the role. Without a version, the value now counts as unusable, which blocks Save until it is fixed in the JSON. A real list still saves. Also trims the editor modules' narrative doc comments down to the invariants they guard. Refs #1634 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… drops hidden attributes The cluster user type was offered to new roles on every 5.0.0 prerelease and on an unparseable version, because the Harper 4 check was "not at least 5.0.0". It now means a parseable version below 5.0.0-0. Its Harper 4 copy also says what the flag does grant: describe_all lists every database, table and attribute for it (harperdb dataLayer/schemaDescribe.js:36). It gets no table access, since the operations gate and REST short-circuit only on super_user. Turning attribute scoping on lists only the attributes describe_all shows, so a computed or relationship attribute loses access at that moment. The unscoped panel now says so before the switch is flipped, and the model's doc comment no longer claims access is unchanged. The notInSchema doc comment no longer says those entries block Save, which no code does. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… not during render StructureUserSection wrote its last-selection ref on every render, which keeps render impure: a render React discards would still have moved it. The ref is only read when the mode radio changes, so it is now written there too, the way OperationsAllowlistEditor stashes its allowlist. The visual editor tests now render under StrictMode, as main.tsx does, and pin the Selected → another mode → Selected restore. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… not the rendered one TablePermissionsSection refreshed latestRef during render, so a render React discards could leave the shared updater patching a document that never reached the screen. It is now refreshed in a layout effect, which runs before the browser can deliver the next click. The scoping-restore map is passed down as its ref and read only in the toggle handler, so render no longer reads a ref at all. `update` and the ref stay stable, so the memoized rows still bail out: on a 500-table database each click re-renders exactly one TableRows, before and after (median ~3ms per click in jsdom either way). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…r StrictMode RolePermissionEditor decides between the visual and JSON views during the first render that has the schema, guarded so it fires once per mount. Both modal suites now render under StrictMode, as main.tsx does, so that decision runs through React's double render in every modal test. A new case pins what the guard is for: a role that opened in the JSON view stays there after the author fixes it, until they choose the visual editor. Deriving the view from availability instead fails it, because the editor unmounts mid-edit. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
8722738 to
d9c3134
Compare
eb7dd36
into
claude/1299-role-attribute-permissions
⊙ Problem
The role editor is a JSON editor (#1634). Edit Role opens Monaco on a template that
calculateDefaultPermissionsbuilds from describe_all. The only structured control is the operations allowlist lens above it. Add Role takes a name and two checkboxes, and you set table permissions afterwards in the JSON.Two defects come with that:
falserow. Harper otherwise derives the key's access from the other attributes (permissionsTranslator.jsgetTableAttrPerms), so opening and saving a scoped role revoked its primary key.structure_usernext tosuper_user. Harper'svalidateNoSUPermsrejects any second key next tosuper_user: true. Against the 5.3.1 build,{super_user: true, structure_user: false}returns "Roles with 'super_user' set to true cannot have other permissions set."💡 Solution
Both modals open on a visual editor, and Edit as JSON switches to the same draft in Monaco. This follows the schema editor's toggle (
SchemaEditorView).OperationsAllowlistEditor, unchanged and still version-gated.The JSON text stays the only source of truth. Every visual edit is a copy-on-write patch of the keys it touches, so content the GUI does not model passes through unchanged. Examples are a database record's
describeand an attribute entry'sdelete. That content is listed as "kept as they are". The visual view is offered only while the document is one it can represent. Otherwise the JSON view lists the reasons, for example a non-boolean CRUD flag or an unusableoperationsvalue. Edit no longer seeds a template, so tables the role doesn't mention stay out of the JSON, and the GUI shows them as no access.The edit modal also stops two lost-update paths. Its draft is seeded once, so the modal is keyed per role: moving straight from one role to another used to keep the first draft. And because
alter_rolereplaces the whole permission with no revision check, a newer polled__updatedtime__blocks Save until you Load the latest version or Keep my changes.Every claim in the new copy was traced to Harper source: what
structure_usergrants, super user bypassing table permissions, an unchecked attribute having no access, the derived primary key, timestamps being read-only, and the cluster user flag on Harper 4 storing a recoverable password (harperdbsecurity/user.ts) and listing every database, table and attribute in describe_all (dataLayer/schemaDescribe.js:36). The table-level rules were then run through the 5.3.1 build (Verification).Not covered yet, still JSON-only: granting
@computedand relationship attributes, because describe_all omits them (#1818 adds theinclude_computedoption), legacy pre-2.2attribute_restrictionsroles, and a database record'sdescribeflag, which is preserved but not shown. There is no table filter for very large schemas either.⚖️ Alternatives
🔧 Changes
rolePermissionModel.ts, is pure and unit-tested. Every write goes through own-key helpers that rebuild objects withObject.fromEntries, because Harper permits a database, table or attribute named__proto__, and an assignment would hit the prototype setter. Its parts:getVisualEditorBlockersdecides when only the JSON view can show a document. A table entry with noattribute_permissionsgets a blocker that says how to fix it.getPreservedEntrieslists content kept but not shown, with "not in this instance's schema" kept separate soremoveEntriesNotInSchemacan remove exactly that.setRoleTypewrites a flag being turned off asfalseonly where the key already exists.MISMATCHED_TABLE_ATTR_PERMS).setDatabaseActiondoes a whole database in one rebuild.setAttributeScopinglists every attribute with the table's own access when switched on, so nothing changes at that moment. When it restores a list switched off this session, it clamps that list to the table.setAttributeActionadds an unlisted attribute with its effective access first, and also grants the table an action given to one of its attributes.useRolePermissionDraft.ts, keeps the object a visual edit produced instead of re-parsing its own text. Untouched databases and tables keep their identity.RolePermissionEditor.tsx, loads describe_all and the version.isOperationsValueUnusableblocks Save on anoperationsvalue that save would drop. Without a version, that means anything but a list of names. The JSON view defers its blocker check while you type, and the initial view is decided once the schema loads. A schema that doesn't load leaves the JSON view in charge, with Retry, and an unusableoperationsvalue still gets the existing notice.RoleVisualEditor.tsx:DatabaseSectionandTableRows.EditRoleModal.tsxseeds the draft from the stored permission, announces a newer stored version and blocks Save until it is resolved. It remounts the editor when the latest version is loaded.AddRoleModal.tsxuses the same editor and sends the prepared permission in oneadd_role.index.tsxkeys the edit modal per role, and mounts Add only while it is open, so a closed Add fetches nothing and every open starts fresh. Adding a role no longer opens the edit modal afterwards.preparePermissionForSave.tsdropsstructure_userbesidesuper_user/cluster_user, whichvalidateNoSUPermsrejects.addRole.tstakes a fullpermissioninstead of the two booleans, andaddRoleFormSchema.tsdrops them from the form schema.defaultCalculator.tsand its test. Nothing else used the template.DESIGN.mdrecords the text-as-truth invariant, what forces the JSON view, what Save changes on purpose, and the draft and conflict rules. It is indexed from the rootDESIGN.md.✅ Verification
Route: tests, plus a contract check against Harper's own build. Live Harper was not run end to end. I ran Harper 5.3.1's
role_validationandpermissionsTranslatorfrom the harper checkout'sdist, withgetDatabasesandhdb_schemastubbed to a fixture schema, over the payloads the editor produces. A new table grant, scoping turned on (timestamps read-only), preserveddescribeand attributedelete,structure_user: ['data']with table grants elsewhere, andstructure_user: truewith an allowlist all validate. An attribute grant above its table and a table missing from the schema are rejected, which is what the editor guards against and warns about. The translator's effective access matches whatgetAttributeViewsshows in three cases: a derived key (id= read only, fromname; timestamp insert ignored), a key made explicit, and a key derived from a listed attribute describe_all doesn't show. It also confirms that astructure_userdatabase gets full CRUD whatever its table entries say. The add-role shapes were checked the same way:{super_user: true, structure_user: false}is rejected, and{super_user: true}and{}pass. Off-repo, so reviewers can't re-run it.Tests, from
npx vitest run src/features/instance/config/roles/(127 passed). The visual editor and both modal suites render under StrictMode, asmain.tsxdoes:rolePermissionModel.test.tspins every blocker and preserved-entry category, and every updater keeping untouched keys. That includes__proto__names, the derived primary key and read-only timestamps, and a key derived from an attribute describe_all doesn't show.RoleVisualEditor.test.tsxcovers the tri-state database row, no restored attribute grant exceeding its table, the database picks restored after a structure-user mode switch, the cluster user copy per version, no cluster user type for a new role on a 5.0.0 prerelease or an unparseable version, the hidden-attribute caveat shown before scoping is switched on and only one database opening.EditRoleModal.test.tsxdrives the real modal through alter_role:operationsguard;AddRoleModal.test.tsxchecks that a super user role is created as{super_user: true}.index.test.tsxswitches roles while the editor is open and checks that Save sends the second role's own permission.preparePermissionForSave.test.tscoversstructure_userand false flags beside a super/cluster flag.Mutation checks: I reverted each new guard in turn, 19 in all, and each turned at least one test red. The last three are from the adjudicated round: the old Harper 4 check, the pre-toggle caveat and the cluster user copy. For the gemini-code-assist follow-ups, removing the structure-user stash, removing the layout-effect write, and applying gemini's derived view state each turn a test red. Removing the guard on the initial-view
setPreferJsonmakes 22 modal tests fail with "Too many re-renders".Large schema: with 5 databases, 500 tables, 30 attributes each and every table granted, a checkbox click re-renders in about 5ms under the React Profiler in jsdom. Without the per-database memo it took 83ms. After the updater's ref moved into a layout effect, a click on a 500-table database still re-renders exactly one
TableRows, at about 3ms in jsdom before and after.Gates: the full
vitest runpassed (398 files, 3717 tests, 11 skipped).tsc -b,oxlintanddprint checkall exited 0. The pre-commit hook ran on every commit without--no-verify. Browser verification was skipped because port 5173 is shared with parallel sessions.Cross-model review: six rounds after a codex planning review of the design (verdict
chosen-approach-sound). The Cursor legs could not fetch through the 1Password SSH agent in rounds 1-3 and were not selected in rounds 4-6.operationssave.e603b0f0. 5.0.0 prereleases and unparseable versions were offered the cluster user type as if they were Harper 4. The warning that scoping drops computed and relationship attributes appeared only after the switch was flipped. Two doc comments contradicted the code: thenotInSchemaone said those entries block Save, and the scoping one said access doesn't change. Its cluster-user major is answered in the cluster user Your call above.fromPermissionorders the permission's top-level keys twice per edit, which costs one shallow object per click and keeps a single serializer.PreservedEntriesandRoleEditorSchemaare exported with no importer, but they type exported functions' results. The remaining comment-narration nit is a style call.notInSchema, Harper 4's cluster user type and a revision precondition are the Your calls above. Leaving unlisted tables out of a new role gives the same effective access, because Harper reads an absent table as no access (permissionsTranslator.jstranslateRolePermissions). Round 6 added one more: a layout-effect ref for the table grid's updater, versus a functionalsetDraft(prev => …)updater. I kept the ref, which leaves the draft one write path. A functional updater would add a second one, plus a null branch for apermissionthe visual editor never sees as null.I rejected these on evidence:
preparePermissionForSavekeeps a false flag or drops an arraystructure_user's table grants. It doesn't: the early return tests=== true, the=== falsebranch drops false flags, and the tests asserting both pass at HEAD.attribute_permissionsis valid. Harper rejects it (role_validation.ts:196-198,ATTR_PERMS_ARRAY_MISSING), so it stays a blocker, now with a message that says how to fix it.@/aliases.gemini-code-assist on the PR raised three render-purity points. Two were real and are fixed.
8ad3f5estashes the structure-user selection in the mode handler instead of writing a ref during render.2b87940refreshes the table grid's updater ref in a layout effect and stops reading the scoping-restore ref during render. I declined the third, on the thread. The guardedsetPreferJsonduring render is React's supported pattern, and the suggested derivation would switch views under the author.8722738adds the StrictMode harnesses and the test that pins this.🤖 Generated by Claude Code (Claude Opus); posted via @dawsontoth.
Related PRs: #1875 overlaps (the base of this stack; this diff deletes the defaultCalculator it changed and keeps its save-side null → [] repair), #1888 overlaps (adds maxLength to the same Add Role name input and creates AddRoleModal.test.tsx against the old props; whichever lands second rebases)
Complexity: complicated
Review-Coverage: authored=claude; ran=codex,gemini; adjudicated=domain; blocked=cursor-grok(no-receipt),cursor-composer(no-receipt); declined=cursor-kimi,cursor-muse; rounds=6; full=2 @ 8722738
Review-Attention: study ~20m (sensitive: addRole.ts, addRoleFormSchema.ts; decisions: do-less-alternative, committed-ref-vs-functional-updater, schema-invalid-save-policy, client-side-revision-check, offer-cluster-user-on-v4, single-step-add) @ 8722738