diff --git a/docs/buzz-help.md b/docs/buzz-help.md index 0612f509df..acf73ee3b6 100644 --- a/docs/buzz-help.md +++ b/docs/buzz-help.md @@ -94,14 +94,15 @@ mention anyone. Explicit agent watches on that room can still receive them. without Builderlab sign-in do not upload a backup during setup. Later, while signed in to Builderlab on a build with backup, a key with no backup choice is backed up automatically if you belong to a Builderlab-hosted community; - otherwise Buzz asks **Sync your identity with Builderlab?**, again on the - next launch or sign-in if the dialog is closed. **Settings → Buzz → Identity - → Identity sync** shows the sync status and, while backup is off, offers - **Sync with Builderlab**. When the key isn’t linked to your Builderlab - account, or the account holds a different key, its **Builderlab** link opens - **Settings → Plugins → Builderlab**, where **Connect Buzz identity** links - this key, or **Switch to this device’s identity** replaces the account’s key - (this permanently unlinks the old one). After signing in to the + otherwise Buzz asks **Back up your key?**, again on the + next launch or sign-in if the dialog is closed. In **Settings → Buzz → Identity**, + the ID card shows backup status. Open **More options** at the top-left of its + container for **Retry backup** or, while backup is off, **Back up key**, which + opens the consent dialog. When the key isn’t linked to your Builderlab + account, or the account holds a different key, the notice’s **Builderlab** link + opens **Settings → Plugins → Builderlab**, where **Connect Buzz identity** + links this key, or **Switch to this device’s identity** replaces the account’s + key (this permanently unlinks the old one). After signing in to the public Builderlab, **Connect to a community** also offers **Create a community** when that account is linked to this device’s key and may create one. Choose an available address, then **Create community** joins it right away, @@ -134,12 +135,34 @@ mention anyone. Explicit agent watches on that room can still receive them. This lets the key direct your Buzz coding agents to run commands as you. Authorize only keys you recognize: the proof has no time or event-kind limits. Your private key stays in Buzz. This does not join the agent to a community or sign it in to an organization. +- The back of your Identity badge shows known backup and recovery details below + the private-key strip and scrolls when its details run long. Buzz starts checking + when the Identity page opens. The back always explains that the key is stored + securely on this device. It names the backup account when a configured service + confirms a backup and the account lookup has not failed. A failed lookup hides + that account sentence until recovery. Initial status loading stays quiet; a + failed check shows **Backup status unknown** with **Retry backup** in the + bottom action space on the back. This rereads local status and then starts a + backup attempt for the current key + when the read succeeds. It stays disabled and shows a spinner through both steps. + The private-key menu contains + the **Private key** heading above Copy and Reveal. If a confirmed backup’s + latest backup attempt fails, **Check backup** flips the card while the back + keeps the confirmed-backup explanation. Unconfirmed failures also show + **Check backup**. A spinner appears only while a backup attempt is running. + **Retry backup** uses that same space above **Remove from device** on the + back and shows a disabled loading button during an attempt. A confirmed, settled backup + shows **Backed up** with a destination tooltip. Only one recovery button + appears at a time. Initial status loading shows no pill. + **Remove from device** opens a confirmation before removing Buzz’s saved key + from this device’s secure storage and signing out. Account backups and copies + saved elsewhere stay. Other local data stays unless erasing is also selected. - **Settings → Buzz → Identity → Copy private key** lets someone make their own backup, for example in a password manager. They should handle it privately. When Builderlab backup is on, the same page shows the account's linked key - when it is backed up, with when it was last active in the current community; - until the organization is signed in, that row asks them to sign in instead. - Backups of other keys are not shown, and the row is read-only. + when it is backed up and differs from this device’s key. + Account lookup failures use the card’s **Backup status unknown** caption and + **Retry backup** action. Backups of other keys are not shown, and the row is read-only. Backup and restore options depend on the build and account; do not promise recovery or assume automatic backup is on. Losing every copy of the private key can mean losing the identity. @@ -177,15 +200,16 @@ mention anyone. Explicit agent watches on that room can still receive them. **Not now** opens Buzz anyway, but that organization's communities stay unavailable until you sign in. Sign-in, sign-out and when the current sign-in ends also live under - **Settings → Buzz → Identity → Organization sign-in**. + **Settings → Buzz → Identity → Community sign-ins**. - When a community requires organization sign-in, the sidebar or join dialog explains why and offers **Sign in with** the organization’s name or server address. Complete sign-in to reconnect; in the join dialog, choose **Continue**. -- Signed in to more than one organization server? **Organization sign-in** - lists each one you're signed in to, with the communities that require it, and - each **Sign out** ends only that server's sign-in. Your organization's own - server stays listed with **Sign in** until you sign in to it. **Sign out of - Buzz** still ends them all. +- The **Community sign-ins** cards show each configured server, its account + and the saved communities that require its sign-in. Each **Sign out** asks for + confirmation and ends only that server’s sign-in. Signed-out servers keep **Sign in**. + **Renew sign-in** and the expiry date appear within seven days of expiry; + its caret menu offers **Sign out**. A Builderlab connection link opens its + existing settings. Removing the Buzz key still ends all server sign-ins. - Buzz apps on this computer that use the same organization server, such as Buzz and Buzz Staging, share its sign-in, like the private key. After you sign in in one, **Sign in** in the other finishes without the browser. Signing out diff --git a/docs/identity.md b/docs/identity.md index d3315051a7..af2f51eb2d 100644 --- a/docs/identity.md +++ b/docs/identity.md @@ -330,18 +330,20 @@ open. Quitting with a key on the device but no saved community ends first run: the next launch restores the key and, with setup idle, lifts the gate straight into the in-app **Join a community** landing (`CommunityLanding`), the page a key without a community has always opened on. The key stays on the device, and -the **Identity sync** row in Settings → Identity shows the backup state. A +the ID card in Settings → Identity shows the backup state. A link or backup step still running at quit ends with the process: the native uploader's pass is retired, and whether the link committed on kgoose is unknown to the client. Both are safe to run again — the link reads the account's current key first and does nothing when it already holds this one, and the uploader runs again when the user next signs in to Builderlab in Settings — but nothing re-runs the link on its own, and Settings has no row for it; the Identity -sync row reports only what the uploader's last pass found. Below it, a **Backed-up key** -row shows the account's linked key when `nsec-backups/status` (read with the -bundled Builderlab session as first run reads it) lists it, marked This device -when it is, with when it was last active in a channel on the current -community's relay. Backups of other keys are left out and the row is +card reports what the uploader's last pass found. Below it, a **Backed-up key · Linked** +row appears only when the account's linked key differs from this device's and +`nsec-backups/status` (read with the bundled Builderlab session as first run reads +it) lists it. The same-key summary and community activity lookup are omitted. +Account lookup failures share the card's **Backup status unknown** caption and +**Retry backup** action; retry refreshes the account lookup as well as starting +the native backup attempt. Backups of other keys are left out and the row is read-only: making another key the account's would unlink the current one, and kgoose never links a key again once it is unlinked. @@ -778,10 +780,54 @@ principals; plugin JavaScript can invoke `identity_export` directly. Not registe a plugin key service is an API ownership choice, not a sandbox. The main-webview command permission is not proof of a human gesture. +The Identity page checks key management when it opens. Builds with a backup +service show a front pill once the key's state is known: **Backed up**, with a +destination tooltip, **Check backup** for a failed attempt (including a confirmed +backup's latest attempt), or a spinner only while an attempt runs. +**Check backup** flips to the explanation and retry. +The pill sits inside the artwork, so loading cannot move the card or its controls. +Builds without a service reserve no pill space. The back uses the same concise +storage and recovery explanation for self-managed and organization-managed keys. +The local-storage explanation appears immediately and consistently calls local +secure storage **this device**. When a backup is confirmed and the current account +lookup has not failed, the same paragraph names the configured account destination, +matching the front tooltip. A failed account lookup hides that sentence until the +lookup recovers; the device-storage explanation stays visible. Native confirmation +metadata is retained, so a failed later upload alone does not erase a known backup. +The private-key menu’s save-a-copy guidance follows it. +Backup attention and retry feedback sit in the bottom action stack, with the +full **Retry backup** CTA directly above **Remove from device**. The CTA stays +disabled with a loading spinner while an attempt runs. Failure hints use centered +caption text and omit redundant retry instructions; the inline backup retry row +is removed. +The **Backed up** pill appears only on the front. +Unknown backup state shows no backup claim; a failed check offers a quiet +**Backup status unknown** caption and **Retry backup** in that same bottom action +space. +One recovery control owns the status read and upload retry. With unknown +management, it rereads native state, then automatically starts a native backup +attempt when the returned management matches the current key. A read failure or +key mismatch leaves status unknown and starts no upload; an already-running +attempt is not queued again. The common recovery component owns both steps so +the CTA stays busy across the transition and card flips. Closing Settings or changing +keys before the read completes prevents starting the upload. A refused start +shows recovery feedback; native consent and sign-in checks remain authoritative. +Retrying returns focus to **Back** while the check +runs. Long back content scrolls inside the front face’s existing bounds instead of +stretching both faces. A failed later attempt preserves the confirmed account +backup explanation while the front shows **Check backup**. Only a running attempt +shows a spinner. There is no red retry action on the front. The key +menu and removal confirmation dismiss independently of Settings. +At 390px width with 200% text, the full bottom CTA labels (**Retry backup** +and **Remove from device**) can overflow the card. This shares the accepted +layout limit at that size; body copy, vertical scrolling and keyboard access +remain checked. + ## Sign out of Buzz -Settings → Buzz → Identity → **Sign out of Buzz** removes the private key from this -device only; the npub and its history stay on relays. The dialog presents two +Settings → Buzz → Identity → **Remove from device** removes Buzz’s saved private +key from this device’s secure storage and signs out. Account backups, exported +copies, and the npub and its history on relays stay. The dialog presents two layers: the key always goes, and settings, agents and other Buzz data stay unless the user erases them. It embeds the private-key controls above. Confirm stays disabled until the user reveals or copies the key and ticks "I have my key". @@ -999,21 +1045,23 @@ Membership in any community this build's Builderlab hosts, by the welcome screen's `builderlab-hosted.ts` list matched on the saved relay host alone (no relay is read for its `self`), saves automatic backup with no prompt, unless a choice was saved since it was read: the save checks under the choice lock. -Otherwise **Sync your identity with Builderlab?** asks: **Sync with -Builderlab** and **I will manage my own key** save the answer, which a hosted +Otherwise **Back up your key?** asks: **Back up key** and **I will manage my own +key** save the answer, which a hosted membership arriving while it is saved does not override; closing it saves nothing, and it asks again on the next launch or Builderlab sign-in. A build whose Builderlab hosts no listed community, including an FI build, always asks. -Settings → **Identity sync** explains what syncing is for and shows the -uploader's status: **Syncing…** while a pass runs, **Synced with Builderlab** -only once an upload is confirmed, and loading, sign-in and failure feedback -below the description. While it waits for consent, **Sync with Builderlab** -opens the same prompt, which closes if the wait settles without its answer. +Settings → Identity's card shows the uploader's status and keeps recovery on +its back. While it waits for consent, **Back up this key to Builderlab** appears +above a prominent **Back up key** action in the bottom stack. It opens the same +consent prompt before any choice is saved; the prompt closes if the wait settles +without its answer. Cancel restores the action's focus; saving consent or an +externally settled wait returns focus to **Back**. **Retry backup** and the +consent action share that slot, so retry never bypasses consent. When the key isn’t linked to the account, or the account holds a different -key, both of which native reports only after consent, the sentence’s -**Builderlab** opens Settings → Plugins → Builderlab, whose **Connect Buzz -identity** or **Switch to this device’s identity** is the next step. The -sign-in wait stays plain text: Organization sign-in is on Identity itself. +key, the notice’s **Builderlab** opens Settings → Plugins → Builderlab, whose +**Connect Buzz identity** or **Switch to this device’s identity** is the next +step. Both states arise only after consent. The link is available only while +Builderlab’s Settings card is registered; the sign-in wait stays plain text. Settings does not turn automatic backup off. A successful restore, and a welcome-back import of a key the account already backs up, save automatic backup the same way, only when the key has no saved choice. @@ -1225,21 +1273,24 @@ sends them to the Apps control plane paired with the server's identity origin directory, which native reads and uploads itself, with the same checks as a plugin upload. A 401 is handled as for API calls. -Settings → Identity → Organization sign-in is the same whichever community is -open. It lists each server you are signed in to, with its name and origin, the -account and expiry, **Used by:** the joined communities its hosts cover, and a -**Sign out** that ends that server's session only. A sign-out native could not -finish (for example, secure storage refused) stays below the list, naming the +Settings → Identity → Community sign-ins is the same whichever community is +open. It shows cards for each configured server, its name and origin, account, +and saved communities that require its sign-in. Static community artwork exposes +names and tooltips. **Sign out** confirms before ending that server’s session only. +Within seven days of expiry, **Renew sign-in** and the expiry date appear, with +**Sign out** in the renewal caret menu. The Builderlab connection row observes +the existing plugin session and opens its settings; it starts no account read. +A sign-out native could not finish (for example, secure storage refused) stays below the list, naming the server, with **Try again**; so does an ended session whose saved record native could not remove. A saved session native could not check, for example offline, -is kept and offers no **Try again**. Communities no server covers are not listed. While the build's Builderlab isn't signed in, its row to sign in -from comes first, named by its server beside other servers' rows, with its -expiry or recovery message. +is kept and offers no **Try again**. Communities no server covers are not listed. +While the build’s Builderlab isn’t signed in, its row to sign in from stays listed alongside the other configured servers, with its recovery +message when applicable. When a community refuses a signed-in account (`authorization-denied` or `binding-mismatch`), its prompt names the community's server and account, and -the other joined communities that server covers, and offers **Switch account**: -it signs out of that server only, signs in to it again, and retries. Other +the other joined communities that require its sign-in, and offers **Switch +account**: it signs out of that server only, signs in to it again, and retries. Other servers' sessions are not touched. While it runs the prompt says so, with **Cancel sign-in** during the browser step. A canceled sign-in leaves that server signed out: the prompt says the sign-in was canceled and offers diff --git a/docs/plugin-authoring-guide.md b/docs/plugin-authoring-guide.md index f0db327404..f2d55188e1 100644 --- a/docs/plugin-authoring-guide.md +++ b/docs/plugin-authoring-guide.md @@ -58,7 +58,7 @@ export function apply(ctx) { | --- | --- | --- | | `pages` | A page | `ctx.pages.register({ id, title, component, primary?, placement?, icon?, badge?, sidebar?, companion?, layout? })`. `id` must match the id pattern, `title` non-empty, `component` a function. `placement`: `"sidebar"` (default) or `"dock"` (only for `primary` pages; `"topbar"` and `"toolbar"` are accepted aliases for `"dock"`). `icon`: `data:image/...` URL. `badge`: component shown beside the nav entry. `sidebar`: component receiving `{ target, children }` that replaces the conversation sidebar (wrap content in `className="shell-sidebar-default"`). `layout`: `"document"` or `"workspace"`. | | `panels` | A panel for a link/target string | `ctx.panels.register({ id, title, matches(target) => boolean, component, order?, launcher?, channelLauncher?, channelPlacement?, keepOpen? })`. `component` receives `{ target, close, context?, channelContext? }`. `launcher: { icon: string, target: string }` adds a top-bar button (use `matches: () => false` for launcher-only). `channelLauncher` is a component for the channel header (`channelPlacement`: `"bottom"` default, `"side"`, `"tab"`). | -| `settingsCards` | A Settings destination | `ctx.settingsCards.register({ id, title, component, group?, personal? })`. `component` receives `{ active(), community?, navigation? }`; `navigation.target.route` can carry bounded, versioned presentation data for the card. Opening a route must never submit an operation. Omit `icon` (it is a host icon component). The host supplies navigation and the content column, not a heading. | +| `settingsCards` | A Settings destination | `ctx.settingsCards.register({ id, title, component, group?, personal?, identityRow? })`. `component` receives `{ active(), community?, navigation? }`; `navigation.target.route` can carry bounded, versioned presentation data for the card. Opening a route must never submit an operation. Optional `identityRow` receives `{ active() }` and contributes a compact account-status link inside Identity. Reuse the plugin’s existing session; check `active()` before navigation or an action. The row retires with its owning registration. Omit `icon` (it is a host icon component). The host supplies navigation and the content column, not a heading. | | `sidebarSections` (optional) | A section in the Team sidebar | `ctx.reflect.get("sidebarSections")?.register({ version: 1, id, title, icon, rows: { snapshot, subscribe }, select, actions? })`. `snapshot()` returns a stable array of `{ id, label, icon?, badge?, count?, status? }` until data changes, then notify subscribers. `select(row)` returns an open target such as `{ version: 1, kind: "home" }`. Actions: `{ id, label, icon: ReactElement, onSelect }`. | | `conversation` | Composer/message UI | `registerAccessory({ id, title, component, order?, placement? })` (read-only UI above the composer; props `{ session, scope, channelId, threadRootId?, canOpen, open }`; `placement`: `"composer"`, `"message"`, `"response"`). `registerMessage({ id, title, matches(message), component })`, `registerMessageAction({ id, title, matches(message, session), component })`, `registerLink({ id, title, matches(url), component })`, `registerTool({ id, title, component })`. | | `agents` | Tools and hooks for agents | `ctx.agents.addTool({ name, description, inputSchema, call })`. `name` matches `/^[a-z][a-z0-9_]{0,63}$/`, is unique across plugins and not a Buzz tool name (prefix it with your plugin: `notes_get`). `inputSchema` is a JSON-serializable object with `type: "object"`. `async call(client, context, args)` resolves to a string (or `{ data, type, text? }`) and runs as the agent; a throw is shown to the agent as a tool error. Changing a tool's name, description or schema restarts some agent sessions, so edit deliberately. `ctx.agents.registerHook({ id, harness, event, run })` needs `host.agentHooks`. `ctx.agents.wake(agentKey, { channel, root?, text })` wakes a local agent (`root` is a 64-hex event id). | diff --git a/src-tauri/src/nsec_backup.rs b/src-tauri/src/nsec_backup.rs index ee737a090d..d799e8c6cb 100644 --- a/src-tauri/src/nsec_backup.rs +++ b/src-tauri/src/nsec_backup.rs @@ -86,9 +86,19 @@ pub(crate) struct BackupStatus { pass: u64, /// Storage root present and `source_allowed(Builderlab)`. builderlab: bool, + #[serde(skip_serializing_if = "Option::is_none")] + management: Option, #[serde(flatten)] progress: Progress, } +/// Public presentation policy, separate from consent and the latest pass's health. +#[derive(Clone, Debug, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +struct Management { + pubkey: String, + policy: &'static str, + initial_backup_confirmed: bool, +} #[derive(Clone, Debug, Default, PartialEq, Serialize)] #[serde(tag = "status", rename_all = "snake_case")] enum Progress { @@ -233,6 +243,7 @@ struct State { seq: u64, pass: u64, progress: Progress, + management: Option, watcher: Option>, } impl State { @@ -338,6 +349,8 @@ impl NsecBackupHost { if let Ok(mut state) = self.state.lock() { state.retire(); state.retire_restore(); + state.management = None; + self.publish(&mut state, Progress::Idle); } self.trigger(identity); } @@ -356,11 +369,46 @@ impl NsecBackupHost { let state = self.state.lock().unwrap_or_else(|e| e.into_inner()); self.snapshot(&state) } + async fn initialize_management(&self, identity: &IdentityHost) -> Result<()> { + let epoch = self.state.lock().map_err(|_| RETRY)?.epoch; + let pubkey = identity.saved_viewer().await?; + let mut state = self.state.lock().map_err(|_| RETRY)?; + if state.closed || state.epoch != epoch { + return Err(CANCELED.into()); + } + if let Some(pubkey) = pubkey { + self.management_for(&mut state, &pubkey); + } else { + state.management = None; + } + let progress = state.progress.clone(); + self.publish(&mut state, progress); + Ok(()) + } + fn management_for(&self, state: &mut State, pubkey: &str) { + if state + .management + .as_ref() + .is_some_and(|m| m.pubkey == pubkey) + { + return; + } + state.management = Some(Management { + pubkey: pubkey.into(), + policy: if managed_build() { + "organization-managed" + } else { + "self-managed" + }, + initial_backup_confirmed: false, + }); + } fn snapshot(&self, state: &State) -> BackupStatus { BackupStatus { seq: state.seq, pass: state.pass, builderlab: self.builderlab(), + management: state.management.clone(), progress: state.progress.clone(), } } @@ -420,6 +468,13 @@ impl NsecBackupHost { } state.retire(); state.session = federated; + // A new account must prove its own scoped receipt. Signing out + // keeps the already-confirmed presentation. + if state.session.is_some() { + if let Some(management) = &mut state.management { + management.initial_backup_confirmed = false; + } + } drop(state); Ok(self.trigger(identity)) } @@ -521,7 +576,14 @@ impl NsecBackupHost { return; } let progress = match outcome { - Ok(Outcome::Complete) => Progress::Complete, + Ok(Outcome::Complete) => { + // Complete comes only from a scoped persisted receipt or a + // confirmed upload, after checking this account's binding. + if let Some(management) = &mut state.management { + management.initial_backup_confirmed = true; + } + Progress::Complete + } Ok(Outcome::Waiting(reason)) => Progress::Waiting { reason }, Err(Stop::Failed(kind)) => Progress::Failed { kind }, Ok(Outcome::Idle) | Err(Stop::Canceled) => Progress::Idle, @@ -554,6 +616,13 @@ impl NsecBackupHost { // A denied, busy or locked store is not an absent key. Err(_) => return Err(Stop::Failed(Failure::Storage)), }; + { + let mut state = self.state.lock().map_err(|_| RETRY)?; + if state.closed || state.epoch != epoch { + return Err(Stop::Canceled); + } + self.management_for(&mut state, &pubkey); + } // Hold consent's shared lease until this pass ends, including persisted retries. let Some(_consent) = crate::nsec_backup_choice::admit( &self.root.as_ref().ok_or(UNAVAILABLE)?.join("choice.json"), @@ -1220,8 +1289,12 @@ pub(crate) fn nsec_backup_retry( host.retry(identity.inner().clone()) } #[tauri::command] -pub(crate) fn nsec_backup_state(host: tauri::State<'_, NsecBackupHost>) -> BackupStatus { - host.status() +pub(crate) async fn nsec_backup_state( + host: tauri::State<'_, NsecBackupHost>, + identity: tauri::State<'_, IdentityHost>, +) -> Result { + host.initialize_management(&identity).await?; + Ok(host.status()) } /// Replace the status watcher. Register before reading `nsec_backup_state`, then keep /// whichever status has the higher `seq`. diff --git a/src-tauri/src/nsec_backup/tests.rs b/src-tauri/src/nsec_backup/tests.rs index 717568a860..ff5455b88a 100644 --- a/src-tauri/src/nsec_backup/tests.rs +++ b/src-tauri/src/nsec_backup/tests.rs @@ -1630,7 +1630,10 @@ async fn a_key_install_supersedes_a_pass_that_then_reports_nothing() { service.pause_upload.store(false, Ordering::SeqCst); service.release_upload.add_permits(2); finish_background(&host).await; - assert_eq!(statuses(&seen), ["1 running", "2 running", "2 complete"]); + assert_eq!( + statuses(&seen), + ["1 running", "1 idle", "2 running", "2 complete"] + ); } #[tokio::test] @@ -1979,6 +1982,7 @@ fn statuses_serialize_flat_and_report_whether_builderlab_backup_can_run() { seq: 7, pass: 3, builderlab: true, + management: None, progress, }) .unwrap() @@ -2059,3 +2063,120 @@ fn statuses_serialize_flat_and_report_whether_builderlab_backup_can_run() { ); } } + +#[tokio::test] +async fn management_requires_a_scoped_confirmed_receipt_and_survives_session_loss() { + let service = Service::new().await; + let directory = tempfile::tempdir().unwrap(); + let host = service.host(directory.path()); + let identity = IdentityHost::fixture(); + host.initialize_management(&identity).await.unwrap(); + let initial = host.status().management.unwrap(); + assert_eq!(initial.pubkey, pubkey([1; 32])); + assert_eq!( + initial.policy, + if managed_build() { + "organization-managed" + } else { + "self-managed" + } + ); + assert!(!initial.initial_backup_confirmed); + assert_eq!( + serde_json::to_value(host.status()).unwrap()["management"], + serde_json::json!({"pubkey": initial.pubkey, "policy": initial.policy, "initialBackupConfirmed": false}) + ); + service.enable(&host).await; + service.state.lock().unwrap().receipt_stored = false; + host.state.lock().unwrap().session = Some(service.session()); + host.trigger(identity.clone()); + finish_background(&host).await; + assert!(!host.status().management.unwrap().initial_backup_confirmed); + service.state.lock().unwrap().receipt_stored = true; + host.trigger(identity.clone()); + finish_background(&host).await; + assert!(host.status().management.unwrap().initial_backup_confirmed); + // A fresh process starts conservatively, then verifies the same scoped + // durable receipt without uploading another copy. + let restored = service.host(directory.path()); + restored.initialize_management(&identity).await.unwrap(); + assert!( + !restored + .status() + .management + .unwrap() + .initial_backup_confirmed + ); + restored.state.lock().unwrap().session = Some(service.session()); + let before = service.state.lock().unwrap().uploads.len(); + restored.trigger(identity.clone()); + finish_background(&restored).await; + assert!( + restored + .status() + .management + .unwrap() + .initial_backup_confirmed + ); + assert_eq!(service.state.lock().unwrap().uploads.len(), before); + // Losing the last session keeps the confirmed backup presentation. + let login = NsecBackupHost { + config: production(), + ..restored.clone() + }; + login.session(1, None, identity).unwrap(); + assert!( + restored + .status() + .management + .unwrap() + .initial_backup_confirmed + ); +} + +#[tokio::test] +async fn new_account_handoff_and_key_install_retire_protection_evidence() { + let directory = tempfile::tempdir().unwrap(); + let (fi, revision) = signed_in(); + let host = NsecBackupHost { + federated: Some(fi), + state: Default::default(), + root: Some(directory.path().to_owned()), + config: production(), + }; + let identity = IdentityHost::fixture(); + host.initialize_management(&identity).await.unwrap(); + host.state + .lock() + .unwrap() + .management + .as_mut() + .unwrap() + .initial_backup_confirmed = true; + // No consent is saved; this synthetic handoff cannot make any request. + host.session(1, Some(revision), identity.clone()).unwrap(); + finish_background(&host).await; + assert!(!host.status().management.unwrap().initial_backup_confirmed); + host.session(2, None, identity.clone()).unwrap(); + host.local_key_installed(identity.clone()); + assert!(host.status().management.is_none()); + host.initialize_management(&identity).await.unwrap(); + assert!(!host.status().management.unwrap().initial_backup_confirmed); + host.close(); + assert!(host.initialize_management(&identity).await.is_err()); +} + +#[tokio::test] +async fn management_read_preserves_first_run_backup_availability_before_a_key_exists() { + let directory = tempfile::tempdir().unwrap(); + let host = NsecBackupHost { + config: production(), + root: Some(directory.path().to_owned()), + ..NsecBackupHost::default() + }; + host.initialize_management(&IdentityHost::fixture_without_key(true)) + .await + .unwrap(); + assert!(host.status().builderlab); + assert!(host.status().management.is_none()); +} diff --git a/src/app/ExternalIdentityAuthorization.test.tsx b/src/app/ExternalIdentityAuthorization.test.tsx index d0b30db54d..ed7247eda6 100644 --- a/src/app/ExternalIdentityAuthorization.test.tsx +++ b/src/app/ExternalIdentityAuthorization.test.tsx @@ -206,6 +206,7 @@ const settingsIdentity = () => snapshot: () => undefined, subscribe: () => () => {}, ready: async () => ({ builderlab: false }), + refresh: async () => ({ builderlab: false }), }, exportKey: vi.fn(), wipeRefusal: async () => "Erasing is unavailable in this test", @@ -224,7 +225,7 @@ it("clears the real settings form when the displayed owner changes", async () => expect(screen.getByLabelText("Application public key (hex)")).toHaveValue(""); }); -it("discards an in-flight signer result when leaving Security to sign out in Identity", async () => { +it("discards an in-flight signer result when leaving Security to remove the key in Identity", async () => { let complete!: (result: unknown) => void; vi.mocked(invoke) .mockResolvedValueOnce(viewer) @@ -242,10 +243,13 @@ it("discards an in-flight signer result when leaving Security to sign out in Ide view.rerender( , ); - await user.click(screen.getByRole("button", { name: "Sign out of Buzz" })); + await user.click(screen.getByRole("button", { name: "More options" })); + await user.click(screen.getByRole("button", { name: "Remove from device" })); await act(async () => complete(tag)); expect( - screen.getByRole("alertdialog", { name: "Sign out of Buzz?" }), + screen.getByRole("alertdialog", { + name: "Remove your key from this device?", + }), ).toBeInTheDocument(); expect(screen.queryByLabelText("Ownership proof")).not.toBeInTheDocument(); expect( diff --git a/src/app/IdentityBadge.module.css b/src/app/IdentityBadge.module.css new file mode 100644 index 0000000000..c24e108978 --- /dev/null +++ b/src/app/IdentityBadge.module.css @@ -0,0 +1,340 @@ +/* Feature-owned identity artwork; shared controls retain their own recipes. */ +.badge { + display: grid; + justify-items: center; + gap: var(--space-3); + width: 100%; + text-align: center; + padding: var(--space-4); +} +.actions { + justify-self: start; + align-self: start; + z-index: 1; +} +@container (min-width: 28rem) { + .actions, + .stage { + grid-area: 1 / 1; + } +} +@container (max-width: 24rem) { + .badge { + padding-inline: 0; + } + .actions { + margin-inline: var(--space-4); + } +} +.stage { + width: min(100%, 20rem); + perspective: 60rem; +} +.tilt { + transform-style: preserve-3d; +} +.card { + position: relative; + display: grid; + transform-style: preserve-3d; + transition: transform var(--duration-panel-enter) var(--easing-enter); +} +.card[data-back] { + transform: rotateY(180deg); +} +.face { + grid-area: 1 / 1; + position: relative; + isolation: isolate; + overflow: hidden; + min-height: 25rem; + border: 1px solid var(--border-primary); + border-radius: var(--radius-card); + background: var(--surface-inset); + color: var(--text-primary); + text-align: left; + backface-visibility: hidden; +} +@media (prefers-reduced-motion: no-preference) { + .face { + transition: visibility var(--duration-panel-enter) var(--easing-enter); + } + /* WebKit can paint a mirrored back face despite backface-visibility. */ + .face[aria-hidden="true"] { + visibility: hidden; + } +} +.slot { + position: absolute; + top: var(--space-3); + left: 50%; + width: 3rem; + height: var(--space-2); + transform: translateX(-50%); + border-radius: var(--radius-pill); + background: var(--surface-popover); + z-index: 2; +} +.front { + display: flex; + flex-direction: column; + justify-content: space-between; +} +.artwork { + position: absolute; + inset: 0; + z-index: -1; + overflow: hidden; +} +/* Static material grain stays below the portrait labels and controls. */ +.artwork::after, +.stripe::after { + content: ""; + position: absolute; + inset: 0; + pointer-events: none; + background-image: url("../shared/assets/card-grain.png"); + background-size: 128px 128px; + opacity: 0.06; +} +.photoArea { + position: relative; + isolation: isolate; + width: 100%; + aspect-ratio: 1; + flex-shrink: 0; +} +.backupCaption { + position: absolute; + inset-inline-end: var(--space-5); + bottom: var(--space-4); + min-height: var(--space-6); + display: flex; + align-items: center; +} +.backupPill { + display: inline-flex; + align-items: center; + gap: var(--space-1); + padding: var(--space-1) var(--space-2); + border: 1px solid var(--border-primary); + border-radius: var(--radius-pill); + background: var(--surface-panel); + color: var(--text-primary); +} +.portrait { + width: 100%; + height: 100%; + object-fit: cover; + object-position: center; + filter: grayscale(1); + opacity: 0; + mask-image: radial-gradient( + ellipse 80% 65% at 50% 45%, + black 30%, + transparent 100% + ); +} +.portrait[data-loaded] { + opacity: 1; +} +.monogram { + position: absolute; + inset: 0; + display: grid; + place-items: center; + font: 900 12rem / 1 var(--font-sans); + color: var(--text-subtle); +} +.tint { + position: absolute; + inset: 0; + background: + radial-gradient( + ellipse at 20% 25%, + color-mix(in srgb, var(--affordance-accent-prominent) 65%, transparent), + transparent 70% + ), + radial-gradient( + ellipse at 80% 75%, + var(--affordance-accent-prominent), + transparent 75% + ); + mix-blend-mode: color; +} +.topline { + display: flex; + justify-content: space-between; + align-items: center; + gap: var(--space-3); + padding: var(--space-8) var(--space-5) var(--space-4); +} +@container (max-width: 10rem) { + .topline { + padding-block-start: calc(var(--space-5) + var(--space-half)); + padding-inline: var(--space-4); + } + .backupCaption { + inset-inline-end: var(--space-4); + } +} +.reflection { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + pointer-events: none; + color: var(--affordance-accent-prominent); + mix-blend-mode: soft-light; +} +.reflection[data-identity-reflection="magnetic"] { + color: var(--text-subtle); +} +.reflection[data-identity-reflection="film"] { + mix-blend-mode: normal; + opacity: 0.04; +} +.photoHint { + display: inline-flex; + border-radius: var(--radius-pill); +} + +.identity { + color: var(--text-standard); + padding: var(--space-4); + display: grid; + gap: var(--space-1); + background: var(--surface-inset); +} +.keyLabel { + padding-block: var(--space-half); + text-align: center; +} +.publicId { + display: grid; + grid-template-columns: minmax(var(--space-5), 1fr) minmax(0, max-content) minmax( + var(--space-5), + 1fr + ); + align-items: center; + gap: var(--space-1); +} +.copyAction { + grid-column: 3; + justify-self: start; + display: inline-flex; +} +.barcodeSticker { + position: relative; + display: flex; + width: 100%; + aspect-ratio: 133 / 37; + border-radius: var(--radius-chip); +} +.barcodeSticker::before { + content: ""; + position: absolute; + inset: var(--space-half); + border-radius: inherit; + background: color-mix( + in srgb, + var(--text-standard) 92%, + var(--surface-panel) + ); +} +.barcodeFilm { + position: relative; + isolation: isolate; + display: block; + width: 100%; + height: 100%; + transform-origin: center; + transform: rotateY(-7deg); +} +.barcodeFilm svg { + display: block; + width: 100%; + height: 100%; +} +.reflection[data-identity-reflection="paper"] { + top: calc(-1 * var(--space-half)); + left: calc(-1 * var(--space-half)); + width: calc(100% + var(--space-1)); + height: calc(100% + var(--space-1)); + border-radius: 0; + mix-blend-mode: normal; + opacity: 0.15; + box-shadow: var(--shadow-panel); +} +.publicId code { + grid-column: 2; + min-width: 0; + overflow-wrap: anywhere; +} +/* The front owns card geometry; long back content cannot stretch either face. */ +.back { + position: absolute; + inset: 0; + min-height: 0; + min-width: 0; + max-height: 100%; + display: flex; + flex-direction: column; + transform: rotateY(180deg); + padding-block-start: var(--space-8); +} +.stripe { + position: relative; + flex-shrink: 0; + overflow: hidden; + height: calc( + var(--space-8) + + var(--space-3) + + var(--space-3) + + var(--space-2) + + var(--space-half) + ); + background: color-mix(in srgb, var(--surface-inset) 70%, black); +} +[data-color-mode="light"] .stripe { + background: var(--surface-panel); +} +@media (prefers-reduced-transparency: reduce), (forced-colors: active) { + .artwork::after, + .stripe::after { + display: none; + } +} +.details { + display: flex; + flex: 1; + flex-direction: column; + min-height: 0; + overflow-y: auto; + justify-content: safe flex-start; + gap: var(--space-4); + padding: var(--space-5); +} +.details p { + margin: 0; +} +@media (prefers-reduced-motion: reduce) { + /* Motion writes inline transforms; the accessibility preference wins. */ + .tilt { + /* biome-ignore lint/complexity/noImportantStyles: Reduced motion must override Motion's inline transform. */ + transform: none !important; + } + .tilt, + .card, + .card[data-back], + .back { + transform: none; + transition: none; + } + .face { + transition: opacity var(--duration-state) linear; + } + .face[aria-hidden="true"] { + opacity: 0; + } +} diff --git a/src/app/IdentityBadge.tsx b/src/app/IdentityBadge.tsx new file mode 100644 index 0000000000..7906fc6677 --- /dev/null +++ b/src/app/IdentityBadge.tsx @@ -0,0 +1,288 @@ +import { + useId, + useLayoutEffect, + useRef, + useState, + type ReactNode, + type RefObject, +} from "react"; +import { motion } from "motion/react"; +import { abbreviatedPublicKey } from "../shared/identity/public-key"; +import { Avatar } from "../shared/design-system/ui/Avatar"; +import { Button } from "../shared/design-system/ui/Button"; +import { IconButton } from "../shared/design-system/ui/IconButton"; +import { Tooltip } from "../shared/design-system/ui/Tooltip"; +import { Panel } from "../shared/design-system/ui/Panel"; +import { + CaretLeftIcon, + CheckIcon, + CircleNotchIcon, + CopyIcon, + DotsThreeIcon, +} from "../shared/design-system/icons"; +import styles from "./IdentityBadge.module.css"; +import { IdentityReflection } from "./IdentityReflection"; +import { useIdentityTilt } from "./useIdentityTilt"; + +const barcodeBars = + "M10 0h2v24h-2zM13 0h1v24h-1zM16 0h1v24h-1zM21 0h1v24h-1zM25 0h1v24h-1zM27 0h2v24h-2zM32 0h2v24h-2zM35 0h3v24h-3zM39 0h3v24h-3zM43 0h3v24h-3zM47 0h2v24h-2zM52 0h1v24h-1zM54 0h3v24h-3zM58 0h2v24h-2zM63 0h1v24h-1zM65 0h1v24h-1zM69 0h1v24h-1zM71 0h2v24h-2zM76 0h2v24h-2zM79 0h3v24h-3zM83 0h3v24h-3zM87 0h3v24h-3zM91 0h2v24h-2zM96 0h1v24h-1zM98 0h3v24h-3zM102 0h2v24h-2zM107 0h1v24h-1zM109 0h2v24h-2zM112 0h3v24h-3zM116 0h1v24h-1zM120 0h2v24h-2zM125 0h3v24h-3zM129 0h1v24h-1zM131 0h2v24h-2z"; + +/** Public identity artwork. Secret-key access stays with the existing key owner. */ +export function IdentityBadge({ + viewer, + name, + picture, + photoContext, + backup, + backupLocation = "Builderlab", + onCopy, + children, +}: { + viewer: string; + name: string; + picture?: string | undefined; + photoContext?: string | undefined; + backup?: "pending" | "confirmed" | "attention" | "running" | undefined; + backupLocation?: string | undefined; + onCopy(): void; + children( + active: boolean, + action: RefObject, + ): ReactNode; +}) { + const [back, setBack] = useState(false); + const [loaded, setLoaded] = useState(); + const faceId = useId(); + const action = useRef(null); + const details = useRef(null); + const [overflowing, setOverflowing] = useState(false); + useLayoutEffect(() => { + const element = details.current; + if (!element) return; + const measure = () => + setOverflowing(element.scrollHeight > element.clientHeight); + measure(); + const observer = + typeof ResizeObserver === "undefined" + ? undefined + : new ResizeObserver(measure); + // Content can grow without changing the bounded scrollport's own size. + for (const box of [element, ...element.children]) observer?.observe(box); + return () => observer?.disconnect(); + }); + const tilt = useIdentityTilt(); + const { x: pointerX, y: pointerY, reducedMotion } = tilt; + return ( + +
+
+ + +
+
+ +
+
+ +
+ + ); +} diff --git a/src/app/IdentityReflection.tsx b/src/app/IdentityReflection.tsx new file mode 100644 index 0000000000..aecd718ea5 --- /dev/null +++ b/src/app/IdentityReflection.tsx @@ -0,0 +1,200 @@ +import { useEffect, useRef, useState } from "react"; +import type { MotionValue } from "motion/react"; +import fragment from "./identity-reflection.frag?raw"; +import styles from "./IdentityBadge.module.css"; + +/** Pointer-driven badge artwork. The CSS accent tint remains the GPU fallback. */ +export function IdentityReflection({ + x, + y, + active, + surface = "accent", + regions, +}: { + x: MotionValue; + y: MotionValue; + active: boolean; + surface?: "accent" | "magnetic" | "paper" | "film"; + /** Local artwork bounds when one canvas paints several separate surfaces. */ + regions?: () => readonly { + x: number; + y: number; + width: number; + height: number; + }[]; +}) { + const ref = useRef(null); + const [painted, setPainted] = useState(false); + const [revision, setRevision] = useState(0); + // biome-ignore lint/correctness/useExhaustiveDependencies: Restored contexts need fresh GPU resources. + useEffect(() => { + const canvas = ref.current; + setPainted(false); + if (!canvas || !active || typeof WebGLRenderingContext === "undefined") + return; + const gl = canvas.getContext("webgl", { + alpha: true, + premultipliedAlpha: false, + antialias: false, + // This surface paints on demand and retains its last reflection at rest. + preserveDrawingBuffer: true, + }); + if (!gl) return; + const shaders: WebGLShader[] = []; + const program = gl.createProgram(); + const buffer = gl.createBuffer(); + let frame = 0; + const release = () => { + cancelAnimationFrame(frame); + for (const shader of shaders) + if (gl.isShader(shader)) gl.deleteShader(shader); + if (gl.isBuffer(buffer)) gl.deleteBuffer(buffer); + if (gl.isProgram(program)) gl.deleteProgram(program); + }; + const compile = (type: number, source: string) => { + const shader = gl.createShader(type); + if (!shader) throw new Error("Reflection shader unavailable"); + shaders.push(shader); + gl.shaderSource(shader, source); + gl.compileShader(shader); + if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) + throw new Error("Reflection shader failed"); + return shader; + }; + try { + if (!program || !buffer) + throw new Error("Reflection resources unavailable"); + gl.attachShader( + program, + compile( + gl.VERTEX_SHADER, + "attribute vec2 position; void main(){gl_Position=vec4(position,0.0,1.0);}", + ), + ); + gl.attachShader(program, compile(gl.FRAGMENT_SHADER, fragment)); + gl.linkProgram(program); + if (!gl.getProgramParameter(program, gl.LINK_STATUS)) + throw new Error("Reflection shader link failed"); + // biome-ignore lint/correctness/useHookAtTopLevel: WebGL method, not a React hook. + gl.useProgram(program); + gl.bindBuffer(gl.ARRAY_BUFFER, buffer); + gl.bufferData( + gl.ARRAY_BUFFER, + new Float32Array([-1, -1, 1, -1, -1, 1, 1, 1]), + gl.STATIC_DRAW, + ); + const position = gl.getAttribLocation(program, "position"); + gl.enableVertexAttribArray(position); + gl.vertexAttribPointer(position, 2, gl.FLOAT, false, 0, 0); + } catch { + release(); + return; + } + if (!program) return; + const size = gl.getUniformLocation(program, "u_size"); + const origin = gl.getUniformLocation(program, "u_origin"); + const tilt = gl.getUniformLocation(program, "u_tilt"); + const accent = gl.getUniformLocation(program, "u_accent"); + const magnetic = gl.getUniformLocation(program, "u_magnetic"); + const paper = gl.getUniformLocation(program, "u_paper"); + const film = gl.getUniformLocation(program, "u_film"); + const sample = document.createElement("canvas"); + sample.width = sample.height = 1; + const color = sample.getContext("2d", { willReadFrequently: true }); + if (!color) { + release(); + return; + } + const reduced = matchMedia("(prefers-reduced-motion: reduce)"); + const forced = matchMedia("(forced-colors: active)"); + const draw = () => { + frame = 0; + if (reduced.matches || forced.matches) { + setPainted(false); + return; + } + if (document.hidden || gl.isContextLost() || !gl.isProgram(program)) + return; + const ratio = Math.min(devicePixelRatio || 1, 1.5); + const width = Math.max(1, Math.round(canvas.clientWidth * ratio)); + const height = Math.max(1, Math.round(canvas.clientHeight * ratio)); + if (canvas.width !== width || canvas.height !== height) { + canvas.width = width; + canvas.height = height; + } + color.fillStyle = getComputedStyle(canvas).color; + color.fillRect(0, 0, 1, 1); + const [r = 0, g = 0, b = 0] = color.getImageData(0, 0, 1, 1).data; + gl.clearColor(0, 0, 0, 0); + gl.clear(gl.COLOR_BUFFER_BIT); + // biome-ignore lint/correctness/useHookAtTopLevel: WebGL method, not a React hook. + gl.useProgram(program); + gl.uniform2f(tilt, x.get(), -y.get()); + gl.uniform3f(accent, r / 255, g / 255, b / 255); + gl.uniform1f(magnetic, surface === "magnetic" ? 1 : 0); + gl.uniform1f(paper, surface === "paper" ? 1 : 0); + gl.uniform1f(film, surface === "film" ? 1 : 0); + const surfaces = regions?.() ?? [ + { x: 0, y: 0, width: canvas.clientWidth, height: canvas.clientHeight }, + ]; + for (const region of surfaces) { + const left = Math.round(region.x * ratio); + const bottom = height - Math.round((region.y + region.height) * ratio); + const w = Math.max(1, Math.round(region.width * ratio)); + const h = Math.max(1, Math.round(region.height * ratio)); + gl.viewport(left, bottom, w, h); + gl.uniform2f(size, w, h); + gl.uniform2f(origin, left, bottom); + gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4); + } + setPainted(true); + }; + const schedule = () => { + if (!frame) frame = requestAnimationFrame(draw); + }; + const lost = (event: Event) => { + event.preventDefault(); + cancelAnimationFrame(frame); + frame = 0; + setPainted(false); + }; + const restored = () => setRevision((value) => value + 1); + const offX = x.on("change", schedule); + const offY = y.on("change", schedule); + const resize = new ResizeObserver(schedule); + resize.observe(canvas); + const theme = new MutationObserver(schedule); + theme.observe(document.documentElement, { + attributes: true, + attributeFilter: ["data-accent", "data-color-mode", "data-color-level"], + }); + document.addEventListener("visibilitychange", schedule); + reduced.addEventListener("change", schedule); + forced.addEventListener("change", schedule); + canvas.addEventListener("webglcontextlost", lost); + canvas.addEventListener("webglcontextrestored", restored); + schedule(); + return () => { + offX(); + offY(); + resize.disconnect(); + theme.disconnect(); + document.removeEventListener("visibilitychange", schedule); + reduced.removeEventListener("change", schedule); + forced.removeEventListener("change", schedule); + canvas.removeEventListener("webglcontextlost", lost); + canvas.removeEventListener("webglcontextrestored", restored); + release(); + }; + }, [active, x, y, revision, surface, regions]); + return ( +
+ +

+ {problem && recoveryMessage(problem)} +

+
+ + {signedIn && confirmRevision === state.revision && ( + setConfirmRevision(null)} + actions={ + <> + + + + } + /> )} ); - if (listed) return row; - return ( -
- - {row} + return listed ? ( + content + ) : ( +
+
+ + {content} + +
); } + +function BackupRecovery({ + viewer, + management, + status, + statusError, + identity, + offered, + openBuilderlab, + accountError, + accountPending, + onStatusError, + onRetry, + finalFocus, +}: { + viewer: string; + management: IdentityManagement | undefined; + status: BackupStatus | undefined; + statusError: boolean; + identity: Identity; + offered: boolean; + openBuilderlab: (() => void) | undefined; + accountError: boolean; + accountPending: boolean; + onStatusError(failed: boolean): void; + onRetry(): void; + finalFocus(): HTMLButtonElement | null; +}) { + const { backup } = identity; + const [asking, setAsking] = useState(false); + const action = useRef(null); + const backedUp = useRef(false); + const close = useCallback((saved?: boolean) => { + backedUp.current = saved === true; + setAsking(false); + }, []); + const needsConsent = + status?.status === "waiting" && status.reason === "no_consent"; + const waiting = offered && needsConsent; + const settled = + !!status && + status.status !== "running" && + status.status !== "idle" && + !needsConsent; + const [failedAt, setFailedAt] = useState(); + const [starting, setStarting] = useState(false); + const pending = useRef(false); + const current = useRef(true); + useEffect(() => { + current.current = true; + return () => { + current.current = false; + }; + }, []); + const confirmed = + management?.initialBackupConfirmed || status?.status === "complete"; + const running = !!management && status?.status === "running"; + // A new native status retires a failed-start message from an earlier pass. + const failure = failedAt !== undefined && failedAt === status?.seq; + const problem = + status?.status === "failed" ? BACKUP_FAILURES[status.kind] : undefined; + const feedback = waiting + ? "Back up this key to Builderlab" + : statusError || accountError + ? "Backup status unknown" + : status?.status === "waiting" + ? status.reason === "no_session" + ? "Sign in to Builderlab to back up your key" + : BACKUP_FAILURES.not_linked.message.replace(/\.$/, "") + : problem && + ("cause" in problem ? problem.cause : problem.message).replace( + /\.$/, + "", + ); + // These are the two waits whose next step is on Builderlab's Settings page. + const at = + openBuilderlab && + ((status?.status === "waiting" && status.reason === "not_linked") || + (status?.status === "failed" && status.kind === "other_key_linked")) + ? (feedback?.indexOf("Builderlab") ?? -1) + : -1; + async function retry() { + if (pending.current || running) return; + pending.current = true; + onRetry(); + setStarting(true); + setFailedAt(undefined); + try { + if (!management) { + const next = await backup.refresh().catch(() => undefined); + if (!current.current) return; + if (next?.management?.pubkey !== viewer) { + onStatusError(true); + return; + } + onStatusError(false); + if (next.status === "running") return; + } + const ticket = await backup.retry(); + if (current.current && typeof ticket !== "number") + setFailedAt(backup.snapshot()?.seq); + } catch { + if (current.current) setFailedAt(backup.snapshot()?.seq); + } finally { + pending.current = false; + if (current.current) setStarting(false); + } + } + const recovery = + !!management && + (offered || management.policy === "organization-managed") && + (status?.status === "failed" || + (status?.status === "waiting" && !needsConsent) || + (management.policy === "organization-managed" && !confirmed)); + const busy = starting || running || (accountError && accountPending); + if ( + !waiting && + !asking && + !statusError && + !accountError && + !recovery && + !busy && + !failure + ) + return null; + return ( +
+ {(waiting || + statusError || + accountError || + (!confirmed && problem) || + status?.status === "waiting") && ( +

+ {feedback && at >= 0 ? ( + <> + {feedback.slice(0, at)} + + Builderlab + + {feedback.slice(at + "Builderlab".length)} + + ) : ( + feedback + )} +

+ )} + {failure && ( +

+ Couldn’t start the backup. Try signing in again. +

+ )} + {waiting ? ( + + ) : ( + + )} + {asking && ( + + (backedUp.current ? null : action.current) ?? finalFocus() + } + /> + )} +
+ ); +} diff --git a/src/app/Settings.test.tsx b/src/app/Settings.test.tsx index e998a59dac..b3ec65f79a 100644 --- a/src/app/Settings.test.tsx +++ b/src/app/Settings.test.tsx @@ -22,6 +22,7 @@ import { type SettingsCards, } from "../features/settings/service"; import type { ClientSnapshot } from "../features/communities/service"; +import { federatedFixture } from "../features/federated-identity/testing"; import type { DecisionsService } from "../features/decisions/service"; import type { RelayData } from "../features/relay/service"; import type { Contribution } from "../plugins/contributions"; @@ -1347,6 +1348,11 @@ it("opens Builderlab from Identity sync only while its card is registered", asyn builderlab: true, status: "waiting", reason: "not_linked", + management: { + pubkey: communityState.viewer, + policy: "self-managed", + initialBackupConfirmed: false, + }, }; const identity = { exportKey: vi.fn(), @@ -1380,17 +1386,17 @@ it("opens Builderlab from Identity sync only while its card is registered", asyn const link = () => within(identitySection()).queryByRole("button", { name: "Builderlab" }); const sentence = () => - within(identitySection()) - .getAllByRole("status") - .find( - (region) => - region.textContent === - "This key isn’t linked to your Builderlab account yet, so it can’t be backed up.", - ); + within(identitySection()).queryByText( + (_, el) => + el?.tagName === "P" && + el.textContent === + "This key isn’t linked to your Builderlab account yet, so it can’t be backed up", + ); const { rerender } = render(view("identity")); + await user.click(screen.getByRole("button", { name: "More options" })); await within(identitySection()).findByRole("button", { name: "Builderlab" }); const shown = sentence(); - expect(shown).toBeDefined(); + expect(shown).not.toBeNull(); // Turning the plugin off while Identity is open unlinks the name in place. act(() => set([])); expect(link()).toBeNull(); @@ -1406,9 +1412,10 @@ it("opens Builderlab from Identity sync only while its card is registered", asyn // With the plugin off, the sentence still names Builderlab, unlinked. act(() => set([])); rerender(view("identity")); - await within(identitySection()).findByText("Identity sync"); + await user.click(screen.getByRole("button", { name: "More options" })); + await waitFor(() => expect(sentence()).not.toBeNull()); expect(link()).toBeNull(); - expect(sentence()).toBeDefined(); + expect(sentence()).not.toBeNull(); }); function pluginSectionsFixture( @@ -2784,19 +2791,56 @@ it("keeps identity independent of community profile and clears revealed keys on screen.queryByRole("button", { name: "Reveal private key" }), ).toBeNull(); await user.click(screen.getByRole("button", { name: "Identity" })); - await user.click(screen.getByRole("button", { name: "Reveal private key" })); + await user.click(screen.getByRole("button", { name: "More options" })); + await user.click(screen.getByRole("button", { name: "Private key actions" })); + await user.click( + await screen.findByRole("menuitem", { name: "Reveal private key" }), + ); expect(screen.getByLabelText("Private key (nsec)")).toHaveValue( "nsec-public-test-fixture", ); await user.click(screen.getByRole("button", { name: "Appearance" })); expect(screen.queryByLabelText("Private key (nsec)")).toBeNull(); await user.click(screen.getByRole("button", { name: "Identity" })); - expect(screen.getByLabelText("Private key (nsec)")).toHaveValue( - "••••••••••••••••", - ); + await user.click(screen.getByRole("button", { name: "More options" })); + expect( + screen.getByRole("button", { name: "Private key actions" }), + ).toHaveTextContent("••••••••••••••••"); expect(exportKey).toHaveBeenCalledTimes(1); }); +it("retires contributed identity rows and their stale actions with the owning revision", async () => { + const navigate = vi.fn(); + let stillActive = () => false; + const entry = { + ...card("account", "Account", "Plugins"), + identityRow: ({ active }: { active(): boolean }) => { + stillActive = active; + return ( + + ); + }, + }; + const { cards, set } = registry([entry]); + const h = federatedFixture(); + render( + + + , + ); + fireEvent.click(screen.getByRole("button", { name: "Identity" })); + const link = await screen.findByRole("button", { + name: "Open account settings", + }); + fireEvent.click(link); + expect(navigate).toHaveBeenCalledOnce(); + act(() => set([])); + expect(screen.queryByText("Open account settings")).toBeNull(); + expect(stillActive()).toBe(false); +}); + it("offers Retry beside a failed installed revision without changing saved state", () => { const fixture = reviewPluginFixture(); const retryPlugin = vi.fn(() => true); diff --git a/src/app/Settings.tsx b/src/app/Settings.tsx index 3e7767daf7..c97543f288 100644 --- a/src/app/Settings.tsx +++ b/src/app/Settings.tsx @@ -37,7 +37,6 @@ import { type ReactNode, forwardRef, type RefObject, - useCallback, useEffect, useLayoutEffect, useMemo, @@ -76,7 +75,7 @@ import { bundledReadmes } from "../bundled/readmes"; import { bundledIcons } from "../bundled/icons"; import type { Identity } from "../features/identity/service"; import { ProfileSettings } from "./ProfileSettings"; -import { IdentitySettings, keyActivity } from "./IdentitySettings"; +import { IdentitySettings } from "./IdentitySettings"; import { SecuritySettings } from "./SecuritySettings"; import type { FederatedIdentity } from "../features/federated-identity/service"; import { CommunityLocalSettings } from "./CommunityLocalSettings"; @@ -756,6 +755,7 @@ export function Settings({ decisions?.subscribe ?? noAccessSubscription, decisions?.providers ?? noDecisionProviders, ); + const identityCards = contributed.filter((card) => card.identityRow); const client = useSyncExternalStore( communities.subscribe, communities.snapshot, @@ -1342,16 +1342,11 @@ export function Settings({ ); }; - // A new connection's session reads activity again. + // The current community supplies the profile shown on the identity card. const relay = useSyncExternalStore( communities.relay.subscribe, communities.relay.snapshot, ); - const relaySession = relay.status === "ready" ? relay.session : undefined; - const lastActive = useCallback( - (pubkey: string) => keyActivity(relaySession, pubkey), - [relaySession], - ); const authorizePluginGit = async (repository: string) => { const connection = communities.relay.snapshot(); return connection.status === "ready" @@ -1484,13 +1479,38 @@ export function Settings({ federatedIdentity={federatedIdentity} memberships={client.memberships} builderlabIdentity={builderlabIdentity} - lastActive={lastActive} openBuilderlab={ openSection && contributed.some((card) => card.key === "block.builderlab/login") ? () => openSection("block.builderlab/login") : undefined } + profileSession={ + relay.viewer === client.viewer && + (relay.status === "ready" || relay.cached) + ? relay.session + : undefined + } + community={selectedCommunity} + accountRows={ + identityCards.length + ? identityCards.map( + (card) => + card.identityRow && ( + + {(entry, active) => { + const Row = entry.identityRow; + return Row ? : null; + }} + + ), + ) + : undefined + } /> )} {selected === "security" && ( diff --git a/src/app/identity-reflection.frag b/src/app/identity-reflection.frag new file mode 100644 index 0000000000..9cbfe91b42 --- /dev/null +++ b/src/app/identity-reflection.frag @@ -0,0 +1,35 @@ +precision mediump float; + +uniform vec2 u_size; +uniform vec2 u_origin; +uniform vec2 u_tilt; +uniform vec3 u_accent; +uniform float u_magnetic; +uniform float u_paper; +uniform float u_film; + +void main() { + vec2 uv = (gl_FragCoord.xy - u_origin) / u_size; + vec3 normal = normalize(vec3(u_tilt * 0.12, 1.0)); + vec3 view = normalize(vec3((uv - 0.5) * 0.65, 1.0)); + vec3 reflected = reflect(-view, normal); + // Broad studio lights reflected by a satin surface, rather than a sharp streak. + float curve = sin(reflected.y * 5.0 + u_tilt.x * 0.5) * 0.22; + float light = exp(-pow((dot(reflected.xy, vec2(1.1, 0.7)) + curve) * 3.0, 2.0)); + float edge = pow(1.0 - max(dot(view, normal), 0.0), 2.0); + float field = smoothstep(-0.5, 0.5, reflected.y + sin(reflected.x * 4.0 + u_tilt.y * 0.5) * 0.2); + vec3 pigment = mix(u_accent * 0.45, u_accent, field); + vec3 finish = mix(pigment, vec3(1.0), light * 0.4 + edge * 0.3); + float blocks = floor(clamp(dot(reflected.xy, vec2(1.5, 0.6)) + 0.5, 0.0, 0.999) * 5.0) / 4.0; + vec3 satin = mix(u_accent * 0.7, u_accent, blocks); + vec3 paper = mix(vec3(0.2), vec3(0.9), light); + gl_FragColor = vec4(mix(mix(finish, satin, u_magnetic), paper, u_paper), mix(mix(0.65, 0.3, u_magnetic), 0.18, u_paper)); + if (u_film > 0.5) { + // A soft moving band fades before the artwork edge, without a corner cutout. + float sweep = dot(uv - 0.5, vec2(1.0, 0.6)) + dot(u_tilt, vec2(0.3, 0.2)); + vec2 inset = min(uv, 1.0 - uv); + float feather = smoothstep(0.0, 0.2, min(inset.x, inset.y)); + float shimmer = exp(-sweep * sweep * 12.0); + gl_FragColor = vec4(vec3(1.0), shimmer * feather * 0.1); + } +} diff --git a/src/app/useIdentityTilt.ts b/src/app/useIdentityTilt.ts new file mode 100644 index 0000000000..18b474195e --- /dev/null +++ b/src/app/useIdentityTilt.ts @@ -0,0 +1,30 @@ +import type { PointerEvent } from "react"; +import { useReducedMotion, useSpring, useTransform } from "motion/react"; + +/** The physical Identity cards share one pointer response and motion preference. */ +export function useIdentityTilt() { + const reducedMotion = useReducedMotion(); + const x = useSpring(0, { stiffness: 220, damping: 26 }); + const y = useSpring(0, { stiffness: 220, damping: 26 }); + const rotateX = useTransform(y, [-1, 1], [3, -3]); + const rotateY = useTransform(x, [-1, 1], [-3, 3]); + return { + x, + y, + reducedMotion, + style: { + rotateX: reducedMotion ? 0 : rotateX, + rotateY: reducedMotion ? 0 : rotateY, + }, + onPointerMove(event: PointerEvent) { + if (event.pointerType !== "mouse" || reducedMotion) return; + const bounds = event.currentTarget.getBoundingClientRect(); + x.set(((event.clientX - bounds.left) / bounds.width - 0.5) * 2); + y.set(((event.clientY - bounds.top) / bounds.height - 0.5) * 2); + }, + onPointerLeave() { + x.set(0); + y.set(0); + }, + }; +} diff --git a/src/bundled/agents/AgentCard.module.css b/src/bundled/agents/AgentCard.module.css index 5b9b2ea260..0770d31912 100644 --- a/src/bundled/agents/AgentCard.module.css +++ b/src/bundled/agents/AgentCard.module.css @@ -73,7 +73,7 @@ z-index: -1; border-radius: inherit; pointer-events: none; - background-image: url("./card-grain.png"); + background-image: url("../../shared/assets/card-grain.png"); background-size: 128px 128px; opacity: 0.02; } diff --git a/src/bundled/builderlab/index.test.tsx b/src/bundled/builderlab/index.test.tsx index e9fa01ce2b..a7eaa8160b 100644 --- a/src/bundled/builderlab/index.test.tsx +++ b/src/bundled/builderlab/index.test.tsx @@ -327,9 +327,12 @@ it.each([undefined, false, true])( const card = cards.snapshot()[0]; if (!card) throw new Error("Missing Builderlab card"); const Card = card.component; + const IdentityRow = card.identityRow; + if (!IdentityRow) throw new Error("Missing Builderlab identity row"); render( <> true} /> + true} /> , ); @@ -503,6 +506,11 @@ it.each([true, false])( await user.click( screen.getByRole("button", { name: "Sign in with builderlab.example" }), ); + // Sign-in replaces the section's header; wait before using its Back control. + await screen.findByRole("button", { + name: "Manage account", + hidden: true, + }); await settingsOverview(); expect( await screen.findByRole("button", { diff --git a/src/bundled/builderlab/index.tsx b/src/bundled/builderlab/index.tsx index 5e303a2c71..455b7d2247 100644 --- a/src/bundled/builderlab/index.tsx +++ b/src/bundled/builderlab/index.tsx @@ -1,3 +1,4 @@ +import { BuilderlabIdentityRow } from "./login/IdentityRow"; import { useLayoutEffect, useState, @@ -342,6 +343,17 @@ export const apply: PluginModule["apply"] = (ctx) => { title: "Builderlab", icon: BuilderlabLogoIcon, group: "Plugins", + ...(nativeBuilderlab() && !unavailable + ? { + identityRow: ({ active }: { active(): boolean }) => ( + openSettings("block.builderlab/login")} + /> + ), + } + : {}), component: Settings, }); }; diff --git a/src/bundled/builderlab/login/IdentityRow.tsx b/src/bundled/builderlab/login/IdentityRow.tsx new file mode 100644 index 0000000000..318cbae93d --- /dev/null +++ b/src/bundled/builderlab/login/IdentityRow.tsx @@ -0,0 +1,58 @@ +import { useSyncExternalStore } from "react"; +import { motion, useReducedMotion } from "motion/react"; +import { BuilderlabLogoIcon } from "../../../shared/design-system/icons"; +import { Button } from "../../../shared/design-system/ui/Button"; +import type { BuilderlabSession } from "../session"; + +/** Observe the existing session; Identity never starts a second account read. */ +export function BuilderlabIdentityRow({ + session, + active, + openSettings, +}: { + session: BuilderlabSession; + active(): boolean; + openSettings(): void; +}) { + const state = useSyncExternalStore(session.subscribe, session.snapshot); + const reducedMotion = useReducedMotion(); + if (state.status === "pending") return null; + return ( + + + + {state.error && ( + + Needs attention + + )} + + + ); +} diff --git a/src/bundled/builderlab/session.test.ts b/src/bundled/builderlab/session.test.ts index 412a974a99..c688d7a573 100644 --- a/src/bundled/builderlab/session.test.ts +++ b/src/bundled/builderlab/session.test.ts @@ -24,6 +24,7 @@ it("reads each native session's account once and fences requests and viewers to const h = federatedFixture(); h.request.mockResolvedValueOnce(me("first")); const session = createNativeSession(h.service); + expect(session.providerName).toBe("builderlab.example"); expect(session.snapshot().status).toBe("signed-out"); await session.signIn(); await expect.poll(() => session.snapshot().status).toBe("signed-in"); diff --git a/src/features/federated-identity/community-servers.ts b/src/features/federated-identity/community-servers.ts index 7d120bfff9..798280c711 100644 --- a/src/features/federated-identity/community-servers.ts +++ b/src/features/federated-identity/community-servers.ts @@ -5,12 +5,12 @@ import type { Membership } from "../communities/service"; import { coversCommunity } from "./community-hosts"; import type { FederatedIdentity } from "./service"; -/** The joined communities whose access a server's session decides, by name, +/** The joined memberships whose access a server's session decides, * in order: those its hosts cover that require NIP-FI. Until discovery says a * community doesn't, it counts: still being discovered or failed, it may, and * a quick switch must not skip the warning. Outside native, discovery * rejects, so every covered community counts, as it does without discovery. */ -export function useSignInCommunities( +export function useSignInMemberships( identity: FederatedIdentity | undefined, hosts: readonly string[], memberships: readonly Membership[], @@ -45,7 +45,16 @@ export function useSignInCommunities( }, [identity, key]); return covered .filter(({ url }) => requires.get(url) !== false) - .map(({ membership }) => communityName(membership)); + .map(({ membership }) => membership); +} + +/** Names for recovery copy; account cards use the same memberships for artwork. */ +export function useSignInCommunities( + identity: FederatedIdentity | undefined, + hosts: readonly string[], + memberships: readonly Membership[], +) { + return useSignInMemberships(identity, hosts, memberships).map(communityName); } /** A sign-in server as copy names it: its organization, or its host. */ diff --git a/src/features/identity/BackupConsent.test.tsx b/src/features/identity/BackupConsent.test.tsx index 6cd2691538..b6a913a8c1 100644 --- a/src/features/identity/BackupConsent.test.tsx +++ b/src/features/identity/BackupConsent.test.tsx @@ -248,7 +248,7 @@ it("a key in a hosted community is backed up with no prompt", async () => { }); it.each([ - ["Sync with Builderlab", true], + ["Back up key", true], ["I will manage my own key", false], ] as const)( "a key in only independent communities is asked, and %s saves its answer", @@ -256,7 +256,7 @@ it.each([ const app = setup(consent(), [INDEPENDENT]); const user = userEvent.setup(); const dialog = await screen.findByRole("alertdialog", { - name: "Sync your identity with Builderlab?", + name: "Back up your key?", }); expect(dialog).toHaveAccessibleDescription( "Buzz will save an encrypted copy of your identity key with your Builderlab account. That copy can restore your identity, including in other apps where you use the same key.", @@ -511,16 +511,12 @@ it("a refused save stays in the prompt with its reason, and Escape then closes i onClose={onClose} />, ); - await user.click( - await screen.findByRole("button", { name: "Sync with Builderlab" }), - ); + await user.click(await screen.findByRole("button", { name: "Back up key" })); expect(await screen.findByRole("alert")).toHaveTextContent( "Another backup of this key is in progress.", ); expect(onClose).not.toHaveBeenCalled(); - expect( - screen.getByRole("button", { name: "Sync with Builderlab" }), - ).toBeEnabled(); + expect(screen.getByRole("button", { name: "Back up key" })).toBeEnabled(); await user.keyboard("{Escape}"); expect(onClose).toHaveBeenCalledOnce(); expect(setBackupChoice).toHaveBeenCalledOnce(); diff --git a/src/features/identity/BackupConsent.tsx b/src/features/identity/BackupConsent.tsx index a233fb5f15..59dc6873e2 100644 --- a/src/features/identity/BackupConsent.tsx +++ b/src/features/identity/BackupConsent.tsx @@ -167,8 +167,8 @@ export function BackupConsent({ } /** - * Asks whether to sync this device's identity with Builderlab. Sync with - * Builderlab and I will manage my own key save the answer for `pubkey` and + * Asks whether to back up this device's key. Back up key and I will manage + * my own key save the answer for `pubkey` and * close with it; Close and Escape save nothing. A refused save stays in the * dialog. Once `ended`, it closes as Close does unless its own answer is * still being saved or was refused. @@ -216,7 +216,7 @@ export function BackupPrompt({ }; return ( answer(true)} > - Sync with Builderlab + Back up key } diff --git a/src/features/identity/PrivateKey.tsx b/src/features/identity/PrivateKey.tsx index eab434286a..28bc90c103 100644 --- a/src/features/identity/PrivateKey.tsx +++ b/src/features/identity/PrivateKey.tsx @@ -1,6 +1,27 @@ -import { useEffect, useRef, useState } from "react"; +import { + useEffect, + useId, + useLayoutEffect, + useRef, + useState, + type ReactNode, + type SyntheticEvent, +} from "react"; import { Button } from "../../shared/design-system/ui/Button"; import { Input } from "../../shared/design-system/ui/Input"; +import { FieldButton } from "../../shared/design-system/ui/FieldButton"; +import { InputGroup } from "../../shared/design-system/ui/InputGroup"; +import { IconButton } from "../../shared/design-system/ui/IconButton"; +import { + MenuGroup, + MenuGroupLabel, + MenuItem, + MenuPopup, + MenuRoot, + MenuTrigger, +} from "../../shared/design-system/ui/Menu"; +import { CaretDownIcon } from "../../shared/design-system/icons"; +import { ToastNotice } from "../../shared/design-system/ui/Toast"; import type { Identity } from "./service"; import { writeClipboardText } from "../../shared/clipboard"; @@ -8,16 +29,40 @@ import { writeClipboardText } from "../../shared/clipboard"; export function PrivateKey({ identity, onInteraction, + children, + presentation = "default", }: { identity: Identity; /** Called once the key was revealed or copied, e.g. to gate Sign out. */ onInteraction?: () => void; + presentation?: "default" | "magnetic"; + /** Place the existing field and controls without moving key access or state. */ + children?: (parts: { + input: ReactNode; + warning: ReactNode; + actions: ReactNode; + feedback: ReactNode; + }) => ReactNode; }) { + const warningId = useId(); + const fieldRef = useRef(null); + const triggerRef = useRef(null); + const focusReveal = useRef(false); const [secret, setSecret] = useState(""); const [busy, setBusy] = useState(false); const [message, setMessage] = useState(""); const [failed, setFailed] = useState(false); const generation = useRef(0); + useLayoutEffect(() => { + // A late reveal replaces the trigger after the menu already returned focus. + if ( + secret && + focusReveal.current && + document.activeElement === document.body + ) + fieldRef.current?.focus(); + focusReveal.current = false; + }, [secret]); useEffect(() => { const clear = () => { generation.current++; @@ -28,12 +73,21 @@ export function PrivateKey({ const visibility = () => { if (document.hidden) clear(); }; + const blur = (event: FocusEvent) => { + // Focus returning from a menu stays inside this document. + if ( + event.relatedTarget instanceof Node && + document.contains(event.relatedTarget) + ) + return; + clear(); + }; document.addEventListener("visibilitychange", visibility); - window.addEventListener("blur", clear); + window.addEventListener("blur", blur); return () => { generation.current++; document.removeEventListener("visibilitychange", visibility); - window.removeEventListener("blur", clear); + window.removeEventListener("blur", blur); }; }, []); function hide() { @@ -56,7 +110,12 @@ export function PrivateKey({ setMessage( "Private key copied. Keep it somewhere safe; your clipboard now contains it.", ); - } else setSecret(value); + } else { + focusReveal.current = + presentation === "magnetic" && + document.activeElement === triggerRef.current; + setSecret(value); + } if (current === generation.current) onInteraction?.(); } catch { if (current === generation.current) { @@ -71,34 +130,115 @@ export function PrivateKey({ if (current === generation.current) setBusy(false); } } - return ( -
- -

- Anyone with this key can act as you. Never share it. -

- { - if (secret) event.currentTarget.select(); - }} - /> + const preventEdit = (event: SyntheticEvent) => event.preventDefault(); + const field = ( + undefined} + value={secret || "••••••••••••••••"} + onFocus={(event) => { + if (secret && presentation === "default") event.currentTarget.select(); + }} + /> + ); + const input = + presentation === "magnetic" ? ( + + {!secret ? ( + + } + > + + + ) : ( + + )} + + + Private key + void access(true)} + > + Copy + + (secret ? hide() : void access(false))} + > + {secret ? "Hide" : "Reveal"} + + {busy && Cancel} + + + + ) : ( + field + ); + const warning = ( +

+ Anyone with this key can act as you. Never share it. +

+ ); + const actions = + presentation === "magnetic" ? null : (
- {busy && ( @@ -107,7 +247,33 @@ export function PrivateKey({ )}
- {message &&

{message}

} + ); + const feedback = message ? ( + presentation === "magnetic" && !failed ? ( + setMessage("")} + /> + ) : ( +

{message}

+ ) + ) : null; + if (children) return children({ input, warning, actions, feedback }); + return ( +
+ + {warning} + {input} + {actions} + {feedback}
); } diff --git a/src/features/identity/SignOut.tsx b/src/features/identity/SignOut.tsx index 40e8479cfc..9e6d5ebc57 100644 --- a/src/features/identity/SignOut.tsx +++ b/src/features/identity/SignOut.tsx @@ -75,8 +75,8 @@ export function SignOutDialog({ } return ( void confirm()} > - {wipe ? "Sign out and erase" : "Sign out"} + {wipe ? "Remove and erase data" : "Remove from device"} } diff --git a/src/features/identity/backup-failure.ts b/src/features/identity/backup-failure.ts index a9263952fe..70428df1df 100644 --- a/src/features/identity/backup-failure.ts +++ b/src/features/identity/backup-failure.ts @@ -57,7 +57,7 @@ export const BACKUP_FAILURES = { "Check your connection and try again.", ), // A retry re-sends the same encrypted bytes, which kgoose treats as stored. - unconfirmed: retry("Builderlab didn’t confirm your backup."), + unconfirmed: retry("Builderlab couldn’t confirm your backup."), storage: retry("Buzz couldn’t save backup progress on this device."), unknown: { message: "Your key wasn’t backed up.", retryable: false }, } as const satisfies Record< diff --git a/src/features/identity/backup.test.ts b/src/features/identity/backup.test.ts index 48a4bbc422..22762371b5 100644 --- a/src/features/identity/backup.test.ts +++ b/src/features/identity/backup.test.ts @@ -378,3 +378,37 @@ it("never shows native text when the status cannot be read or consent written", // The credential is never handed over without consent. expect(commands(unwritten).at(-1)).toBe("nsec_backup_set_choice"); }); + +it("refreshes public management metadata without replacing the watcher or regressing a newer identity", async () => { + const first: BackupStatus = { + ...at(1, 0, "idle"), + management: { + pubkey: "ab".repeat(32), + policy: "self-managed", + initialBackupConfirmed: false, + }, + }; + const host = native(first); + const store = host.store(); + await store.ready(); + const release = host.holdState({ ...first, seq: 2 }); + const refresh = store.refresh(); + await vi.waitFor(() => + expect( + host.send.mock.calls.filter(([name]) => name === "nsec_backup_state"), + ).toHaveLength(2), + ); + const next: BackupStatus = { + ...first, + seq: 3, + management: { + pubkey: "cd".repeat(32), + policy: "organization-managed", + initialBackupConfirmed: false, + }, + }; + host.watchers[0]?.onmessage(next); + release(); + await expect(refresh).resolves.toEqual(next); + expect(host.watchers).toHaveLength(1); +}); diff --git a/src/features/identity/backup.ts b/src/features/identity/backup.ts index 8471e38576..38bd4ab8e8 100644 --- a/src/features/identity/backup.ts +++ b/src/features/identity/backup.ts @@ -26,8 +26,19 @@ export function handoffFederatedBackupSession( /** What the native uploader last did for this device's key. `seq` orders * statuses; a pass numbered at or above a handoff's ticket started after it. */ +export type IdentityManagement = Readonly<{ + pubkey: string; + policy: "self-managed" | "organization-managed"; + initialBackupConfirmed: boolean; +}>; + export type BackupStatus = Readonly< - { seq: number; pass: number; builderlab: boolean } & ( + { + seq: number; + pass: number; + builderlab: boolean; + management?: IdentityManagement; + } & ( | { status: "idle" | "running" | "complete" } | { status: "waiting"; reason: "no_consent" | "no_session" | "not_linked" } | { @@ -79,6 +90,12 @@ export function createBackupStatus(send: Send = invoke) { return { snapshot: () => status, ready, + retry: () => send("nsec_backup_retry"), + async refresh() { + await ready(); + accept(await send("nsec_backup_state")); + return status as BackupStatus; + }, subscribe(listener: () => void) { listeners.add(listener); void ready().catch(() => {}); diff --git a/src/features/identity/identity.test.tsx b/src/features/identity/identity.test.tsx index 8e4ba1c3c7..1b45973980 100644 --- a/src/features/identity/identity.test.tsx +++ b/src/features/identity/identity.test.tsx @@ -1937,7 +1937,7 @@ it("Use an existing key from the choice imports the key with no sign-in, no link }); expect( screen.queryByRole("heading", { - name: "Sync your identity with Builderlab?", + name: "Back up your key?", }), ).toBeNull(); expect( @@ -6004,7 +6004,7 @@ it("importing a linked key backup status already lists discloses automatic backu ).toBeVisible(); expect( screen.queryByRole("heading", { - name: "Sync your identity with Builderlab?", + name: "Back up your key?", }), ).toBeNull(); expect(backUp).not.toHaveBeenCalled(); @@ -7049,7 +7049,7 @@ it("a development seed's fail-once backup fails only the first backup, and Try a ); await user.click(await screen.findByRole("button", { name: SIGN_IN })); expect(await screen.findByRole("alert")).toHaveTextContent( - "Builderlab didn’t confirm your backup. Try again.", + "Builderlab couldn’t confirm your backup. Try again.", ); expect(screen.getByText(keySavedNote)).toBeVisible(); await user.click(screen.getByRole("button", { name: "Try again" })); @@ -7406,7 +7406,7 @@ it.each(["Linux x86_64", "Win32"])( }, ); -it("unlocks Sign out only after a reveal or copy, the key box, and the wipe phrase when wiping", async () => { +it("unlocks key removal only after a reveal or copy, the key box, and the wipe phrase when wiping", async () => { vi.mocked(invoke).mockResolvedValueOnce(viewer); const identity = createIdentity(); await identity.ready; @@ -7416,7 +7416,10 @@ it("unlocks Sign out only after a reveal or copy, the key box, and the wipe phra await waitFor(() => expect(screen.getByLabelText("Private key (nsec)")).toHaveFocus(), ); - const confirm = () => screen.getByRole("button", { name: /^Sign out/ }); + const confirm = () => + screen.getByRole("button", { + name: /^Remove (from device|and erase data)/, + }); const haveKey = screen.getByRole("checkbox", { name: "I have my key" }); expect(haveKey).toHaveAttribute("aria-disabled", "true"); expect(confirm()).toBeDisabled(); @@ -7517,7 +7520,7 @@ it("keeps the dialog open and shows the error when native sign out refuses", asy await user.click(screen.getByRole("button", { name: "Reveal private key" })); await user.click(screen.getByRole("checkbox", { name: "I have my key" })); vi.mocked(invoke).mockRejectedValueOnce("Agents didn't stop"); - await user.click(screen.getByRole("button", { name: "Sign out" })); + await user.click(screen.getByRole("button", { name: "Remove from device" })); expect(await screen.findByRole("alert")).toHaveTextContent( "Agents didn't stop", ); @@ -7541,11 +7544,13 @@ it("shows a native refusal's message and offers a retry", async () => { vi.mocked(invoke).mockRejectedValueOnce({ message: "Quit every other Buzz window using this sign-in.", }); - await user.click(screen.getByRole("button", { name: "Sign out" })); + await user.click(screen.getByRole("button", { name: "Remove from device" })); expect(await screen.findByRole("alert")).toHaveTextContent( "Quit every other Buzz window using this sign-in.", ); - expect(screen.getByRole("button", { name: "Sign out" })).toBeEnabled(); + expect( + screen.getByRole("button", { name: "Remove from device" }), + ).toBeEnabled(); expect(screen.getByRole("button", { name: "Cancel" })).toBeEnabled(); identity.dispose(); }); @@ -8026,12 +8031,10 @@ it("once the app opens, a key with no backup choice and only independent communi , ); const prompt = await screen.findByRole("alertdialog", { - name: "Sync your identity with Builderlab?", + name: "Back up your key?", }); expect(screen.getByText("Signed in")).toBeInTheDocument(); - await user.click( - within(prompt).getByRole("button", { name: "Sync with Builderlab" }), - ); + await user.click(within(prompt).getByRole("button", { name: "Back up key" })); await waitFor(() => expect(screen.queryByRole("alertdialog")).toBeNull()); expect(invoke).toHaveBeenCalledWith("nsec_backup_set_choice", { pubkey: viewer, @@ -9045,7 +9048,7 @@ it("a backup prompt closed while the account's key is read stays closed once the , ); await screen.findByRole("alertdialog", { - name: "Sync your identity with Builderlab?", + name: "Back up your key?", }); await user.keyboard("{Escape}"); await waitFor(() => expect(screen.queryByRole("alertdialog")).toBeNull()); diff --git a/src/features/settings/service.test.ts b/src/features/settings/service.test.ts index 9280c2ed98..b5e59dd745 100644 --- a/src/features/settings/service.test.ts +++ b/src/features/settings/service.test.ts @@ -122,4 +122,7 @@ it("refuses a Personal-space card that also claims a group or section", () => { expect(() => cards.register({ ...card, section: "administration" })).toThrow( "Settings cards need an id, title and component", ); + expect(() => cards.register({ ...card, identityRow: true as never })).toThrow( + "Settings cards need an id, title and component", + ); }); diff --git a/src/features/settings/service.ts b/src/features/settings/service.ts index 9a00df2ab2..c4fe5e6150 100644 --- a/src/features/settings/service.ts +++ b/src/features/settings/service.ts @@ -18,6 +18,8 @@ export type SettingsCard = { navigation?: PageNavigation | undefined; community?: { id: string; name: string }; }>; + /** Account status/link inside Identity; the plugin retains its session owner. */ + identityRow?: ComponentType<{ active(): boolean }>; /** Show this card under a labelled account-level group instead of the selected community. */ group?: string; /** Separate permission-gated controls from ordinary settings for the selected community. */ @@ -104,6 +106,8 @@ export class SettingsCardsService extends Service implements SettingsCards { !/^[a-z0-9][a-z0-9._-]*$/.test(card.id) || !card.title?.trim() || typeof card.component !== "function" || + (card.identityRow !== undefined && + typeof card.identityRow !== "function") || (card.group !== undefined && (typeof card.group !== "string" || !card.group.trim())) || (card.section !== undefined && card.section !== "administration") || diff --git a/src/shared/assets/card-grain.png b/src/shared/assets/card-grain.png new file mode 100644 index 0000000000..799006b727 Binary files /dev/null and b/src/shared/assets/card-grain.png differ diff --git a/src/shared/design-system/DESIGN.md b/src/shared/design-system/DESIGN.md index b03a3eed52..18f4d2d8da 100644 --- a/src/shared/design-system/DESIGN.md +++ b/src/shared/design-system/DESIGN.md @@ -1030,6 +1030,8 @@ selection changes. Standalone icon actions continue to use `IconButton`. Disabled controls retain their filled, outline, or unfilled treatment and cannot activate. Loading retains the label's geometry, accessible name, focus and variant colors while blocking activation; never swap in a differently sized loading label. +For a visibly unavailable busy action, `loadingAppearance="disabled"` opts into +the existing disabled color roles while retaining the spinner and geometry. Pointer hover uses shared state timing; expanded triggers retain pressed emphasis. Keep keyboard-only focus and reduced-motion behavior owned by the system. @@ -1083,6 +1085,11 @@ carry the shared field appearance. RadioGroup is for one choice, Checkbox for an independent choice and Switch for an immediate on/off setting. Use the native form semantics exposed by those Base UI primitives rather than duplicating them. +`FieldButton appearance="masked"` centers larger, spaced, subtle placeholder dots that +cannot be selected. It opens the field’s action menu. The feature owns masking; +a revealed value uses the ordinary Input and can pair with a trailing menu in +InputGroup. Use `prefix` for ordinary text before masked dots. + `Checkbox` and `Radio` default to 40px rows with 20px controls. For compact inline choices, use `size="sm"`: 32px rows, 16px controls, and an 8px label gap. Pair small radios with `RadioGroup size="sm"` to remove the extra group gap. diff --git a/src/shared/design-system/styles/components.css b/src/shared/design-system/styles/components.css index fdd17bee77..61cf2c2ace 100644 --- a/src/shared/design-system/styles/components.css +++ b/src/shared/design-system/styles/components.css @@ -331,11 +331,19 @@ .buzz-button[data-disabled] .buzz-button-attention rect { animation: none; } - .buzz-button[data-variant="attention"][data-disabled]:not([data-loading]) + .buzz-button[data-variant="attention"][data-disabled]:is( + :not([data-loading]), + [data-loading-appearance="disabled"] + ) .buzz-button-attention { opacity: 1; } - .buzz-button[data-disabled]:not([data-loading]) .buzz-button-attention rect { + .buzz-button[data-disabled]:is( + :not([data-loading]), + [data-loading-appearance="disabled"] + ) + .buzz-button-attention + rect { stroke: var(--border-standard); } @media (prefers-reduced-motion: reduce) { @@ -716,8 +724,11 @@ background: var(--affordance-accent-hover); } - /* Disabled keeps variant geometry; loading keeps its available colors. */ - .buzz-button[data-disabled]:not([data-loading]) { + /* Loading keeps its colors unless the caller requests disabled appearance. */ + .buzz-button[data-disabled]:is( + :not([data-loading]), + [data-loading-appearance="disabled"] + ) { color: var(--text-unavailable); background: var(--affordance-disabled); cursor: not-allowed; @@ -728,15 +739,20 @@ [data-variant="outline"], [data-variant="attention"], [data-variant="link"] - )[data-disabled]:not( - [data-loading], - [data-icon-variant="tint"], - [data-icon-variant="chrome"] - ) { + )[data-disabled]:is( + :not([data-loading]), + [data-loading-appearance="disabled"] + ):not([data-icon-variant="tint"], [data-icon-variant="chrome"]) { background: transparent; } - .buzz-button[data-variant="prominent"][data-disabled]:not([data-loading]), - .buzz-button[data-variant="outline"][data-disabled]:not([data-loading]) { + .buzz-button[data-variant="prominent"][data-disabled]:is( + :not([data-loading]), + [data-loading-appearance="disabled"] + ), + .buzz-button[data-variant="outline"][data-disabled]:is( + :not([data-loading]), + [data-loading-appearance="disabled"] + ) { box-shadow: inset 0 0 0 1px var(--border-standard); } diff --git a/src/shared/design-system/styles/forms.css b/src/shared/design-system/styles/forms.css index 7c1cce98e3..85f85c9e09 100644 --- a/src/shared/design-system/styles/forms.css +++ b/src/shared/design-system/styles/forms.css @@ -97,6 +97,34 @@ calc((var(--size-control-sm) - 1lh - 2px) / 2) ); } + /* Mask placeholders read as a centered pattern; revealed values use ordinary type. */ + .buzz-input[data-appearance="masked"] { + @apply text-body-lg; + text-align: center; + letter-spacing: 0.2em; + text-indent: 0.2em; + user-select: none; + color: var(--text-subtle); + } + .buzz-input[data-appearance="masked"] .buzz-select-value { + flex: 1; + display: inline-flex; + align-items: center; + justify-content: safe center; + gap: var(--space-1); + overflow: hidden; + white-space: nowrap; + } + .buzz-select-prefix { + @apply text-body-sm; + flex-shrink: 0; + letter-spacing: normal; + text-indent: 0; + } + .buzz-input[data-appearance="masked"] .buzz-select-value > :last-child { + min-width: 0; + overflow: hidden; + } .buzz-textarea { min-height: 6rem; padding: var(--space-control-inset); diff --git a/src/shared/design-system/ui/Button.tsx b/src/shared/design-system/ui/Button.tsx index 4648d0e1a0..1e80089041 100644 --- a/src/shared/design-system/ui/Button.tsx +++ b/src/shared/design-system/ui/Button.tsx @@ -40,6 +40,8 @@ export type ButtonProps = Omit< variant?: ButtonVariant; size?: ButtonSize; loading?: boolean; + /** Use disabled colors while retaining the loading spinner and geometry. */ + loadingAppearance?: "default" | "disabled"; /** How long the `title` hint waits; see DESIGN.md "Hover timing". */ hint?: HintTiming; } & ( @@ -56,6 +58,7 @@ export function Button({ shape = "capsule", type = "button", loading = false, + loadingAppearance, disabled, onClick, title, @@ -98,6 +101,7 @@ export function Button({ data-size={size === "compact" ? "sm" : size === "default" ? "md" : size} data-button-shape={shape} data-loading={loading || undefined} + data-loading-appearance={loading ? loadingAppearance : undefined} > {variant === "attention" && ( , "className"> & { /** Show the dropdown chevron; omit it for triggers that open a system picker. */ dropdown?: boolean; + appearance?: "field" | "masked"; + /** Fixed text before a masked value, kept at ordinary text size. */ + prefix?: string; children?: ReactNode; }) { return ( @@ -18,9 +23,13 @@ export function FieldButton({ {...props} type={type} data-buzz-ui="" + data-appearance={appearance === "field" ? undefined : appearance} className="buzz-input buzz-select-trigger" > - {children} + + {prefix && {prefix}} + {children} + {dropdown && ( { + const server = await createServer({ + configFile: false, + envFile: false, + logLevel: "error", + server: { host: "127.0.0.1", port: 0 }, + }); + await server.listen(); + const origin = `http://127.0.0.1:${server.httpServer.address().port}`; + const height = (badge) => badge.evaluate((el) => el.offsetHeight); + const openFixture = async (path, mode) => { + await page.setViewportSize({ width: 1920, height: 1440 }); + await page.goto(`${origin}/tests/fixtures/${path}`); + await page.evaluate(async (colorMode) => { + document.documentElement.dataset.colorMode = colorMode; + await document.fonts.ready; + }, mode); + }; + try { + await page.emulateMedia({ reducedMotion: "no-preference" }); + for (const mode of ["light", "dark"]) { + await openFixture("identity-badge.html", mode); + const badge = page.getByRole("region", { name: "Your Buzz ID" }); + const check = badge.getByRole("button", { name: "Check backup" }); + await expect(check).toBeVisible(); + const front = await height(badge); + await check.focus(); + // Pause the real flip near its start: settled checks miss a blank first half. + await badge.evaluate((el) => { + document.addEventListener( + "click", + () => { + for (const animation of el.getAnimations({ subtree: true })) { + animation.pause(); + animation.currentTime = 1; + } + }, + { once: true }, + ); + }); + await page.keyboard.press("Enter"); + const back = badge.getByRole("button", { name: "Back", exact: true }); + await expect(back).toBeFocused(); + const inactive = badge.locator('[inert][aria-hidden="true"]'); + await expect(inactive).toHaveCSS("visibility", "visible"); + await badge.evaluate((el) => { + for (const animation of el.getAnimations({ subtree: true })) + animation.finish(); + }); + // Compare the card alone: DOM/geometry miss the overlay; the corner tooltip animates. + const card = inactive.locator(".."); + const painted = await card.screenshot(); + await inactive.evaluate((el) => (el.style.visibility = "hidden")); + const hidden = await card.screenshot(); + await inactive.evaluate((el) => el.style.removeProperty("visibility")); + expect(painted.equals(hidden), "inactive face must not paint").toBe(true); + const keyMenu = badge.getByRole("button", { + name: "Private key actions", + }); + await keyMenu.click(); + await expect(page.getByRole("menu")).toBeVisible(); + await page.keyboard.press("Escape"); + await expect(keyMenu).toBeFocused(); + const retry = badge.getByRole("button", { name: "Retry backup" }); + const remove = badge.getByRole("button", { name: "Remove from device" }); + const retryBox = await retry.boundingBox(); + expect(retryBox.y + retryBox.height).toBeLessThan( + (await remove.boundingBox()).y, + ); + expect(await height(badge)).toBe(front); + await remove.click(); + await expect(page.getByRole("alertdialog")).toBeVisible(); + await page.keyboard.press("Escape"); + await expect(page.getByRole("alertdialog")).toHaveCount(0); + await expect(remove).toBeFocused(); + } + + await page.goto(`${origin}/tests/fixtures/identity-badge.html`); + const badge = page.getByRole("region", { name: "Your Buzz ID" }); + await page.setViewportSize({ width: 390, height: 950 }); + await page.evaluate( + () => (document.documentElement.style.fontSize = "32px"), + ); + const check = badge.getByRole("button", { name: "Check backup" }); + await expect(check).toBeVisible(); + expect( + await check.evaluate((el) => { + const r = el.parentElement.getBoundingClientRect(); + const face = el.closest('[aria-hidden="false"]'); + const card = face.getBoundingClientRect(); + const heading = face.querySelector("strong").getBoundingClientRect(); + return ( + r.left >= card.left && + r.right <= card.right && + r.top >= heading.bottom + ); + }), + ).toBe(true); + await check.click(); + await page.setViewportSize({ width: 1920, height: 1440 }); + await page.evaluate( + () => (document.documentElement.style.fontSize = "16px"), + ); + const details = badge.getByRole("region", { name: "Identity details" }); + await expect(details).toHaveCount(0); + // The concise attention copy fits at normal size; enlarged text overflows. + await page.setViewportSize({ width: 390, height: 950 }); + await page.evaluate( + () => (document.documentElement.style.fontSize = "32px"), + ); + await expect(details).toBeVisible(); + const enlarged = await height(badge); + const sizes = await details.evaluate((el) => ({ + scroll: el.scrollHeight, + client: el.clientHeight, + })); + expect(sizes.scroll).toBeGreaterThan(sizes.client); + const remove = badge.getByRole("button", { + name: "Remove from device", + exact: true, + }); + // The two full bottom CTA labels share the accepted 390/200% limit. + expect( + await remove.evaluate((button) => { + const panel = button.closest("section"); + const retry = [...button.parentElement.querySelectorAll("button")].find( + (candidate) => candidate.textContent.trim() === "Retry backup", + ); + const style = getComputedStyle(panel); + const width = + panel.clientWidth - + parseFloat(style.paddingLeft) - + parseFloat(style.paddingRight); + const fits = (child) => + child === button || + child === retry || + (child.clientWidth <= width && + (child.contains(button) || (retry && child.contains(retry)) + ? [...child.children].every(fits) + : child.scrollWidth <= child.clientWidth)); + return [...panel.children].every(fits); + }), + ).toBe(true); + expect(await height(badge)).toBe(enlarged); + await details.focus(); + await page.keyboard.press("End"); + await expect + .poll(() => details.evaluate((el) => el.scrollTop)) + .toBeGreaterThan(0); + await remove.focus(); + expect( + await remove.evaluate((el) => { + const r = el.getBoundingClientRect(), + p = el + .closest('[aria-label="Identity details"]') + .getBoundingClientRect(); + return r.top >= p.top && r.bottom <= p.bottom; + }), + ).toBe(true); + await page.setViewportSize({ width: 1440, height: 950 }); + await page.evaluate( + () => (document.documentElement.style.fontSize = "16px"), + ); + await expect(details).toHaveCount(0); + for (const mode of ["light", "dark"]) { + await openFixture("identity-badge.html?accounts", mode); + await expect(page.getByRole("article")).toHaveCount(2); + const menu = page.getByRole("button", { + name: "More actions for Example Org", + }); + await menu.click(); + await page.getByRole("menuitem", { name: "Sign out" }).click(); + const confirmation = page.getByRole("alertdialog"); + await expect(confirmation).toContainText("Sign out of Example Org?"); + await confirmation.getByRole("button", { name: "Cancel" }).click(); + await expect( + page.getByRole("button", { name: "Renew sign-in", exact: true }), + ).toBeFocused(); + await expect( + page.getByRole("dialog", { name: "Settings" }), + ).toBeVisible(); + const builderlab = page.getByRole("button", { + name: "Open Builderlab settings", + }); + await page.setViewportSize({ width: 390, height: 950 }); + await page.evaluate( + () => (document.documentElement.style.fontSize = "32px"), + ); + const primary = page.getByRole("article").first(); + for (const control of [menu, builderlab]) { + await control.scrollIntoViewIfNeeded(); + expect( + await control.evaluate((el) => { + const c = el.getBoundingClientRect(); + const a = el.closest("article").getBoundingClientRect(); + return c.left >= a.left && c.right <= a.right; + }), + ).toBe(true); + } + await expect( + primary.getByRole("button", { name: "Renew sign-in" }), + ).toBeVisible(); + await page.getByRole("article").last().scrollIntoViewIfNeeded(); + expect( + await page.evaluate( + () => document.documentElement.scrollWidth <= window.innerWidth, + ), + ).toBe(true); + await expect( + page.getByRole("button", { name: "Sign in", exact: true }), + ).toBeVisible(); + } + } finally { + await server.close(); + } +}); diff --git a/tests/browser/settings-layout.spec.mjs b/tests/browser/settings-layout.spec.mjs index e6cc0a64d4..40ab21152a 100644 --- a/tests/browser/settings-layout.spec.mjs +++ b/tests/browser/settings-layout.spec.mjs @@ -5,6 +5,7 @@ import { settingsDialog, } from "./navigation.mjs"; import { test, expect } from "./fixture.mjs"; +import { npubEncode } from "nostr-tools/nip19"; test.use({ historyCounts: { alpha: 0, beta: 0 } }); @@ -28,13 +29,15 @@ test("settings share a centered, bounded column and keep controls contained acro await expect(settingsDialog(page).locator(":focus")).toHaveCount(1); await selectSettingsSection(page, "Identity"); // Copy failure must leave the entire value selectable as ordinary text. - const profile = page.getByRole("region", { + const identity = page.getByRole("region", { name: "Identity", exact: true, }); - await expect( - profile.getByRole("button", { name: "Copy public key" }), - ).toBeVisible(); + const copyId = identity.getByRole("button", { + name: "Copy Buzz ID", + exact: true, + }); + await expect(copyId).toBeVisible(); await page.evaluate(() => { Object.defineProperty(navigator, "clipboard", { configurable: true, @@ -45,18 +48,20 @@ test("settings share a centered, bounded column and keep controls contained acro }, }); }); - await profile.getByRole("button", { name: "Copy public key" }).click(); + await copyId.click(); await expect(page.getByText(/Select it and copy manually/)).toBeVisible(); - const publicKey = profile.locator("code").first(); - const key = await publicKey.textContent(); + // The card abbreviates the ID; clipboard failure exposes the full value below it. + const publicKey = identity.getByText(/^Full Buzz ID:/).locator("code"); + const key = npubEncode(app.viewer); + await expect(publicKey).toHaveText(key); await publicKey.click(); await expect .poll(() => page.evaluate(() => window.getSelection()?.toString())) .toBe(key); - await profile.getByRole("button", { name: "Copy public key" }).focus(); - await page.keyboard.press("Tab"); + await copyId.focus(); + await page.keyboard.press("Shift+Tab"); await expect( - profile.getByRole("button", { name: "Copy nostr address" }), + identity.getByRole("button", { name: "More options", exact: true }), ).toBeFocused(); await page .getByRole("dialog", { name: "Identity wasn’t copied" }) diff --git a/tests/fixtures/design-system/ui/ButtonSpecimens.tsx b/tests/fixtures/design-system/ui/ButtonSpecimens.tsx index 02cf16d259..cddd01c510 100644 --- a/tests/fixtures/design-system/ui/ButtonSpecimens.tsx +++ b/tests/fixtures/design-system/ui/ButtonSpecimens.tsx @@ -300,6 +300,9 @@ export function ButtonSpecimen() { +
+