Craft & engineering
Painting an admin UI in collaboration with AI
I. The guy who could not paint
My mother painted. Really painted, in the technical sense: color, perspective, light, sure gesture. My father, for his part, makes tracings — a sheet of translucent paper laid over an image, a pencil that reproduces the line. Tracing is an honest discipline: you invent nothing, you copy well. I grew up between the two and inherited neither her hand nor his patience. What I did keep was a taste for naive and abstract art — works that don't try to fool the eye, that own their clumsiness, that lay down flat colors and move forward layer by layer.
I'm a backend developer. For years, I built services, data models, APIs, engines. Graphical interfaces I left to others. I knew how to read a CSS file, wire up a form, hold a page together. But painting a real admin UI — with its lists, filters, paginations, cross-referenced details, error messages, tooltips, translations — I had neither the hand, nor the instinct, nor the appetite.
And yet, on Ezkey, I had to do it. The product needed an administration console to drive tenants, integrations, administrators, users, API keys, enrollments and authentication attempts. No designer, no frontend team. Just me, the AI in the IDE, and a blank canvas.
This article is the story of how that admin UI appeared, in layers, over roughly four months. It is not a pure method article. It is an honest account of a backend developer who would not have painted alone, and who still ended up putting something coherent on the canvas thanks to a tool that filled in exactly the gaps that needed filling.
II. From a Python TUI to a React admin UI
In late January 2026, I wasn't ready yet to attack a graphical interface. Ezkey needed a way to inspect its entities without going through the database and without inventing a Postman client for every screen. The first iteration was therefore a text console: an interactive Python TUI — screen, list, keyboard selection, direct access to the administration endpoints. A few days to make it useful, a few weeks to make it comfortable.
The TUI played its part. It made the structure of the backend visible. It forced the first operating rules: what do we list, what do we filter, what can evolve on the lifecycle side. But it had two obvious limits. First: a non-developer operator would never use it. Second: it stopped at text. No sortable tables, no status pills, no drill-down, no tooltips, no demo mode. The full richness of a real operations console stayed out of reach.
So in late February, I started a first graphical interface under the name tenant-ui. The initial idea was more modest than a complete admin: expose what a tenant administrator would need to see. After a week, two things became clear. First, the tenant / global boundary was going to be more porous than imagined: a Global Admin and a Tenant Admin share a big half of their surface, and duplicating the effort would be absurd. Second, the stack I was laying under tenant-ui could serve the global admin without modification.
In early March I owned the pivot: tenant-ui was abandoned as a standalone product, its code was folded into a single ezkey-admin-ui, and the story restarted on that new base. The plans of the old line were archived cleanly rather than deleted — the trace is more useful than silence.
The technical stack that carried everything that followed is deliberately orthodox and minimal: React 19 and React DOM 19, TypeScript, Vite as the bundler, TanStack Query for server cache and invalidation, React Router for navigation, React Hook Form and Zod for forms and their validation, i18next and react-i18next for localization, Tailwind CSS for layout, lucide-react for icons, and Orval to generate a typed client from the OpenAPI contract exposed by the Admin API. No ready-made component library like Material UI or Chakra. No in-house UI framework. Nothing proprietary. Each brick is what a 2026 React developer would find familiar at first glance at the package.json.
I have to make a confession at this point. Of this alphabet soup, I didn't even know the names of most of them six months earlier. Looking at all this, for a modest backend developer, could one seriously imagine that this whole richness of UI construction would have been possible without the absolutely considerable and decisive contribution of human–AI collaboration?
This stack discipline isn't a vanity move: it's what allowed me, as someone who is not a frontend developer, to lean on the AI without asking it to invent conventions. Everything it proposed clung to libraries it knew by heart. The risk margin stayed in the domain — Ezkey's lifecycle, the operator constraints, the eligibility rules — not in the rendering. What remained was to figure out where to start.
III. The base layer: width before depth
On the canvas, you start by covering. You don't draw an eye before laying down a background. If you attack a detail too early, it falls into the void and you have to redo it when you fill in what surrounds it. This, I believe, is the only real technical inheritance I took from painting without having learned to paint: respect the order of the layers.
I applied the same rule to the admin UI. Before going into the fineness of a single screen, I wanted to lay all the entities end to end. Tenants, administrators, integrations, users, API keys, enrollments, authentication attempts, audits, settings: each needed to have a list page, a detail page, a button that works, a visible state. Not so they would be beautiful. So they would exist.
This horizontal pass held for a few intense days in early March. The technical click was bringing in Orval right from this first pass: the OpenAPI contract published by the Admin API automatically becomes a typed TypeScript client, with TanStack Query hooks generated for every endpoint. No more handwritten DTOs, no more service layers to invent. The mapping between a business operation and an HTTP call becomes direct again, and I can spend my attention on what is actually UI: layout, feedback, sequencing.
This base layer had obvious gaps. Many screens didn't yet show clean errors. Pagination was sometimes local, sometimes nonexistent. FKs were raw numbers. Tooltips didn't exist. That was the intent: better an entirely covered canvas of provisional flat colors than one very detailed area next to an empty area. Once the canvas is covered, you can start to paint.
IV. The finishing layers
From the moment all the entities existed, the work became a sequence of targeted passes. Each one touches several screens at once, because each is carried by a cross-cutting mechanism — a hook, a component, a convention. It is never "redo a screen." It is always "lay a layer" that settles, at the same time, over half the console.
The Dashboard, from skeleton to real tool
The Dashboard is the screen that best sums up the move from flat color to finish. On March 8th, it's a skeleton page that shows a counter of pending authentication attempts. A week later, it knows how to auto-refresh and display in French. On April 14th, it publishes a real authentication-health widget and an enrollments aggregate; the same day, the pills become clickable and every number in the table opens the filtered sub-list in the audit or attempts screen. The Dashboard then stops being a notice board and becomes an operational entry point. Later still, some inconsistencies between backend statistics and the actual state of a removed integration had to be realigned — a useful reminder that the Dashboard depends on the seriousness of the counters it's fed.
Pagination, sorting, dates: the baseline becomes uniform
The first version of the admin UI used heterogeneous pagination modes: some screens paginated client-side, others not at all, others with a server API whose pagination didn't follow the same contract. At the very beginning of March, simply aligning the sort parameter across all lists already created the unification effect. In the same wave, the migration to Orval hooks unified how a page is requested: same signature, same cache mode, same invalidation. Then, in waves, each entity switched to server pagination, with first / last page navigation, size selection, and per-column activatable sorting. By late March, archiving the "pagination uniformity" plan on the Admin API side signaled that the layer was set: consistency no longer depended on a per-screen effort, but on a shared contract.
Internationalization and readable errors
On March 13th, the internationalization layer went through all at once: all the main pages — administrators, users, integrations, API keys, enrollments, attempts, audits — received their translation keys the same day. It's one of the most visible days in the Git history: a blitz of a dozen commits that turns an English-only admin into a properly bilingual console, with a persisted language selection.
The complementary layer arrived in early April, on the error side. The Admin API had migrated its error responses to the RFC 9457 (ProblemDetail) format: a structured object with a type, a title, a status, a detail and extension fields. The UI client now knew that every error response followed the same shape. From there, a catalog of human-readable French errors could be built, parametrized, and presented to the operator without exposing a stack trace or a raw message. The precision of that mapping is not yet complete at the time of this article — some messages remain generic — but the rail is laid.
Contextual help and detail navigation
An administration console lives by its small helps. In mid-March, a tooltip and popover component was introduced. In the same sequence, a contextual help system completed every main screen with a small ? button that opens a short text explaining what the page represents and what you can do on it. It isn't long documentation; it's a marker, placed exactly where the operator looks when they hesitate.
The same logic applies to navigation. In late March, the detail pages received a previous / next navigation that respects the current list sort: you read one tenant, you move to the next, you come back to the previous one without going back through the list. In mid-April, audits tied to an entity gained their own contextual navigation. In early May, several detail screens added inline feedback and better highlighting of recent actions — the operator has a clearer idea of what just happened on the entity they're looking at.
Time zone and timestamps that actually mean something
In mid-April, the per-tenant time-zone strategy was wired all the way into the admin UI. A date displayed in the console is explicitly tied to a known time zone, and relative labels ("a few minutes ago") coexist with absolute labels where the operator needs precision. In early May, audit timestamps were enriched to better serve incident correlation, and the "recent activity" view of an enrollment got a real UI surface.
Demo mode: a console you can show
As early as March 8th, a demo mode was introduced. When it's active, the console shows consistent synthetic data, and certain pre-written scenarios serve as examples. A first theme, "Garage du coin" (the corner garage), illustrated a small-business case. A second one, "InterCube," illustrated a more institutional case. Later, "demo reason" badges and localized presets made the mode more credible for showing to someone without risking dirtying a real database.
It's a small layer, in absolute terms. But it's one of the most profitable: it allowed me to show Ezkey out loud in meetings without having to apologize for the poverty of real data, and it forced the UI to be robust against complete entity sets, not only against the three rows of the local backend.
Instance and operator identity
In mid-March, the header received a logo and an "About" box that says clearly which instance you're connected to and at which version. In late March, a public instance-information endpoint was published, and both the login screen and the Auth API started using it. These are very discreet layers — an operator doesn't notice them the first time around — but their absence shows: without them, the operator never knows which instance they're acting on.
These layers settled roughly in order. Others demanded to be redone.
V. The owned reversals
There are two kinds of layers you lay badly: the ones you don't see right away, and the ones you defend on principle. Three reversals deserve to be recalled here, because they sum up a posture that mattered a lot in what followed.
First reversal: pagination. The first wave of screens didn't paginate. It was an honest shortcut: half the entities had five rows locally, and the effort of uniform pagination seemed disproportionate. Reality settled it quickly. As soon as a demo loaded two thousand audits or five hundred authentication attempts, the console became unusable. Uniform server pagination didn't become an option: it became the contract. The uniformity plan was written, executed, archived.
Second reversal: numeric foreign keys. There was a moment, very dogmatic, when I found it legitimate to show the operator a raw identifier — "this field references entity X whose ID is 42." The argument was thin but stubborn: it's honest, it's unambiguous, it's what the backend stores. Real usage settled this as quickly as it had settled pagination: an operator doesn't read numbers, they read names, labels, short descriptions. In waves, the FKs started resolving to readable labels, sometimes with a small link to the corresponding record. This work isn't finished. It moves forward by diffusion rather than by decree, and that's healthy: each screen receives the resolution when its usage context makes it a priority.
Third reversal: the storage of the UI authentication token. The first implementation used localStorage, like just about every React app that starts up fast. That choice works, until you sit down seriously in front of the threat model of an administration console. It was replaced. The layer became cross-cutting, and it deserves its own section.
VI. Security as a cross-cutting layer
I have to be honest about one thing: for a long time, my web-side security terminology was approximate. I talked about "nonce," I talked about "replay," I mixed up concepts that sit side by side without being the same thing. Working on the security of the administration console forced me to learn the right words, because code, unlike speech, doesn't tolerate approximation.
What the console does today rests on three building blocks, laid end-to-end in late April 2026. First, session authentication is no longer carried by a token stored in localStorage readable by any script on the page. It's carried by a session cookie marked HttpOnly, so inaccessible to the browser's JavaScript, and marked SameSite, so protected against the majority of cross-request contexts. Second, and this is the part I clumsily called "anti-replay," a CSRF protection mechanism was wired in: every mutating operation of the console is accompanied by a CSRF token that a third-party attacker cannot know; the server silently refuses the operation if the token is absent or doesn't match. Finally, the admin session expiration was aligned with the authentication cycle — the coupling is explicit, rather than left to two desynchronized configurations.
On top of that, an auditing discipline: every significant admin action leaves a trace, and these traces are themselves navigable from the console. That's also security, in the sense that it shifts part of the trust contract from individual memory to a persisted, inspectable layer.
I don't claim this stack is the absolute state of the art. I claim two things. First, that it uses the right words and the right mechanisms for the pair "administration console + Spring backend," and that those words — HttpOnly, SameSite, CSRF token — are the ones any serious web developer can recognize and critique. Second, that it arrived as a late layer, after the console was already usable, and that's probably the correct sequence: hardening a surface you know is less risky than hardening a surface you're still drawing.
VII. Activate an admin, recover them, close the door
Session security is still only half of the access contract. An administration console also has an access lifecycle that plays out alongside the backend: create an administrator, activate them, let them come back if they get lost, deny them entry if their enrollment is compromised. From late March onward, this cycle became a project of its own.
The initial activation of an administrator was wired as a path separate from normal login: the administrator receives a one-time code, presents it to the console, and only enters the standard MFA cycle once that step is cleared. A few weeks later, the ability to re-issue an activation code for an administrator still pending was added — without it, the smallest typo in an email address would have doomed an account.
Recovery got the same care. A recovery-code mechanism was thought through, written, documented, then integrated: an administrator who loses their device is no longer in a dead end. The "copy enrollment identifier" action was added to the login recovery screen, because an operator in panic needs a tangible gesture, not an abstract procedure.
Finally, the closed door: a dedicated plan for redirecting to the login page when the server responds with 401 was materialized, executed, archived. The console never stays open on a data view when the session has expired. It's a small detail, but it's exactly the kind of detail that distinguishes a tool you use every day from a tool you tolerate.
VIII. Quality as a distinct layer
For a long time, the quality of the console rested only on backend unit tests, on the OpenAPI contract tests, and on my own operator's eye. That was enough as long as there was only one operator and a single machine in play. It is no longer enough once you want a UI you can evolve without breaking everything with each pass.
In early April, a Playwright suite was introduced. Not to cover every pixel of the console, but to anchor a few critical scenarios: login, navigation between the main entities, sensitive paths. The logic was owned: UI tests are not an automatism — they are an editorial choice. You add one when a path is critical, sensitive, or complex enough to be worth it. You don't multiply them to inflate a counter. This posture is documented in the project conventions.
Over the same window, a big cleanup pass took place: lint cleanup, normalization of typing conventions, harmonization of shared components. A console that has to live demands a discipline of cleanliness that its first passes didn't require.
IX. The amplifier
Come back to the canvas for a moment. On the canvas, what changes everything between a beginner and someone who knows how to paint is not the brush. It's the ability to anticipate the layers, to know which to lay before the other, to allow yourself reversals when a layer refuses to hold. The gesture is an amplifier. Without layer thinking, it serves nothing. With it, it becomes the arm that brings into existence what you have already understood.
That's exactly the relationship I had with the AI in the IDE over these four months. It didn't paint the admin UI for me. It didn't have ideas about the demo mode, about the order of the layers, about the FK reversal, about the pagination strategy, about the security stack, about separating activation from login. These decisions are the tracing of the background. They came from operator usage, from Ezkey's domain model, and from my refusal to listen to myself when I wanted to defend a shortcut.
What the AI did was make my lack of frontend virtuosity profitable. It laid down coherent Tailwind components when I described an intention. It wired up an Orval hook correctly when I described an endpoint. It planed off three iterations of a form for me when I didn't have the energy to reread the validation code. It whispered the right Playwright test signature when I ventured out of my zone. And above all, it took notes: every plan, every decision, every owned reversal stayed in writing, because at every step I could ask it to materialize what we had just decided.
Six months earlier, without that amplification, I would probably have given up writing the admin UI myself. I would have taken a generic console, accepted a UI foreign to the rest of the product, or slowed everything else down while waiting for a frontend developer. The collaboration shifted the boundary of what I could reasonably allow myself.
What I take away, in the end, is not a point about painting. It's a point about honesty: a backend developer today can lay down a real administration console without pretending to be what they are not. They need an honest stack, a clean OpenAPI contract, a discipline of layers, the courage of reversals, and an amplifier that makes their lack of instinct profitable. For the rest, it's enough to cover the canvas, and to come back, and to come back again.
Français : Version française de cet article
See also: From code to intent
See also: Back to foundations — the developer in the AI era
See also: From monolith to contract
See also: AI coding manifesto