my-provider-dash-manager 0.9.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +11 -2
  2. package/package.json +1 -1
  3. package/src/main.js +204 -2
package/README.md CHANGED
@@ -190,8 +190,14 @@ guard trusts (`npm run smoke:services`). Google sign-in was verified against Goo
190
190
  works in the app. Anything else can be added by URL or as a plugin.
191
191
 
192
192
  ### Password manager with per-service import
193
+ - **A picker for the site you are on.** A key chip beside the address bar counts the saved logins for
194
+ the page's current site; click it for a **searchable** list with **Use** (fills the email and
195
+ password fields and sets that account's default, with Undo), **Copy email** and **Copy password**.
196
+ The chip refreshes as you navigate and disappears when the site has none.
193
197
  - **Remembers logins you type** inside an account tab, bound to *that* account, so two accounts on the
194
198
  same service refill with different credentials.
199
+ - **Your imported logins show up on every matching site.** The picker lists this account's own saved
200
+ logins *and* every imported login whose site matches the page, labelled with where it came from.
195
201
  - **See each account's logins.** Open an account's **Manage** (gear) window and its *Saved logins* list
196
202
  shows exactly the logins that belong to it, with **Use** to make one that account's default and fill
197
203
  it into the open tab. A small key on the rail row marks every account that has a saved login, even
@@ -204,6 +210,9 @@ works in the app. Anything else can be added by URL or as a plugin.
204
210
  filterable by service or by account, with set/clear default and delete. No password is ever shown.
205
211
  - **Choose which login fills:** when more than one matches a site the app asks instead of guessing,
206
212
  and your choice is remembered as that account's default (marked ★ in the manager).
213
+ - **Copy without revealing:** copying an email or password puts it on the Windows clipboard from the
214
+ app's secured process. A password is never displayed and never sent to the window that draws the
215
+ interface, and a copy is refused unless the tab is on that login's own site.
207
216
  - **Fills from anywhere** on the service's own site: if the page you are on has no matching login (a
208
217
  dashboard, say), the account's login for that service is used. Filling on any other domain is
209
218
  refused, and a fill that cannot happen now says why instead of failing quietly.
@@ -360,7 +369,7 @@ npm start # run the app
360
369
  npm run smoke # sessions, registry, partitions, tab navigation (12 checks)
361
370
  npm run smoke:restore # sessions survive a restart (real cookies)
362
371
  npm run smoke:tokens # DPAPI round-trip + live fake-token rejection per provider (15)
363
- npm run smoke:creds # import, per-account/manager lists, defaults, fill fallback, auto-fill plan, encryption at rest (39)
372
+ npm run smoke:creds # import, per-account/manager/site lists, defaults, copy, fill fallback, auto-fill plan, encryption at rest (50)
364
373
  npm run smoke:autofill # real page: form detection, fill + request ids, undo, capture, origin guard (15)
365
374
  npm run smoke:updates # version comparison, silent-install mode, auto-update toggle, real release-feed check (11)
366
375
  npm run smoke:services # every built-in login page loads inside an isolated partition (42)
@@ -373,7 +382,7 @@ npm start -- --print-paths # show the app name, version and data fold
373
382
  npm start -- --open vercel::personal # open an account on launch
374
383
  ```
375
384
 
376
- The suite is 145 checks across eight modes. `MAM_USER_DATA=<dir>` points any command at an isolated
385
+ The suite is 156 checks across eight modes. `MAM_USER_DATA=<dir>` points any command at an isolated
377
386
  profile.
378
387
 
379
388
  Releases are cut by pushing a `v*` tag: GitHub Actions checks the tag against `package.json`, builds
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "my-provider-dash-manager",
3
- "version": "0.9.0",
3
+ "version": "0.10.0",
4
4
  "description": "CLI to install and update My Provider Dash Manager: a free Windows desktop app that keeps many accounts per service (Vercel, Netlify, Supabase, Cloudflare, AWS, Google, Microsoft, GitHub, Gmail) signed in at once, each in a fully isolated session. Also available as mpdm.",
5
5
  "main": "src/main.js",
6
6
  "scripts": {
package/src/main.js CHANGED
@@ -1,4 +1,4 @@
1
- const { app, BaseWindow, BrowserWindow, WebContentsView, session, ipcMain, shell, Menu } = require('electron');
1
+ const { app, BaseWindow, BrowserWindow, WebContentsView, session, ipcMain, shell, Menu, clipboard } = require('electron');
2
2
  const path = require('path');
3
3
  const fs = require('fs');
4
4
  const crypto = require('crypto');
@@ -227,6 +227,67 @@ function useAsDefault(svc, id, credentialId) {
227
227
  creds.setDefaultForAccount(credentialId, svc, id, host);
228
228
  }
229
229
 
230
+ // ---------- per-site picker (0.10.0) ----------
231
+ // What the key chip in the address strip counts, and what its popover lists: everything that could
232
+ // sign you in on the host the tab is ON right now — this account's own captures for that host, plus
233
+ // every SHARED import whose host relates to it (the owner's "all my google passes on any google
234
+ // login field"; imports are not owned by an account, so they are the same list for every account).
235
+ // A capture made by a DIFFERENT account of the same service is deliberately NOT here: invariant 1
236
+ // binds a credential to its account AND origin, and 0.9.0's accountLoginSummaries draws the same
237
+ // boundary ("it is that account's login"). Widening this to siblings would let one account's tab
238
+ // read another account's saved password on a shared host — the account half of D14.
239
+ // Summaries only: id / username / host / source / ★.
240
+ function siteLoginRows(svc, id, liveUrl, list) {
241
+ const host = creds.hostOf(liveUrl || '');
242
+ if (!host) return { host: '', rows: [] };
243
+ const key = accountKey(svc, id);
244
+ const rows = (list || credSummaries())
245
+ .filter(c => c.host && creds.hostRelated(c.host, host))
246
+ .filter(c => !c.accountKey || c.accountKey === key)
247
+ .map(c => {
248
+ // An import has no owning account, so its label is null; if a credential ever carries another
249
+ // account's key it would be named here (the projection stays honest either way).
250
+ const owner = c.accountKey && c.accountKey !== key ? store.accounts.find(a => accountKey(a.svc, a.id) === c.accountKey) : null;
251
+ return {
252
+ id: c.id, username: c.username || '', host: c.host || '', source: c.source || 'unknown',
253
+ own: c.accountKey === key, accountLabel: owner ? (owner.label || owner.id) : null,
254
+ isDefault: (c.defaultForAccounts || []).includes(key),
255
+ };
256
+ })
257
+ .sort((a, b) => Number(b.isDefault) - Number(a.isDefault) || Number(b.own) - Number(a.own)
258
+ || String(a.username).localeCompare(String(b.username)));
259
+ return { host, rows };
260
+ }
261
+
262
+ // Copying a saved password is the one operation that touches the secret outside a fill. It stays in
263
+ // the MAIN process: the value goes from credentials.js straight to the OS clipboard, and the caller
264
+ // gets a label, never the value (invariant 3 holds and is not weakened by a reveal path).
265
+ // `svc`/`id`/`liveUrl` are supplied by the IPC wrapper from the ACTIVE TAB (never from the renderer),
266
+ // so a copy can only ever happen on the tab the picker belongs to.
267
+ function copyCredentialField(credentialId, field, svc, id, liveUrl) {
268
+ if (field !== 'username' && field !== 'password') return { ok: false, reason: 'bad-field', message: 'Unknown field.' };
269
+ const liveHost = creds.hostOf(liveUrl || '');
270
+ // The same gate every fill passes (D14): the tab must be ON one of this service's own sites...
271
+ if (!originAllowed(svc, liveUrl)) {
272
+ return {
273
+ ok: false, reason: 'foreign-host',
274
+ message: `This tab is on ${liveHost || 'another site'}, not ${serviceHosts(svc)[0] || 'the service'} — refusing to copy a saved password here.`,
275
+ };
276
+ }
277
+ const key = accountKey(svc, id);
278
+ // ...and the credential must be one THIS account may use on this page: its own capture for the
279
+ // live site, or a shared import for it. Another account's capture is refused here even though the
280
+ // picker never lists it — the account half of invariant 1 (a capture belongs to its account).
281
+ const cred = creds.loadAll().find(c => c.id === credentialId);
282
+ if (!cred) return { ok: false, reason: 'unknown-id', message: 'That saved login no longer exists.' };
283
+ if (cred.accountKey && cred.accountKey !== key) return { ok: false, reason: 'other-account', message: 'That saved login belongs to a different account.' };
284
+ if (!cred.host || !creds.hostRelated(cred.host, liveHost)) return { ok: false, reason: 'other-site', message: 'That saved login belongs to a different site.' };
285
+ const value = field === 'password' ? cred.password : cred.username;
286
+ if (!value) return { ok: false, reason: 'empty', message: `That saved login has no ${field} stored.` };
287
+ clipboard.writeText(value);
288
+ return { ok: true, field, label: `${field === 'password' ? 'password' : 'email'} for ${cred.username || cred.host || 'this login'}` };
289
+ }
290
+
230
291
  // Explicit clear semantics for creds-default (0.8.0 relied on the "null id never matches" side
231
292
  // effect). A null id may ONLY clear: it never sets a default anywhere, and it needs the account it
232
293
  // applies to. Returns the authoritative manager list either way.
@@ -479,6 +540,9 @@ class ViewManager {
479
540
  // isolated-world reset requires — the world (and Dark Reader in it) is gone by then and must
480
541
  // be re-injected. Same-document (pushState) routing never reaches here, so it never re-injects.
481
542
  if (store.settings.darkMode) dark.enable(view.webContents);
543
+ // A full load can land on a different host than did-navigate reported (client-side redirects):
544
+ // refresh the address strip's key chip from the URL that actually finished loading (0.10.0).
545
+ this.emitState();
482
546
  });
483
547
  const onNav = (_e, url2) => {
484
548
  tab.url = url2;
@@ -648,8 +712,19 @@ class ViewManager {
648
712
  emitState() {
649
713
  if (!this.window || !this.window.webContents) return;
650
714
  const loginList = credSummaries(); // one decryption per state emit, not one per account
715
+ // The key chip in the address strip counts saved logins for the ACTIVE tab's live host (0.10.0).
716
+ // Recomputed here, so it is fresh after every navigation and every tab activation, and it is
717
+ // computed for the active tab only — the other tabs refresh when they are activated.
718
+ let siteLogins = null;
719
+ const activeTab = this.activeKey ? this.tabs.get(this.activeKey) : null;
720
+ if (activeTab && !activeTab.view.webContents.isDestroyed()) {
721
+ const liveUrl = activeTab.view.webContents.getURL();
722
+ const { host, rows } = siteLoginRows(activeTab.svc, activeTab.id, liveUrl, loginList);
723
+ siteLogins = { key: this.activeKey, host, count: host ? rows.length : 0 };
724
+ }
651
725
  this.window.webContents.send('tabs-state', {
652
726
  activeKey: this.activeKey,
727
+ siteLogins,
653
728
  warmLimit: store.settings.warmLimit,
654
729
  registry: store.accounts.map(a => ({
655
730
  ...a,
@@ -1111,6 +1186,65 @@ async function runSmokeCreds() {
1111
1186
  const plan = autoFillPlan('vercel', 'team-c', 'https://vercel.com/login');
1112
1187
  return plan.kind === 'choose' && !JSON.stringify(plan.choices).includes('shared-pw') && !JSON.stringify(plan.choices).includes('carol-pw');
1113
1188
  })());
1189
+
1190
+ // ---------- 0.10.0: the per-site picker ----------
1191
+ // The fixture above: team-a holds a@example.com plus the two shared imports, team-b owns
1192
+ // b@example.com, netlify's account owns nothing. A picker on vercel.com for team-a shows the
1193
+ // account's own capture AND every shared import on the live host — never a sibling's capture
1194
+ // (invariant 1: a capture belongs to the account that made it).
1195
+ const siteA = siteLoginRows('vercel', 'team-a', 'https://vercel.com/login');
1196
+ check('site-list-includes-shared-imports-from-another-account',
1197
+ siteA.rows.map(r => r.username).sort().join() === ['a@example.com', 'carol@example.com', 'shared@example.com'].join()
1198
+ && !siteA.rows.some(r => r.username === 'b@example.com'),
1199
+ `${siteA.host}: ${siteA.rows.map(r => `${r.username}${r.accountLabel ? `[${r.accountLabel}]` : r.own ? '[own]' : '[import]'}`).join(',')}`);
1200
+ check('site-list-rows-have-no-password-key',
1201
+ siteA.rows.every(r => !('password' in r) && !JSON.stringify(r).includes('shared-pw') && !JSON.stringify(r).includes('pw-a')),
1202
+ `${siteA.rows.length} rows: ${JSON.stringify(Object.keys(siteA.rows[0] || {}))}`);
1203
+ // An import is shared (no owning account); a capture made by another account is that account's
1204
+ // login and is neither listed nor copyable from here (invariant 1, the 0.9.0 boundary).
1205
+ check('site-list-scope-is-own-captures-plus-shared-imports',
1206
+ siteA.rows.filter(r => r.accountLabel === null).length === 3
1207
+ && siteA.rows.find(r => r.username === 'a@example.com').own === true
1208
+ && siteA.rows.filter(r => r.username === 'shared@example.com' || r.username === 'carol@example.com').every(r => r.own === false && r.accountLabel === null),
1209
+ siteA.rows.map(r => `${r.username}:${r.own ? 'own' : 'shared'}`).join(' '));
1210
+ // D14: a copy is refused when the tab is not on the service's own site ...
1211
+ const offSite = copyCredentialField(siteA.rows[0].id, 'password', 'vercel', 'team-a', 'https://accounts.google.com/signin');
1212
+ check('copy-refused-off-site', offSite.ok === false && offSite.reason === 'foreign-host', `reason=${offSite.reason}`);
1213
+ // ... and an unknown id never yields a secret
1214
+ const unknown = copyCredentialField('no-such-credential', 'password', 'vercel', 'team-a', 'https://vercel.com/login');
1215
+ check('copy-refused-unknown-id', unknown.ok === false && unknown.reason === 'unknown-id', `reason=${unknown.reason}`);
1216
+ // a sibling account's capture is never available here, even on its own site (invariant 1)
1217
+ const otherAcct = creds.loadAll().find(c => c.accountKey === 'vercel::team-b');
1218
+ const sibling = copyCredentialField(otherAcct.id, 'password', 'vercel', 'team-a', 'https://vercel.com/login');
1219
+ check('copy-refused-another-accounts-capture', sibling.ok === false && sibling.reason === 'other-account', `reason=${sibling.reason}`);
1220
+ clipboard.clear();
1221
+ // the real copy path: main writes the clipboard, and the answer is a label — never the secret
1222
+ clipboard.writeText('sentinel-before-copy');
1223
+ const sharedRow = siteA.rows.find(r => r.username === 'shared@example.com');
1224
+ const copied = copyCredentialField(sharedRow.id, 'password', 'vercel', 'team-a', 'https://vercel.com/login');
1225
+ check('copy-writes-the-right-password-to-the-clipboard',
1226
+ copied.ok === true && clipboard.readText() === 'shared-pw' && !JSON.stringify(copied).includes('shared-pw'),
1227
+ `ok=${copied.ok} label="${copied.label}" clipboard-match=${clipboard.readText() === 'shared-pw'}`);
1228
+ const copiedUser = copyCredentialField(sharedRow.id, 'username', 'vercel', 'team-a', 'https://vercel.com/login');
1229
+ check('copy-email-writes-the-username', copiedUser.ok === true && clipboard.readText() === 'shared@example.com',
1230
+ `label="${copiedUser.label}"`);
1231
+ const badField = copyCredentialField(sharedRow.id, 'reveal', 'vercel', 'team-a', 'https://vercel.com/login');
1232
+ check('copy-refuses-an-unknown-field', badField.ok === false && badField.reason === 'bad-field', `reason=${badField.reason}`);
1233
+ clipboard.clear();
1234
+ // the chip counts exactly the rows the picker would list (main computes both)
1235
+ check('site-list-count-matches-the-rows',
1236
+ siteLoginRows('vercel', 'team-a', 'https://vercel.com/login').rows.length === 3
1237
+ && siteLoginRows('vercel', 'team-c', 'https://example.com/').rows.length === 0,
1238
+ `team-a=${siteLoginRows('vercel', 'team-a', 'https://vercel.com/login').rows.length}`);
1239
+ // Use from the picker is the SAME 0.9.0 path: fill-login(key, id) sets the default, then fills.
1240
+ // Main-side we can prove the default half; the fill half is the existing Use contract.
1241
+ const pickRow = siteA.rows.find(r => r.username === 'carol@example.com');
1242
+ useAsDefault('vercel', 'team-a', pickRow.id);
1243
+ check('use-from-picker-sets-the-account-default',
1244
+ siteLoginRows('vercel', 'team-a', 'https://vercel.com/login').rows.find(r => r.id === pickRow.id).isDefault === true
1245
+ && siteLoginRows('vercel', 'team-b', 'https://vercel.com/login').rows.find(r => r.id === pickRow.id).isDefault === false,
1246
+ managerRows().find(r => r.id === pickRow.id).defaultForAccounts.join() || 'none');
1247
+ setOrClearDefault(null, 'vercel', 'team-a', 'https://vercel.com/login');
1114
1248
  } finally { try { fs.rmSync(csv3, { force: true }); } catch {} }
1115
1249
 
1116
1250
  const st = creds.stats();
@@ -1439,7 +1573,16 @@ function runInteractive(captureMode = false) {
1439
1573
  // --capture-scroll <px> scroll the Home grid down first
1440
1574
  // --capture-wait <ms> extra settle time
1441
1575
  // --capture-js "<expr>" run an expression in the chrome page (e.g. open a modal)
1442
- const argVal = name => { const i = process.argv.indexOf(name); return i > -1 ? process.argv[i + 1] : null; };
1576
+ // Both "--flag value" and "--flag=value" are accepted. The "=" form matters on this machine:
1577
+ // an account key contains "::" and the Git Bash/MSYS layer mangles that in a separate argv
1578
+ // token (the process exits 127 before it starts), while "--capture-open=vercel::work" passes
1579
+ // through intact. Same value, same code path.
1580
+ const argVal = name => {
1581
+ const viaEq = process.argv.find(a => a.startsWith(`${name}=`));
1582
+ if (viaEq) return viaEq.slice(name.length + 1);
1583
+ const i = process.argv.indexOf(name);
1584
+ return i > -1 ? process.argv[i + 1] : null;
1585
+ };
1443
1586
  const outFile = argVal('--capture-out') || 'ui-screenshot.png';
1444
1587
  const openKey = argVal('--capture-open');
1445
1588
  const scrollPx = Number(argVal('--capture-scroll') || 0);
@@ -1472,6 +1615,47 @@ function runInteractive(captureMode = false) {
1472
1615
  console.log('[capture] demo notice:', plan.kind, (plan.choices || []).length);
1473
1616
  await new Promise(r => setTimeout(r, 700));
1474
1617
  }
1618
+ // --demo-picker: open the address-strip key chip's picker from MAIN (state → emitState → the
1619
+ // renderer's own click handler), so a screenshot shows the real popover and not hand-written
1620
+ // DOM. The chip is drawn from state.siteLogins, so one emitState is enough. Verification aid
1621
+ // for a scratch demo profile, like --demo-notice (0.9.0); it changes no shipped behaviour.
1622
+ if (process.argv.includes('--demo-picker') && openKey) {
1623
+ const key = openKey;
1624
+ if (vm.tabs.has(key)) vm.activate(key); // the chip only exists for the ACTIVE tab
1625
+ await new Promise(r => setTimeout(r, 800));
1626
+ vm.emitState();
1627
+ await new Promise(r => setTimeout(r, 600));
1628
+ if (win.webContents && !win.webContents.isDestroyed()) {
1629
+ await win.webContents.executeJavaScript('document.getElementById("keysite").click()').catch(err => console.error('[capture] picker click failed:', err.message));
1630
+ }
1631
+ const info = await win.webContents.executeJavaScript(`JSON.stringify({
1632
+ chip: document.getElementById('keysite').hidden ? 'hidden' : document.getElementById('keysite').innerText.trim(),
1633
+ open: !document.getElementById('keypicker').hidden,
1634
+ rows: document.querySelectorAll('#keypicker [data-kp-id]').length,
1635
+ search: !!document.getElementById('kp-q'),
1636
+ rowsMeta: [...document.querySelectorAll('#keypicker .kp-meta')].map(m => m.innerText.trim()).slice(0, 4),
1637
+ })`).catch(err => 'probe failed: ' + err.message);
1638
+ console.log('[capture] demo picker:', info);
1639
+ // --demo-picker-copied: type in the search box and press Copy password through the real
1640
+ // renderer → preload → main chain, then read the clipboard back from MAIN (the secret must
1641
+ // never appear in the renderer). Photographs the searched + "Copied ✓" state.
1642
+ if (process.argv.includes('--demo-picker-copied') && win.webContents && !win.webContents.isDestroyed()) {
1643
+ const wired = await win.webContents.executeJavaScript(`(async () => {
1644
+ const q = document.getElementById('kp-q');
1645
+ q.value = 'demo.user.two'; q.dispatchEvent(new Event('input', { bubbles: true }));
1646
+ const visible = [...document.querySelectorAll('#keypicker [data-kp-id]')].filter(r => !r.hidden).length;
1647
+ const btn = [...document.querySelectorAll('#keypicker [data-kp-copy][data-kp-field="password"]')]
1648
+ .find(b => !b.closest('[data-kp-id]').hidden);
1649
+ if (!btn) return JSON.stringify({ filterVisible: visible, btnFound: false });
1650
+ btn.click();
1651
+ await new Promise(r => setTimeout(r, 400));
1652
+ return JSON.stringify({ filterVisible: visible, btnFound: true, btnText: btn.innerText.trim() });
1653
+ })()`).catch(err => 'wired probe failed: ' + err.message);
1654
+ console.log('[capture] demo picker wired:', wired, 'clipboard-is-a-demo-password:', clipboard.readText().startsWith('demo-placeholder-'));
1655
+ clipboard.clear();
1656
+ }
1657
+ await new Promise(r => setTimeout(r, 250));
1658
+ }
1475
1659
  if (extraWait) await new Promise(r => setTimeout(r, extraWait));
1476
1660
  // UnknownVizError / empty frames clear once the compositor produces a frame — retry
1477
1661
  let img = null; let lastErr = null;
@@ -1829,6 +2013,24 @@ function runInteractive(captureMode = false) {
1829
2013
  logins: accountLoginSummaries(svc, id),
1830
2014
  signInUrl: (serviceByKey(svc) || {}).loginUrl || null,
1831
2015
  }));
2016
+ // Per-SITE picker (0.10.0): the logins that can sign you in on the ACTIVE tab's live host. The
2017
+ // renderer names the tab; main reads that tab's LIVE url, so the list can never describe a page the
2018
+ // user has navigated away from. Summaries only (no `password` key, ever).
2019
+ ipcMain.handle('creds-site-list', (_e, key) => {
2020
+ const t = key ? vm.tabs.get(String(key)) : null;
2021
+ if (!t) return { host: '', logins: [], count: 0, live: false };
2022
+ const liveUrl = t.view.webContents.isDestroyed() ? '' : t.view.webContents.getURL();
2023
+ const { host, rows } = siteLoginRows(t.svc, t.id, liveUrl);
2024
+ return { host, logins: rows, count: rows.length, live: true };
2025
+ });
2026
+ // Copy a saved email/password to the OS clipboard without ever showing it (main-process clipboard).
2027
+ // The account and the site gate come from the ACTIVE tab in main, not from the caller.
2028
+ ipcMain.handle('creds-copy', (_e, key, credentialId, field) => {
2029
+ const t = key ? vm.tabs.get(String(key)) : null;
2030
+ if (!t) return { ok: false, reason: 'asleep', message: 'That tab is asleep — open it first.' };
2031
+ const liveUrl = t.view.webContents.isDestroyed() ? '' : t.view.webContents.getURL();
2032
+ return copyCredentialField(String(credentialId || ''), String(field || ''), t.svc, t.id, liveUrl);
2033
+ });
1832
2034
  // Credential manager: summaries only, never passwords — the renderer must not hold one.
1833
2035
  // Imported passwords are classified by host, with the accounts each row can serve, so the manager
1834
2036
  // filters and the ★ markers both come from the main process (the only authority on them).