feat(auth-ui): asset editor - #153
Conversation
auth-ui had no test runner. This adds vitest on jsdom with one seam at HTTP: the global fetch is stubbed by the setup file, before any page module loads, because the shared api client resolves its base url at module load behind a top-level await on /config.json. The editor library cannot run under jsdom, so its React wrapper is aliased to a text area that forwards content and changes plus a two-sided diff component. Highlighting and diff rendering are therefore outside component-test coverage; the real library still loads in the tokenizer tests. Include patterns confine the runner to tests/assets. Existing application code gets no tests here. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A pure module owning the conversion between the api's base64 representation of an asset's content and the text an editor shows, so no page hand-rolls it. Decoding reports whether the bytes were valid utf-8; content that is not opens read-only later, because re-encoding a lossy decode would corrupt an asset that cannot be restored. Encoding is chunked: the whole byte array spread into one String.fromCharCode call blows the argument limit long before the api's 1 MB body limit is reached. Encoded size is estimated without encoding, so a page can warn on every keystroke. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
An Assets entry appended to the sidebar after Domains, and a list answering "what is currently deployed?" without database access: name, asset version, type, targeted environments, the template flag and the uri, with an asset that targets nothing marked rather than left blank. Environment, type and template filters go to the server; the environment filter is single-select because the server's compiles to an array-contains, so a multi-select would read as "targets all of these". Name search, sort and pagination run client-side, because GET /asset offers none of them. All of it round-trips through the url. The route tree moves to src/routes.tsx and the application to a data router. Tests mount the same tree, and the asset editor's later unsaved-work guard needs useBlocker, which only a data router provides. The existing routes are unchanged. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Monaco ships no Rego, so this registers one: a Monarch tokenizer and a language configuration written here, covering keywords including the current-generation syntax, built-ins, literals, numbers, quoted and raw strings, comments, operators and delimiters. Highlighting only, per ADR-0002 — syntax errors keep surfacing downstream where the bundle is built. A second id, rego-template, adds one rule for the handlebars interpolations auth-bundler substitutes into a template asset, ahead of the operator and delimiter rules so an opening interpolation is not read as two braces. The two share every other rule, so they cannot drift apart. Registration is idempotent, which hot reload and strict mode's double invocation both need. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A row on the list opens /assets/:assetName — the application's first nested route — where an asset's content is readable as highlighted source. A full page rather than a dialog, per ADR-0001, and a linkable url. The page reads the named-asset response as a list of asset version rows of unknown length and shows the latest; a single-element response gets no special case. Language resolves from the asset type, and for a data document from the name's extension. Name, asset version and creation time are shown but never offered for editing: the name is half the asset's identity and there is no delete to undo a second asset. The new editor component wraps the library at a fixed height filling its container, so a save action will stay on screen on a long policy, and follows the application theme by prop rather than by remounting. The json editor the opa validator uses is left untouched. Content that is not valid text opens read-only with an explanation, because re-encoding a lossy decode would corrupt an asset that cannot be restored. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Content, asset type, uri, targeted environments and the template flag become editable; name, asset version and creation time stay read-only. The type is deliberately not locked after creation — locking it would make a duplicate asset the only route to a type change, with no delete to clean up — so changing it switches the editor language at once and warns the content will not follow. The uri accepts a leading slash, which the bundle build treats as relative, and rejects parent-directory segments and backslashes before any request. An empty set of targeted environments is permitted, matching the api. Saving is irreversible, so a diff against the stored content is one click away first. The request carries the asset version that was loaded as its concurrency token and is built from a local type that omits creation time, which the shared schema marks both required and read-only. Ctrl/Cmd+S saves and swallows the browser default. On success both the collection and named-asset queries are invalidated, so the list is not stale on return. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three failure edges of an irreversible save. A 409 is reported as a conflict — a save refused because it declared a stale asset version, with nothing merged. The working content is kept, the stored content is refetched, and the two open in the diff so the author decides what to re-apply. Telling a conflict from any other failure needs the response status, which the query wrapper does not surface, so the mutation goes through the shared fetch client directly. Navigating away with unsaved edits raises a confirmation through the router's blocker, and only when there are edits. There is no draft persistence, so the guard is all there is between leaving and losing the work. Content is measured against the api's 1 MB body limit as it is typed: a warning as it approaches, and a refusal here rather than an opaque server error above. Also fixes a save test whose stub did not keep what it was sent, which the new guard exposed by blocking the navigation the test made afterwards. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A full page at /assets/new, like the single-asset route rather than a dialog, per ADR-0001. The defaults leave only what is specific to the asset: version 1, which the api requires for one that does not yet exist, type POLICY, uri /, template off, no targeted environments. A name shorter than three characters is rejected before any request. On success the author lands in the editor for the asset just created. A 409 here means the name is taken, and is worded as that — a different failure with a different remedy from the stale asset version an edit hits. The editable metadata moves into one component the two pages share, and uri.ts becomes validation.ts now that it holds the name rule too. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The editor header gains an asset version dropdown, sorted descending with latest marked, reading the asset version rows the named-asset endpoint returns. Which one is on screen is a url search param, so it stays linkable. Selecting a non-latest asset version opens it read-only behind a banner and a route back to latest — overwriting what is deployed with an older body is exactly the mistake this guards — with a comparison against latest in the diff view, older on the left. Today the api leaves exactly one asset version row per name, so the dropdown usually lists one entry. That is a probable backend bug, recorded in the spec's Further Notes and deliberately not worked around: the page reads the response as a list of unknown length, so it fills in with no change here if the write path is corrected. The unsaved-work guard now covers a search-only navigation too, since on this page that is a switch of asset version away from the edit surface. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Spec gaps closed: the create page had neither the unsaved-work guard nor the request-size warning, so a freshly typed policy was one sidebar click from gone and a large one would have failed as an opaque server error. The size alert is now one component both pages render, and the redirect after a create moves into an effect so the guard sees the save land before the navigation. Correctness: a save no longer raises the guard in the window between success and the refetch; `?page=abc` no longer renders "Page NaN"; content that is not base64 at all is flagged as not-valid-text instead of throwing out of atob and taking the page down with it; and the root route gains an errorElement, since a data router catches a render error itself and the react boundary around the tree no longer sees it. Standards: the save transport, the timestamp formatter and the asset-version dropdown's derived props each had a duplicate or a redundant parameter; the per-render base64 decode is memoised; the url's filter values are checked against the enums before they travel to the server; and the environment labels take the glossary's plural. The save shortcut is also registered as an editor command, not only on the wrapper, so it is bound where the spec says it is. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
CptSchnitz
left a comment
There was a problem hiding this comment.
- the structure of the filters is different than the other pages, it should be the same.
- the english is not good - "all targeted environments", "templates and not" are not clear.
- in the new asset page, when you choose environment, the "No targeted environments" is gone and changes the layout. the control should stay at the same place.
- the asset version in the new asset page is not clear, something about the design.
- before saving changes there should be "are you sure" prompt.
- when creating a new asset or new version, its not clear that it worked, as you go to the asset page that looks the same, you dont get any indication.
- there should a button to enter edit mode in the asset page, and also a undo / cancel button. it should not always be with edit enabled.
- in the assets page, when choosing stuff in the filter, it changes the layout (might not matter after the filter changes.
- right now when if for example version 1 is np, and version 2 is prod, when filtering by np you cant see it, even when its still the working version for np.
Nine points from the review of #153. The assets list takes the filter shape the other entity pages use: a search box, a filter toggle carrying a count, the filters themselves in a panel, and the active ones as removable badges. Every part of it is mounted before any filter is chosen, and the loading spinner moves inside the table, so choosing a filter changes what the table holds and nothing about where the controls sit — a full-page spinner was previously unmounting the whole block mid-click. An asset now opens read-only. Editing is a mode with one way in and two ways out: Save, which asks first, and Cancel, which asks before throwing edits away. Nothing about an asset is irreversible-by-accident any more. A create and a save both land on a page that used to look untouched, so both now leave a notice naming the asset version they produced. Latest and in-use came apart with no way to tell: an environment builds its bundle from the highest asset version targeting it, so a version the latest one stopped targeting stays live. The version dropdown and the non-latest banner now say which of the two a version is, computed the way the bundler computes it. Also: the environments hint holds its line, so ticking a box does not move the controls beside it; a new asset's version is stated beside the title rather than sitting in a field that looks fillable; and the filter wording that read as machine-generated is rewritten. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The filter row takes the other pages' order exactly: the search box takes the width, the filter toggle sits hard against the right edge, and the count badge, the clear button and the active-filter badges each appear only once there is something to show. Reserving their space kept the layout still but pushed the toggle off the right edge, which is the more visible of the two. The three full-page asset views carried no padding of their own while every list page adds p-6 on top of the main element's, so moving between the list and an asset jumped. They now match. The save confirmation warned about losing history. The asset version is the history; what a save actually changes is what each targeted environment builds its next bundle from, so that is what it now says — by name, and saying so plainly when an asset targets nothing at all. The read-only warning made the same wrong claim and is corrected with it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…s is The way back sat on a line of its own above the title, so the title row — and the button in it — started one line lower than on the list page. Moving between the assets list and an asset jumped by exactly that line, which the padding fix did not touch. The way back is now an icon button at the head of the title row, so every asset page opens with the same 36px row the list pages do: Add asset and Create sit in the same place, as do Edit and Save. The version view kept its version dropdown in that row, which made the row taller than any other page's. It joins the stack underneath, where the asset page already has it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…sidebar The way back was a bare arrow, which said nothing about where it went. It is now the path it stands for — "Assets / authz.rego", with the first half a muted link — which names the destination, keeps the heading itself to the asset's name, and still sits on the title's line so the action button holds the list page's height. "Will be saved as asset version 1" was stating a promise. The version is the api's to assign and is always 1 for a new asset, so the create page no longer mentions it; the notice on the page a create lands on states it once it is a fact. The sidebar marked an entry only on an exact path match. Assets is the first entry with routes underneath it, so creating or opening an asset left nothing marked at all. It now matches an entry's own children too. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
| return await saveAsset(body); | ||
| } catch (failure) { | ||
| // A conflict here is a name already taken — a different failure, with a different | ||
| // remedy, from the stale asset version a save on an existing asset hits. |
There was a problem hiding this comment.
A create silently overwrites an existing asset whose version is 1, rather than reporting a name collision.
This page always posts version: 1, and upsertAsset (apps/auth-manager/src/asset/models/assetManager.ts:63-79) only throws AssetVersionMismatchError when maxVersion !== asset.version. An asset already at version 1 takes the update branch: its content is replaced by whatever was just typed and stored as version 2. No 409 ever reaches here.
Every asset that has never been edited sits at version 1, so this is the ordinary case, not an edge one. There is no delete and no content history, so the overwritten content is gone. tests/assets/create-asset.spec.tsx:99 stubs the 409 and asserts the wording, which is why the suite is green over the hole — the server's unit tests (apps/auth-manager/tests/unit/asset/models/assetManager.spec.mts) cover "version is not 1" and "version doesn't match", not this one.
The spec's whole safety story is that destructive mistakes are made hard; this is the one path that destroys an asset with no confirmation at all. GET /asset/{assetName} is already wired for AssetPage and answers with an empty list for an unknown name — check it before posting and refuse when it returns rows. If that is out of scope, it at least belongs in the spec's Further Notes with the other API gaps, because the API change it implies is not optional.
|
|
||
| // A data router rather than <BrowserRouter>: the asset editor's unsaved-work guard | ||
| // needs useBlocker, which only a data router provides. | ||
| const router = createBrowserRouter(appRoutes); |
There was a problem hiding this comment.
The move to a data router changes the meaning of what three existing pages already do to the URL. ClientsPage.tsx:63, ConnectionsPage.tsx:60 and DomainsPage.tsx:46 each call window.history.replaceState({}, '', url.toString()) on every filter change. Under <BrowserRouter> that was harmless. createBrowserRouter keeps {usr, key, idx} in history state and reads idx on every push — index = getIndex() + 1 — so after the wipe every entry pushed thereafter carries idx: NaN. I confirmed that in this repo with a throwaway spec: filter the clients list, click a sidebar link, history.state.idx is NaN.
useBlocker is the only consumer of that number, and it is this PR's unsaved-work guard. On a POP it computes delta = NaN, which passes its own delta != null check, and calls history.go(NaN); react-router's warning string names this exact scenario ("navigating outside the router via window.history.pushState"). I could not reproduce the browser-side symptom under jsdom — its history.go is not a faithful oracle — so please check it in a browser: filter the clients list, open an asset, edit it, press Back. Story 47 asks for no regression on the existing pages, and the guard is the one thing between an edit and losing it.
The change is one token in each of the three pages:
window.history.replaceState(window.history.state, '', url.toString());or switch them to setSearchParams(..., { replace: true }) the way AssetsPage already does.
| const sort = parseSort(searchParams.get('sort')); | ||
| const page = positiveInteger(searchParams.get('page'), 1); | ||
| const pageSize = positiveInteger(searchParams.get('pageSize'), 10); | ||
| const showFilters = searchParams.get('showFilters') === 'true'; |
There was a problem hiding this comment.
environment, type and template are all checked against their allowed values — "the url is external input" — but pageSize takes any positive integer. ?pageSize=999 slices correctly and leaves the page-size Select rendering nothing, since no SelectItem matches and there is no placeholder. Consider running it through oneOf(..., PAGE_SIZES).
| import { describe, expect, it } from 'vitest'; | ||
| import { decodeAssetContent } from '@/lib/asset-content'; | ||
| import { appRoutes } from '../../src/routes'; | ||
| import { MAX_ENCODED_BYTES } from '@/lib/asset-content'; |
There was a problem hiding this comment.
Fold into the import on line 4 — same module, two statements.
…tate alone
A create posts asset version 1, and the api's upsert refuses a post only when the
declared version is not the name's current maximum. An asset already at version 1 —
every asset that has never been edited — therefore took the update branch: its content
was replaced by whatever had just been typed. No 409 was ever emitted, so the page had
nothing to report. With no content history and no delete, that content was gone.
The create page now reads the named-asset endpoint and refuses a name that already has
an asset, before it posts anything. It is a check and not a lock, so the 409 handling
stays as the backstop and both paths word it the same way. The declared 404 the endpoint
does not emit today is read as a free name rather than as a failure to check. The api
gaining a create that is not an upsert is recorded in the spec's Further Notes.
The three existing list pages wrote the url with `window.history.replaceState({}, ...)`.
Under the plain router that was harmless; the data router this branch introduced keeps
its history index in that state, so wiping it left every entry pushed afterwards with an
index of NaN — the number the unsaved-work guard reads on a back navigation. They now
pass the state through. The new spec proves the three pages preserve it; the consequence
for the guard is browser behaviour and still wants a check in one.
A page size from the url is checked against the four the select offers, as the other
three url filters already were. `?pageSize=999` used to slice correctly and leave the
select rendering nothing.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
| </TableCell> | ||
| </TableRow> | ||
| ) : ( | ||
| assets.map((asset) => ( |
There was a problem hiding this comment.
An asset name is not unique in the /asset collection response — every version of an asset is a separate row with the same name. Your own seed data has this today (olKmsNiH v1/v2/v3, skpsBeRC v1/v2, fuYkBNgE v1/v2), so this fires on a stock checkout, no special setup needed.
To see the visible symptom (not just the console warning)
Take a screenshot or just note the order of rows on /assets right after it first loads.
Type a character into the search box, then delete it (so the filter is back to "no filter" — same data as before).
Compare the row order to step 1 — it's different, even though it's the exact same 19 assets with no filter applied. That's React silently reusing/misplacing DOM nodes because it can't tell rows with the same key apart.
| export const UnsavedChangesDialog = ({ when }: { when: boolean }) => { | ||
| // Search included, not just the path: on the asset page the search is which asset | ||
| // version is being viewed, and switching it leaves the edit surface just the same. | ||
| const blocker = useBlocker( |
There was a problem hiding this comment.
[nitpick] - kind of 😄
useBlocker only intercepts in-app (react-router) navigations. It does not intercept a hard reload, a tab close, or a same-page window.location.reload()
Two versions of the same asset share a name, so keying table rows by name alone let React confuse rows across re-renders. Unsaved-edit guard also only covered in-app navigation, missing reload/tab close. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…tent The diff editor only ever showed the content change. A save that only touched type, URI, environments, or the template flag looked like it had no diff at all, so nothing was there to review before saving it. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Assets were the only part of the authorization system with no user interface. Answering "what is deployed right now?" meant database access, and changing a policy meant hand-crafting an HTTP request with a base64 payload and a guessed version number.
This adds a fourth managed entity to auth-ui: browse every asset, open one into a full page, read it with syntax highlighting, edit it, and create new ones. Everything runs offline and no runtime dependency is added.
Commits, one per ticket
07690ea6163e3dd5c8f7ab864c6bd4fc688f2c43d532e0c60c22bdda1a464a0db3c86a16748c65bf1804c2ff05da14d308Changed after review
The nine points from the review, in order.
The filters take the shape the other entity pages use — search box, a filter toggle carrying a count, the filters in a panel, the active ones as removable badges. Every part of that block is mounted before any filter is chosen, and the loading spinner moved inside the table, because the real cause of the list shifting was the full-page spinner: starting a new query unmounted the header and the filters mid-click. The wording that read as machine-generated is rewritten — "All targeted environments" is now "All environments", "Templates and not" is "Templates and regular assets".
An asset opens read-only. Editing is a mode with one way in — an Edit button — and two ways out: Save, which asks before it writes, and Cancel, which asks before it throws edits away. Metadata follows the editor in and out, so the page cannot be half-editable.
A create and a save both say so. Both land on a page that otherwise looks exactly as it did before, so both leave a notice naming the asset version they produced. The toast alone was too easy to miss behind a navigation.
Latest and in-use are now told apart. An environment builds its bundle from the highest asset version targeting it, so a version the latest one stopped targeting stays live for that environment — previously the page called it "not the latest, read-only" and said nothing about it still running. The version dropdown annotates each entry (
3 (latest),1 (in use: np)), the non-latest banner adds what the version is still in use for, and the asset's header carries an "In use" badge. All of it computed the way the bundler computes it. The list is unchanged: a row still opens the latest.Third round
The way back is the path, not an arrow. An icon-only arrow aligned the buttons but said nothing about where it went. The title row now reads
Assets / authz.rego, the first half a muted link. The heading itself is still just the asset's name, so it is still the page's heading rather than a breadcrumb, and the row keeps the list page's height.A new asset no longer promises its version. "Will be saved as asset version 1" was a promise about something the api assigns and that is always 1 here. The create page says nothing about it; the notice on the page a create lands on states it once it is a fact.
The sidebar marks an entry from anywhere underneath it. It compared the path for exact equality, and assets is the first entry with routes below it, so creating or opening an asset left nothing in the sidebar marked. It now matches an entry's children too — no change for the other entries, which have none.
Two smaller ones. The environments hint holds its line whether or not there is a hint in it, so ticking a box no longer moves the controls beside it. And a new asset's version is stated beside the title rather than sitting in what looked like a fillable field.
Second round
The filter row follows the other pages' order exactly. The first attempt reserved space for the count badge, the clear button and the badge row so that turning a filter on moved nothing — but that pushed the filter toggle off the right edge, which is the more visible of the two problems. Each of them now appears only when there is something to show, as on every other page. The one stability fix that stays is the loading spinner, which belongs in the table: it was the reason a filter change tore out the header and the filters.
The full-page asset views were missing their padding. Every list page adds
p-6on top of the main element's own, and the asset, create and version pages added none. That was half the jump between the list and an asset; the other half was the way back sitting on a line of its own above the title, which started the title row — and the button in it — one line lower than on the list page. The way back is now an icon button at the head of the title row, so every asset page opens with the same 36px row the list pages do: Add asset and Create sit in the same place, as do Edit and Save. The version view kept its version dropdown in that row, making it taller than any other page's; it joins the stack underneath, where the asset page already had it.The save confirmation was warning about the wrong thing. It claimed an asset keeps no content history. The asset version is the history; what a save actually changes is what each targeted environment builds its next bundle from. It now names them — "This writes asset version 4. np, prod build their next bundle from it, in place of asset version 3." — and says so plainly when an asset targets nothing. The read-only warning made the same wrong claim and is corrected with it.
Worth a reviewer's attention
The application moves to a data router.
App.tsxused<BrowserRouter>with a single flat level of children. The unsaved-work guard needsuseBlocker, which only a data router provides, so the tree moves tocreateBrowserRouterover route objects insrc/routes.tsx— shared with the tests, so both mount the same thing. The routes themselves are unchanged. One consequence: a data router catches render errors itself, so the root route gains anerrorElement; without it a page that throws would reach the router's own default screen rather than this application's.Saving goes through the shared fetch client, not
$api.useMutation. Telling a409from any other failure needs the response status, and the query wrapper only surfaces the error body.src/pages/assets/save.tsis the one place that reaches past it, and the one place the schema's read-only-yet-requiredcreatedAtis papered over.Safety around an irreversible save. There is no content history and no delete, so: a diff before every save, a confirmation on navigating away with edits, a stale save reported as a conflict that keeps the working content and shows it against the refetched stored content, a read-only mode for content that is not valid text, and a size warning before the api's 1 MB body limit turns into an opaque server error.
Rego highlighting is lexical only, per ADR-0002 — no parsing, no diagnostics. Syntax errors keep surfacing downstream where the bundle is built.
Testing
auth-ui had no test runner; this adds the first one, confined by include patterns to this feature. Existing application code gets no tests here.
One seam: the global fetch, stubbed by the setup file before any page module loads, because the shared api client resolves its base url at module load behind a top-level await. Tests render real components through the real fetch client, query library and component tree. One forced seam: the editor cannot run under jsdom, so its React wrapper is aliased to a text area and a two-sided diff — highlighting and diff rendering are therefore outside component-test coverage, and the tokenizer is tested against the real library instead.
163 tests across 12 files.
turbo run build,testandlintare green, andknipis clean.Deliberately not done
Replication to other sites, deleting assets, Rego validation, server-side pagination, any API or schema change, and the existing JSON editor's dark-mode bug. API gaps found along the way — including a uri with no server-side traversal validation, and an asset version write path that overwrites rather than inserts — are recorded in the spec's Further Notes rather than worked around.
🤖 Generated with Claude Code