Skip to content

feat: manage users from the dashboard - #1461

Merged
raman325 merged 15 commits into
v3from
feat/manage-users-from-the-dashboard
Aug 21, 2026
Merged

feat: manage users from the dashboard#1461
raman325 merged 15 commits into
v3from
feat/manage-users-from-the-dashboard

Conversation

@raman325

Copy link
Copy Markdown
Owner

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_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 delete_user action.

Add. A new lcm-add-user card sits in its own section between the users and
the 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

  • Dependency upgrade
  • Bugfix (non-breaking change which fixes an issue)
  • New feature (which adds functionality)
  • Breaking change (fix/feature causing existing functionality to break)
  • Code quality improvements to existing code or addition of tests

Additional information

  • This PR fixes or closes issue: fixes #
  • This PR is related to issue:

Builds on the add_user / delete_user actions from #1451 and the user-centric
card from #1458. 763 TypeScript tests pass; add-user-card.ts is 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

raman325 and others added 2 commits August 20, 2026 12:13
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
Copilot AI lite review requested due to automatic review settings August 20, 2026 16:24

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@github-actions github-actions Bot added javascript Pull requests that update javascript code enhancement New feature or request labels Aug 20, 2026
@codecov

codecov Bot commented Aug 20, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 99.07%. Comparing base (e17eadc) to head (30fdd01).
⚠️ Report is 1 commits behind head on v3.
✅ All tests successful. No failed tests found.

Additional details and impacted files

Impacted file tree graph

@@            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              
Flag Coverage Δ
python 100.00% <100.00%> (ø)
typescript 95.45% <100.00%> (+0.51%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

Files with missing lines Coverage Δ
custom_components/lock_code_manager/__init__.py 100.00% <100.00%> (ø)
...stom_components/lock_code_manager/domain/models.py 100.00% <100.00%> (ø)
...om_components/lock_code_manager/domain/services.py 100.00% <100.00%> (ø)
custom_components/lock_code_manager/domain/util.py 100.00% <100.00%> (ø)
custom_components/lock_code_manager/entity.py 100.00% <ø> (ø)
custom_components/lock_code_manager/event.py 100.00% <ø> (ø)
...om_components/lock_code_manager/providers/_base.py 100.00% <100.00%> (ø)
ts/add-user-card.ts 100.00% <100.00%> (ø)
ts/generate-view.ts 99.10% <100.00%> (+<0.01%) ⬆️
ts/ha-components.ts 100.00% <100.00%> (ø)
... and 4 more
🚀 New features to boost your workflow:
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

raman325 and others added 6 commits August 20, 2026 12:26
`.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
@github-actions github-actions Bot added the python Pull requests that update Python code label Aug 20, 2026
raman325 and others added 7 commits August 20, 2026 14:06
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
@raman325
raman325 merged commit b897c84 into v3 Aug 21, 2026
15 checks passed
@raman325
raman325 deleted the feat/manage-users-from-the-dashboard branch August 21, 2026 02:59
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request javascript Pull requests that update javascript code python Pull requests that update Python code

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants