facett-auth-ui — the shared device-code sign-in pane
One-line pitch
One robot-testable egui Facet that renders an RFC 8628 device-code login, so every suite app's Sign in surface (dwarves' Profile · korp · holger-ui) is ONE pane — not a per-app twin.
Why it exists (LAW #5 — DRY)
dwarves-demo/src/profile.rs grew an interim LoginSeam whose real backend (NornirLogin::sign_in) could only leave a "(see terminal for the device code)" note — the RFC 8628 prompt never rendered in the GUI. korp and holger-ui would each grow their own copy of the same Sign-in-button → device-prompt → poll → signed-in flow. That is a data-parameterised pane masquerading as N copies. This leaf owns the one pane body; the HOST supplies the data through a seam.
Layering — AUTH-LIBRARY-AGNOSTIC (the important constraint)
facett carries no nornir-auth-client / edda auth dependency. The pane is presentational + host-driven: the HOST implements the LoginSeam trait (wiring the real device-code grant + token store), and LoginPane only ever renders the states the seam surfaces. This mirrors how the Release tab takes a ReleaseConvergence in and the About card takes an Identity in — the pane never reaches for a backend.
Deps: facett-core (the Facet contract + the shared clipboard::put the Copy buttons reuse), egui, serde_json. Optional nornir-testmatrix behind the testmatrix feature only. A leaf beside facett-about / facett-confirm.
Design — the chosen shape
The seam surfaces four states; the pane paints whichever one it holds:
LoginState pane renders
├─ SignedOut "Sign in" button → seam.begin_device_code()
├─ AwaitingUser{user_code,uri} URL (hyperlink, clickable) + Copy URL
│ user_code (monospace) + Copy code
│ "Waiting for approval…" → seam.poll() each frame
├─ SignedIn{subject} "Signed in as {subject}"
│ credential NAMES (seam.credential_names — never values)
│ "Sign out" button → seam.sign_out()
└─ Error(msg) red message + "Try again" → begin()
The seam (HOST implements)
pub trait LoginSeam {
fn source(&self) -> &str { "device-code" } // honest banner
fn begin_device_code(&mut self) -> LoginState; // → AwaitingUser | Error | SignedIn
fn poll(&mut self) -> LoginState; // → AwaitingUser | SignedIn | Error
fn current_identity(&self) -> Option<String> { None } // cached token (no network)
fn sign_out(&mut self) -> LoginState { LoginState::SignedOut }
fn credential_names(&self) -> Vec<String> { Vec::new() } // NAMES only — never values
}
Generalises dwarves' interim single-blocking sign_in into the begin/poll shape a real in-GUI device-code flow needs. No method exposes a token/secret value — by construction a credential value can never reach the pane or its state_json.
API
LoginPane::new(Box<dyn LoginSeam>)— opens signed-in if the seam has a cached identity, else signed-out..with_title(..),.with_auto_poll(bool).LoginPaneis afacett_core::Facet(title/ui/state_json/update_json), so it drops into anyFacetDeck.state()/is_signed_in()for host branching.update_json:{"begin":true}/{"poll":true}/{"sign_out":true}.StubLogin— a network-free seam (honest"stub device-code (demo)"source) that simulates the grant (approves after N polls) so the shipped demo + the robot test drive the whole flow with no IdP. Holds a secret sentinel it never surfaces.
Robot test (L4) — matrix cell facett-auth-ui/login-pane
tests/robot.rs renders the REAL LoginPane under egui_kittest over a StubLogin and drives SignedOut → AwaitingUser → SignedIn, asserting off the live AccessKit tree: the Sign in button paints; the user-code + the verification URL paint (the prompt is shown IN-app, not left as a terminal note); the subject + a Sign out button paint; and NO secret value string appears anywhere in the pane's state_json. Inline src/lib.rs tests cover the headless update_json/state_json drive, a cached-identity open, and the Error state. RED-when-broken: dropping the user-code/URL from the awaiting surface, or leaking a token into state_json, fails an assertion. Emits gated #[cfg(feature="testmatrix")].
Security invariant (must-NOT-change)
The seam has no value getter and state_json carries no value/token/secret key — only the subject (identity) + credential names. The robot's no-secret assertion is the contract; any future field that could carry a value must not land here.
Status / gap
Pane + seam + robot test shipped. The per-app hosts implementing LoginSeam over nornir-auth-client and mounting this pane — dwarves-demo's Profile (replacing its interim profile.rs LoginSeam + "see terminal" note), korp, holger-ui — are DEFERRED to the host-side agents (separate work). The QR-of-URL nicety is a deferred follow-up (no encoder dep pulled in here).