akv-manager 0.0.3 → 0.0.5

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.
package/public/index.html CHANGED
@@ -3,7 +3,7 @@
3
3
  <head>
4
4
  <meta charset="UTF-8" />
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
- <title>Keyvault Console — Accounts · Subscriptions · Expiry</title>
6
+ <title>Keyvault Console — Operations</title>
7
7
  <link rel="preconnect" href="https://fonts.googleapis.com">
8
8
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
9
9
  <link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600&family=IBM+Plex+Sans:wght@400;500;600;700&display=swap" rel="stylesheet">
@@ -48,151 +48,68 @@
48
48
  </svg>
49
49
  <div class="wm">KEYVAULT<small>OPS CONSOLE</small></div>
50
50
  </div>
51
- <div class="navlabel">Console</div>
51
+
52
+ <button class="navpin" id="cmdkBtn" style="margin:2px 0 6px;border:1px solid var(--line);justify-content:flex-start">
53
+ <svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="7"/><path d="M21 21l-4.3-4.3"/></svg>
54
+ <span class="pn">Search everything</span><kbd class="mono" style="font-size:9.5px;color:var(--faint)">⌘K</kbd>
55
+ </button>
56
+
57
+ <div class="navlabel">Operate</div>
52
58
  <nav>
53
- <button data-view="overview" class="active">
59
+ <button data-view="dashboard" class="active">
54
60
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/><rect x="3" y="14" width="7" height="7" rx="1"/><rect x="14" y="14" width="7" height="7" rx="1"/></svg>
55
- Overview</button>
56
- <button data-view="subscriptions">
57
- <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linejoin="round"><path d="M12 3l9 5-9 5-9-5 9-5z"/><path d="M3 13l9 5 9-5"/></svg>
58
- Subscriptions</button>
61
+ Dashboard</button>
59
62
  <button data-view="vaults">
60
63
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="4" y="10" width="16" height="11" rx="2"/><path d="M8 10V7a4 4 0 0 1 8 0v3"/></svg>
61
64
  Key Vaults</button>
62
- <button data-view="expiring">
65
+ <button data-view="expiry">
63
66
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3.5 2"/></svg>
64
67
  Expiry</button>
65
68
  </nav>
66
- <div class="who" id="who"><span class="spin"></span> connecting…</div>
67
- <div class="ver mono faint" id="ver"></div>
69
+
70
+ <div class="navlabel" id="pinLabel">Pinned</div>
71
+ <div class="navpins" id="navPins"></div>
72
+ <div class="navlabel" id="recentLabel">Recent</div>
73
+ <div class="navpins" id="navRecent"></div>
74
+
75
+ <div class="acct">
76
+ <button class="acctbtn" id="acctBtn">
77
+ <span class="dot" id="acctDot" style="background:var(--faint)"></span>
78
+ <span style="overflow:hidden"><b id="acctUser">connecting…</b><br><span class="mono faint" id="acctSub" style="font-size:10.5px"></span></span>
79
+ <svg class="chev" viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 6l6 6-6 6"/></svg>
80
+ </button>
81
+ <div class="ver mono faint" id="ver"></div>
82
+ </div>
68
83
  </aside>
69
84
 
70
85
  <main class="max-w-[1560px] px-8 pt-[26px] pb-[60px] max-[900px]:p-5">
71
86
  <div class="banner" id="banner"></div>
72
87
 
73
88
  <div class="flex items-end justify-between gap-[18px] mb-[26px]">
74
- <div>
75
- <div class="eyebrow" id="eyebrow">Overview</div>
76
- <h1 id="title">Overview</h1>
89
+ <div id="headblock">
90
+ <div class="eyebrow" id="eyebrow">Operations</div>
91
+ <h1 id="title">Dashboard</h1>
77
92
  <div class="sub" id="subtitle">Live view of your Azure access via the local az CLI</div>
78
93
  </div>
79
- <div class="row">
80
- <input class="search hide" id="search" placeholder="search vault · resource group · service…" />
94
+ <div class="row" id="topactions">
95
+ <input class="search hide" id="search" placeholder="search vault · resource group · tag…" />
81
96
  <button class="btn ghost" id="refresh">↻ Refresh</button>
82
97
  </div>
83
98
  </div>
84
99
 
85
- <!-- OVERVIEW -->
86
- <section id="view-overview">
87
- <div class="cards" id="ovCards"></div>
88
- <div class="panel">
89
- <div class="scroll">
90
- <table>
91
- <thead><tr><th>Subscription</th><th>Env</th><th>State</th><th class="right">Key Vaults</th><th>Subscription ID</th></tr></thead>
92
- <tbody id="ovSubs"></tbody>
93
- </table>
94
- </div>
95
- </div>
96
- </section>
97
-
98
- <!-- SUBSCRIPTIONS -->
99
- <section id="view-subscriptions" class="hide">
100
- <div class="panel">
101
- <div class="scroll">
102
- <table>
103
- <thead><tr><th>Name</th><th>Env</th><th>State</th><th>Active</th><th class="right">Vaults</th><th>Subscription ID</th><th></th></tr></thead>
104
- <tbody id="subsBody"></tbody>
105
- </table>
106
- </div>
107
- </div>
108
- </section>
109
-
110
- <!-- VAULTS -->
111
- <section id="view-vaults" class="hide">
112
- <div class="chips" id="envChips"></div>
113
- <div class="panel">
114
- <div class="scroll">
115
- <table>
116
- <thead><tr>
117
- <th data-sort="name">Vault ▾</th><th data-sort="env">Env</th>
118
- <th data-sort="service">Service</th><th data-sort="resourceGroup">Resource Group</th>
119
- <th data-sort="sku">SKU</th><th>Auth</th><th class="right">Actions</th>
120
- </tr></thead>
121
- <tbody id="vaultsBody"></tbody>
122
- </table>
123
- </div>
124
- </div>
125
- <div class="tablenote" id="vaultCount"></div>
126
- </section>
127
-
128
- <!-- EXPIRING -->
129
- <section id="view-expiring" class="hide">
130
- <div class="row" style="margin-bottom:18px;justify-content:space-between">
131
- <div class="row">
132
- <span class="eyebrow">Window</span>
133
- <div class="seg" id="expWindow">
134
- <button data-d="7">7d</button>
135
- <button data-d="30" class="active">30d</button>
136
- <button data-d="60">60d</button>
137
- <button data-d="90">90d</button>
138
- <button data-d="365">1y</button>
139
- </div>
140
- <button class="btn" id="expScan">⟳ Scan vaults</button>
141
- </div>
142
- <span class="tablenote" id="expMeta"></span>
143
- </div>
144
-
145
- <div class="panel horizon hide" id="expHorizon"></div>
146
- <div class="cards" id="expCards"></div>
147
-
148
- <div class="panel">
149
- <div class="scroll">
150
- <table>
151
- <thead><tr>
152
- <th data-esort="daysLeft">Expires in ▾</th><th data-esort="expires">Expiry date</th>
153
- <th data-esort="type">Type</th><th data-esort="name">Item</th>
154
- <th data-esort="vault">Vault</th><th data-esort="env">Env</th>
155
- <th>Status</th><th class="right">Actions</th>
156
- </tr></thead>
157
- <tbody id="expBody"><tr><td colspan="8" class="empty">Press <b>Scan vaults</b> to check expiry across every vault you can read.</td></tr></tbody>
158
- </table>
159
- </div>
160
- </div>
161
- <div class="tablenote" id="expTableNote"></div>
162
- </section>
100
+ <div id="route"></div>
163
101
  </main>
164
102
  </div>
165
103
 
166
- <!-- Drawer -->
167
- <div class="overlay" id="overlay"></div>
168
- <div class="drawer" id="drawer">
169
- <header>
170
- <div>
171
- <div class="dt" id="dTitle">Vault</div>
172
- <div class="mono muted" id="dSub" style="font-size:11.5px;margin-top:5px;word-break:break-all"></div>
173
- </div>
174
- <button class="iconbtn" id="dClose">✕ Close</button>
175
- </header>
176
- <div class="body">
177
- <div class="seg" id="dSeg" style="width:100%;display:flex">
178
- <button data-kind="secrets" class="active" style="flex:1">Secrets</button>
179
- <button data-kind="keys" style="flex:1">Keys</button>
180
- <button data-kind="certificates" style="flex:1">Certificates</button>
104
+ <!-- Command palette -->
105
+ <div class="cmdk-ov" id="cmdkOv">
106
+ <div class="cmdk" role="dialog" aria-label="Command palette">
107
+ <div class="cmdk-in">
108
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="7"/><path d="M21 21l-4.3-4.3"/></svg>
109
+ <input id="cmdkIn" placeholder="Jump to a vault, a view, or an action…" autocomplete="off" spellcheck="false">
110
+ <kbd>esc</kbd>
181
111
  </div>
182
- <div class="dtools" id="dTools">
183
- <input class="search" id="dFilter" placeholder="filter by name…" autocomplete="off" />
184
- <label class="selall"><input type="checkbox" id="dSelAll" /> select all</label>
185
- <span class="faint mono" id="dCount"></span>
186
- <span class="newbtns" id="dNewBtns">
187
- <button class="btn sm" id="dNew">+ New</button>
188
- <button class="btn sm ghost" id="dNewBatch">+ Batch</button>
189
- </span>
190
- </div>
191
- <div class="createp hide" id="dCreate"></div>
192
- <div class="batchbar hide" id="dBatch"></div>
193
- <div id="dContent" style="margin-top:12px"><div class="empty"><span class="spin"></span></div></div>
194
- <div class="secthead">CLI commands</div>
195
- <div id="dCmds"></div>
112
+ <div class="cmdk-list" id="cmdkList"></div>
196
113
  </div>
197
114
  </div>
198
115
 
@@ -200,16 +117,77 @@
200
117
 
201
118
  <script>
202
119
  const $ = s => document.querySelector(s);
120
+ const esc = s => String(s==null?'':s).replace(/[&<>"]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]));
203
121
  const api = async (p, opts) => { const r = await fetch(p, opts); return r.json(); };
204
- let STATE = { vaults: [], subs: [], account: null, env: 'all', q: '', sort: 'name', dir: 1, drawerVault: null, kind: 'secrets' };
205
- let CFG = { envRules: null, title: null }; // optional operator config from /api/config
122
+ let STATE = { vaults: [], subs: [], account: null, env: 'all', q: '', sort: 'name', dir: 1,
123
+ kind: 'secrets', drawerVault: null, route: 'dashboard', facets: {} };
124
+ let CFG = { envRules: null, title: null, version: null, protectedTags: [] };
125
+ function tagMatch(p,k){ p=String(p).toLowerCase(); k=String(k).toLowerCase(); return p.endsWith('*')?k.startsWith(p.slice(0,-1)):k===p; }
126
+ function isProtectedTag(k){ return (CFG.protectedTags||[]).some(p=>tagMatch(p,k)); }
206
127
  const SUBMETA = {};
207
128
 
129
+ // ---------- local prefs (pins / recents) ----------
130
+ const LS = {
131
+ read(k){ try{ return JSON.parse(localStorage.getItem(k)||'[]'); }catch{ return []; } },
132
+ write(k,v){ try{ localStorage.setItem(k, JSON.stringify(v)); }catch{} },
133
+ };
134
+ const pins = () => LS.read('akv.pins');
135
+ const recents = () => LS.read('akv.recents');
136
+ const isPinned = n => pins().includes(n);
137
+ function togglePin(n){ const p=pins(); const i=p.indexOf(n); if(i>=0)p.splice(i,1); else p.unshift(n); LS.write('akv.pins',p); renderSidebar(); refreshCurrent(); }
138
+ function pushRecent(n){ const r=recents().filter(x=>x!==n); r.unshift(n); LS.write('akv.recents', r.slice(0,8)); renderSidebar(); }
139
+
208
140
  function toast(msg){ const t=$('#toast'); t.textContent=msg; t.classList.add('show'); setTimeout(()=>t.classList.remove('show'),1600); }
209
141
  function copy(text){ navigator.clipboard.writeText(text).then(()=>toast('Copied to clipboard')); }
210
142
  function banner(msg){ const b=$('#banner'); if(!msg){b.classList.remove('show');return;} b.innerHTML=msg; b.classList.add('show'); }
211
143
  function envOf(id){ return (SUBMETA[id]&&SUBMETA[id].env)||'?'; }
212
144
  function subName(id){ return (SUBMETA[id]&&SUBMETA[id].name)||id; }
145
+ const findVault = n => STATE.vaults.find(v=>v.name.toLowerCase()===String(n||'').toLowerCase());
146
+
147
+ // ---------- environment + service derivation (now tag-aware) ----------
148
+ function prettyEnv(s){
149
+ const x=String(s).toLowerCase().trim();
150
+ // exact short codes commonly used in `env` tags
151
+ const code={dv:'Dev',dev:'Dev',d:'Dev',qa:'QA',ts:'Test',tst:'Test',tes:'Test',t:'Test',
152
+ st:'Stage',stg:'Stage',stag:'Stage',uat:'Stage',pp:'Stage',pr:'Prod',prd:'Prod',prod:'Prod',p:'Prod',
153
+ sb:'Sandbox',sbx:'Sandbox',sand:'Sandbox'}[x];
154
+ if(code) return code;
155
+ if(/prod|prd|\blive\b/.test(x)) return 'Prod';
156
+ if(/stag|stg|\buat\b|preprod|pre-prod|accept/.test(x)) return 'Stage';
157
+ if(/\bqa\b|quality/.test(x)) return 'QA';
158
+ if(/sandbox|sbx|\bdemo\b/.test(x)) return 'Sandbox';
159
+ if(/dev|develop/.test(x)) return 'Dev';
160
+ if(/test|tst|\bint\b|integration|nonprod/.test(x)) return 'Test';
161
+ return s.charAt(0).toUpperCase()+s.slice(1);
162
+ }
163
+ // Prefer the vault's real `env` tag; fall back to the subscription-name heuristic.
164
+ function vaultEnv(v){
165
+ const t = v.tags||{};
166
+ const raw = t.env || t.Environment || t.environment || t.ENV || t.Env;
167
+ return raw ? prettyEnv(raw) : envOf(v.subscriptionId);
168
+ }
169
+ function vaultService(v){
170
+ const t = v.tags||{};
171
+ return t.solution || t.Solution || t.application || t.app || serviceOf(v.resourceGroup);
172
+ }
173
+ function envLabel(name){
174
+ const n = name || '';
175
+ if(CFG.envRules){ for(const [pat,label] of CFG.envRules){ try{ if(new RegExp(pat,'i').test(n)) return label; }catch{} } }
176
+ // Only label a subscription when a known environment keyword is present; otherwise '—'.
177
+ return /(prod|prd|\blive\b|stag|stg|\buat\b|preprod|pre-prod|accept|\bqa\b|sandbox|sbx|\bdemo\b|\bdev\b|develop|test|tst|\bint\b|integration|nonprod)/i.test(n)
178
+ ? prettyEnv(n) : '—';
179
+ }
180
+ function envClass(env){
181
+ const s = (env||'').toLowerCase();
182
+ if(/prod|live/.test(s)) return 'e-prod';
183
+ if(/stag|uat|preprod/.test(s)) return 'e-stage';
184
+ if(/qa/.test(s)) return 'e-qa';
185
+ if(/sandbox|demo/.test(s)) return 'e-sandbox';
186
+ if(/dev/.test(s)) return 'e-dev';
187
+ if(/test|int/.test(s)) return 'e-test';
188
+ return 'e-other';
189
+ }
190
+ function serviceOf(rg){ return (rg||'').replace(/^rg[-_]?/i,'').replace(/[-_]?\d+$/,'') || rg; }
213
191
 
214
192
  // ---------- bootstrap ----------
215
193
  async function boot(){
@@ -218,18 +196,23 @@ async function boot(){
218
196
  $('#ver').textContent = 'akv-manager v' + (CFG.version || '?');
219
197
  const acc = await api('/api/account');
220
198
  if(!acc.ok){
221
- $('#who').innerHTML = '<span class="dot" style="background:var(--alert)"></span> not connected';
222
- const detail = acc.error ? `<div class="hint" style="margin-top:6px">${acc.error}</div>` : '';
199
+ $('#acctUser').textContent = 'not connected'; $('#acctDot').style.background='var(--alert)';
200
+ const detail = acc.error ? `<div class="hint" style="margin-top:6px">${esc(acc.error)}</div>` : '';
223
201
  banner((acc.needLogin
224
202
  ? 'Azure CLI is signed out. Run <code>az login</code>, then press Refresh.'
225
203
  : 'Could not reach the Azure CLI. Make sure <code>az</code> is installed and on your PATH (on Windows it is <code>az.cmd</code>), then press Refresh.') + detail);
226
204
  return;
227
205
  }
228
206
  STATE.account = acc.data;
229
- $('#who').innerHTML = `<span class="dot" style="background:var(--ok)"></span> <b>${acc.data.user.name}</b><br><span class="mono">${acc.data.name}</span><br><span class="mono faint">tenant ${(acc.data.tenantId||'').slice(0,8)}…</span>`;
207
+ $('#acctDot').style.background='var(--ok)';
208
+ $('#acctUser').textContent = acc.data.user.name;
209
+ $('#acctSub').textContent = acc.data.name;
230
210
  banner('');
231
- await loadSubs(); // populate the subscription→env map first
232
- await loadVaults(); // …so vault/expiry rows resolve their environment
211
+ await loadSubs();
212
+ await loadVaults();
213
+ renderSidebar();
214
+ router();
215
+ kickExpiryScan(); // warm the dashboard's lifecycle numbers in the background
233
216
  }
234
217
 
235
218
  async function loadSubs(){
@@ -237,160 +220,288 @@ async function loadSubs(){
237
220
  if(!r.ok) return;
238
221
  STATE.subs = r.data;
239
222
  r.data.forEach(s => SUBMETA[s.id] = { name: s.name, env: envLabel(s.name) });
240
- renderOverview(); renderSubs();
241
- // If the expiry view rendered before subs arrived, re-resolve its env tags now.
242
- if(EXP.scanned){ EXP.items = EXP.items.map(it=>({...it, env: envOf(it.subscriptionId)})); renderExpiring(); }
243
223
  }
244
- // Infer an environment label from a subscription name. Operators can override the
245
- // matching with AZBO_ENV_RULES (see /api/config); otherwise generic keywords are used.
246
- function envLabel(name){
247
- const n = name || '';
248
- if(CFG.envRules){ for(const [pat,label] of CFG.envRules){ try{ if(new RegExp(pat,'i').test(n)) return label; }catch{} } }
249
- const s = n.toLowerCase();
250
- if(/prod|prd|production|\blive\b/.test(s)) return 'Prod';
251
- if(/preprod|pre-prod|stag|stg|\buat\b|accept/.test(s)) return 'Stage';
252
- if(/\bqa\b|quality/.test(s)) return 'QA';
253
- if(/sandbox|sbx|\bdemo\b/.test(s)) return 'Sandbox';
254
- if(/dev|develop/.test(s)) return 'Dev';
255
- if(/test|tst|\bint\b|integration|nonprod|non-prod/.test(s)) return 'Test';
256
- return '—';
257
- }
258
- // Map any env label to one of a fixed set of colour buckets for the tag styling.
259
- function envClass(env){
260
- const s = (env||'').toLowerCase();
261
- if(/prod|live/.test(s)) return 'e-prod';
262
- if(/stag|uat|preprod/.test(s)) return 'e-stage';
263
- if(/qa/.test(s)) return 'e-qa';
264
- if(/sandbox|demo/.test(s)) return 'e-sandbox';
265
- if(/dev/.test(s)) return 'e-dev';
266
- if(/test|int/.test(s)) return 'e-test';
267
- return 'e-other';
268
- }
269
-
270
224
  async function loadVaults(refresh){
271
- $('#vaultsBody').innerHTML = '<tr><td colspan="7" class="empty"><span class="spin"></span> querying Resource Graph…</td></tr>';
272
225
  const r = await api('/api/vaults'+(refresh?'?refresh=1':''));
273
- if(!r.ok){ $('#vaultsBody').innerHTML = `<tr><td colspan="7" class="empty">${r.error||'failed'}</td></tr>`; return; }
274
- STATE.vaults = r.data.map(v => ({...v, env: envOf(v.subscriptionId), service: serviceOf(v.resourceGroup)}));
275
- renderOverview(); renderEnvChips(); renderVaults();
276
- }
277
- // Best-effort short name from a resource group: drop a leading rg / rg- and trailing index.
278
- function serviceOf(rg){
279
- return rg.replace(/^rg[-_]?/i,'').replace(/[-_]?\d+$/,'') || rg;
226
+ if(!r.ok){ STATE.vaults=[]; return r; }
227
+ STATE.vaults = r.data.map(v => ({...v, env: vaultEnv(v), service: vaultService(v)}));
228
+ return r;
280
229
  }
281
230
 
282
- // ---------- overview ----------
283
- function renderOverview(){
284
- const envs = {};
285
- STATE.vaults.forEach(v => envs[v.env]=(envs[v.env]||0)+1);
286
- const namedEnvs = Object.entries(envs).filter(([e])=>e!=='?'&&e!=='—').sort((a,b)=>b[1]-a[1]);
287
- const cards = [
288
- card('Subscriptions', STATE.subs.length, STATE.subs.filter(s=>s.state==='Enabled').length+' enabled',''),
289
- card('Key Vaults', STATE.vaults.length, 'across '+namedEnvs.length+' environment'+(namedEnvs.length!==1?'s':''),'azure'),
290
- ...namedEnvs.slice(0,3).map(([e,n])=>card(e, n, 'environment','')),
291
- ];
292
- $('#ovCards').innerHTML = cards.join('');
293
- const counts = {}; STATE.vaults.forEach(v=>counts[v.subscriptionId]=(counts[v.subscriptionId]||0)+1);
294
- $('#ovSubs').innerHTML = STATE.subs.map(s=>`<tr>
295
- <td><b>${s.name}</b></td><td><span class="tag ${envClass(SUBMETA[s.id].env)}">${SUBMETA[s.id].env}</span></td>
296
- <td>${s.state}</td><td class="right mono">${counts[s.id]||0}</td><td class="mono muted">${s.id}</td></tr>`).join('');
297
- }
298
- function card(k,v,sub,mod){
299
- return `<div class="card ${mod?'card--'+mod:''}"><div class="k">${k}</div><div class="v">${v} <small>${sub}</small></div></div>`;
231
+ // ---------- router ----------
232
+ function parseHash(){
233
+ return location.hash.replace(/^#\/?/,'').split('/').filter(Boolean).map(decodeURIComponent);
300
234
  }
235
+ function navigate(path){ location.hash = '#/'+path; }
236
+ const META = {
237
+ dashboard:['Operations','Dashboard','Secrets and certificates that need attention, your pinned vaults, and access health.'],
238
+ vaults:['Inventory','Key Vaults','Every readable vault across your subscriptions. Filter by environment and tags; open one to manage it.'],
239
+ expiry:['Lifecycle','Expiring soon','Secrets and certificates that are expired or approaching expiry across every vault you can read.'],
240
+ settings:['Account','Account & subscriptions','Your Azure identity and the subscription the console is pointed at.'],
241
+ };
242
+ let mountedVault = null;
243
+ function setHead(view){
244
+ const onVault = view==='vault';
245
+ $('#headblock').style.display = onVault ? 'none' : '';
246
+ $('#search').classList.toggle('hide', onVault);
247
+ if(!onVault && META[view]){ $('#eyebrow').textContent=META[view][0]; $('#title').textContent=META[view][1]; $('#subtitle').textContent=META[view][2]; }
248
+ document.querySelectorAll('nav button').forEach(b=>b.classList.toggle('active', b.dataset.view===view));
249
+ }
250
+ function router(){
251
+ if(!STATE.account) return;
252
+ const parts = parseHash();
253
+ const route = parts[0]||'dashboard';
254
+ STATE.route = route;
255
+ if(route!=='vault') mountedVault=null;
256
+ if(route==='vaults'){ setHead('vaults'); viewVaults(); }
257
+ else if(route==='vault'){ setHead('vault'); viewVault(parts[1], parts[2]||'secrets'); }
258
+ else if(route==='expiry'){ setHead('expiry'); viewExpiry(); }
259
+ else if(route==='settings'){ setHead('settings'); viewSettings(); }
260
+ else { setHead('dashboard'); viewDashboard(); }
261
+ renderSidebar();
262
+ }
263
+ function refreshCurrent(){ if(STATE.account) router(); }
264
+ window.addEventListener('hashchange', router);
301
265
 
302
- // ---------- subscriptions ----------
303
- function renderSubs(){
304
- const counts = {}; STATE.vaults.forEach(v=>counts[v.subscriptionId]=(counts[v.subscriptionId]||0)+1);
305
- $('#subsBody').innerHTML = STATE.subs.map(s=>`<tr>
306
- <td><b>${s.name}</b></td>
307
- <td><span class="tag ${envClass(SUBMETA[s.id].env)}">${SUBMETA[s.id].env}</span></td>
308
- <td>${s.state}</td>
309
- <td>${s.isDefault?'<span class="pill rbac">active</span>':''}</td>
310
- <td class="right mono">${counts[s.id]||0}</td>
311
- <td class="mono muted">${s.id}</td>
312
- <td class="right">${s.isDefault?'':`<button class="btn sm ghost" onclick="setSub('${s.id}')">Switch</button>`}</td>
313
- </tr>`).join('');
314
- }
315
- async function setSub(id){
316
- toast('Switching subscription…');
317
- const r = await api('/api/subscription/set',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({subscription:id})});
318
- if(r.ok){ STATE.account=r.data; await loadSubs(); toast('Active subscription set'); } else toast(r.error||'failed');
266
+ // ---------- sidebar pinned / recent ----------
267
+ function renderSidebar(){
268
+ const parts = parseHash(); const curVault = parts[0]==='vault'?(parts[1]||'').toLowerCase():null;
269
+ const tile = n => {
270
+ const v = findVault(n); const env = v?envClass(v.env):'e-other';
271
+ return `<button class="navpin ${curVault===n.toLowerCase()?'active':''}" onclick="openVault('${esc(n)}')" title="${esc(n)}">
272
+ <span class="env-dot" style="background:var(--azure)"></span><span class="pn">${esc(n)}</span></button>`;
273
+ };
274
+ const p = pins();
275
+ $('#navPins').innerHTML = p.length ? p.map(tile).join('') : '<div class="navempty">Pin vaults you manage often</div>';
276
+ $('#pinLabel').style.display = '';
277
+ const r = recents().filter(n=>!p.includes(n));
278
+ $('#recentLabel').style.display = r.length? '' : 'none';
279
+ $('#navRecent').innerHTML = r.map(tile).join('');
319
280
  }
320
281
 
321
- // ---------- vaults ----------
322
- function renderEnvChips(){
323
- const envs = {}; STATE.vaults.forEach(v=>envs[v.env]=(envs[v.env]||0)+1);
324
- const order = ['all',...Object.keys(envs).sort()];
325
- $('#envChips').innerHTML = order.map(e=>{
326
- const n = e==='all'?STATE.vaults.length:envs[e];
327
- return `<div class="chip ${STATE.env===e?'active':''}" onclick="setEnv('${e}')">${e==='all'?'All environments':e}<span class="n">${n}</span></div>`;
328
- }).join('');
282
+ // ---------- DASHBOARD ----------
283
+ function viewDashboard(){
284
+ const sevOf = e => e.expired?'alert':(e.soon?'warn':'azure');
285
+ const exp = EXP.scanned ? expCounts() : null;
286
+ const denied = EXP.stats ? EXP.stats.forbidden : null;
287
+ const card = (k,v,sub,sev,onclick,icon) =>
288
+ `<button class="actcard sev-${sev}" onclick="${onclick}"><div class="k">${icon||''}${k}</div>
289
+ <div class="v">${v}</div><div class="sub">${sub}</div></button>`;
290
+ const clock = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/></svg>';
291
+ const lock = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="4" y="10" width="16" height="11" rx="2"/><path d="M8 10V7a4 4 0 0 1 8 0v3"/></svg>';
292
+ const grid = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/><rect x="3" y="14" width="7" height="7" rx="1"/><rect x="14" y="14" width="7" height="7" rx="1"/></svg>';
293
+ const scanning = EXP.polling || (!EXP.scanned);
294
+ const dim = scanning ? '<span class="spin" style="width:13px;height:13px;border-width:2px"></span>' : '';
295
+ const cards = [
296
+ card('Expired', exp? exp.expired : dim, 'needs rotation now', exp&&exp.expired?'alert':'ok', "navigate('expiry')", clock),
297
+ card('≤ 7 days', exp? exp.soon : dim, 'rotate this week', exp&&exp.soon?'warn':'ok', "navigate('expiry')", clock),
298
+ card('≤ 30 days', exp? exp.month : dim, 'plan ahead', exp&&exp.month?'azure':'ok', "navigate('expiry')", clock),
299
+ card('Key Vaults', STATE.vaults.length, `${denied!=null?denied+' you cannot read':'across '+envCount()+' environments'}`, 'azure', "navigate('vaults')", grid),
300
+ ];
301
+ const tileRow = n => {
302
+ const v = findVault(n); if(!v) return '';
303
+ return `<div class="vtile" onclick="openVault('${esc(n)}')">
304
+ <button class="star ${isPinned(n)?'':'off'}" onclick="event.stopPropagation();togglePin('${esc(n)}')" title="pin">★</button>
305
+ <span class="nm">${esc(n)}</span>
306
+ <span class="tag ${envClass(v.env)}">${esc(v.env)}</span>
307
+ <span class="rg mono">${esc(v.resourceGroup)}</span></div>`;
308
+ };
309
+ const p = pins().filter(findVault), rec = recents().filter(n=>!p.includes(n)&&findVault(n));
310
+ $('#route').innerHTML = `
311
+ <div class="actcards">${cards.join('')}</div>
312
+ <div class="dashgrid">
313
+ <div>
314
+ <div class="secthead2"><span class="eyebrow">Pinned vaults</span>
315
+ <button class="iconbtn" onclick="navigate('vaults')">All vaults →</button></div>
316
+ <div class="vtiles">${p.length? p.map(tileRow).join('') : `<div class="empty" style="padding:30px">Open any vault and press ★ to pin it here for one-click access.</div>`}</div>
317
+ </div>
318
+ <div>
319
+ <div class="secthead2"><span class="eyebrow">Recently opened</span>
320
+ ${EXP.scannedAt?`<span class="tablenote">expiry scanned ${new Date(EXP.scannedAt).toLocaleTimeString()}</span>`:''}</div>
321
+ <div class="vtiles">${rec.length? rec.map(tileRow).join('') : `<div class="empty" style="padding:30px">Vaults you open will appear here.</div>`}</div>
322
+ </div>
323
+ </div>`;
324
+ }
325
+ function envCount(){ const s=new Set(STATE.vaults.map(v=>v.env).filter(e=>e&&e!=='—'&&e!=='?')); return s.size; }
326
+ function expCounts(){
327
+ const items = EXP.allItems||[];
328
+ return {
329
+ expired: items.filter(i=>i.daysLeft<0).length,
330
+ soon: items.filter(i=>i.daysLeft>=0&&i.daysLeft<=7).length,
331
+ month: items.filter(i=>i.daysLeft>=0&&i.daysLeft<=30).length,
332
+ };
329
333
  }
330
- function setEnv(e){ STATE.env=e; renderEnvChips(); renderVaults(); }
331
334
 
335
+ // ---------- VAULTS (inventory) ----------
336
+ const FACET_PREF = ['solution','cost-center','contact-main','contact-ccoe','owner','roles'];
337
+ const isEnvKey = k => /^(env|environment)$/i.test(k);
338
+ // Pick up to 4 tag dimensions to expose as dropdown facets: preferred keys first
339
+ // (case-insensitive match to the real tag casing), then the most common remaining keys.
340
+ function facetKeys(){
341
+ const freq = {};
342
+ STATE.vaults.forEach(v=>Object.keys(v.tags||{}).forEach(k=>{ if(!isEnvKey(k)) freq[k]=(freq[k]||0)+1; }));
343
+ const real = Object.keys(freq);
344
+ const matchPref = p => real.find(k=>k.toLowerCase()===p);
345
+ const picked = [];
346
+ for(const p of FACET_PREF){ const k=matchPref(p); if(k && !picked.includes(k)) picked.push(k); }
347
+ for(const [k] of Object.entries(freq).sort((a,b)=>b[1]-a[1])){ if(picked.length>=4) break; if(!picked.includes(k)) picked.push(k); }
348
+ return picked.slice(0,4);
349
+ }
350
+ function tagVal(v,k){ const t=v.tags||{}; return t[k]!=null?String(t[k]):''; }
332
351
  function filteredVaults(){
333
352
  const q = STATE.q.toLowerCase();
334
- let list = STATE.vaults.filter(v =>
335
- (STATE.env==='all'||v.env===STATE.env) &&
336
- (!q || v.name.toLowerCase().includes(q) || v.resourceGroup.toLowerCase().includes(q) || (v.service||'').toLowerCase().includes(q)));
337
- const k = STATE.sort, d = STATE.dir;
338
- list.sort((a,b)=>String(a[k]||'').localeCompare(String(b[k]||''))*d);
353
+ let list = STATE.vaults.filter(v=>{
354
+ if(STATE.env!=='all' && v.env!==STATE.env) return false;
355
+ for(const [k,val] of Object.entries(STATE.facets)){ if(val && tagVal(v,k)!==val) return false; }
356
+ if(q){
357
+ const hay = [v.name, v.resourceGroup, v.service, ...Object.values(v.tags||{})].join(' ').toLowerCase();
358
+ if(!hay.includes(q)) return false;
359
+ }
360
+ return true;
361
+ });
362
+ const k=STATE.sort, d=STATE.dir;
363
+ list.sort((a,b)=>{ if(k==='pin'){ return ((isPinned(b.name)?1:0)-(isPinned(a.name)?1:0))*d || a.name.localeCompare(b.name); }
364
+ return String(a[k]||'').localeCompare(String(b[k]||''))*d; });
339
365
  return list;
340
366
  }
341
- function renderVaults(){
367
+ function viewVaults(){
368
+ if(!STATE.vaults.length){
369
+ $('#route').innerHTML = `<div class="panel"><div class="empty"><span class="spin"></span> querying Resource Graph…</div></div>`;
370
+ loadVaults().then(()=>{ if(STATE.route==='vaults') viewVaults(); });
371
+ return;
372
+ }
373
+ const envs = {}; STATE.vaults.forEach(v=>envs[v.env]=(envs[v.env]||0)+1);
374
+ const order = ['all',...Object.keys(envs).sort()];
375
+ const chips = order.map(e=>{ const n=e==='all'?STATE.vaults.length:envs[e];
376
+ return `<div class="chip ${STATE.env===e?'active':''}" onclick="setEnv('${esc(e)}')">${e==='all'?'All environments':esc(e)}<span class="n">${n}</span></div>`; }).join('');
377
+ const fkeys = facetKeys();
378
+ const facets = fkeys.map(k=>{
379
+ const vals=[...new Set(STATE.vaults.map(v=>tagVal(v,k)).filter(Boolean))].sort();
380
+ if(!vals.length) return '';
381
+ const sel=STATE.facets[k]||'';
382
+ return `<div class="facet"><label>${esc(k)}</label>
383
+ <select class="facetsel ${sel?'on':''}" onchange="setFacet('${esc(k)}',this.value)">
384
+ <option value="">any</option>${vals.map(x=>`<option ${x===sel?'selected':''} value="${esc(x)}">${esc(x)}</option>`).join('')}</select></div>`;
385
+ }).join('');
386
+ const anyFacet = Object.values(STATE.facets).some(Boolean);
387
+ $('#route').innerHTML = `
388
+ <div class="chips">${chips}</div>
389
+ ${facets?`<div class="facets">${facets}${anyFacet?`<button class="facetclear" onclick="clearFacets()">clear filters</button>`:''}</div>`:''}
390
+ <div class="panel"><div class="scroll"><table>
391
+ <thead><tr>
392
+ <th data-sort="pin" style="width:30px"></th>
393
+ <th data-sort="name">Vault</th><th data-sort="env">Env</th>
394
+ <th data-sort="service">Service</th><th>Tags</th><th data-sort="resourceGroup">Resource Group</th>
395
+ <th data-sort="sku">SKU</th><th>Auth</th><th class="right">Actions</th>
396
+ </tr></thead><tbody id="vaultsBody"></tbody>
397
+ </table></div></div>
398
+ <div class="tablenote" id="vaultCount"></div>`;
399
+ document.querySelectorAll('th[data-sort]').forEach(th=>th.onclick=()=>{ const k=th.dataset.sort; STATE.dir=STATE.sort===k?-STATE.dir:1; STATE.sort=k; renderVaultRows(); });
400
+ renderVaultRows();
401
+ }
402
+ function setEnv(e){ STATE.env=e; viewVaults(); }
403
+ function setFacet(k,val){ if(val) STATE.facets[k]=val; else delete STATE.facets[k]; viewVaults(); }
404
+ function clearFacets(){ STATE.facets={}; viewVaults(); }
405
+ function renderVaultRows(){
342
406
  const list = filteredVaults();
343
407
  $('#vaultCount').textContent = `${list.length} vault${list.length!==1?'s':''} shown`;
344
- if(!list.length){ $('#vaultsBody').innerHTML='<tr><td colspan="7" class="empty">No vaults match your filter.</td></tr>'; return; }
345
- $('#vaultsBody').innerHTML = list.map(v=>`<tr>
346
- <td class="mono"><b>${v.name}</b></td>
347
- <td><span class="tag ${envClass(v.env)}">${v.env}</span></td>
348
- <td class="muted">${v.service||''}</td>
349
- <td class="mono muted">${v.resourceGroup}</td>
350
- <td class="mono">${v.sku||'—'}</td>
408
+ if(!list.length){ $('#vaultsBody').innerHTML='<tr><td colspan="9" class="empty">No vaults match your filter.</td></tr>'; return; }
409
+ $('#vaultsBody').innerHTML = list.map(v=>{
410
+ const tags = Object.entries(v.tags||{}).filter(([k])=>!/^env$/i.test(k)).slice(0,2)
411
+ .map(([k,val])=>`<span class="tagchip"><b>${esc(k)}</b> ${esc(val)}</span>`).join('');
412
+ const more = Object.keys(v.tags||{}).filter(k=>!/^env$/i.test(k)).length-2;
413
+ return `<tr>
414
+ <td><button class="star ${isPinned(v.name)?'on':''}" onclick="togglePin('${esc(v.name)}')" title="pin vault">★</button></td>
415
+ <td class="mono"><b>${esc(v.name)}</b></td>
416
+ <td><span class="tag ${envClass(v.env)}">${esc(v.env)}</span></td>
417
+ <td class="muted">${esc(v.service||'')}</td>
418
+ <td><div class="tagchips">${tags}${more>0?`<span class="tagchip">+${more}</span>`:''}</div></td>
419
+ <td class="mono muted">${esc(v.resourceGroup)}</td>
420
+ <td class="mono">${esc(v.sku||'—')}</td>
351
421
  <td>${v.enableRbac==='true'?'<span class="pill rbac">RBAC</span>':'<span class="pill policy">Policy</span>'}</td>
352
422
  <td class="right">
353
- <button class="iconbtn" onclick="openVault('${v.name}','${v.subscriptionId}')">Open</button>
354
- <button class="iconbtn" onclick="copy('az keyvault secret list --vault-name ${v.name} -o table')">⧉</button>
355
- </td></tr>`).join('');
423
+ <button class="iconbtn" onclick="openVault('${esc(v.name)}')">Open</button>
424
+ <button class="iconbtn" onclick="copy('az keyvault secret list --vault-name ${esc(v.name)} -o table')">⧉</button>
425
+ </td></tr>`;}).join('');
356
426
  }
357
427
 
358
- // ---------- drawer ----------
428
+ // ---------- VAULT PAGE (routed) ----------
359
429
  let DRW = { vault:'', kind:'secrets', items:[], filter:'', sel:new Set() };
360
- function openVault(name, subId){
361
- const v = STATE.vaults.find(x=>x.name===name) || {name, subscriptionId:subId, resourceGroup:'', uri:''};
362
- STATE.drawerVault = v; STATE.kind='secrets';
363
- DRW = { vault:name, kind:'secrets', items:[], filter:'', sel:new Set() };
364
- $('#dFilter').value=''; $('#dSelAll').checked=false;
365
- $('#dTitle').textContent = name;
366
- $('#dSub').innerHTML = `${subName(subId)} · ${v.resourceGroup||''}${v.uri?' · '+v.uri:''}`;
367
- $('#dSeg').querySelectorAll('button').forEach(b=>b.classList.toggle('active',b.dataset.kind==='secrets'));
368
- $('#overlay').classList.add('show'); $('#drawer').classList.add('open');
369
- renderCmds(v); loadKind();
370
- }
371
- function closeDrawer(){ $('#overlay').classList.remove('show'); $('#drawer').classList.remove('open'); }
430
+ function openVault(name){ pushRecent(name); navigate('vault/'+encodeURIComponent(name)); }
431
+ const KIND_TABS = [['secrets','Secrets'],['keys','Keys'],['certificates','Certificates'],['tags','Tags']];
432
+ function viewVault(name, kind){
433
+ const v = findVault(name) || (name?{name, subscriptionId:'', resourceGroup:'', uri:'', enableRbac:'', tags:{}}:null);
434
+ if(!v){ $('#route').innerHTML=`<div class="panel"><div class="empty">No vault specified. <a href="#/vaults">Back to Key Vaults</a></div></div>`; return; }
435
+ STATE.drawerVault=v; STATE.kind=kind;
436
+ if(mountedVault!==name){
437
+ mountedVault=name;
438
+ DRW={vault:name, kind, items:[], filter:'', sel:new Set()};
439
+ const tabs = KIND_TABS.map(([k,l])=>`<button data-kind="${k}" class="${k===kind?'active':''}" onclick="goKind('${esc(name)}','${k}')">${l}</button>`).join('');
440
+ $('#route').innerHTML = `
441
+ <div class="vhead">
442
+ <div class="crumb"><a href="#/vaults">Key Vaults</a><span class="sep">/</span>
443
+ <span class="here">${esc(name)}</span>
444
+ <button class="star ${isPinned(name)?'on':''}" onclick="togglePin('${esc(name)}')" title="pin">★</button>
445
+ <span class="tag ${envClass(v.env)}">${esc(v.env||'')}</span>
446
+ ${v.enableRbac==='true'?'<span class="pill rbac">RBAC</span>':v.enableRbac==='false'?'<span class="pill policy">Policy</span>':''}</div>
447
+ <div class="vmeta">
448
+ <span>${esc(subName(v.subscriptionId))}</span><span class="sep">·</span>
449
+ <span class="mono">${esc(v.resourceGroup||'')}</span>
450
+ ${v.uri?`<span class="sep">·</span><span class="mono">${esc(v.uri)}</span><button class="iconbtn" onclick="copy('${esc(v.uri)}')">⧉</button>`:''}</div>
451
+ </div>
452
+ <div class="tabs" id="dSeg">${tabs}</div>
453
+ <div id="vbody"></div>`;
454
+ } else {
455
+ $('#dSeg').querySelectorAll('button').forEach(b=>b.classList.toggle('active', b.dataset.kind===kind));
456
+ }
457
+ DRW.kind=kind;
458
+ if(kind==='tags') renderTagsTab(v); else renderItemsShell();
459
+ }
460
+ function goKind(name, kind){ STATE.kind=kind; navigate('vault/'+encodeURIComponent(name)+'/'+kind); }
461
+
462
+ function renderItemsShell(){
463
+ const isSecrets = STATE.kind==='secrets';
464
+ $('#vbody').innerHTML = `
465
+ <div class="vtools">
466
+ <div class="vtools-row">
467
+ <input class="search" id="dFilter" placeholder="filter by name…" autocomplete="off" value="${esc(DRW.filter)}">
468
+ <span class="faint mono" id="dCount"></span>
469
+ ${isSecrets?`<span class="vtools-actions"><button class="btn sm" id="dNew">+ New secret</button><button class="btn sm ghost" id="dNewBatch">+ Batch</button></span>`:''}
470
+ </div>
471
+ <div class="vtools-row" id="selRow"><label class="selall"><input type="checkbox" id="dSelAll"> select all</label></div>
472
+ </div>
473
+ <div class="createp hide" id="dCreate"></div>
474
+ <div class="batchbar hide stick" id="dBatch"></div>
475
+ <div class="itemwrap" id="dContent"><div class="empty"><span class="spin"></span></div></div>
476
+ <div class="secthead">CLI commands</div>
477
+ <div id="dCmds"></div>`;
478
+ $('#dFilter').oninput=e=>{ DRW.filter=e.target.value; renderDrawerList(); };
479
+ $('#dSelAll').onchange=e=>selectAll(e.target.checked);
480
+ if(isSecrets){ $('#dNew').onclick=()=>openCreate('single'); $('#dNewBatch').onclick=()=>openCreate('batch'); }
481
+ renderCmds(STATE.drawerVault);
482
+ loadKind();
483
+ }
372
484
  function renderCmds(v){
373
485
  const cmds = [
374
486
  `az keyvault secret list --vault-name ${v.name} -o table`,
375
487
  `az keyvault key list --vault-name ${v.name} -o table`,
376
488
  `az keyvault show --name ${v.name} -o jsonc`,
377
489
  ];
378
- $('#dCmds').innerHTML = cmds.map(c=>`<div class="cmd"><span>${c}</span><button class="iconbtn" onclick='copy(${JSON.stringify(c)})'>⧉</button></div>`).join('');
490
+ $('#dCmds').innerHTML = cmds.map(c=>`<div class="cmd"><span>${esc(c)}</span><button class="iconbtn" onclick='copy(${JSON.stringify(c)})'>⧉</button></div>`).join('');
379
491
  }
380
492
  const isEditable = () => DRW.kind==='secrets' || DRW.kind==='certificates';
381
493
  async function loadKind(){
382
494
  const v = STATE.drawerVault; DRW.kind = STATE.kind; DRW.sel.clear();
383
- $('#dTools').style.display = '';
384
495
  closeCreate();
385
- $('#dNewBtns').style.display = STATE.kind==='secrets' ? '' : 'none';
386
496
  $('#dContent').innerHTML = '<div class="empty"><span class="spin"></span></div>';
387
497
  const r = await api(`/api/vaults/${v.name}/${DRW.kind}`);
498
+ if(STATE.route!=='vault') return;
388
499
  if(!r.ok){
389
- DRW.items=[]; $('#dBatch').classList.add('hide'); $('#dCount').textContent='';
500
+ DRW.items=[]; $('#dBatch').classList.add('hide'); if($('#dCount'))$('#dCount').textContent='';
390
501
  let why;
391
- if(r.firewall) why = `🚫 This vault's firewall is blocking your network — your current IP isn't allowed. Connect to the corporate VPN and retry.<div class="hint" style="margin-top:8px">${r.error||''}</div>`;
502
+ if(r.firewall) why = `🚫 This vault's firewall is blocking your network — your current IP isn't allowed. Connect to the corporate VPN and retry.<div class="hint" style="margin-top:8px">${esc(r.error||'')}</div>`;
392
503
  else if(r.forbidden) why = `🔒 You can see this vault but have no data-plane permission on its ${DRW.kind}.`;
393
- else why = r.error||'failed';
504
+ else why = esc(r.error||'failed');
394
505
  $('#dContent').innerHTML = `<div class="empty">${why}</div>`;
395
506
  return;
396
507
  }
@@ -398,37 +509,33 @@ async function loadKind(){
398
509
  name: item.name || (item.id||'').split('/').pop(),
399
510
  enabled: item.attributes ? item.attributes.enabled!==false : true,
400
511
  expires: item.attributes && item.attributes.expires || null,
401
- managed: item.managed===true, // cert-backed secret — controlled by its certificate
512
+ managed: item.managed===true,
402
513
  }));
403
514
  renderDrawerList();
404
515
  }
405
- function drawerFiltered(){
406
- const q = DRW.filter.toLowerCase();
407
- return DRW.items.filter(i=>!q || i.name.toLowerCase().includes(q));
408
- }
516
+ function drawerFiltered(){ const q=DRW.filter.toLowerCase(); return DRW.items.filter(i=>!q||i.name.toLowerCase().includes(q)); }
409
517
  function renderDrawerList(){
410
- const list = drawerFiltered();
411
- const editable = isEditable();
412
- $('#dCount').textContent = `${list.length}${list.length!==DRW.items.length?' / '+DRW.items.length:''} ${DRW.kind}`;
413
- $('#dSelAll').parentElement.style.display = editable?'':'none';
518
+ const list = drawerFiltered(); const editable = isEditable();
519
+ if($('#dCount')) $('#dCount').textContent = `${list.length}${list.length!==DRW.items.length?' / '+DRW.items.length:''} ${DRW.kind}`;
520
+ if($('#selRow')) $('#selRow').style.display = editable?'':'none';
414
521
  if(!DRW.items.length){ $('#dContent').innerHTML=`<div class="empty">No ${DRW.kind} in this vault, or none visible to you.</div>`; renderBatchBar(); return; }
415
- if(!list.length){ $('#dContent').innerHTML=`<div class="empty">No ${DRW.kind} match “${DRW.filter}”.</div>`; renderBatchBar(); return; }
522
+ if(!list.length){ $('#dContent').innerHTML=`<div class="empty">No ${DRW.kind} match “${esc(DRW.filter)}”.</div>`; renderBatchBar(); return; }
416
523
  $('#dContent').innerHTML = list.map(i=>{
417
524
  const nm=i.name, sel=DRW.sel.has(nm), lock=i.managed;
418
- const check = editable && !lock?`<input type="checkbox" ${sel?'checked':''} onclick="toggleSel('${nm}')">`:'';
525
+ const check = editable && !lock?`<input type="checkbox" ${sel?'checked':''} onclick="toggleSel('${esc(nm)}')">`:'';
419
526
  const pills = `${i.enabled?'':'<span class="pill">disabled</span>'}${i.expires?`<span class="pill">exp ${i.expires.slice(0,10)}</span>`:''}${lock?'<span class="pill" title="managed by a certificate — edit it in the Certificates tab">managed</span>':''}`;
420
- const reveal = DRW.kind==='secrets'?`<button class="loadval" onclick="reveal('${nm}')">reveal</button>`:'';
421
- const edit = editable && !lock?`<button class="iconbtn" onclick="toggleEdit('${nm}')">Edit</button>`:'';
527
+ const reveal = DRW.kind==='secrets'?`<button class="loadval" onclick="reveal('${esc(nm)}')">reveal</button>`:'';
528
+ const edit = editable && !lock?`<button class="iconbtn" onclick="toggleEdit('${esc(nm)}')">Edit</button>`:'';
422
529
  return `<div class="kvitem ${sel?'sel':''}">
423
- <div class="kvline">${check}<span class="nm" title="${nm}">${nm}</span><span class="meta">${pills}${reveal}${edit}</span></div>
424
- <div class="revealed hide" id="rv-${nm}"></div>
425
- <div class="editor hide" id="ed-${nm}"></div>
530
+ <div class="kvline">${check}<span class="nm" title="${esc(nm)}">${esc(nm)}</span><span class="meta">${pills}${reveal}${edit}</span></div>
531
+ <div class="revealed hide" id="rv-${esc(nm)}"></div>
532
+ <div class="editor hide" id="ed-${esc(nm)}"></div>
426
533
  </div>`;
427
534
  }).join('');
428
535
  renderBatchBar();
429
536
  }
430
537
  function renderBatchBar(){
431
- const bar=$('#dBatch'), n=DRW.sel.size;
538
+ const bar=$('#dBatch'); if(!bar) return; const n=DRW.sel.size;
432
539
  if(!n || !isEditable() || !$('#dCreate').classList.contains('hide')){ bar.classList.add('hide'); return; }
433
540
  bar.classList.remove('hide');
434
541
  const secretOnly = DRW.kind==='secrets'
@@ -440,11 +547,9 @@ function renderBatchBar(){
440
547
  ${secretOnly}
441
548
  <button class="iconbtn" onclick="clearSel()">Clear</button>`;
442
549
  }
443
- // Batch-edit the VALUES of the selected secrets, pre-filled with their current values.
444
550
  async function batchEditValues(){
445
551
  const names=[...DRW.sel]; if(!names.length) return;
446
- const el=$('#dCreate'); $('#dBatch').classList.add('hide');
447
- el.classList.remove('hide');
552
+ const el=$('#dCreate'); $('#dBatch').classList.add('hide'); el.classList.remove('hide');
448
553
  el.innerHTML = `<h4>Edit ${names.length} secret value${names.length!==1?'s':''}</h4><div class="empty"><span class="spin"></span> loading current values…</div>`;
449
554
  const vals = await Promise.all(names.map(n=>
450
555
  api(`/api/vaults/${DRW.vault}/secrets/${n}/value`).then(r=>({n, ok:r.ok, val:r.ok?(r.data.value||''):'', err:r.forbidden?'no read permission':(r.error||'failed')}))));
@@ -456,14 +561,14 @@ async function batchEditValues(){
456
561
  if(blocked.length) notes.push(`${blocked.length} value(s) couldn't be read and were skipped`);
457
562
  if(multiline.length) notes.push(`${multiline.length} multi-line value(s) skipped — edit those individually`);
458
563
  el.innerHTML = `<h4>Edit ${usable.length} secret value${usable.length!==1?'s':''}</h4>
459
- <div class="fld" style="align-items:flex-start"><label>Pairs</label><textarea id="cBatch">${lines.replace(/</g,'&lt;')}</textarea></div>
564
+ <div class="fld" style="align-items:flex-start"><label>Pairs</label><textarea id="cBatch">${esc(lines)}</textarea></div>
460
565
  <div class="fld"><label>Expires</label><input type="date" id="cExp"><span class="hint">optional · applied to every saved secret</span></div>
461
566
  <div class="actions"><button class="btn sm" onclick="submitCreate('batch')">Save changes</button>
462
567
  <button class="btn sm ghost" onclick="closeCreate()">Cancel</button>
463
- <span class="hint" id="cMsg">${notes.join(' · ')}</span></div>`;
568
+ <span class="hint" id="cMsg">${esc(notes.join(' · '))}</span></div>`;
464
569
  }
465
570
  function toggleSel(nm){ DRW.sel.has(nm)?DRW.sel.delete(nm):DRW.sel.add(nm); renderDrawerList(); }
466
- function clearSel(){ DRW.sel.clear(); $('#dSelAll').checked=false; renderDrawerList(); }
571
+ function clearSel(){ DRW.sel.clear(); if($('#dSelAll'))$('#dSelAll').checked=false; renderDrawerList(); }
467
572
  function selectAll(on){ DRW.sel.clear(); if(on) drawerFiltered().filter(i=>!i.managed).forEach(i=>DRW.sel.add(i.name)); renderDrawerList(); }
468
573
  function openCreate(mode){
469
574
  const el=$('#dCreate'); DRW.sel.clear(); $('#dBatch').classList.add('hide');
@@ -513,7 +618,7 @@ async function submitCreate(mode){
513
618
  const ok=r.results.filter(x=>x.ok).length, fail=r.results.length-ok;
514
619
  toast(`Created ${ok} secret${ok!==1?'s':''}${fail?`, ${fail} failed`:''}`);
515
620
  if(fail){ const f=r.results.filter(x=>!x.ok);
516
- banner(`Create: ${ok} ok, ${fail} failed (${f.slice(0,3).map(x=>x.name+': '+x.error).join('; ')}${fail>3?'…':''}).`);
621
+ banner(`Create: ${ok} ok, ${fail} failed (${f.slice(0,3).map(x=>esc(x.name+': '+x.error)).join('; ')}${fail>3?'…':''}).`);
517
622
  setTimeout(()=>banner(''),9000); }
518
623
  closeCreate(); setTimeout(loadKind, 350);
519
624
  }
@@ -524,18 +629,17 @@ function toggleEdit(nm){
524
629
  const cert = DRW.kind==='certificates';
525
630
  const exp = it.expires?it.expires.slice(0,10):'';
526
631
  el.innerHTML = `
527
- <div class="fld"><label>Enabled</label><label class="toggle"><input type="checkbox" id="edEn-${nm}" ${it.enabled?'checked':''}> ${it.enabled?'enabled':'disabled'}</label></div>
632
+ <div class="fld"><label>Enabled</label><label class="toggle"><input type="checkbox" id="edEn-${esc(nm)}" ${it.enabled?'checked':''}> ${it.enabled?'enabled':'disabled'}</label></div>
528
633
  ${cert
529
634
  ? `<div class="fld"><label>Expires</label><span class="hint">${exp||'—'} · set by the certificate, not editable here</span></div>`
530
- : `<div class="fld"><label>Expires</label><input type="date" id="edExp-${nm}" value="${exp}"><button class="iconbtn" onclick="document.getElementById('edExp-${CSS.escape(nm)}').value=''">clear</button></div>
531
- <div class="fld"><label>Value</label><input type="text" id="edVal-${nm}" placeholder="loading current value…"></div>
635
+ : `<div class="fld"><label>Expires</label><input type="date" id="edExp-${esc(nm)}" value="${exp}"><button class="iconbtn" onclick="document.getElementById('edExp-${CSS.escape(nm)}').value=''">clear</button></div>
636
+ <div class="fld"><label>Value</label><input type="text" id="edVal-${esc(nm)}" placeholder="loading current value…"></div>
532
637
  <div class="fld"><label></label><span class="hint">current value is pre-filled — saving re-writes only if you change it</span></div>`}
533
- <div class="fld"><label></label><button class="btn sm" onclick="saveEdit('${nm}')">Save changes</button>
534
- <button class="btn sm ghost" onclick="toggleEdit('${nm}')">Cancel</button>
535
- <span class="hint" id="edMsg-${nm}"></span></div>`;
638
+ <div class="fld"><label></label><button class="btn sm" onclick="saveEdit('${esc(nm)}')">Save changes</button>
639
+ <button class="btn sm ghost" onclick="toggleEdit('${esc(nm)}')">Cancel</button>
640
+ <span class="hint" id="edMsg-${esc(nm)}"></span></div>`;
536
641
  el.classList.remove('hide');
537
642
  if(!cert){
538
- const vEl=document.getElementById('edVal-'+nm);
539
643
  api(`/api/vaults/${DRW.vault}/secrets/${nm}/value`).then(r=>{
540
644
  const live=document.getElementById('edVal-'+nm); if(!live) return;
541
645
  if(r.ok){ live.value=r.data.value||''; live.dataset.orig=r.data.value||''; live.placeholder='secret value'; }
@@ -584,123 +688,208 @@ async function batchApply(action){
584
688
  const ok=r.results.filter(x=>x.ok).length, fail=r.results.length-ok;
585
689
  toast(`${ok} updated${fail?`, ${fail} failed`:''}`);
586
690
  if(fail){ const failed=r.results.filter(x=>!x.ok); console.warn('Batch failures:', failed);
587
- banner(`Batch: ${ok} updated, ${fail} failed (${failed.slice(0,3).map(f=>f.name+': '+f.error).join('; ')}${fail>3?'…':''}). See console for all.`);
691
+ banner(`Batch: ${ok} updated, ${fail} failed (${failed.slice(0,3).map(f=>esc(f.name+': '+f.error)).join('; ')}${fail>3?'…':''}). See console for all.`);
588
692
  setTimeout(()=>banner(''),8000); }
589
- DRW.sel.clear(); $('#dSelAll').checked=false;
693
+ DRW.sel.clear(); if($('#dSelAll'))$('#dSelAll').checked=false;
590
694
  setTimeout(loadKind, 350);
591
695
  }
592
- // Reveal a secret value on its own full-width line below the row (toggles).
593
696
  async function reveal(name){
594
- const box = document.getElementById('rv-'+name);
595
- if(!box) return;
697
+ const box = document.getElementById('rv-'+name); if(!box) return;
596
698
  if(!box.classList.contains('hide')){ box.classList.add('hide'); box.innerHTML=''; return; }
597
699
  box.classList.remove('hide'); box.innerHTML = '<span class="spin"></span>';
598
700
  const r = await api(`/api/vaults/${DRW.vault}/secrets/${name}/value`);
599
- if(!r.ok){ box.innerHTML = `<span class="muted mono">${r.firewall?'🚫 blocked by vault firewall — connect to VPN':r.forbidden?'🔒 no permission':r.error}</span>`; return; }
701
+ if(!r.ok){ box.innerHTML = `<span class="muted mono">${r.firewall?'🚫 blocked by vault firewall — connect to VPN':r.forbidden?'🔒 no permission':esc(r.error)}</span>`; return; }
600
702
  const val = r.data.value || '';
601
- box.innerHTML = `<code class="val" title="click to select">${val.replace(/</g,'&lt;')}</code><button class="iconbtn" onclick='copy(${JSON.stringify(val)})'>⧉</button>`;
703
+ box.innerHTML = `<code class="val" title="click to select">${esc(val)}</code><button class="iconbtn" onclick='copy(${JSON.stringify(val)})'>⧉</button>`;
704
+ }
705
+
706
+ // ---------- VAULT TAGS tab ----------
707
+ function renderTagsTab(v){
708
+ const all = Object.entries(v.tags||{});
709
+ const locked = all.filter(([k])=>isProtectedTag(k));
710
+ const editable = all.filter(([k])=>!isProtectedTag(k));
711
+ const lockedRow = (k,val) => `<div class="tagrow fixed" title="default tag — managed by policy, read-only here">
712
+ <input class="tk" value="${esc(k)}" disabled>
713
+ <input class="tv" value="${esc(val)}" disabled>
714
+ <span class="del" style="display:inline-flex;width:30px;justify-content:center;color:var(--faint)" title="protected">🔒</span></div>`;
715
+ const editRow = (k,val) => `<div class="tagrow">
716
+ <input class="tk" placeholder="tag name" value="${esc(k)}">
717
+ <input class="tv" placeholder="value" value="${esc(val)}">
718
+ <button class="iconbtn del" onclick="this.closest('.tagrow').remove()">✕</button></div>`;
719
+ $('#vbody').innerHTML = `
720
+ <div class="tagedit" id="tagEdit">
721
+ <div class="sub" style="margin-bottom:4px">Vault tags drive the Env label, Service column and tag filters.
722
+ Saving replaces the editable tag set (Azure allows up to 50).
723
+ ${locked.length?`<b>${locked.length} default tag${locked.length!==1?'s are':' is'}</b> 🔒 locked and cannot be changed here.`:''}</div>
724
+ ${locked.length?`<div class="secthead" style="margin:14px 0 2px">Default tags · read-only</div>
725
+ <div>${locked.map(([k,val])=>lockedRow(k,val)).join('')}</div>`:''}
726
+ <div class="secthead" style="margin:16px 0 2px">Custom tags</div>
727
+ <div id="tagRows">${editable.length? editable.map(([k,val])=>editRow(k,val)).join('') : editRow('','')}</div>
728
+ <div class="row" style="margin-top:8px">
729
+ <button class="btn sm ghost" onclick="addTagRow()">+ Add tag</button>
730
+ <button class="btn sm" id="tagSave" onclick="saveTags('${esc(v.name)}')">Save tags</button>
731
+ <span class="hint" id="tagMsg"></span>
732
+ </div>
733
+ </div>`;
734
+ }
735
+ function addTagRow(){
736
+ const wrap=$('#tagRows'); const div=document.createElement('div'); div.className='tagrow';
737
+ div.innerHTML=`<input class="tk" placeholder="tag name"><input class="tv" placeholder="value">
738
+ <button class="iconbtn del" onclick="this.closest('.tagrow').remove()">✕</button>`;
739
+ wrap.appendChild(div); div.querySelector('.tk').focus();
740
+ }
741
+ async function saveTags(name){
742
+ const msg=$('#tagMsg'); const tags={};
743
+ let bad=false, protectedName=null, dupe=null;
744
+ $('#tagRows').querySelectorAll('.tagrow').forEach(r=>{
745
+ const k=r.querySelector('.tk').value.trim(), val=r.querySelector('.tv').value;
746
+ if(!k) return;
747
+ if(k.length>512 || val.length>256) bad=true;
748
+ if(isProtectedTag(k)) protectedName=k;
749
+ if(k in tags) dupe=k;
750
+ tags[k]=val;
751
+ });
752
+ if(bad){ msg.textContent='tag name ≤512 / value ≤256 chars'; return; }
753
+ if(dupe){ msg.textContent=`duplicate tag name "${dupe}"`; return; }
754
+ if(protectedName){ msg.textContent=`"${protectedName}" is a default/protected tag name — choose another`; return; }
755
+ if(Object.keys(tags).length>50){ msg.textContent='too many tags (max 50)'; return; }
756
+ msg.textContent='saving…';
757
+ const r=await api(`/api/vaults/${name}/tags`,{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify({tags})});
758
+ if(!r.ok){ msg.textContent = r.forbidden?'🔒 no permission to edit tags (needs Contributor / Tag Contributor)':(r.error||'failed'); return; }
759
+ const v=findVault(name); if(v){ v.tags=r.data.tags||tags; v.env=vaultEnv(v); v.service=vaultService(v); STATE.drawerVault=v; }
760
+ msg.textContent='saved ✓'; toast('Tags updated');
602
761
  }
603
762
 
604
- // ---------- expiring ----------
605
- let EXP = { days: 30, items: [], stats: null, scannedAt: 0, sort: 'daysLeft', dir: 1, scanned: false, polling: false };
763
+ // ---------- EXPIRY ----------
764
+ let EXP = { days: 30, items: [], allItems: [], stats: null, scannedAt: 0, sort: 'daysLeft', dir: 1, scanned: false, polling: false };
606
765
  const ROW_CAP = 500;
607
- function applyStatus(s){
608
- EXP.polling=false; EXP.scanned=true;
609
- $('#expScan').disabled=false; $('#expScan').textContent='⟳ Re-scan';
610
- EXP.items=(s.items||[]).map(it=>({...it, env: envOf(it.subscriptionId)}));
766
+ function summarize(items, days){
767
+ const now=Date.now(), horizon=now+days*86400000;
768
+ return items.filter(i=>Date.parse(i.expires)<=horizon).sort((a,b)=>a.daysLeft-b.daysLeft);
769
+ }
770
+ function applyStatusData(s){
771
+ EXP.allItems = (s.allItems||s.items||[]).map(it=>({...it, env:(findVault(it.vault)?.env)||envOf(it.subscriptionId)}));
611
772
  EXP.stats=s.stats; EXP.scannedAt=s.scannedAt;
612
- const when = s.scannedAt? new Date(s.scannedAt).toLocaleTimeString():'';
613
- $('#expMeta').textContent = s.stats? `${s.stats.readable} readable · ${s.stats.forbidden} denied · ${s.stats.total} vaults · scanned ${when}` : '';
614
- renderExpiring();
615
773
  }
616
- // On first visit, reuse the last completed scan if one exists; only scan when there's nothing yet.
617
- async function enterExpiring(){
774
+ // Kick a background scan early so the dashboard's lifecycle cards fill in.
775
+ async function kickExpiryScan(){
618
776
  if(EXP.scanned || EXP.polling) return;
619
- const s = await api(`/api/expiring/status?days=${EXP.days}`);
620
- if(s.ok && s.running){ EXP.polling=true; pollExpiring(); return; }
621
- if(s.ok && s.scannedAt){ applyStatus(s); return; }
622
- scanExpiring();
777
+ const s = await api(`/api/expiring/status?days=365`);
778
+ if(s.ok && s.running){ EXP.polling=true; pollExpiring(true); return; }
779
+ if(s.ok && s.scannedAt){ EXP.scanned=true; applyStatusData({...s, allItems:s.items}); refreshCurrent(); return; }
780
+ const start = await api('/api/expiring/scan',{method:'POST'});
781
+ if(start.ok){ EXP.polling=true; pollExpiring(true); }
782
+ }
783
+ function viewExpiry(){
784
+ $('#route').innerHTML = `
785
+ <div class="row" style="margin-bottom:18px;justify-content:space-between">
786
+ <div class="row">
787
+ <span class="eyebrow">Window</span>
788
+ <div class="seg" id="expWindow">
789
+ <button data-d="7">7d</button><button data-d="30" class="active">30d</button>
790
+ <button data-d="60">60d</button><button data-d="90">90d</button><button data-d="365">1y</button></div>
791
+ <button class="btn" id="expScan">⟳ Scan vaults</button>
792
+ </div>
793
+ <span class="tablenote" id="expMeta"></span>
794
+ </div>
795
+ <div class="panel horizon hide" id="expHorizon"></div>
796
+ <div class="cards" id="expCards"></div>
797
+ <div class="panel"><div class="scroll"><table>
798
+ <thead><tr>
799
+ <th data-esort="daysLeft">Expires in</th><th data-esort="expires">Expiry date</th>
800
+ <th data-esort="type">Type</th><th data-esort="name">Item</th>
801
+ <th data-esort="vault">Vault</th><th data-esort="env">Env</th>
802
+ <th>Status</th><th class="right">Actions</th>
803
+ </tr></thead>
804
+ <tbody id="expBody"><tr><td colspan="8" class="empty">Press <b>Scan vaults</b> to check expiry across every vault you can read.</td></tr></tbody>
805
+ </table></div></div>
806
+ <div class="tablenote" id="expTableNote"></div>`;
807
+ $('#expWindow').querySelectorAll('button').forEach(b=>{ b.classList.toggle('active',+b.dataset.d===EXP.days);
808
+ b.onclick=()=>{ $('#expWindow').querySelectorAll('button').forEach(x=>x.classList.toggle('active',x===b)); EXP.days=+b.dataset.d; renderExpiring(); }; });
809
+ $('#expScan').onclick=()=>scanExpiring();
810
+ document.querySelectorAll('th[data-esort]').forEach(th=>th.onclick=()=>{ const k=th.dataset.esort; EXP.dir=EXP.sort===k?-EXP.dir:1; EXP.sort=k; renderExpiring(); });
811
+ if(EXP.polling){ $('#expScan').disabled=true; $('#expScan').textContent=`Scanning ${EXP._done||0}/${EXP._total||'…'}`; renderScanProgress(); }
812
+ else if(!EXP.scanned){ scanExpiring(); }
813
+ else { $('#expScan').textContent='⟳ Re-scan'; renderExpiring(); }
623
814
  }
624
815
  async function scanExpiring(){
625
816
  if(EXP.polling) return;
626
- $('#expScan').disabled = true;
627
817
  const start = await api('/api/expiring/scan', {method:'POST'});
628
- if(!start.ok){ $('#expBody').innerHTML = `<tr><td colspan="8" class="empty">${start.needLogin?'Not signed in — run az login, then Refresh.':(start.error||'failed')}</td></tr>`; $('#expScan').disabled=false; return; }
629
- EXP.polling = true;
818
+ if(!start.ok){ if($('#expBody'))$('#expBody').innerHTML = `<tr><td colspan="8" class="empty">${start.needLogin?'Not signed in — run az login, then Refresh.':esc(start.error||'failed')}</td></tr>`; return; }
819
+ EXP.polling = true; if($('#expScan'))$('#expScan').disabled=true;
630
820
  pollExpiring();
631
821
  }
632
- async function pollExpiring(){
633
- const s = await api(`/api/expiring/status?days=${EXP.days}`);
634
- if(!s.ok){ EXP.polling=false; $('#expScan').disabled=false; return; }
822
+ function renderScanProgress(){
823
+ if(STATE.route!=='expiry' || !$('#expBody')) return;
824
+ const pct = EXP._total? Math.round(EXP._done/EXP._total*100):0;
825
+ $('#expHorizon').classList.add('hide');
826
+ $('#expBody').innerHTML = `<tr><td colspan="8" class="empty">
827
+ <div class="spin"></div>
828
+ <div style="margin-top:16px">Inspecting <b>${EXP._done||0}</b> of <b>${EXP._total||'…'}</b> vaults…</div>
829
+ <div class="progress"><div class="bar" style="width:${pct}%"></div></div>
830
+ <div class="faint mono" style="margin-top:10px;font-size:11px">Read-denied vaults skip instantly · only your readable vaults are inspected</div>
831
+ </td></tr>`;
832
+ }
833
+ async function pollExpiring(silent){
834
+ const s = await api(`/api/expiring/status?days=365`);
835
+ if(!s.ok){ EXP.polling=false; if($('#expScan')){$('#expScan').disabled=false;} return; }
836
+ EXP._done=s.done; EXP._total=s.total;
635
837
  if(s.running){
636
- const pct = s.total? Math.round(s.done/s.total*100):0;
637
- $('#expScan').textContent = `Scanning ${s.done}/${s.total}`;
638
- $('#expHorizon').classList.add('hide');
639
- $('#expBody').innerHTML = `<tr><td colspan="8" class="empty">
640
- <div class="spin"></div>
641
- <div style="margin-top:16px">Inspecting <b>${s.done}</b> of <b>${s.total}</b> vaults…</div>
642
- <div class="progress"><div class="bar" style="width:${pct}%"></div></div>
643
- <div class="faint mono" style="margin-top:10px;font-size:11px">Read-denied vaults skip instantly · only your readable vaults are inspected</div>
644
- </td></tr>`;
645
- setTimeout(pollExpiring, 1500);
838
+ if($('#expScan')) $('#expScan').textContent = `Scanning ${s.done}/${s.total}`;
839
+ renderScanProgress();
840
+ setTimeout(()=>pollExpiring(silent), 1500);
646
841
  return;
647
842
  }
648
- EXP.polling = false; EXP.scanned = true;
649
- $('#expScan').disabled = false; $('#expScan').textContent = '⟳ Re-scan';
650
- EXP.items = (s.items||[]).map(it=>({...it, env: envOf(it.subscriptionId)}));
651
- EXP.stats = s.stats; EXP.scannedAt = s.scannedAt;
652
- const when = s.scannedAt? new Date(s.scannedAt).toLocaleTimeString():'';
653
- $('#expMeta').textContent = s.stats? `${s.stats.readable} readable · ${s.stats.forbidden} denied · ${s.stats.total} vaults · scanned ${when}` : '';
654
- renderExpiring();
655
- }
656
- function expSorted(){
843
+ EXP.polling=false; EXP.scanned=true;
844
+ applyStatusData({...s, allItems:s.items});
845
+ if($('#expScan')){ $('#expScan').disabled=false; $('#expScan').textContent='⟳ Re-scan'; }
846
+ refreshCurrent();
847
+ }
848
+ function expSorted(items){
657
849
  const k=EXP.sort, d=EXP.dir;
658
- return [...EXP.items].sort((a,b)=>{const av=k==='daysLeft'?a[k]:String(a[k]||''),bv=k==='daysLeft'?b[k]:String(b[k]||'');return (av>bv?1:av<bv?-1:0)*d;});
850
+ return [...items].sort((a,b)=>{const av=k==='daysLeft'?a[k]:String(a[k]||''),bv=k==='daysLeft'?b[k]:String(b[k]||'');return (av>bv?1:av<bv?-1:0)*d;});
659
851
  }
660
852
  function renderExpiring(){
661
- const items = EXP.items;
853
+ if(STATE.route!=='expiry' || !$('#expCards')) return;
854
+ const items = summarize(EXP.allItems, EXP.days);
662
855
  const expired = items.filter(i=>i.daysLeft<0).length;
663
856
  const soon = items.filter(i=>i.daysLeft>=0&&i.daysLeft<=7).length;
664
857
  const certs = items.filter(i=>i.type==='certificate').length;
858
+ const when = EXP.scannedAt? new Date(EXP.scannedAt).toLocaleTimeString():'';
859
+ $('#expMeta').textContent = EXP.stats? `${EXP.stats.readable} readable · ${EXP.stats.forbidden} denied · ${EXP.stats.total} vaults · scanned ${when}` : '';
665
860
  $('#expCards').innerHTML = [
666
- card('Flagged', items.length, `within ${EXP.days} days`, items.length?'':''),
861
+ card('Flagged', items.length, `within ${EXP.days} days`, ''),
667
862
  card('Expired', expired, 'needs rotation', expired?'alert':''),
668
863
  card('≤ 7 days', soon, 'urgent', soon?'warn':''),
669
864
  card('Certificates', certs, (items.length-certs)+' secrets', 'azure'),
670
865
  ].join('');
671
- renderHorizon();
866
+ renderHorizon(items);
672
867
  if(!items.length){
673
- $('#expHorizon').classList.add('hide');
674
- $('#expTableNote').textContent='';
868
+ $('#expHorizon').classList.add('hide'); $('#expTableNote').textContent='';
675
869
  $('#expBody').innerHTML = `<tr><td colspan="8" class="empty">✓ Nothing expiring within ${EXP.days} days across the ${EXP.stats?EXP.stats.readable:0} vault(s) you can read.</td></tr>`;
676
870
  return;
677
871
  }
678
- const list = expSorted();
679
- const shown = list.slice(0, ROW_CAP);
872
+ const list = expSorted(items); const shown = list.slice(0, ROW_CAP);
680
873
  $('#expTableNote').textContent = list.length>ROW_CAP ? `Showing first ${ROW_CAP} of ${list.length} — narrow the window or sort to see the rest.` : '';
681
874
  $('#expBody').innerHTML = shown.map(i=>{
682
875
  const sev = i.daysLeft<0?'bad':i.daysLeft<=7?'bad':i.daysLeft<=30?'warn':'good';
683
876
  const label = i.daysLeft<0?`expired ${-i.daysLeft}d ago`:i.daysLeft===0?'today':`${i.daysLeft} days`;
684
- const status = i.daysLeft<0?'<span class="sev expired">EXPIRED</span>':
685
- i.daysLeft<=7?'<span class="sev urgent">URGENT</span>':
686
- '<span class="sev ok">OK</span>';
877
+ const status = i.daysLeft<0?'<span class="sev expired">EXPIRED</span>':i.daysLeft<=7?'<span class="sev urgent">URGENT</span>':'<span class="sev ok">OK</span>';
687
878
  return `<tr>
688
879
  <td><span class="dot" style="background:var(--${sev})"></span> <b>${label}</b></td>
689
880
  <td class="mono muted">${(i.expires||'').slice(0,10)}</td>
690
881
  <td class="mono">${i.type==='certificate'?'cert':'secret'}</td>
691
- <td class="mono">${i.name}${i.enabled?'':' <span class="pill">disabled</span>'}</td>
692
- <td class="mono muted">${i.vault}</td>
693
- <td><span class="tag ${envClass(i.env)}">${i.env}</span></td>
882
+ <td class="mono">${esc(i.name)}${i.enabled?'':' <span class="pill">disabled</span>'}</td>
883
+ <td class="mono muted">${esc(i.vault)}</td>
884
+ <td><span class="tag ${envClass(i.env)}">${esc(i.env)}</span></td>
694
885
  <td>${status}</td>
695
- <td class="right"><button class="iconbtn" onclick="openVault('${i.vault}','${i.subscriptionId}')">Open</button>
696
- <button class="iconbtn" onclick="copy('az keyvault ${i.type==='certificate'?'certificate':'secret'} show --vault-name ${i.vault} --name ${i.name} -o jsonc')">⧉</button></td>
886
+ <td class="right"><button class="iconbtn" onclick="openVault('${esc(i.vault)}')">Open</button>
887
+ <button class="iconbtn" onclick="copy('az keyvault ${i.type==='certificate'?'certificate':'secret'} show --vault-name ${esc(i.vault)} --name ${esc(i.name)} -o jsonc')">⧉</button></td>
697
888
  </tr>`;
698
889
  }).join('');
699
890
  }
700
- // Signature: expiry horizon — a time axis where every item is a temperature-colored tick.
701
- function renderHorizon(){
702
- const el = $('#expHorizon');
703
- const items = EXP.items;
891
+ function renderHorizon(items){
892
+ const el = $('#expHorizon'); if(!el) return;
704
893
  if(!items.length){ el.classList.add('hide'); el.innerHTML=''; return; }
705
894
  el.classList.remove('hide');
706
895
  const days = EXP.days;
@@ -712,11 +901,11 @@ function renderHorizon(){
712
901
  const ticks = future.map(i=>{
713
902
  const left = Math.min(99.6, Math.max(.4, i.daysLeft/days*100));
714
903
  const sev = i.daysLeft<=7?'alert':i.daysLeft<=30?'warn':'ok';
715
- return `<div class="hz-tick ${sev}" style="left:${left}%" title="${i.name} · ${i.vault} · in ${i.daysLeft}d"></div>`;
904
+ return `<div class="hz-tick ${sev}" style="left:${left}%" title="${esc(i.name)} · ${esc(i.vault)} · in ${i.daysLeft}d"></div>`;
716
905
  }).join('');
717
906
  const expTicks = expired.slice(0,140).map((i,idx,arr)=>{
718
907
  const left = arr.length>1 ? 8 + (idx/(arr.length-1))*84 : 50;
719
- return `<div class="hz-tick alert" style="left:${left}%" title="${i.name} · ${i.vault} · expired ${-i.daysLeft}d ago"></div>`;
908
+ return `<div class="hz-tick alert" style="left:${left}%" title="${esc(i.name)} · ${esc(i.vault)} · expired ${-i.daysLeft}d ago"></div>`;
720
909
  }).join('');
721
910
  el.innerHTML = `
722
911
  <div class="hz-head">
@@ -724,54 +913,110 @@ function renderHorizon(){
724
913
  <div class="hz-legend">
725
914
  <span><i style="background:var(--alert)"></i>≤7d / expired</span>
726
915
  <span><i style="background:var(--warn)"></i>≤30d</span>
727
- <span><i style="background:var(--ok)"></i>beyond</span>
728
- </div>
916
+ <span><i style="background:var(--ok)"></i>beyond</span></div>
729
917
  </div>
730
918
  <div class="hz-body">
731
919
  <div class="hz-expired" title="${expired.length} already expired">
732
- <div class="hz-expired-n">${expired.length}</div>
733
- <div class="hz-expired-l">EXPIRED</div>
734
- ${expTicks}
735
- </div>
920
+ <div class="hz-expired-n">${expired.length}</div><div class="hz-expired-l">EXPIRED</div>${expTicks}</div>
736
921
  <div class="hz-axis">${grid}${ticks}</div>
737
922
  </div>`;
738
923
  }
739
924
 
740
- // ---------- view switching ----------
741
- const META = {
742
- overview:['Overview','Overview','Accounts, subscriptions and vault inventory — read live from your az session.'],
743
- subscriptions:['Subscriptions','Subscriptions','Switch the subscription the az CLI is currently pointed at.'],
744
- vaults:['Key Vaults','Key Vaults','Browse every readable vault. Open one to inspect its secrets, keys and certificates.'],
745
- expiring:['Expiry','Expiring soon','Secrets and certificates that are expired or approaching expiry, across every vault you can read.'],
746
- };
747
- function show(view){
748
- document.querySelectorAll('nav button').forEach(b=>b.classList.toggle('active',b.dataset.view===view));
749
- ['overview','subscriptions','vaults','expiring'].forEach(v=>$('#view-'+v).classList.toggle('hide',v!==view));
750
- $('#search').classList.toggle('hide', view!=='vaults');
751
- $('#eyebrow').textContent = META[view][0];
752
- $('#title').textContent = META[view][1];
753
- $('#subtitle').textContent = META[view][2];
754
- if(view==='expiring' && !EXP.scanned) enterExpiring();
925
+ // ---------- SETTINGS (account + subscriptions) ----------
926
+ function viewSettings(){
927
+ const counts={}; STATE.vaults.forEach(v=>counts[v.subscriptionId]=(counts[v.subscriptionId]||0)+1);
928
+ $('#route').innerHTML = `
929
+ <div class="cards" style="margin-bottom:24px">
930
+ ${card('Signed in as', '', STATE.account?STATE.account.user.name:'—','')}
931
+ ${card('Tenant', (STATE.account&&STATE.account.tenantId||'').slice(0,8)+'…','active directory','')}
932
+ ${card('Subscriptions', STATE.subs.length, STATE.subs.filter(s=>s.state==='Enabled').length+' enabled','azure')}
933
+ </div>
934
+ <div class="panel"><div class="scroll"><table>
935
+ <thead><tr><th>Name</th><th>Env</th><th>State</th><th>Active</th><th class="right">Vaults</th><th>Subscription ID</th><th></th></tr></thead>
936
+ <tbody>${STATE.subs.map(s=>`<tr>
937
+ <td><b>${esc(s.name)}</b></td>
938
+ <td><span class="tag ${envClass(SUBMETA[s.id].env)}">${esc(SUBMETA[s.id].env)}</span></td>
939
+ <td>${esc(s.state)}</td>
940
+ <td>${s.isDefault?'<span class="pill rbac">active</span>':''}</td>
941
+ <td class="right mono">${counts[s.id]||0}</td>
942
+ <td class="mono muted">${esc(s.id)}</td>
943
+ <td class="right">${s.isDefault?'':`<button class="btn sm ghost" onclick="setSub('${esc(s.id)}')">Switch</button>`}</td>
944
+ </tr>`).join('')}</tbody>
945
+ </table></div></div>`;
946
+ }
947
+ async function setSub(id){
948
+ toast('Switching subscription…');
949
+ const r = await api('/api/subscription/set',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({subscription:id})});
950
+ if(r.ok){ STATE.account=r.data; $('#acctSub').textContent=r.data.name; await loadSubs(); refreshCurrent(); toast('Active subscription set'); } else toast(r.error||'failed');
755
951
  }
756
952
 
953
+ function card(k,v,sub,mod){
954
+ return `<div class="card ${mod?'card--'+mod:''}"><div class="k">${esc(k)}</div><div class="v">${v} <small>${esc(sub)}</small></div></div>`;
955
+ }
956
+
957
+ // ---------- command palette ----------
958
+ let CMDK = { open:false, items:[], cur:0 };
959
+ function cmdkActions(){
960
+ return [
961
+ {type:'view', label:'Dashboard', hint:'go', act:()=>navigate('dashboard')},
962
+ {type:'view', label:'Key Vaults', hint:'go', act:()=>navigate('vaults')},
963
+ {type:'view', label:'Expiry', hint:'go', act:()=>navigate('expiry')},
964
+ {type:'view', label:'Account & subscriptions', hint:'go', act:()=>navigate('settings')},
965
+ {type:'action', label:'Refresh inventory', hint:'run', act:()=>{ doRefresh(); }},
966
+ ];
967
+ }
968
+ function openCmdk(){ CMDK.open=true; $('#cmdkOv').classList.add('show'); const i=$('#cmdkIn'); i.value=''; renderCmdk(''); i.focus(); }
969
+ function closeCmdk(){ CMDK.open=false; $('#cmdkOv').classList.remove('show'); }
970
+ function renderCmdk(q){
971
+ q=q.toLowerCase().trim();
972
+ const acts = cmdkActions().filter(a=>!q||a.label.toLowerCase().includes(q));
973
+ const vaults = STATE.vaults
974
+ .filter(v=>!q || v.name.toLowerCase().includes(q) || (v.service||'').toLowerCase().includes(q) || Object.values(v.tags||{}).join(' ').toLowerCase().includes(q))
975
+ .slice(0,40)
976
+ .map(v=>({type:'vault', label:v.name, sub:v.env, env:v.env, act:()=>openVault(v.name)}));
977
+ CMDK.items = [...acts, ...vaults]; CMDK.cur=0;
978
+ const list=$('#cmdkList');
979
+ if(!CMDK.items.length){ list.innerHTML='<div class="cmdk-empty">No matches.</div>'; return; }
980
+ const vIco='<svg class="ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="4" y="10" width="16" height="11" rx="2"/><path d="M8 10V7a4 4 0 0 1 8 0v3"/></svg>';
981
+ const gIco='<svg class="ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 12h14M5 6h14M5 18h14"/></svg>';
982
+ let html=''; let lastType='';
983
+ CMDK.items.forEach((it,idx)=>{
984
+ if(it.type!==lastType){ html+=`<div class="cmdk-sec">${it.type==='vault'?'Vaults':it.type==='view'?'Go to':'Actions'}</div>`; lastType=it.type; }
985
+ html+=`<div class="cmdk-it ${idx===CMDK.cur?'cur':''}" data-i="${idx}">
986
+ ${it.type==='vault'?vIco:gIco}
987
+ <span class="lbl">${esc(it.label)}${it.sub?`<small>${esc(it.sub)}</small>`:''}</span>
988
+ <span class="hint">${it.hint?esc(it.hint):'open'}</span></div>`;
989
+ });
990
+ list.innerHTML=html;
991
+ list.querySelectorAll('.cmdk-it').forEach(el=>{
992
+ el.onmousemove=()=>{ CMDK.cur=+el.dataset.i; markCur(); };
993
+ el.onclick=()=>runCmdk(+el.dataset.i);
994
+ });
995
+ }
996
+ function markCur(){ $('#cmdkList').querySelectorAll('.cmdk-it').forEach(el=>el.classList.toggle('cur',+el.dataset.i===CMDK.cur)); }
997
+ function moveCur(d){ if(!CMDK.items.length)return; CMDK.cur=(CMDK.cur+d+CMDK.items.length)%CMDK.items.length; markCur();
998
+ const el=$('#cmdkList').querySelector('.cmdk-it.cur'); if(el) el.scrollIntoView({block:'nearest'}); }
999
+ function runCmdk(i){ const it=CMDK.items[i]; if(!it)return; closeCmdk(); it.act(); }
1000
+
1001
+ function doRefresh(){ toast('Refreshing…'); loadSubs().then(()=>loadVaults(true)).then(()=>{ EXP.scanned=false; EXP.polling=false; refreshCurrent(); kickExpiryScan(); }); }
1002
+
757
1003
  // ---------- events ----------
758
- document.querySelectorAll('nav button').forEach(b=>b.onclick=()=>show(b.dataset.view));
759
- $('#refresh').onclick=()=>{ toast('Refreshing…'); boot(); loadVaults(true); };
760
- $('#search').oninput=e=>{ STATE.q=e.target.value; renderVaults(); };
761
- $('#dClose').onclick=closeDrawer; $('#overlay').onclick=closeDrawer;
762
- $('#dSeg').querySelectorAll('button').forEach(b=>b.onclick=()=>{ STATE.kind=b.dataset.kind; $('#dFilter').value=''; DRW.filter=''; $('#dSelAll').checked=false; $('#dSeg').querySelectorAll('button').forEach(x=>x.classList.toggle('active',x===b)); loadKind(); });
763
- $('#dFilter').oninput=e=>{ DRW.filter=e.target.value; renderDrawerList(); };
764
- $('#dSelAll').onchange=e=>selectAll(e.target.checked);
765
- $('#dNew').onclick=()=>openCreate('single');
766
- $('#dNewBatch').onclick=()=>openCreate('batch');
767
- document.querySelectorAll('th[data-sort]').forEach(th=>th.onclick=()=>{ const k=th.dataset.sort; STATE.dir = STATE.sort===k?-STATE.dir:1; STATE.sort=k; renderVaults(); });
768
- document.querySelectorAll('th[data-esort]').forEach(th=>th.onclick=()=>{ const k=th.dataset.esort; EXP.dir = EXP.sort===k?-EXP.dir:1; EXP.sort=k; renderExpiring(); });
769
- $('#expWindow').querySelectorAll('button').forEach(b=>b.onclick=async()=>{ $('#expWindow').querySelectorAll('button').forEach(x=>x.classList.toggle('active',x===b)); EXP.days=+b.dataset.d; if(EXP.scanned && !EXP.polling){ const s=await api(`/api/expiring/status?days=${EXP.days}`); if(s.ok){ EXP.items=(s.items||[]).map(it=>({...it,env:envOf(it.subscriptionId)})); renderExpiring(); } } });
770
- $('#expScan').onclick=()=>scanExpiring(true);
771
- document.addEventListener('keydown',e=>{ if(e.key==='Escape') closeDrawer(); });
1004
+ document.querySelectorAll('nav button').forEach(b=>b.onclick=()=>navigate(b.dataset.view));
1005
+ $('#refresh').onclick=doRefresh;
1006
+ $('#acctBtn').onclick=()=>navigate('settings');
1007
+ $('#cmdkBtn').onclick=openCmdk;
1008
+ $('#search').oninput=e=>{ STATE.q=e.target.value; if(STATE.route!=='vaults'){ navigate('vaults'); } else { renderVaultRows(); } };
1009
+ $('#cmdkIn').oninput=e=>renderCmdk(e.target.value);
1010
+ $('#cmdkOv').onclick=e=>{ if(e.target===$('#cmdkOv')) closeCmdk(); };
1011
+ document.addEventListener('keydown',e=>{
1012
+ if((e.metaKey||e.ctrlKey) && e.key.toLowerCase()==='k'){ e.preventDefault(); CMDK.open?closeCmdk():openCmdk(); return; }
1013
+ if(!CMDK.open){ if(e.key==='Escape' && STATE.route==='vault'){ navigate('vaults'); } return; }
1014
+ if(e.key==='Escape'){ closeCmdk(); }
1015
+ else if(e.key==='ArrowDown'){ e.preventDefault(); moveCur(1); }
1016
+ else if(e.key==='ArrowUp'){ e.preventDefault(); moveCur(-1); }
1017
+ else if(e.key==='Enter'){ e.preventDefault(); runCmdk(CMDK.cur); }
1018
+ });
772
1019
 
773
- const initView = new URLSearchParams(location.search).get('view');
774
- if(initView && META[initView]) show(initView);
775
1020
  boot();
776
1021
  </script>
777
1022
  </body>