feat: manage users from the dashboard - #1461
Merged
Merged
Conversation
The card could show a user and change their PIN, but getting rid of one still meant going back to the config entry. Adds a delete button to the hero row that opens a confirmation naming the user. The confirmation exposes `clear_credentials` rather than deciding it, because the two answers are genuinely different intents: revoke the code, or stop managing a code that should keep working. It defaults to clearing, matching the service. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XDxiHpQJkRKWctS9BfQmbY Entire-Checkpoint: 8b26fc04762b
The dashboard could show and change users but not create one, so adding somebody still meant the config entry. Puts an Add user button in its own section between the users and the locks, opening a dialog for a name, an optional PIN and whether to enable them. The card decides nothing: it hands the three fields to the add_user action and lets the integration pick the slot, because capacity and allocation rules already live in one place and a second copy in the frontend would drift. On success it reloads, since the view is strategy-generated and the new user has no card until the strategy runs again. Section counts in the view tests now filter by strategy type. They were asserting on the total, so a new section read as a change in how many users or locks were rendered. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XDxiHpQJkRKWctS9BfQmbY Entire-Checkpoint: b86fc380bdb8
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## v3 #1461 +/- ##
==========================================
+ Coverage 99.05% 99.07% +0.02%
==========================================
Files 60 62 +2
Lines 7491 7680 +189
Branches 484 520 +36
==========================================
+ Hits 7420 7609 +189
Misses 71 71
Flags with carried forward coverage won't be shown. Click here to find out more.
🚀 New features to boost your workflow:
|
`.edit-input` sets `width: 100%` with padding and a border but left box-sizing at its content-box default, so the input was 18px wider than the element holding it. In the hero row that put the name field under the state pill. Adds a guard: a rule that declares `width: 100%` alongside padding or a border has to declare `box-sizing` too. The overrun is easy to miss because most containers do not clip -- the element just slides under whatever sits next to it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XDxiHpQJkRKWctS9BfQmbY Entire-Checkpoint: 595583963514
`ha-button`, `ha-checkbox` and `ha-textfield` are not registered on a Lovelace view, so the browser treated them as unknown elements and drew nothing: the remove dialog had no buttons and the add dialog had no fields. `ha-dialog` and `ha-switch` come along with the entity rows a dashboard already loads, which is why the modals themselves appeared. Replaces them with plain elements styled to match, the same thing every other control in these cards already does. `ha-entity-picker` stays, because the card force-loads it first. Adds a guard listing the Home Assistant elements proven to render on a dashboard. Card editors are exempt -- they run inside HA's config dialog, which has the form components loaded. The allowlist is empirical: an element earns a place by rendering on a real dashboard, not by existing in HA's source. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XDxiHpQJkRKWctS9BfQmbY Entire-Checkpoint: 50848beb9ce1
Follows the convention the condition dialog already documents: ha-dialog's primaryAction/secondaryAction slots do not reliably pick up children projected from a card's shadow root, so the buttons go at the foot of the dialog body instead. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XDxiHpQJkRKWctS9BfQmbY Entire-Checkpoint: c68eb6bb7652
The add dialog now offers the condition alongside the name and PIN, so a time-limited user takes one step instead of two. It uses Home Assistant's own `ha-entity-picker`, force-loaded the way the user card already loads it for its condition dialog -- picking an entity means searching every entity in the instance, and nothing we can write by hand substitutes for that. Home Assistant does not promise a dashboard the picker, though, so when the load fails the field is left out entirely rather than replaced by a free-text entity id: one typed from memory is a support ticket, and the user card covers the case a step later. Moves the loader and the condition-domain list into ha-components.ts. The slot card had the only copy, and a second one would have drifted. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XDxiHpQJkRKWctS9BfQmbY Entire-Checkpoint: 129662e2596b
The identity row put the state icon, the name, the state chip and the remove button on one line. None of the three besides the name grows with it, and on a narrow card they took around 220px -- roughly five characters of name before the ellipsis. Everything that describes the user rather than names them moves to a thin meta row above, and the name takes the line below it, wrapping instead of truncating. A name with no spaces breaks mid-word rather than overflowing. The chip no longer caps itself at 60% -- it now has a row where a long state like "Blocked by condition" reads in full -- and the remove button pins right so it does not drift with the chip's width. Adds a guard asserting the name rule declares neither nowrap nor an ellipsis. A cut-off name is a cut-off identity. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XDxiHpQJkRKWctS9BfQmbY Entire-Checkpoint: 5e4a6176a1a2
Two halves of the same gap between "the write landed" and "the entry finished reacting to it". Home Assistant runs update listeners as a task rather than awaiting them, so a service that writes to the entry returned before the entities for the users it changed existed. A script that adds a user and then sets their PIN through the new text entity would find nothing to set, and the dashboard reloads the moment the call returns, so it could re-render without the card it was reloading for. All four writing services now clear a settle event, write, and wait for the update listener to signal a finished pass -- bounded, because a pass that never finishes must not hold the caller forever, and the write is already durable by then. The other half: removing a user closed the dialog and stopped. The user's entities are gone and the subscription simply stops reporting, so the card sat there showing the person who had just been removed, PIN and all, until a manual refresh. It reloads now, as adding already did. The add-side test states the contract but does not prove it: that path finishes inside awaits the call already makes, so it passes either way. The delete-side test is the one that fails without the wait. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XDxiHpQJkRKWctS9BfQmbY Entire-Checkpoint: 48b065f7c3c7
The add dialog masked the PIN with no way to check it, so a typo could only be found later, by the code not working. Uses the reveal button the user card already has, so the control means the same thing in both places. It reopens hidden however it was left: the dialog is on a dashboard, and a PIN left on screen from a previous add is not the state to come back to. Both fields move to explicit label association. A button inside a <label> activates the label as well as itself, which is not what pressing the reveal should do. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XDxiHpQJkRKWctS9BfQmbY Entire-Checkpoint: 748d93f1c2dd
The dialog took a PIN but offered no way to get a good one, so the honest options were reusing something or making one up. Adds a Generate button beside the field, with a length box, calling the `generate_pin` action. The action owns what safe means -- it draws from `secrets` and rejects sequences, repeats and the common-leak list -- so the card asks for a PIN rather than inventing one. Length mirrors the action's own bounds and is checked here too, so a bad one is a message under the field rather than a failed call. Generating reveals the result. A code you cannot read is one you cannot pass on, which is the whole point of generating it. A Generate/Provide mode toggle would have been the other shape. This keeps the field as the single source of truth: you see what came out, and you can type over it. `callService` in the type stubs claimed to return void. Home Assistant returns the response of any action that declares one, and this is the first place we ask for it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XDxiHpQJkRKWctS9BfQmbY Entire-Checkpoint: f5093aa567fb
"Generate" alone leaves the object to the label above it, which is easy to lose once the row has a number and a unit beside it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XDxiHpQJkRKWctS9BfQmbY Entire-Checkpoint: 29e1778329ff
Three things the device page was telling users, none of them true. **The lock in a per-lock entity's name was its entity id.** `BaseLock.display_name` read the entity registry row and stopped there, so a lock that sets `has_entity_name` with no name of its own -- Z-Wave, Matter, most modern integrations -- has an empty `original_name` and fell through to `lock.front_door`. Every per-lock entity was called "... lock.front_door in sync" and "... lock.back_door PIN". There were two resolvers for this one fact, disagreeing: the one that builds entity IDs fell back to the de-slugged object id, so the IDs came out readable while the names on screen did not. They are now one function, and it asks the device registry, which is where such a lock's name actually lives. **The credential-used event had no name.** It declared `state_attributes` and no `name`, so Home Assistant appended nothing to the device name and the entity was called "All Locks Raman and Sherene", giving no clue what it was. **The parent device's serial number was the config entry id.** A ULID presented as hardware provenance. The device itself stays: the per-user devices name it in `via_device`, and Home Assistant drops that link if the target is missing. The two new contract tests are the general form: every entity key owes a name, and `strings.json` and `translations/en.json` must agree -- a name that reaches only the first is a name nobody sees. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XDxiHpQJkRKWctS9BfQmbY Entire-Checkpoint: 8dd33e9a3e94
It set `_attr_name = None`, which with `has_entity_name` means "this entity IS the device". Home Assistant appended nothing, so the entity was called "All Locks Raman and Sherene" -- indistinguishable from its device -- and its id carried no suffix at all. That made sense when the device was a slot and the event was the only thing on it. The device is a user now, and the event is one of several things about them. Named "Credential used" rather than "PIN used" because the entity id is derived from the name, and a PIN is one credential among the several this is growing to cover; renaming it later would move the id. Migration needed the same fix from the other side. An entity that arrives with no `original_name` has nothing to append, and the branch handling that swapped the device slug and kept whatever followed it -- which for this entity was nothing. It now uses the key inside the unique ID, which is there whether or not the entity was created with a name, and is what the translated name is looked up by anyway. The strings-level contract test added alongside this passed the whole time: it asserted the name existed, not that the code used it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XDxiHpQJkRKWctS9BfQmbY Entire-Checkpoint: 3e9b1d9056ab
It held no entities. It existed so the per-user devices could name it in `via_device` and be drawn beneath it, which bought a line on a device page and cost a device on every page that lists them. The users are the devices worth having. The `via_device` goes with it, and had to. Home Assistant reports a `via_device` naming a device that is not there as a use it intends to break, so leaving it behind would log on every registration -- and `via_device` is deprecated in its own right, the registry saying so where it resolves one. Migration removes the device an earlier version left, so an upgraded install ends up shaped like a fresh one. Three tests were written around the hub and now say what they were really about: an empty device of ours is not a leftover copy, a device whose identifier names no slot cannot be deleted from the UI, and device diagnostics for anything that is not a slot falls through to the entry's. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XDxiHpQJkRKWctS9BfQmbY Entire-Checkpoint: 9320d5439e15
The migration re-slugs every entity id onto its user's name, and nothing checked what that costs someone who has been running the integration for months. Home Assistant repoints recorder history on a rename, but only when the rename goes through the entity registry in place -- remove and recreate silently strands it, and so does a destination id that is already taken. Neither fails loudly: the recorder logs a warning and the history is just not there. Records a state under the pre-upgrade id, migrates, and asserts the recorder now holds it under the new one and not the old. Verified it fails if the rename is changed to a remove. The second test pins the case that does lose history: a configuration rewound past an upgrade while the recorder database moves forward, so the destination id already exists. Reached by restoring a pre-upgrade backup and upgrading again -- not by upgrading once. The integration cannot merge the two, so this documents the hazard rather than fixing it. Exercising the recorder at all needed the project-wide autouse `enable_custom_integrations` overridden at module level: it pulls in `hass`, and `recorder_db_url` refuses to run once that has happened. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XDxiHpQJkRKWctS9BfQmbY Entire-Checkpoint: 6a8734801858
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Proposed change
The dashboard could show a user and change their PIN, but the two operations that
change who exists still meant going back to the config entry. This adds both to
the generated view.
Remove. Each user card gets a delete button in the hero row, opening a
confirmation that names the user. The confirmation exposes
clear_credentialsrather than deciding it, because the two answers are genuinely different intents —
revoke the code, or stop managing a code that should keep working. It defaults to
clearing, matching the
delete_useraction.Add. A new
lcm-add-usercard sits in its own section between the users andthe locks, rendering as a button. It opens a dialog for a name, an optional PIN
and whether to enable them, then calls
add_user.The add card decides nothing beyond those three fields — the integration picks the
slot, because capacity and allocation rules already live in one place and a second
copy in the frontend would drift. On success it reloads the page: the view is
strategy-generated, so a new user has no card until the strategy runs again.
Both actions address the config entry by id when the card has one and by title
otherwise, never both, since the actions treat the pair as exclusive.
Type of change
Additional information
Builds on the
add_user/delete_useractions from #1451 and the user-centriccard from #1458. 763 TypeScript tests pass;
add-user-card.tsis at 100%statements, branches, functions and lines.
The view tests now filter sections by strategy type instead of asserting on the
total count — they were reading a new section as a change in how many users or
locks got rendered.
Not yet smoke-tested against a real Home Assistant; that is the next step.
🤖 Generated with Claude Code
https://claude.ai/code/session_01XDxiHpQJkRKWctS9BfQmbY