akv-manager 0.0.1 → 0.0.4

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,266 +3,38 @@
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">
10
- <style>
11
- :root{
12
- --ink:#0A0D14; --ink2:#0C1019;
13
- --panel:#121826; --panel2:#0E141F; --raise:#171F2E;
14
- --line:#222C3E; --line2:#1A2233;
15
- --text:#E6EBF2; --muted:#79859B; --faint:#4C586E;
16
- --azure:#5B9DFF; --azure2:#3F86F0; --azuredim:rgba(91,157,255,.12);
17
- --ok:#46D6B4; --warn:#F2B53D; --alert:#FF5C6C; --bad:#FF5C6C; --good:#46D6B4;
18
- --violet:#A98BFF;
19
- --r:12px; --r-sm:8px;
20
- --shadow:0 18px 40px -22px rgba(0,0,0,.8);
21
- --mono:'IBM Plex Mono',ui-monospace,SFMono-Regular,Menlo,monospace;
22
- --sans:'IBM Plex Sans',-apple-system,BlinkMacSystemFont,sans-serif;
23
- }
24
- *{box-sizing:border-box}
25
- html,body{margin:0}
26
- body{font-family:var(--sans);font-size:14px;line-height:1.5;color:var(--text);min-height:100vh;
27
- background:
28
- radial-gradient(900px 500px at 88% -8%,rgba(91,157,255,.07),transparent 60%),
29
- linear-gradient(180deg,var(--ink2),var(--ink));
30
- -webkit-font-smoothing:antialiased}
31
- ::selection{background:rgba(91,157,255,.32)}
32
- a{color:var(--azure);text-decoration:none}
33
- :focus-visible{outline:2px solid var(--azure);outline-offset:2px;border-radius:4px}
34
- .mono{font-family:var(--mono);font-variant-numeric:tabular-nums}
35
- .muted{color:var(--muted)} .faint{color:var(--faint)} .right{text-align:right}
36
- .hide{display:none!important}
37
- .eyebrow{font-family:var(--mono);font-size:10.5px;font-weight:500;letter-spacing:.18em;
38
- text-transform:uppercase;color:var(--faint)}
39
-
40
- .app{display:grid;grid-template-columns:236px 1fr;min-height:100vh}
41
-
42
- /* ---------- sidebar ---------- */
43
- aside{background:linear-gradient(180deg,rgba(255,255,255,.015),transparent),var(--panel2);
44
- border-right:1px solid var(--line);padding:22px 14px;display:flex;flex-direction:column;
45
- position:sticky;top:0;height:100vh}
46
- .brand{display:flex;align-items:center;gap:11px;padding:0 6px 20px}
47
- .brand .mark{width:34px;height:34px;flex:none}
48
- .brand .wm{font-family:var(--mono);font-weight:600;font-size:13px;letter-spacing:.14em;color:var(--text);line-height:1.15}
49
- .brand .wm small{display:block;font-size:9.5px;letter-spacing:.22em;color:var(--faint);font-weight:400;margin-top:2px}
50
- .navlabel{font-family:var(--mono);font-size:9.5px;letter-spacing:.2em;color:var(--faint);
51
- text-transform:uppercase;padding:14px 10px 7px}
52
- nav button{display:flex;align-items:center;gap:11px;width:100%;text-align:left;background:transparent;
53
- border:0;color:var(--muted);padding:9px 11px;border-radius:var(--r-sm);cursor:pointer;
54
- font-family:var(--sans);font-size:13.5px;font-weight:500;position:relative;transition:.15s}
55
- nav button svg{width:17px;height:17px;flex:none;opacity:.8}
56
- nav button:hover{background:rgba(255,255,255,.03);color:var(--text)}
57
- nav button.active{background:var(--azuredim);color:#fff}
58
- nav button.active svg{opacity:1;color:var(--azure)}
59
- nav button.active::before{content:"";position:absolute;left:-14px;top:7px;bottom:7px;width:3px;
60
- background:var(--azure);border-radius:0 3px 3px 0}
61
- .who{margin-top:auto;font-size:12px;color:var(--muted);border-top:1px solid var(--line2);
62
- padding:14px 8px 4px;line-height:1.6}
63
- .who b{color:var(--text);font-weight:600}
64
- .who .mono{font-size:11px}
65
- .dot{width:7px;height:7px;border-radius:50%;display:inline-block;vertical-align:middle}
66
-
67
- /* ---------- main ---------- */
68
- main{padding:26px 32px 60px;max-width:1560px}
69
- .topbar{display:flex;align-items:flex-end;justify-content:space-between;gap:18px;margin-bottom:26px}
70
- h1{font-size:23px;font-weight:600;margin:4px 0 0;letter-spacing:-.01em}
71
- .sub{color:var(--muted);font-size:13px;margin-top:3px;max-width:62ch}
72
- .row{display:flex;gap:10px;align-items:center;flex-wrap:wrap}
73
-
74
- .btn{font-family:var(--sans);font-weight:500;font-size:13px;border:0;border-radius:var(--r-sm);
75
- padding:9px 15px;cursor:pointer;color:#06101f;background:var(--azure);transition:.15s}
76
- .btn:hover{background:#75AEFF}
77
- .btn:disabled{opacity:.55;cursor:default}
78
- .btn.ghost{background:var(--raise);color:var(--text);border:1px solid var(--line)}
79
- .btn.ghost:hover{border-color:var(--azure);background:var(--raise)}
80
- .btn.sm{padding:5px 11px;font-size:12px}
81
- input.search{font-family:var(--mono);background:var(--panel2);border:1px solid var(--line);color:var(--text);
82
- padding:9px 13px;border-radius:var(--r-sm);min-width:320px;font-size:12.5px}
83
- input.search::placeholder{color:var(--faint)}
84
- input.search:focus{outline:none;border-color:var(--azure);background:var(--panel)}
85
-
86
- /* ---------- KPI cards ---------- */
87
- .cards{display:grid;grid-template-columns:repeat(auto-fill,minmax(190px,1fr));gap:13px;margin-bottom:24px}
88
- .card{background:linear-gradient(180deg,var(--panel),var(--panel2));border:1px solid var(--line);
89
- border-radius:var(--r);padding:15px 17px;position:relative;overflow:hidden}
90
- .card::after{content:"";position:absolute;inset:0 0 auto;height:2px;background:var(--line)}
91
- .card .k{font-family:var(--mono);color:var(--muted);font-size:10.5px;text-transform:uppercase;letter-spacing:.13em}
92
- .card .v{font-family:var(--mono);font-size:30px;font-weight:600;margin-top:9px;line-height:1;letter-spacing:-.02em}
93
- .card .v small{font-family:var(--sans);font-size:12px;font-weight:400;color:var(--muted);letter-spacing:0}
94
- .card--alert::after{background:var(--alert)} .card--alert .v{color:#FF8893}
95
- .card--warn::after{background:var(--warn)} .card--warn .v{color:#FBC56B}
96
- .card--azure::after{background:var(--azure)} .card--azure .v{color:#8FBEFF}
97
-
98
- /* ---------- chips ---------- */
99
- .chips{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:16px}
100
- .chip{font-family:var(--mono);background:var(--panel2);border:1px solid var(--line);color:var(--muted);
101
- padding:6px 12px;border-radius:7px;cursor:pointer;font-size:11.5px;display:flex;gap:8px;align-items:center;transition:.15s}
102
- .chip:hover{color:var(--text);border-color:var(--line)}
103
- .chip.active{border-color:var(--azure);color:#fff;background:var(--azuredim)}
104
- .chip .n{background:var(--ink);border-radius:5px;padding:1px 7px;font-size:10.5px;color:var(--muted)}
105
- .chip.active .n{color:var(--azure)}
106
-
107
- /* ---------- panel + table ---------- */
108
- .panel{background:var(--panel);border:1px solid var(--line);border-radius:var(--r);overflow:hidden;box-shadow:var(--shadow)}
109
- table{width:100%;border-collapse:collapse;font-size:13px}
110
- th,td{padding:11px 16px;text-align:left;border-bottom:1px solid var(--line2);white-space:nowrap}
111
- thead th{position:sticky;top:0;z-index:2;background:var(--panel2);color:var(--muted);font-weight:500;
112
- font-family:var(--mono);font-size:10.5px;text-transform:uppercase;letter-spacing:.1em;
113
- cursor:pointer;user-select:none}
114
- thead th:hover{color:var(--text)}
115
- tbody tr{transition:background .12s}
116
- tbody tr:hover{background:rgba(91,157,255,.05)}
117
- tbody tr:last-child td{border-bottom:0}
118
- td.mono,.mono{font-family:var(--mono);font-size:12.5px}
119
- td b{font-weight:600}
120
-
121
- .tag{display:inline-flex;align-items:center;font-family:var(--mono);font-size:10px;font-weight:500;
122
- letter-spacing:.08em;text-transform:uppercase;padding:2px 7px;border-radius:5px;border:1px solid transparent}
123
- .tag.e-dev{color:#7FE0C8;background:rgba(70,214,180,.10);border-color:rgba(70,214,180,.22)}
124
- .tag.e-test{color:#7FD0E0;background:rgba(70,180,214,.10);border-color:rgba(70,180,214,.22)}
125
- .tag.e-qa{color:#93C0FF;background:rgba(91,157,255,.12);border-color:rgba(91,157,255,.24)}
126
- .tag.e-stage{color:#F4C977;background:rgba(242,181,61,.10);border-color:rgba(242,181,61,.22)}
127
- .tag.e-prod{color:#FF9BA4;background:rgba(255,92,108,.10);border-color:rgba(255,92,108,.26)}
128
- .tag.e-sandbox{color:#BCA8FF;background:rgba(169,139,255,.10);border-color:rgba(169,139,255,.22)}
129
- .tag.e-other{color:var(--muted);background:rgba(255,255,255,.04);border-color:var(--line)}
130
-
131
- .pill{font-family:var(--mono);font-size:10px;letter-spacing:.06em;color:var(--muted);
132
- border:1px solid var(--line);border-radius:5px;padding:1px 7px;white-space:nowrap}
133
- .pill.rbac{color:#7FE0C8;border-color:rgba(70,214,180,.3)}
134
- .pill.policy{color:#F4C977;border-color:rgba(242,181,61,.3)}
135
- .sev{font-family:var(--mono);font-size:10px;letter-spacing:.1em;padding:2px 8px;border-radius:5px;border:1px solid}
136
- .sev.expired{color:#FF8893;background:rgba(255,92,108,.12);border-color:rgba(255,92,108,.32)}
137
- .sev.urgent{color:#FBC56B;background:rgba(242,181,61,.12);border-color:rgba(242,181,61,.32)}
138
- .sev.ok{color:var(--muted);border-color:var(--line);background:transparent}
139
-
140
- .iconbtn{font-family:var(--mono);background:var(--raise);border:1px solid var(--line);color:var(--muted);
141
- border-radius:6px;padding:4px 9px;cursor:pointer;font-size:11.5px;transition:.15s}
142
- .iconbtn:hover{color:#fff;border-color:var(--azure)}
143
- .empty{padding:48px 24px;text-align:center;color:var(--muted)}
144
- .scroll{max-height:calc(100vh - 380px);overflow:auto}
145
- .scroll::-webkit-scrollbar{width:10px;height:10px}
146
- .scroll::-webkit-scrollbar-thumb{background:var(--line);border-radius:8px;border:2px solid var(--panel)}
147
- .tablenote{font-family:var(--mono);font-size:11px;color:var(--faint);margin-top:10px}
148
-
149
- /* ---------- signature: expiry horizon ---------- */
150
- .horizon{padding:18px 20px;margin-bottom:22px}
151
- .hz-head{display:flex;justify-content:space-between;align-items:baseline;margin-bottom:14px}
152
- .hz-body{display:flex;gap:12px;height:104px}
153
- .hz-expired{position:relative;width:118px;flex:none;border:1px solid rgba(255,92,108,.28);
154
- background:linear-gradient(180deg,rgba(255,92,108,.12),rgba(255,92,108,.02));
155
- border-radius:10px;display:flex;flex-direction:column;align-items:center;justify-content:center;overflow:hidden}
156
- .hz-expired-n{font-family:var(--mono);font-size:30px;font-weight:600;color:#FF8893;line-height:1}
157
- .hz-expired-l{font-family:var(--mono);font-size:9.5px;letter-spacing:.18em;color:#FF8893;margin-top:3px}
158
- .hz-axis{position:relative;flex:1;border:1px solid var(--line);border-radius:10px;
159
- background:linear-gradient(180deg,rgba(255,255,255,.015),transparent);overflow:hidden}
160
- .hz-grid{position:absolute;top:0;bottom:0;border-left:1px dashed var(--line);}
161
- .hz-grid:first-of-type{border-left-color:rgba(91,157,255,.35)}
162
- .hz-grid span{position:absolute;bottom:7px;left:6px;font-family:var(--mono);font-size:9.5px;color:var(--faint);white-space:nowrap}
163
- .hz-tick{position:absolute;top:12px;bottom:26px;width:2px;border-radius:2px;background:var(--ok);
164
- transform:translateX(-1px);opacity:.9}
165
- .hz-tick.warn{background:var(--warn)} .hz-tick.alert{background:var(--alert)}
166
- .hz-tick:hover{opacity:1;width:3px;box-shadow:0 0 8px currentColor}
167
- .hz-expired .hz-tick{top:auto;bottom:9px;height:16px;opacity:.7}
168
- .hz-legend{display:flex;gap:16px;align-items:center}
169
- .hz-legend span{font-family:var(--mono);font-size:10px;color:var(--muted);display:flex;align-items:center;gap:5px}
170
- .hz-legend i{width:8px;height:8px;border-radius:2px;display:inline-block}
171
-
172
- /* ---------- segmented + window ---------- */
173
- .seg{display:inline-flex;gap:4px;background:var(--panel2);border:1px solid var(--line);
174
- border-radius:var(--r-sm);padding:3px}
175
- .seg button{font-family:var(--mono);background:transparent;border:0;color:var(--muted);padding:6px 12px;
176
- border-radius:6px;cursor:pointer;font-size:12px;transition:.15s}
177
- .seg button:hover{color:var(--text)}
178
- .seg button.active{background:var(--raise);color:#fff;box-shadow:0 1px 0 rgba(0,0,0,.4)}
179
-
180
- /* ---------- drawer ---------- */
181
- .overlay{position:fixed;inset:0;background:rgba(5,8,14,.6);backdrop-filter:blur(2px);opacity:0;
182
- pointer-events:none;transition:.25s;z-index:30}
183
- .overlay.show{opacity:1;pointer-events:auto}
184
- .drawer{position:fixed;top:0;right:0;height:100vh;width:100vw;max-width:100vw;background:var(--panel);
185
- border-left:1px solid var(--line);box-shadow:-30px 0 80px -30px rgba(0,0,0,.9);
186
- transform:translateX(100%);transition:transform .28s cubic-bezier(.2,.7,.2,1);z-index:40;
187
- display:flex;flex-direction:column}
188
- .drawer.open{transform:translateX(0)}
189
- .drawer header{padding:20px 22px;border-bottom:1px solid var(--line);display:flex;
190
- justify-content:space-between;align-items:flex-start;gap:14px}
191
- .drawer header .dt{font-family:var(--mono);font-size:16px;font-weight:600;word-break:break-all}
192
- .drawer .body{padding:18px 22px;overflow:auto}
193
- .kvitem{border-bottom:1px solid var(--line2)}
194
- .kvitem:last-child{border-bottom:0}
195
- .kvitem.sel{background:rgba(91,157,255,.06);margin:0 -22px;padding:0 22px}
196
- .kvline{display:flex;align-items:center;gap:10px;padding:10px 0}
197
- .kvline .nm{flex:1 1 auto;min-width:60px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-family:var(--mono);font-size:12.5px}
198
- .kvline .meta{display:flex;align-items:center;gap:8px;flex:0 0 auto}
199
- .kvline input[type=checkbox]{flex:0 0 auto}
200
- .revealed{padding:0 0 12px;display:flex;align-items:flex-start;gap:8px}
201
- .revealed code.val{display:block;flex:1;min-width:0;background:var(--ink);border:1px solid var(--line);
202
- border-radius:6px;padding:8px 10px;max-height:120px;overflow:auto}
203
- .cmd{background:var(--ink);border:1px solid var(--line);border-radius:var(--r-sm);padding:10px 12px;
204
- margin:8px 0;font-family:var(--mono);font-size:11.5px;color:#A9D2FF;display:flex;justify-content:space-between;gap:10px;align-items:center}
205
- .secthead{font-family:var(--mono);margin:24px 0 6px;color:var(--faint);font-size:10px;text-transform:uppercase;letter-spacing:.16em}
206
- .loadval{font-family:var(--mono);font-size:11px;color:var(--azure);cursor:pointer;background:none;border:0;text-decoration:underline}
207
- code.val{font-family:var(--mono);user-select:all;color:#A9D2FF;font-size:11.5px;word-break:break-all}
208
- /* drawer edit + batch */
209
- .dtools{display:flex;align-items:center;gap:12px;margin-top:14px}
210
- .dtools .search{min-width:0;flex:1;padding:8px 11px}
211
- .selall{font-family:var(--mono);font-size:11px;color:var(--muted);display:flex;align-items:center;gap:6px;cursor:pointer;white-space:nowrap}
212
- input[type=checkbox]{accent-color:var(--azure);width:14px;height:14px;cursor:pointer}
213
- .batchbar{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-top:12px;padding:10px 12px;
214
- background:var(--azuredim);border:1px solid rgba(91,157,255,.4);border-radius:var(--r-sm)}
215
- .batchbar .bn{font-family:var(--mono);font-size:12px;color:#cfe2ff;margin-right:2px}
216
- .batchbar input[type=date]{font-family:var(--mono);font-size:12px;background:var(--ink);border:1px solid var(--line);
217
- color:var(--text);border-radius:6px;padding:5px 8px;color-scheme:dark}
218
- .editor{margin:0 -22px;padding:14px 22px;background:var(--panel2);border-top:1px solid var(--line2)}
219
- /* create panel */
220
- .createp{margin-top:12px;padding:16px;background:var(--panel2);border:1px solid var(--azure);border-radius:var(--r-sm)}
221
- .createp h4{margin:0 0 12px;font-family:var(--mono);font-size:12px;color:#cfe2ff;font-weight:500;letter-spacing:.04em}
222
- .createp .fld{display:flex;align-items:center;gap:10px;margin-bottom:10px}
223
- .createp label{font-family:var(--mono);font-size:11px;color:var(--muted);width:72px;flex:none}
224
- .createp input[type=text],.createp input[type=date],.createp textarea{font-family:var(--mono);font-size:12px;
225
- background:var(--ink);border:1px solid var(--line);color:var(--text);border-radius:6px;padding:8px 10px;flex:1;width:100%;color-scheme:dark}
226
- .createp textarea{min-height:120px;resize:vertical;line-height:1.6}
227
- .createp .actions{display:flex;gap:8px;align-items:center;margin-top:6px}
228
- .newbtns{display:flex;gap:6px;margin-left:auto}
229
- .editor .fld{display:flex;align-items:center;gap:10px;margin-bottom:10px}
230
- .editor label{font-family:var(--mono);font-size:11px;color:var(--muted);width:78px;flex:none}
231
- .editor input[type=text],.editor input[type=date]{font-family:var(--mono);font-size:12px;background:var(--ink);
232
- border:1px solid var(--line);color:var(--text);border-radius:6px;padding:7px 9px;flex:1;color-scheme:dark}
233
- .editor input[type=date],.createp input[type=date]{flex:0 0 auto;width:200px}
234
- .editor .hint{font-family:var(--mono);font-size:10.5px;color:var(--faint)}
235
- .toggle{display:flex;align-items:center;gap:7px;font-family:var(--mono);font-size:12px;color:var(--text);cursor:pointer}
236
-
237
- /* ---------- misc ---------- */
238
- .toast{position:fixed;bottom:24px;left:50%;transform:translateX(-50%) translateY(20px);
239
- background:var(--raise);border:1px solid var(--azure);color:#fff;padding:10px 18px;
240
- border-radius:var(--r-sm);opacity:0;transition:.25s;z-index:60;font-size:13px}
241
- .toast.show{opacity:1;transform:translateX(-50%) translateY(0)}
242
- .banner{background:linear-gradient(180deg,rgba(255,92,108,.14),rgba(255,92,108,.04));
243
- border:1px solid rgba(255,92,108,.4);color:#FFC4C9;padding:13px 16px;border-radius:var(--r);
244
- margin-bottom:18px;display:none;font-size:13px}
245
- .banner.show{display:block}
246
- .banner code{font-family:var(--mono);background:rgba(0,0,0,.3);padding:1px 6px;border-radius:4px;color:#fff}
247
- .spin{width:18px;height:18px;border:2px solid var(--line);border-top-color:var(--azure);border-radius:50%;
248
- display:inline-block;animation:spin .7s linear infinite;vertical-align:-4px}
249
- @keyframes spin{to{transform:rotate(360deg)}}
250
- .progress{width:64%;max-width:380px;height:6px;background:var(--ink);border:1px solid var(--line);
251
- border-radius:999px;margin:16px auto 0;overflow:hidden}
252
- .progress .bar{height:100%;background:linear-gradient(90deg,var(--azure),var(--ok));border-radius:999px;transition:width .4s}
253
-
254
- @media (max-width:900px){
255
- .app{grid-template-columns:1fr}
256
- aside{position:static;height:auto;flex-direction:row;flex-wrap:wrap;align-items:center}
257
- aside nav{display:flex;gap:4px;flex-wrap:wrap} .navlabel{display:none} .who{display:none}
258
- main{padding:20px}
259
- input.search{min-width:180px}
260
- }
261
- @media (prefers-reduced-motion:reduce){*{animation-duration:.01ms!important;transition:none!important}}
262
- </style>
10
+ <script src="https://cdn.tailwindcss.com"></script>
11
+ <script>
12
+ // Tailwind Play CDN, themed to the design tokens. Preflight is OFF so the
13
+ // hand-authored component layer in styles.css stays authoritative.
14
+ tailwind.config = {
15
+ corePlugins: { preflight: false },
16
+ theme: {
17
+ extend: {
18
+ colors: {
19
+ ink: '#0A0D14', 'ink-2': '#0C1019',
20
+ panel: '#121826', 'panel-2': '#0E141F', raise: '#171F2E',
21
+ line: '#222C3E', 'line-2': '#1A2233',
22
+ text: '#E6EBF2', muted: '#79859B', faint: '#4C586E',
23
+ azure: '#5B9DFF', azure2: '#3F86F0',
24
+ ok: '#46D6B4', warn: '#F2B53D', alert: '#FF5C6C', violet: '#A98BFF',
25
+ },
26
+ fontFamily: {
27
+ sans: ['"IBM Plex Sans"', 'system-ui', 'sans-serif'],
28
+ mono: ['"IBM Plex Mono"', 'ui-monospace', 'monospace'],
29
+ },
30
+ },
31
+ },
32
+ };
33
+ </script>
34
+ <link rel="stylesheet" href="/styles.css">
263
35
  </head>
264
36
  <body>
265
- <div class="app">
37
+ <div class="grid min-h-screen grid-cols-[236px_1fr] max-[900px]:grid-cols-1">
266
38
  <aside>
267
39
  <div class="brand">
268
40
  <svg class="mark" viewBox="0 0 34 34" fill="none" xmlns="http://www.w3.org/2000/svg">
@@ -276,150 +48,68 @@
276
48
  </svg>
277
49
  <div class="wm">KEYVAULT<small>OPS CONSOLE</small></div>
278
50
  </div>
279
- <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>
280
58
  <nav>
281
- <button data-view="overview" class="active">
59
+ <button data-view="dashboard" class="active">
282
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>
283
- Overview</button>
284
- <button data-view="subscriptions">
285
- <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>
286
- Subscriptions</button>
61
+ Dashboard</button>
287
62
  <button data-view="vaults">
288
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>
289
64
  Key Vaults</button>
290
- <button data-view="expiring">
65
+ <button data-view="expiry">
291
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>
292
67
  Expiry</button>
293
68
  </nav>
294
- <div class="who" id="who"><span class="spin"></span> connecting…</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>
295
83
  </aside>
296
84
 
297
- <main>
85
+ <main class="max-w-[1560px] px-8 pt-[26px] pb-[60px] max-[900px]:p-5">
298
86
  <div class="banner" id="banner"></div>
299
87
 
300
- <div class="topbar">
301
- <div>
302
- <div class="eyebrow" id="eyebrow">Overview</div>
303
- <h1 id="title">Overview</h1>
88
+ <div class="flex items-end justify-between gap-[18px] mb-[26px]">
89
+ <div id="headblock">
90
+ <div class="eyebrow" id="eyebrow">Operations</div>
91
+ <h1 id="title">Dashboard</h1>
304
92
  <div class="sub" id="subtitle">Live view of your Azure access via the local az CLI</div>
305
93
  </div>
306
- <div class="row">
307
- <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…" />
308
96
  <button class="btn ghost" id="refresh">↻ Refresh</button>
309
97
  </div>
310
98
  </div>
311
99
 
312
- <!-- OVERVIEW -->
313
- <section id="view-overview">
314
- <div class="cards" id="ovCards"></div>
315
- <div class="panel">
316
- <div class="scroll">
317
- <table>
318
- <thead><tr><th>Subscription</th><th>Env</th><th>State</th><th class="right">Key Vaults</th><th>Subscription ID</th></tr></thead>
319
- <tbody id="ovSubs"></tbody>
320
- </table>
321
- </div>
322
- </div>
323
- </section>
324
-
325
- <!-- SUBSCRIPTIONS -->
326
- <section id="view-subscriptions" class="hide">
327
- <div class="panel">
328
- <div class="scroll">
329
- <table>
330
- <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>
331
- <tbody id="subsBody"></tbody>
332
- </table>
333
- </div>
334
- </div>
335
- </section>
336
-
337
- <!-- VAULTS -->
338
- <section id="view-vaults" class="hide">
339
- <div class="chips" id="envChips"></div>
340
- <div class="panel">
341
- <div class="scroll">
342
- <table>
343
- <thead><tr>
344
- <th data-sort="name">Vault ▾</th><th data-sort="env">Env</th>
345
- <th data-sort="service">Service</th><th data-sort="resourceGroup">Resource Group</th>
346
- <th data-sort="sku">SKU</th><th>Auth</th><th class="right">Actions</th>
347
- </tr></thead>
348
- <tbody id="vaultsBody"></tbody>
349
- </table>
350
- </div>
351
- </div>
352
- <div class="tablenote" id="vaultCount"></div>
353
- </section>
354
-
355
- <!-- EXPIRING -->
356
- <section id="view-expiring" class="hide">
357
- <div class="row" style="margin-bottom:18px;justify-content:space-between">
358
- <div class="row">
359
- <span class="eyebrow">Window</span>
360
- <div class="seg" id="expWindow">
361
- <button data-d="7">7d</button>
362
- <button data-d="30" class="active">30d</button>
363
- <button data-d="60">60d</button>
364
- <button data-d="90">90d</button>
365
- <button data-d="365">1y</button>
366
- </div>
367
- <button class="btn" id="expScan">⟳ Scan vaults</button>
368
- </div>
369
- <span class="tablenote" id="expMeta"></span>
370
- </div>
371
-
372
- <div class="panel horizon hide" id="expHorizon"></div>
373
- <div class="cards" id="expCards"></div>
374
-
375
- <div class="panel">
376
- <div class="scroll">
377
- <table>
378
- <thead><tr>
379
- <th data-esort="daysLeft">Expires in ▾</th><th data-esort="expires">Expiry date</th>
380
- <th data-esort="type">Type</th><th data-esort="name">Item</th>
381
- <th data-esort="vault">Vault</th><th data-esort="env">Env</th>
382
- <th>Status</th><th class="right">Actions</th>
383
- </tr></thead>
384
- <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>
385
- </table>
386
- </div>
387
- </div>
388
- <div class="tablenote" id="expTableNote"></div>
389
- </section>
100
+ <div id="route"></div>
390
101
  </main>
391
102
  </div>
392
103
 
393
- <!-- Drawer -->
394
- <div class="overlay" id="overlay"></div>
395
- <div class="drawer" id="drawer">
396
- <header>
397
- <div>
398
- <div class="dt" id="dTitle">Vault</div>
399
- <div class="mono muted" id="dSub" style="font-size:11.5px;margin-top:5px;word-break:break-all"></div>
400
- </div>
401
- <button class="iconbtn" id="dClose">✕ Close</button>
402
- </header>
403
- <div class="body">
404
- <div class="seg" id="dSeg" style="width:100%;display:flex">
405
- <button data-kind="secrets" class="active" style="flex:1">Secrets</button>
406
- <button data-kind="keys" style="flex:1">Keys</button>
407
- <button data-kind="certificates" style="flex:1">Certificates</button>
408
- </div>
409
- <div class="dtools" id="dTools">
410
- <input class="search" id="dFilter" placeholder="filter by name…" autocomplete="off" />
411
- <label class="selall"><input type="checkbox" id="dSelAll" /> select all</label>
412
- <span class="faint mono" id="dCount"></span>
413
- <span class="newbtns" id="dNewBtns">
414
- <button class="btn sm" id="dNew">+ New</button>
415
- <button class="btn sm ghost" id="dNewBatch">+ Batch</button>
416
- </span>
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>
417
111
  </div>
418
- <div class="createp hide" id="dCreate"></div>
419
- <div class="batchbar hide" id="dBatch"></div>
420
- <div id="dContent" style="margin-top:12px"><div class="empty"><span class="spin"></span></div></div>
421
- <div class="secthead">CLI commands</div>
422
- <div id="dCmds"></div>
112
+ <div class="cmdk-list" id="cmdkList"></div>
423
113
  </div>
424
114
  </div>
425
115
 
@@ -427,32 +117,102 @@
427
117
 
428
118
  <script>
429
119
  const $ = s => document.querySelector(s);
120
+ const esc = s => String(s==null?'':s).replace(/[&<>"]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]));
430
121
  const api = async (p, opts) => { const r = await fetch(p, opts); return r.json(); };
431
- let STATE = { vaults: [], subs: [], account: null, env: 'all', q: '', sort: 'name', dir: 1, drawerVault: null, kind: 'secrets' };
432
- 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)); }
433
127
  const SUBMETA = {};
434
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
+
435
140
  function toast(msg){ const t=$('#toast'); t.textContent=msg; t.classList.add('show'); setTimeout(()=>t.classList.remove('show'),1600); }
436
141
  function copy(text){ navigator.clipboard.writeText(text).then(()=>toast('Copied to clipboard')); }
437
142
  function banner(msg){ const b=$('#banner'); if(!msg){b.classList.remove('show');return;} b.innerHTML=msg; b.classList.add('show'); }
438
143
  function envOf(id){ return (SUBMETA[id]&&SUBMETA[id].env)||'?'; }
439
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; }
440
191
 
441
192
  // ---------- bootstrap ----------
442
193
  async function boot(){
443
194
  const cfg = await api('/api/config'); if(cfg.ok){ CFG = {...CFG, ...cfg.data};
444
195
  if(CFG.title){ document.title = CFG.title; const wm=document.querySelector('.brand .wm'); if(wm) wm.firstChild.textContent = CFG.title; } }
196
+ $('#ver').textContent = 'akv-manager v' + (CFG.version || '?');
445
197
  const acc = await api('/api/account');
446
198
  if(!acc.ok){
447
- $('#who').innerHTML = '<span class="dot" style="background:var(--alert)"></span> not signed in';
448
- banner('Azure CLI is not signed in. Run <code>az login</code> in your terminal, then press Refresh.');
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>` : '';
201
+ banner((acc.needLogin
202
+ ? 'Azure CLI is signed out. Run <code>az login</code>, then press Refresh.'
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);
449
204
  return;
450
205
  }
451
206
  STATE.account = acc.data;
452
- $('#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;
453
210
  banner('');
454
- await loadSubs(); // populate the subscription→env map first
455
- 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
456
216
  }
457
217
 
458
218
  async function loadSubs(){
@@ -460,160 +220,288 @@ async function loadSubs(){
460
220
  if(!r.ok) return;
461
221
  STATE.subs = r.data;
462
222
  r.data.forEach(s => SUBMETA[s.id] = { name: s.name, env: envLabel(s.name) });
463
- renderOverview(); renderSubs();
464
- // If the expiry view rendered before subs arrived, re-resolve its env tags now.
465
- if(EXP.scanned){ EXP.items = EXP.items.map(it=>({...it, env: envOf(it.subscriptionId)})); renderExpiring(); }
466
223
  }
467
- // Infer an environment label from a subscription name. Operators can override the
468
- // matching with AZBO_ENV_RULES (see /api/config); otherwise generic keywords are used.
469
- function envLabel(name){
470
- const n = name || '';
471
- if(CFG.envRules){ for(const [pat,label] of CFG.envRules){ try{ if(new RegExp(pat,'i').test(n)) return label; }catch{} } }
472
- const s = n.toLowerCase();
473
- if(/prod|prd|production|\blive\b/.test(s)) return 'Prod';
474
- if(/preprod|pre-prod|stag|stg|\buat\b|accept/.test(s)) return 'Stage';
475
- if(/\bqa\b|quality/.test(s)) return 'QA';
476
- if(/sandbox|sbx|\bdemo\b/.test(s)) return 'Sandbox';
477
- if(/dev|develop/.test(s)) return 'Dev';
478
- if(/test|tst|\bint\b|integration|nonprod|non-prod/.test(s)) return 'Test';
479
- return '—';
480
- }
481
- // Map any env label to one of a fixed set of colour buckets for the tag styling.
482
- function envClass(env){
483
- const s = (env||'').toLowerCase();
484
- if(/prod|live/.test(s)) return 'e-prod';
485
- if(/stag|uat|preprod/.test(s)) return 'e-stage';
486
- if(/qa/.test(s)) return 'e-qa';
487
- if(/sandbox|demo/.test(s)) return 'e-sandbox';
488
- if(/dev/.test(s)) return 'e-dev';
489
- if(/test|int/.test(s)) return 'e-test';
490
- return 'e-other';
491
- }
492
-
493
224
  async function loadVaults(refresh){
494
- $('#vaultsBody').innerHTML = '<tr><td colspan="7" class="empty"><span class="spin"></span> querying Resource Graph…</td></tr>';
495
225
  const r = await api('/api/vaults'+(refresh?'?refresh=1':''));
496
- if(!r.ok){ $('#vaultsBody').innerHTML = `<tr><td colspan="7" class="empty">${r.error||'failed'}</td></tr>`; return; }
497
- STATE.vaults = r.data.map(v => ({...v, env: envOf(v.subscriptionId), service: serviceOf(v.resourceGroup)}));
498
- renderOverview(); renderEnvChips(); renderVaults();
499
- }
500
- // Best-effort short name from a resource group: drop a leading rg / rg- and trailing index.
501
- function serviceOf(rg){
502
- 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;
503
229
  }
504
230
 
505
- // ---------- overview ----------
506
- function renderOverview(){
507
- const envs = {};
508
- STATE.vaults.forEach(v => envs[v.env]=(envs[v.env]||0)+1);
509
- const namedEnvs = Object.entries(envs).filter(([e])=>e!=='?'&&e!=='—').sort((a,b)=>b[1]-a[1]);
231
+ // ---------- router ----------
232
+ function parseHash(){
233
+ return location.hash.replace(/^#\/?/,'').split('/').filter(Boolean).map(decodeURIComponent);
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);
265
+
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('');
280
+ }
281
+
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>' : '';
510
295
  const cards = [
511
- card('Subscriptions', STATE.subs.length, STATE.subs.filter(s=>s.state==='Enabled').length+' enabled',''),
512
- card('Key Vaults', STATE.vaults.length, 'across '+namedEnvs.length+' environment'+(namedEnvs.length!==1?'s':''),'azure'),
513
- ...namedEnvs.slice(0,3).map(([e,n])=>card(e, n, 'environment','')),
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),
514
300
  ];
515
- $('#ovCards').innerHTML = cards.join('');
516
- const counts = {}; STATE.vaults.forEach(v=>counts[v.subscriptionId]=(counts[v.subscriptionId]||0)+1);
517
- $('#ovSubs').innerHTML = STATE.subs.map(s=>`<tr>
518
- <td><b>${s.name}</b></td><td><span class="tag ${envClass(SUBMETA[s.id].env)}">${SUBMETA[s.id].env}</span></td>
519
- <td>${s.state}</td><td class="right mono">${counts[s.id]||0}</td><td class="mono muted">${s.id}</td></tr>`).join('');
520
- }
521
- function card(k,v,sub,mod){
522
- return `<div class="card ${mod?'card--'+mod:''}"><div class="k">${k}</div><div class="v">${v} <small>${sub}</small></div></div>`;
523
- }
524
-
525
- // ---------- subscriptions ----------
526
- function renderSubs(){
527
- const counts = {}; STATE.vaults.forEach(v=>counts[v.subscriptionId]=(counts[v.subscriptionId]||0)+1);
528
- $('#subsBody').innerHTML = STATE.subs.map(s=>`<tr>
529
- <td><b>${s.name}</b></td>
530
- <td><span class="tag ${envClass(SUBMETA[s.id].env)}">${SUBMETA[s.id].env}</span></td>
531
- <td>${s.state}</td>
532
- <td>${s.isDefault?'<span class="pill rbac">active</span>':''}</td>
533
- <td class="right mono">${counts[s.id]||0}</td>
534
- <td class="mono muted">${s.id}</td>
535
- <td class="right">${s.isDefault?'':`<button class="btn sm ghost" onclick="setSub('${s.id}')">Switch</button>`}</td>
536
- </tr>`).join('');
537
- }
538
- async function setSub(id){
539
- toast('Switching subscription…');
540
- const r = await api('/api/subscription/set',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({subscription:id})});
541
- if(r.ok){ STATE.account=r.data; await loadSubs(); toast('Active subscription set'); } else toast(r.error||'failed');
542
- }
543
-
544
- // ---------- vaults ----------
545
- function renderEnvChips(){
546
- const envs = {}; STATE.vaults.forEach(v=>envs[v.env]=(envs[v.env]||0)+1);
547
- const order = ['all',...Object.keys(envs).sort()];
548
- $('#envChips').innerHTML = order.map(e=>{
549
- const n = e==='all'?STATE.vaults.length:envs[e];
550
- return `<div class="chip ${STATE.env===e?'active':''}" onclick="setEnv('${e}')">${e==='all'?'All environments':e}<span class="n">${n}</span></div>`;
551
- }).join('');
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>`;
552
324
  }
553
- function setEnv(e){ STATE.env=e; renderEnvChips(); renderVaults(); }
554
-
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
+ };
333
+ }
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]):''; }
555
351
  function filteredVaults(){
556
352
  const q = STATE.q.toLowerCase();
557
- let list = STATE.vaults.filter(v =>
558
- (STATE.env==='all'||v.env===STATE.env) &&
559
- (!q || v.name.toLowerCase().includes(q) || v.resourceGroup.toLowerCase().includes(q) || (v.service||'').toLowerCase().includes(q)));
560
- const k = STATE.sort, d = STATE.dir;
561
- 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; });
562
365
  return list;
563
366
  }
564
- 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(){
565
406
  const list = filteredVaults();
566
407
  $('#vaultCount').textContent = `${list.length} vault${list.length!==1?'s':''} shown`;
567
- if(!list.length){ $('#vaultsBody').innerHTML='<tr><td colspan="7" class="empty">No vaults match your filter.</td></tr>'; return; }
568
- $('#vaultsBody').innerHTML = list.map(v=>`<tr>
569
- <td class="mono"><b>${v.name}</b></td>
570
- <td><span class="tag ${envClass(v.env)}">${v.env}</span></td>
571
- <td class="muted">${v.service||''}</td>
572
- <td class="mono muted">${v.resourceGroup}</td>
573
- <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>
574
421
  <td>${v.enableRbac==='true'?'<span class="pill rbac">RBAC</span>':'<span class="pill policy">Policy</span>'}</td>
575
422
  <td class="right">
576
- <button class="iconbtn" onclick="openVault('${v.name}','${v.subscriptionId}')">Open</button>
577
- <button class="iconbtn" onclick="copy('az keyvault secret list --vault-name ${v.name} -o table')">⧉</button>
578
- </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('');
579
426
  }
580
427
 
581
- // ---------- drawer ----------
428
+ // ---------- VAULT PAGE (routed) ----------
582
429
  let DRW = { vault:'', kind:'secrets', items:[], filter:'', sel:new Set() };
583
- function openVault(name, subId){
584
- const v = STATE.vaults.find(x=>x.name===name) || {name, subscriptionId:subId, resourceGroup:'', uri:''};
585
- STATE.drawerVault = v; STATE.kind='secrets';
586
- DRW = { vault:name, kind:'secrets', items:[], filter:'', sel:new Set() };
587
- $('#dFilter').value=''; $('#dSelAll').checked=false;
588
- $('#dTitle').textContent = name;
589
- $('#dSub').innerHTML = `${subName(subId)} · ${v.resourceGroup||''}${v.uri?' · '+v.uri:''}`;
590
- $('#dSeg').querySelectorAll('button').forEach(b=>b.classList.toggle('active',b.dataset.kind==='secrets'));
591
- $('#overlay').classList.add('show'); $('#drawer').classList.add('open');
592
- renderCmds(v); loadKind();
593
- }
594
- 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
+ }
595
484
  function renderCmds(v){
596
485
  const cmds = [
597
486
  `az keyvault secret list --vault-name ${v.name} -o table`,
598
487
  `az keyvault key list --vault-name ${v.name} -o table`,
599
488
  `az keyvault show --name ${v.name} -o jsonc`,
600
489
  ];
601
- $('#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('');
602
491
  }
603
492
  const isEditable = () => DRW.kind==='secrets' || DRW.kind==='certificates';
604
493
  async function loadKind(){
605
494
  const v = STATE.drawerVault; DRW.kind = STATE.kind; DRW.sel.clear();
606
- $('#dTools').style.display = '';
607
495
  closeCreate();
608
- $('#dNewBtns').style.display = STATE.kind==='secrets' ? '' : 'none';
609
496
  $('#dContent').innerHTML = '<div class="empty"><span class="spin"></span></div>';
610
497
  const r = await api(`/api/vaults/${v.name}/${DRW.kind}`);
498
+ if(STATE.route!=='vault') return;
611
499
  if(!r.ok){
612
- DRW.items=[]; $('#dBatch').classList.add('hide'); $('#dCount').textContent='';
500
+ DRW.items=[]; $('#dBatch').classList.add('hide'); if($('#dCount'))$('#dCount').textContent='';
613
501
  let why;
614
- 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>`;
615
503
  else if(r.forbidden) why = `🔒 You can see this vault but have no data-plane permission on its ${DRW.kind}.`;
616
- else why = r.error||'failed';
504
+ else why = esc(r.error||'failed');
617
505
  $('#dContent').innerHTML = `<div class="empty">${why}</div>`;
618
506
  return;
619
507
  }
@@ -621,37 +509,33 @@ async function loadKind(){
621
509
  name: item.name || (item.id||'').split('/').pop(),
622
510
  enabled: item.attributes ? item.attributes.enabled!==false : true,
623
511
  expires: item.attributes && item.attributes.expires || null,
624
- managed: item.managed===true, // cert-backed secret — controlled by its certificate
512
+ managed: item.managed===true,
625
513
  }));
626
514
  renderDrawerList();
627
515
  }
628
- function drawerFiltered(){
629
- const q = DRW.filter.toLowerCase();
630
- return DRW.items.filter(i=>!q || i.name.toLowerCase().includes(q));
631
- }
516
+ function drawerFiltered(){ const q=DRW.filter.toLowerCase(); return DRW.items.filter(i=>!q||i.name.toLowerCase().includes(q)); }
632
517
  function renderDrawerList(){
633
- const list = drawerFiltered();
634
- const editable = isEditable();
635
- $('#dCount').textContent = `${list.length}${list.length!==DRW.items.length?' / '+DRW.items.length:''} ${DRW.kind}`;
636
- $('#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';
637
521
  if(!DRW.items.length){ $('#dContent').innerHTML=`<div class="empty">No ${DRW.kind} in this vault, or none visible to you.</div>`; renderBatchBar(); return; }
638
- 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; }
639
523
  $('#dContent').innerHTML = list.map(i=>{
640
524
  const nm=i.name, sel=DRW.sel.has(nm), lock=i.managed;
641
- 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)}')">`:'';
642
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>':''}`;
643
- const reveal = DRW.kind==='secrets'?`<button class="loadval" onclick="reveal('${nm}')">reveal</button>`:'';
644
- 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>`:'';
645
529
  return `<div class="kvitem ${sel?'sel':''}">
646
- <div class="kvline">${check}<span class="nm" title="${nm}">${nm}</span><span class="meta">${pills}${reveal}${edit}</span></div>
647
- <div class="revealed hide" id="rv-${nm}"></div>
648
- <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>
649
533
  </div>`;
650
534
  }).join('');
651
535
  renderBatchBar();
652
536
  }
653
537
  function renderBatchBar(){
654
- const bar=$('#dBatch'), n=DRW.sel.size;
538
+ const bar=$('#dBatch'); if(!bar) return; const n=DRW.sel.size;
655
539
  if(!n || !isEditable() || !$('#dCreate').classList.contains('hide')){ bar.classList.add('hide'); return; }
656
540
  bar.classList.remove('hide');
657
541
  const secretOnly = DRW.kind==='secrets'
@@ -663,11 +547,9 @@ function renderBatchBar(){
663
547
  ${secretOnly}
664
548
  <button class="iconbtn" onclick="clearSel()">Clear</button>`;
665
549
  }
666
- // Batch-edit the VALUES of the selected secrets, pre-filled with their current values.
667
550
  async function batchEditValues(){
668
551
  const names=[...DRW.sel]; if(!names.length) return;
669
- const el=$('#dCreate'); $('#dBatch').classList.add('hide');
670
- el.classList.remove('hide');
552
+ const el=$('#dCreate'); $('#dBatch').classList.add('hide'); el.classList.remove('hide');
671
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>`;
672
554
  const vals = await Promise.all(names.map(n=>
673
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')}))));
@@ -679,14 +561,14 @@ async function batchEditValues(){
679
561
  if(blocked.length) notes.push(`${blocked.length} value(s) couldn't be read and were skipped`);
680
562
  if(multiline.length) notes.push(`${multiline.length} multi-line value(s) skipped — edit those individually`);
681
563
  el.innerHTML = `<h4>Edit ${usable.length} secret value${usable.length!==1?'s':''}</h4>
682
- <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>
683
565
  <div class="fld"><label>Expires</label><input type="date" id="cExp"><span class="hint">optional · applied to every saved secret</span></div>
684
566
  <div class="actions"><button class="btn sm" onclick="submitCreate('batch')">Save changes</button>
685
567
  <button class="btn sm ghost" onclick="closeCreate()">Cancel</button>
686
- <span class="hint" id="cMsg">${notes.join(' · ')}</span></div>`;
568
+ <span class="hint" id="cMsg">${esc(notes.join(' · '))}</span></div>`;
687
569
  }
688
570
  function toggleSel(nm){ DRW.sel.has(nm)?DRW.sel.delete(nm):DRW.sel.add(nm); renderDrawerList(); }
689
- function clearSel(){ DRW.sel.clear(); $('#dSelAll').checked=false; renderDrawerList(); }
571
+ function clearSel(){ DRW.sel.clear(); if($('#dSelAll'))$('#dSelAll').checked=false; renderDrawerList(); }
690
572
  function selectAll(on){ DRW.sel.clear(); if(on) drawerFiltered().filter(i=>!i.managed).forEach(i=>DRW.sel.add(i.name)); renderDrawerList(); }
691
573
  function openCreate(mode){
692
574
  const el=$('#dCreate'); DRW.sel.clear(); $('#dBatch').classList.add('hide');
@@ -736,7 +618,7 @@ async function submitCreate(mode){
736
618
  const ok=r.results.filter(x=>x.ok).length, fail=r.results.length-ok;
737
619
  toast(`Created ${ok} secret${ok!==1?'s':''}${fail?`, ${fail} failed`:''}`);
738
620
  if(fail){ const f=r.results.filter(x=>!x.ok);
739
- 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?'…':''}).`);
740
622
  setTimeout(()=>banner(''),9000); }
741
623
  closeCreate(); setTimeout(loadKind, 350);
742
624
  }
@@ -747,18 +629,17 @@ function toggleEdit(nm){
747
629
  const cert = DRW.kind==='certificates';
748
630
  const exp = it.expires?it.expires.slice(0,10):'';
749
631
  el.innerHTML = `
750
- <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>
751
633
  ${cert
752
634
  ? `<div class="fld"><label>Expires</label><span class="hint">${exp||'—'} · set by the certificate, not editable here</span></div>`
753
- : `<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>
754
- <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>
755
637
  <div class="fld"><label></label><span class="hint">current value is pre-filled — saving re-writes only if you change it</span></div>`}
756
- <div class="fld"><label></label><button class="btn sm" onclick="saveEdit('${nm}')">Save changes</button>
757
- <button class="btn sm ghost" onclick="toggleEdit('${nm}')">Cancel</button>
758
- <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>`;
759
641
  el.classList.remove('hide');
760
642
  if(!cert){
761
- const vEl=document.getElementById('edVal-'+nm);
762
643
  api(`/api/vaults/${DRW.vault}/secrets/${nm}/value`).then(r=>{
763
644
  const live=document.getElementById('edVal-'+nm); if(!live) return;
764
645
  if(r.ok){ live.value=r.data.value||''; live.dataset.orig=r.data.value||''; live.placeholder='secret value'; }
@@ -807,123 +688,208 @@ async function batchApply(action){
807
688
  const ok=r.results.filter(x=>x.ok).length, fail=r.results.length-ok;
808
689
  toast(`${ok} updated${fail?`, ${fail} failed`:''}`);
809
690
  if(fail){ const failed=r.results.filter(x=>!x.ok); console.warn('Batch failures:', failed);
810
- 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.`);
811
692
  setTimeout(()=>banner(''),8000); }
812
- DRW.sel.clear(); $('#dSelAll').checked=false;
693
+ DRW.sel.clear(); if($('#dSelAll'))$('#dSelAll').checked=false;
813
694
  setTimeout(loadKind, 350);
814
695
  }
815
- // Reveal a secret value on its own full-width line below the row (toggles).
816
696
  async function reveal(name){
817
- const box = document.getElementById('rv-'+name);
818
- if(!box) return;
697
+ const box = document.getElementById('rv-'+name); if(!box) return;
819
698
  if(!box.classList.contains('hide')){ box.classList.add('hide'); box.innerHTML=''; return; }
820
699
  box.classList.remove('hide'); box.innerHTML = '<span class="spin"></span>';
821
700
  const r = await api(`/api/vaults/${DRW.vault}/secrets/${name}/value`);
822
- 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; }
823
702
  const val = r.data.value || '';
824
- 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>`;
825
734
  }
826
-
827
- // ---------- expiring ----------
828
- let EXP = { days: 30, items: [], stats: null, scannedAt: 0, sort: 'daysLeft', dir: 1, scanned: false, polling: false };
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');
761
+ }
762
+
763
+ // ---------- EXPIRY ----------
764
+ let EXP = { days: 30, items: [], allItems: [], stats: null, scannedAt: 0, sort: 'daysLeft', dir: 1, scanned: false, polling: false };
829
765
  const ROW_CAP = 500;
830
- function applyStatus(s){
831
- EXP.polling=false; EXP.scanned=true;
832
- $('#expScan').disabled=false; $('#expScan').textContent='⟳ Re-scan';
833
- 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)}));
834
772
  EXP.stats=s.stats; EXP.scannedAt=s.scannedAt;
835
- const when = s.scannedAt? new Date(s.scannedAt).toLocaleTimeString():'';
836
- $('#expMeta').textContent = s.stats? `${s.stats.readable} readable · ${s.stats.forbidden} denied · ${s.stats.total} vaults · scanned ${when}` : '';
837
- renderExpiring();
838
773
  }
839
- // On first visit, reuse the last completed scan if one exists; only scan when there's nothing yet.
840
- async function enterExpiring(){
774
+ // Kick a background scan early so the dashboard's lifecycle cards fill in.
775
+ async function kickExpiryScan(){
841
776
  if(EXP.scanned || EXP.polling) return;
842
- const s = await api(`/api/expiring/status?days=${EXP.days}`);
843
- if(s.ok && s.running){ EXP.polling=true; pollExpiring(); return; }
844
- if(s.ok && s.scannedAt){ applyStatus(s); return; }
845
- 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(); }
846
814
  }
847
815
  async function scanExpiring(){
848
816
  if(EXP.polling) return;
849
- $('#expScan').disabled = true;
850
817
  const start = await api('/api/expiring/scan', {method:'POST'});
851
- 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; }
852
- 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;
853
820
  pollExpiring();
854
821
  }
855
- async function pollExpiring(){
856
- const s = await api(`/api/expiring/status?days=${EXP.days}`);
857
- 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;
858
837
  if(s.running){
859
- const pct = s.total? Math.round(s.done/s.total*100):0;
860
- $('#expScan').textContent = `Scanning ${s.done}/${s.total}`;
861
- $('#expHorizon').classList.add('hide');
862
- $('#expBody').innerHTML = `<tr><td colspan="8" class="empty">
863
- <div class="spin"></div>
864
- <div style="margin-top:16px">Inspecting <b>${s.done}</b> of <b>${s.total}</b> vaults…</div>
865
- <div class="progress"><div class="bar" style="width:${pct}%"></div></div>
866
- <div class="faint mono" style="margin-top:10px;font-size:11px">Read-denied vaults skip instantly · only your readable vaults are inspected</div>
867
- </td></tr>`;
868
- setTimeout(pollExpiring, 1500);
838
+ if($('#expScan')) $('#expScan').textContent = `Scanning ${s.done}/${s.total}`;
839
+ renderScanProgress();
840
+ setTimeout(()=>pollExpiring(silent), 1500);
869
841
  return;
870
842
  }
871
- EXP.polling = false; EXP.scanned = true;
872
- $('#expScan').disabled = false; $('#expScan').textContent = '⟳ Re-scan';
873
- EXP.items = (s.items||[]).map(it=>({...it, env: envOf(it.subscriptionId)}));
874
- EXP.stats = s.stats; EXP.scannedAt = s.scannedAt;
875
- const when = s.scannedAt? new Date(s.scannedAt).toLocaleTimeString():'';
876
- $('#expMeta').textContent = s.stats? `${s.stats.readable} readable · ${s.stats.forbidden} denied · ${s.stats.total} vaults · scanned ${when}` : '';
877
- renderExpiring();
878
- }
879
- 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){
880
849
  const k=EXP.sort, d=EXP.dir;
881
- 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;});
882
851
  }
883
852
  function renderExpiring(){
884
- const items = EXP.items;
853
+ if(STATE.route!=='expiry' || !$('#expCards')) return;
854
+ const items = summarize(EXP.allItems, EXP.days);
885
855
  const expired = items.filter(i=>i.daysLeft<0).length;
886
856
  const soon = items.filter(i=>i.daysLeft>=0&&i.daysLeft<=7).length;
887
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}` : '';
888
860
  $('#expCards').innerHTML = [
889
- card('Flagged', items.length, `within ${EXP.days} days`, items.length?'':''),
861
+ card('Flagged', items.length, `within ${EXP.days} days`, ''),
890
862
  card('Expired', expired, 'needs rotation', expired?'alert':''),
891
863
  card('≤ 7 days', soon, 'urgent', soon?'warn':''),
892
864
  card('Certificates', certs, (items.length-certs)+' secrets', 'azure'),
893
865
  ].join('');
894
- renderHorizon();
866
+ renderHorizon(items);
895
867
  if(!items.length){
896
- $('#expHorizon').classList.add('hide');
897
- $('#expTableNote').textContent='';
868
+ $('#expHorizon').classList.add('hide'); $('#expTableNote').textContent='';
898
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>`;
899
870
  return;
900
871
  }
901
- const list = expSorted();
902
- const shown = list.slice(0, ROW_CAP);
872
+ const list = expSorted(items); const shown = list.slice(0, ROW_CAP);
903
873
  $('#expTableNote').textContent = list.length>ROW_CAP ? `Showing first ${ROW_CAP} of ${list.length} — narrow the window or sort to see the rest.` : '';
904
874
  $('#expBody').innerHTML = shown.map(i=>{
905
875
  const sev = i.daysLeft<0?'bad':i.daysLeft<=7?'bad':i.daysLeft<=30?'warn':'good';
906
876
  const label = i.daysLeft<0?`expired ${-i.daysLeft}d ago`:i.daysLeft===0?'today':`${i.daysLeft} days`;
907
- const status = i.daysLeft<0?'<span class="sev expired">EXPIRED</span>':
908
- i.daysLeft<=7?'<span class="sev urgent">URGENT</span>':
909
- '<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>';
910
878
  return `<tr>
911
879
  <td><span class="dot" style="background:var(--${sev})"></span> <b>${label}</b></td>
912
880
  <td class="mono muted">${(i.expires||'').slice(0,10)}</td>
913
881
  <td class="mono">${i.type==='certificate'?'cert':'secret'}</td>
914
- <td class="mono">${i.name}${i.enabled?'':' <span class="pill">disabled</span>'}</td>
915
- <td class="mono muted">${i.vault}</td>
916
- <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>
917
885
  <td>${status}</td>
918
- <td class="right"><button class="iconbtn" onclick="openVault('${i.vault}','${i.subscriptionId}')">Open</button>
919
- <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>
920
888
  </tr>`;
921
889
  }).join('');
922
890
  }
923
- // Signature: expiry horizon — a time axis where every item is a temperature-colored tick.
924
- function renderHorizon(){
925
- const el = $('#expHorizon');
926
- const items = EXP.items;
891
+ function renderHorizon(items){
892
+ const el = $('#expHorizon'); if(!el) return;
927
893
  if(!items.length){ el.classList.add('hide'); el.innerHTML=''; return; }
928
894
  el.classList.remove('hide');
929
895
  const days = EXP.days;
@@ -935,11 +901,11 @@ function renderHorizon(){
935
901
  const ticks = future.map(i=>{
936
902
  const left = Math.min(99.6, Math.max(.4, i.daysLeft/days*100));
937
903
  const sev = i.daysLeft<=7?'alert':i.daysLeft<=30?'warn':'ok';
938
- 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>`;
939
905
  }).join('');
940
906
  const expTicks = expired.slice(0,140).map((i,idx,arr)=>{
941
907
  const left = arr.length>1 ? 8 + (idx/(arr.length-1))*84 : 50;
942
- 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>`;
943
909
  }).join('');
944
910
  el.innerHTML = `
945
911
  <div class="hz-head">
@@ -947,54 +913,110 @@ function renderHorizon(){
947
913
  <div class="hz-legend">
948
914
  <span><i style="background:var(--alert)"></i>≤7d / expired</span>
949
915
  <span><i style="background:var(--warn)"></i>≤30d</span>
950
- <span><i style="background:var(--ok)"></i>beyond</span>
951
- </div>
916
+ <span><i style="background:var(--ok)"></i>beyond</span></div>
952
917
  </div>
953
918
  <div class="hz-body">
954
919
  <div class="hz-expired" title="${expired.length} already expired">
955
- <div class="hz-expired-n">${expired.length}</div>
956
- <div class="hz-expired-l">EXPIRED</div>
957
- ${expTicks}
958
- </div>
920
+ <div class="hz-expired-n">${expired.length}</div><div class="hz-expired-l">EXPIRED</div>${expTicks}</div>
959
921
  <div class="hz-axis">${grid}${ticks}</div>
960
922
  </div>`;
961
923
  }
962
924
 
963
- // ---------- view switching ----------
964
- const META = {
965
- overview:['Overview','Overview','Accounts, subscriptions and vault inventory — read live from your az session.'],
966
- subscriptions:['Subscriptions','Subscriptions','Switch the subscription the az CLI is currently pointed at.'],
967
- vaults:['Key Vaults','Key Vaults','Browse every readable vault. Open one to inspect its secrets, keys and certificates.'],
968
- expiring:['Expiry','Expiring soon','Secrets and certificates that are expired or approaching expiry, across every vault you can read.'],
969
- };
970
- function show(view){
971
- document.querySelectorAll('nav button').forEach(b=>b.classList.toggle('active',b.dataset.view===view));
972
- ['overview','subscriptions','vaults','expiring'].forEach(v=>$('#view-'+v).classList.toggle('hide',v!==view));
973
- $('#search').classList.toggle('hide', view!=='vaults');
974
- $('#eyebrow').textContent = META[view][0];
975
- $('#title').textContent = META[view][1];
976
- $('#subtitle').textContent = META[view][2];
977
- 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');
951
+ }
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
+ ];
978
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(); }); }
979
1002
 
980
1003
  // ---------- events ----------
981
- document.querySelectorAll('nav button').forEach(b=>b.onclick=()=>show(b.dataset.view));
982
- $('#refresh').onclick=()=>{ toast('Refreshing…'); boot(); loadVaults(true); };
983
- $('#search').oninput=e=>{ STATE.q=e.target.value; renderVaults(); };
984
- $('#dClose').onclick=closeDrawer; $('#overlay').onclick=closeDrawer;
985
- $('#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(); });
986
- $('#dFilter').oninput=e=>{ DRW.filter=e.target.value; renderDrawerList(); };
987
- $('#dSelAll').onchange=e=>selectAll(e.target.checked);
988
- $('#dNew').onclick=()=>openCreate('single');
989
- $('#dNewBatch').onclick=()=>openCreate('batch');
990
- 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(); });
991
- 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(); });
992
- $('#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(); } } });
993
- $('#expScan').onclick=()=>scanExpiring(true);
994
- 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
+ });
995
1019
 
996
- const initView = new URLSearchParams(location.search).get('view');
997
- if(initView && META[initView]) show(initView);
998
1020
  boot();
999
1021
  </script>
1000
1022
  </body>