dsh-keys-manager 0.1.0 → 0.2.1

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/lib/client.js CHANGED
@@ -97,9 +97,11 @@ var zh = {
97
97
  failures: "\u5931\u8D25 {n} \u6B21",
98
98
  adoptHint: "\u63A5\u7BA1\u540E\uFF0C\u8BE5 Provider \u7684 API Key \u5C06\u7531\u5BC6\u94A5\u7BA1\u7406\u5668\u7EDF\u4E00\u63D0\u4F9B\u3002\u5F53\u524D\u51ED\u8BC1\u4F1A\u5BFC\u5165\u4E3A\u7B2C\u4E00\u4E2A Key\uFF0C\u539F\u5F15\u7528\u4F1A\u4FDD\u7559\u4EE5\u4FBF\u505C\u6B62\u63A5\u7BA1\u65F6\u6062\u590D\u3002",
99
99
  adoptExtraHint: "\u8FD9\u4E2A\u6269\u5C55 Provider \u5C1A\u672A\u914D\u7F6E\u3002\u6DFB\u52A0 Key \u540E\uFF0C\u63D2\u4EF6\u4F1A\u81EA\u52A8\u5B89\u88C5 llm-pi-ai \u8DEF\u7531\uFF0C\u5E76\u4F7F\u7528\u81EA\u52A8\u53D1\u73B0\u6216\u5185\u7F6E\u6A21\u578B\u5217\u8868\u3002",
100
+ adoptAccountTitle: "\u4F7F\u7528 OAuth \u8D26\u6237\u767B\u5F55\u540E\u5373\u53EF\u4F7F\u7528\u8BE5 Provider\u3002",
100
101
  unsupported: "\u6682\u4E0D\u652F\u6301\u5BC6\u94A5\u6C60",
101
102
  bindingBroken: "Provider \u914D\u7F6E\u5DF2\u4E0D\u518D\u6307\u5411\u5BC6\u94A5\u7BA1\u7406\u5668\u3002\u8BF7\u5148\u505C\u6B62\u63A5\u7BA1\u518D\u91CD\u65B0\u63A5\u7BA1\uFF0C\u6216\u5728\u6A21\u578B\u8BBE\u7F6E\u4E2D\u4FEE\u590D\u8BE5 Provider\u3002",
102
103
  noKeys: "\u8FD8\u6CA1\u6709\u5BC6\u94A5\u3002\u6DFB\u52A0\u4E00\u4E2A\u540E\u5373\u53EF\u4F7F\u7528\u8BE5 Provider\u3002",
104
+ noKeysAccount: "\u8FD8\u6CA1\u6709\u8D26\u6237\u3002\u6DFB\u52A0\u4E00\u4E2A\u8D26\u6237\u540E\u5373\u53EF\u4F7F\u7528\u8BE5 Provider\u3002",
103
105
  warning: "\u6CE8\u610F",
104
106
  settingsTitle: "\u7B56\u7565",
105
107
  settingsIntro: "\u4EE5\u4E0B\u8BBE\u7F6E\u5BF9\u6240\u6709\u5DF2\u63A5\u7BA1\u7684 Provider \u751F\u6548\u3002",
@@ -150,6 +152,7 @@ var zh = {
150
152
  keyStatusCooling: "\u51B7\u5374\u4E2D",
151
153
  keyStatusPaused: "\u5DF2\u6682\u505C",
152
154
  keyStatusMissing: "\u51ED\u8BC1\u7F3A\u5931",
155
+ keyStatusEmpty: "\u6682\u65E0\u5BC6\u94A5",
153
156
  keyStatusUnmanaged: "\u672A\u63A5\u7BA1",
154
157
  providerKeys: "{n} \u4E2A\u5BC6\u94A5",
155
158
  providerKeyOne: "1 \u4E2A\u5BC6\u94A5",
@@ -164,7 +167,25 @@ var zh = {
164
167
  openModels: "\u6253\u5F00\u6A21\u578B\u8BBE\u7F6E",
165
168
  searchResults: "{n} \u4E2A\u5339\u914D",
166
169
  reset: "\u91CD\u7F6E",
167
- attention: "\u9700\u8981\u5904\u7406"
170
+ attention: "\u9700\u8981\u5904\u7406",
171
+ // OAuth account surface — same values as locale/zh.json (dual source
172
+ // until the host's dsh-client-locale merge semantics are confirmed).
173
+ addAccount: "\u6DFB\u52A0\u8D26\u6237",
174
+ accountIdentity: "\u8D26\u6237",
175
+ accountProjected: "\u670D\u52A1\u4E2D",
176
+ accountReauth: "\u91CD\u65B0\u8BA4\u8BC1",
177
+ accountActivate: "\u8BBE\u4E3A\u5F53\u524D",
178
+ loginTitle: "\u8D26\u6237\u767B\u5F55",
179
+ loginPending: "\u7B49\u5F85\u6388\u6743\u2026",
180
+ loginBrowserOpen: "\u5728\u6D4F\u89C8\u5668\u4E2D\u6253\u5F00",
181
+ loginPromptTitle: "\u5B8C\u6210\u767B\u5F55",
182
+ loginPromptSubmit: "\u63D0\u4EA4",
183
+ loginFailed: "\u767B\u5F55\u5931\u8D25",
184
+ loginFlowEnded: "\u672C\u6B21\u767B\u5F55\u6D41\u7A0B\u5DF2\u7ED3\u675F\uFF0C\u8BF7\u5173\u95ED\u7A97\u53E3\u540E\u91CD\u65B0\u5F00\u59CB\u3002",
185
+ loginReady: "\u767B\u5F55\u6210\u529F\u3002",
186
+ deleteAccountTitle: "\u5220\u9664\u8D26\u6237\u201C{alias}\u201D\uFF1F",
187
+ deleteAccountBody: "\u8FD9\u4F1A\u4ECE\u51ED\u8BC1\u5E93\u4E2D\u5220\u9664\u8BE5\u8D26\u6237\u4FDD\u5B58\u7684 OAuth \u6388\u6743\u3002\u5982\u679C\u5B83\u662F\u5F53\u524D\u8D26\u6237\uFF0C\u8BE5 Provider \u8DEF\u7531\u5C06\u505C\u7528\u5B83\u5E76\u56DE\u843D\u5230 API-key \u6C60\uFF1BProvider \u4ECD\u4FDD\u6301\u63A5\u7BA1\u72B6\u6001\u3002",
188
+ deleteAccountBodyNoKeys: "\u8FD9\u4F1A\u4ECE\u51ED\u8BC1\u5E93\u4E2D\u5220\u9664\u8BE5\u8D26\u6237\u4FDD\u5B58\u7684 OAuth \u6388\u6743\u3002\u8BE5\u8DEF\u7531\u6CA1\u6709 API-key \u6C60\uFF0C\u5220\u9664\u540E\u5C06\u505C\u6B62\u670D\u52A1\uFF0C\u76F4\u5230\u91CD\u65B0\u767B\u5F55\u8D26\u6237\u3002"
168
189
  };
169
190
  var en = {
170
191
  nav: "Keys Manager",
@@ -224,9 +245,11 @@ var en = {
224
245
  failures: "{n} failures",
225
246
  adoptHint: "Adopting turns this provider\u2019s API-key source over to Keys Manager. Its current credential is imported as the first key, and the original reference is kept for release.",
226
247
  adoptExtraHint: "This extra provider is not configured yet. Add a key and the plugin installs an llm-pi-ai route with discovered or built-in models.",
248
+ adoptAccountTitle: "Sign in with an OAuth account to use this provider.",
227
249
  unsupported: "Not available for key pools",
228
250
  bindingBroken: "The provider profile no longer points at Keys Manager. Release and re-adopt it, or repair the provider in Models.",
229
251
  noKeys: "No keys yet. Add one to make this provider usable.",
252
+ noKeysAccount: "No accounts yet. Add an account to make this provider usable.",
230
253
  warning: "Warning",
231
254
  settingsTitle: "Policy",
232
255
  settingsIntro: "These values apply to every managed provider.",
@@ -277,6 +300,7 @@ var en = {
277
300
  keyStatusCooling: "Cooling",
278
301
  keyStatusPaused: "Paused",
279
302
  keyStatusMissing: "Credential missing",
303
+ keyStatusEmpty: "No keys",
280
304
  keyStatusUnmanaged: "Not managed",
281
305
  providerKeys: "{n} keys",
282
306
  providerKeyOne: "1 key",
@@ -291,7 +315,25 @@ var en = {
291
315
  openModels: "Open Models",
292
316
  searchResults: "{n} matches",
293
317
  reset: "Reset",
294
- attention: "Needs attention"
318
+ attention: "Needs attention",
319
+ // OAuth account surface — same values as locale/en.json (dual source
320
+ // until the host's dsh-client-locale merge semantics are confirmed).
321
+ addAccount: "Add account",
322
+ accountIdentity: "Account",
323
+ accountProjected: "Serving provider",
324
+ accountReauth: "Re-authenticate",
325
+ accountActivate: "Set active",
326
+ loginTitle: "Account sign-in",
327
+ loginPending: "Waiting for authorization\u2026",
328
+ loginBrowserOpen: "Open in browser",
329
+ loginPromptTitle: "Complete sign-in",
330
+ loginPromptSubmit: "Submit",
331
+ loginFailed: "Login failed",
332
+ loginFlowEnded: "This sign-in session has ended. Close the dialog and start again.",
333
+ loginReady: "Signed in.",
334
+ deleteAccountTitle: "Delete account \u201C{alias}\u201D?",
335
+ deleteAccountBody: "This removes the account\u2019s stored OAuth grant from the credential store. If it is the active account, the provider route stops serving it and falls back to its API-key pool; the provider stays managed.",
336
+ deleteAccountBodyNoKeys: "This removes the account\u2019s stored OAuth grant from the credential store. This route has no API-key pool, so it stops serving requests until an account is signed in again."
295
337
  };
296
338
  var LOCALE_DICTS = { zh, en };
297
339
 
@@ -348,6 +390,10 @@ var CSS = `
348
390
  .km-keys{display:flex;flex-direction:column;border:1px solid var(--dsw-alias-border-l1);border-radius:9px;background:var(--dsw-alias-bg-layer-1);overflow:visible}
349
391
  .km-key-row{position:relative;display:grid;grid-template-columns:minmax(0,1.35fr) minmax(150px,.8fr) minmax(150px,.9fr) 32px;gap:10px;align-items:center;padding:10px 11px;border-top:1px solid var(--dsw-alias-border-l1)}
350
392
  .km-key-row:first-child{border-top:0}
393
+ .km-account-row{position:relative;display:grid;grid-template-columns:minmax(0,1.35fr) minmax(150px,.8fr) minmax(150px,.9fr) 32px;gap:10px;align-items:center;padding:10px 11px 10px 14px;border-top:1px solid var(--dsw-alias-border-l1)}
394
+ .km-account-row:first-child{border-top:0}
395
+ .km-account-row::before{content:'';position:absolute;left:0;top:12px;bottom:12px;width:2px;border-radius:1px;background:color-mix(in srgb,var(--dsw-alias-state-business-primary) 55%,transparent)}
396
+ .km-account-glyph{display:inline-flex;align-items:center;justify-content:center;width:22px;height:22px;flex:0 0 22px;border-radius:50%;background:color-mix(in srgb,var(--dsw-alias-state-business-primary) 14%,var(--dsw-alias-bg-layer-2));color:var(--dsw-alias-state-business-primary)}
351
397
  .km-key-identity{min-width:0;display:flex;flex-direction:column;gap:2px}
352
398
  .km-key-alias-line{display:flex;align-items:center;gap:6px;min-width:0}
353
399
  .km-key-alias{font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:220px}
@@ -458,11 +504,18 @@ select.km-input{appearance:auto}
458
504
  .km-modal-title{font-weight:650;font-size:14px;margin-bottom:7px}
459
505
  .km-modal-body{color:var(--dsw-alias-label-secondary);font-size:12.5px}
460
506
  .km-modal-copy{margin:0;line-height:1.55}
507
+ .km-login-flow{display:flex;flex-direction:column;gap:10px}
508
+ .km-login-pending{display:flex;align-items:center;gap:8px;color:var(--dsw-alias-label-secondary);font-size:12.5px}
509
+ .km-login-notices{color:var(--dsw-alias-label-secondary);font-size:12px;line-height:1.5;word-break:break-word}
510
+ .km-login-link{color:var(--dsw-alias-state-business-primary);text-decoration:underline;text-underline-offset:2px}
511
+ .km-login-link:hover{color:var(--dsw-alias-label-primary)}
512
+ .km-login-prompt{display:flex;flex-direction:column;gap:10px;padding-top:2px;border-top:1px dashed var(--dsw-alias-border-l1)}
513
+ .km-login-prompt-title{font-weight:600;font-size:12.5px;color:var(--dsw-alias-label-primary)}
461
514
  .km-modal-footer{display:flex;justify-content:flex-end;gap:8px;margin-top:16px}
462
515
  @keyframes km-spin{to{transform:rotate(360deg)}}
463
516
  @media(max-width:720px){
464
517
  .km-summary{grid-template-columns:repeat(2,minmax(0,1fr))}
465
- .km-key-row{grid-template-columns:minmax(0,1fr) 32px;gap:6px}
518
+ .km-key-row,.km-account-row{grid-template-columns:minmax(0,1fr) 32px;gap:6px}
466
519
  .km-key-status,.km-key-last{grid-column:1;padding-left:0}
467
520
  .km-key-actions{grid-column:2;grid-row:1}
468
521
  .km-settings-grid{grid-template-columns:1fr}
@@ -556,6 +609,46 @@ function createStore(ctx) {
556
609
  emit();
557
610
  };
558
611
  const rpc = (method, args) => ctx.connection.rpc.call("/api", `keysManager/${method}`, { args });
612
+ const send = async (method, args, onSuccess) => {
613
+ queuedMutations += 1;
614
+ update({ busy: method, error: null });
615
+ const run = async () => {
616
+ ++loadSequence;
617
+ try {
618
+ const result = await rpc(method, args);
619
+ if (!result.ok) throw new Error(result.error.message);
620
+ const outcome = onSuccess(result.value);
621
+ if (queuedMutations === 1) update({ busy: null });
622
+ return outcome;
623
+ } catch (error) {
624
+ const message = messageOf(error);
625
+ if (queuedMutations === 1) update({ busy: null, error: message });
626
+ else update({ error: message });
627
+ return { ok: false, error: message };
628
+ } finally {
629
+ queuedMutations -= 1;
630
+ }
631
+ };
632
+ const next = mutationTail.then(run, run);
633
+ mutationTail = next.then(() => void 0, () => void 0);
634
+ return next;
635
+ };
636
+ const stateOutcome = (value) => {
637
+ const state = value;
638
+ if (queuedMutations === 1) update({ status: "ready", state, now: Date.now(), busy: null, error: null });
639
+ else update({ status: "ready", state, now: Date.now(), error: null });
640
+ return { ok: true, value: state };
641
+ };
642
+ const rpcRead = async (method, args) => {
643
+ try {
644
+ return await rpc(method, args);
645
+ } catch (error) {
646
+ return { ok: false, error: { message: messageOf(error) } };
647
+ }
648
+ };
649
+ const attemptOutcome = (value) => {
650
+ return { ok: true, value: value ?? null };
651
+ };
559
652
  return {
560
653
  getSnapshot: () => snapshot,
561
654
  subscribe(listener) {
@@ -577,32 +670,29 @@ function createStore(ctx) {
577
670
  }
578
671
  },
579
672
  call(method, args) {
580
- queuedMutations += 1;
581
- update({ busy: method, error: null });
582
- const run = async () => {
583
- ++loadSequence;
584
- try {
585
- const result = await rpc(method, args);
586
- if (!result.ok) throw new Error(result.error.message);
587
- const outcome = { ok: true, value: result.value };
588
- if (queuedMutations === 1) {
589
- update({ status: "ready", state: result.value, now: Date.now(), busy: null, error: null });
590
- } else {
591
- update({ status: "ready", state: result.value, now: Date.now(), error: null });
592
- }
593
- return outcome;
594
- } catch (error) {
595
- const message = messageOf(error);
596
- if (queuedMutations === 1) update({ busy: null, error: message });
597
- else update({ error: message });
598
- return { ok: false, error: message };
599
- } finally {
600
- queuedMutations -= 1;
601
- }
602
- };
603
- const next = mutationTail.then(run, run);
604
- mutationTail = next.then(() => void 0, () => void 0);
605
- return next;
673
+ return send(method, args, stateOutcome);
674
+ },
675
+ beginAccountLogin(provider, alias, targetAccountId) {
676
+ return send("beginAccountLogin", { provider, alias, targetAccountId }, attemptOutcome);
677
+ },
678
+ answerAccountPrompt(attemptId, value) {
679
+ return send("answerAccountPrompt", { attemptId, value }, () => ({ ok: true, value: null }));
680
+ },
681
+ async accountAttempt(attemptId) {
682
+ const result = await rpcRead("accountAttempt", { attemptId });
683
+ if (!result.ok) return { ok: false, error: result.error.message };
684
+ return attemptOutcome(result.value);
685
+ },
686
+ async listAccountAttempts() {
687
+ const result = await rpcRead("listAccountAttempts", {});
688
+ if (!result.ok) return { ok: false, error: result.error.message };
689
+ return { ok: true, value: result.value ?? [] };
690
+ },
691
+ activateAccount(provider, accountId) {
692
+ return send("activateAccount", { provider, accountId }, stateOutcome);
693
+ },
694
+ removeAccount(provider, accountId) {
695
+ return send("removeAccount", { provider, accountId }, stateOutcome);
606
696
  },
607
697
  tick() {
608
698
  update({ now: Date.now() });
@@ -672,6 +762,17 @@ function providerInitial(provider) {
672
762
  const source = String(provider?.displayName || provider?.provider || "?").trim();
673
763
  return source.length === 0 ? "?" : source.slice(0, 1).toUpperCase();
674
764
  }
765
+ function oauthCapable(provider) {
766
+ return provider.nativeAuth?.oauth === true && provider.accountSupported !== false;
767
+ }
768
+ function accountIdentityOf(key, provider) {
769
+ const oauth = key.oauth;
770
+ if (oauth !== null && oauth !== void 0) {
771
+ if (oauth.email !== void 0 && oauth.email.length > 0) return oauth.email;
772
+ if (oauth.accountId !== void 0 && oauth.accountId.length > 0) return oauth.accountId;
773
+ }
774
+ return provider.displayName || provider.provider;
775
+ }
675
776
  function statusKey(status) {
676
777
  switch (status) {
677
778
  case "active":
@@ -682,6 +783,10 @@ function statusKey(status) {
682
783
  return "keyStatusPaused";
683
784
  case "missing":
684
785
  return "keyStatusMissing";
786
+ // A managed route with zero keys (round-3 finding 7): "Not managed"
787
+ // would contradict the Managed badge rendered beside it.
788
+ case "empty":
789
+ return "keyStatusEmpty";
685
790
  default:
686
791
  return "keyStatusUnmanaged";
687
792
  }
@@ -696,6 +801,8 @@ function statusTone(status) {
696
801
  return "idle";
697
802
  case "missing":
698
803
  return "error";
804
+ case "empty":
805
+ return "idle";
699
806
  default:
700
807
  return "idle";
701
808
  }
@@ -728,6 +835,7 @@ function Icon({ name, size = 16, className }) {
728
835
  if (name === "refresh") return h("svg", common, h("path", { d: "M20 11a8 8 0 10-2.3 6M20 5v6h-6" }));
729
836
  if (name === "warning") return h("svg", common, h("path", { d: "M12 3l9.5 17h-19L12 3zM12 9v5M12 17.2v.1" }));
730
837
  if (name === "shield") return h("svg", common, h("path", { d: "M12 3l8 3v6c0 5-3.5 8-8 9-4.5-1-8-4-8-9V6l8-3zM9 12l2 2 4-4" }));
838
+ if (name === "user") return h("svg", common, h("circle", { cx: 12, cy: 8, r: 3.6 }), h("path", { d: "M5 20a7 7 0 0114 0" }));
731
839
  return h("svg", common);
732
840
  }
733
841
  function Button({ variant = "secondary", size = "normal", type = "button", disabled, onClick, children, title }) {
@@ -1090,6 +1198,154 @@ function KeyRow({ provider, keyRecord, now }) {
1090
1198
  }, h("p", { className: "km-modal-copy" }, t("deleteBody"))) : null
1091
1199
  );
1092
1200
  }
1201
+ function AccountRow({ provider, keyRecord, now, onReauthenticate }) {
1202
+ const runtime = useRuntime();
1203
+ const t = useT();
1204
+ const locale = useLocaleId();
1205
+ const key = keyRecord;
1206
+ const identity = accountIdentityOf(key, provider);
1207
+ const [menu, setMenu] = import_react2.default.useState(false);
1208
+ const [confirm, setConfirm] = import_react2.default.useState(false);
1209
+ const [busy, setBusy] = import_react2.default.useState(false);
1210
+ const [error, setError] = import_react2.default.useState(null);
1211
+ const rootRef = import_react2.default.useRef(null);
1212
+ import_react2.default.useEffect(() => {
1213
+ if (!menu) return void 0;
1214
+ const onDown = (event) => {
1215
+ if (rootRef.current !== null && !rootRef.current.contains(event.target)) setMenu(false);
1216
+ };
1217
+ const onKey = (event) => {
1218
+ if (event.key === "Escape") setMenu(false);
1219
+ };
1220
+ document.addEventListener("mousedown", onDown);
1221
+ document.addEventListener("keydown", onKey);
1222
+ return () => {
1223
+ document.removeEventListener("mousedown", onDown);
1224
+ document.removeEventListener("keydown", onKey);
1225
+ };
1226
+ }, [menu]);
1227
+ async function activate() {
1228
+ setBusy(true);
1229
+ setError(null);
1230
+ const result = await runtime.store.activateAccount(provider.provider, key.id);
1231
+ setBusy(false);
1232
+ if (!result.ok) setError(result.error);
1233
+ }
1234
+ async function remove() {
1235
+ setBusy(true);
1236
+ setError(null);
1237
+ const result = await runtime.store.removeAccount(provider.provider, key.id);
1238
+ setBusy(false);
1239
+ if (!result.ok) setError(result.error);
1240
+ else setConfirm(false);
1241
+ }
1242
+ const cold = key.status === "cooling" && key.cooldownUntil > now;
1243
+ const metaParts = [
1244
+ key.totalUses > 0 ? t("uses", { n: key.totalUses }) : null,
1245
+ key.totalFailures > 0 ? t("failures", { n: key.totalFailures }) : null
1246
+ ].filter((value) => value !== null);
1247
+ return h(
1248
+ "div",
1249
+ { className: "km-account-row", ref: rootRef },
1250
+ h(
1251
+ "div",
1252
+ { className: "km-key-identity" },
1253
+ h(
1254
+ "div",
1255
+ { className: "km-key-alias-line" },
1256
+ h("span", { className: "km-account-glyph", "aria-hidden": true }, h(Icon, { name: "user", size: 13 })),
1257
+ h("span", { className: "km-key-alias" }, identity),
1258
+ h("span", { className: "km-tag km-tag-accent" }, t("accountIdentity")),
1259
+ // Step 6: the row whose grant IS the live projection is badged.
1260
+ key.projected === true ? h("span", { className: "km-tag km-tag-accent" }, t("accountProjected")) : null
1261
+ ),
1262
+ h(
1263
+ "div",
1264
+ { className: "km-key-sub" },
1265
+ h("span", { className: "km-mono km-muted" }, `\u2022\u2022\u2022\u2022 ${key.id.slice(-4)}`)
1266
+ )
1267
+ ),
1268
+ h(
1269
+ "div",
1270
+ { className: "km-key-status" },
1271
+ h(StatusPill, { status: key.status, t }),
1272
+ cold ? h("div", { className: "km-key-time km-warn" }, t("coolingUntil", { time: formatClock(key.cooldownUntil, locale, t) })) : null,
1273
+ cold ? h("div", { className: "km-key-time" }, t("remaining", { time: durationText(key.cooldownUntil - now, t) })) : null
1274
+ ),
1275
+ h(
1276
+ "div",
1277
+ { className: "km-key-last" },
1278
+ h("div", { className: "km-key-time" }, `${t("lastUsed")}: ${relativeTime(key.lastUsedAt, now, t)}`),
1279
+ metaParts.length > 0 ? h(
1280
+ "div",
1281
+ { className: "km-key-time km-muted" },
1282
+ metaParts.map((value, index) => [
1283
+ index > 0 ? h("span", { key: `sep-${index}`, className: "km-dot-sep", "aria-hidden": true }, "\xB7") : null,
1284
+ h("span", { key: `part-${index}` }, value)
1285
+ ])
1286
+ ) : null
1287
+ ),
1288
+ h(
1289
+ "div",
1290
+ { className: "km-key-actions" },
1291
+ h("button", {
1292
+ type: "button",
1293
+ className: "km-icon-btn",
1294
+ "aria-haspopup": "menu",
1295
+ "aria-expanded": menu,
1296
+ title: t("moreActions"),
1297
+ "aria-label": t("moreActions"),
1298
+ onClick: () => setMenu((current) => !current),
1299
+ disabled: busy
1300
+ }, h(Icon, { name: "dots", size: 17 })),
1301
+ menu ? h(
1302
+ "div",
1303
+ { className: "km-menu", role: "menu" },
1304
+ h("button", {
1305
+ type: "button",
1306
+ className: "km-menu-item",
1307
+ role: "menuitem",
1308
+ onClick: () => {
1309
+ setMenu(false);
1310
+ void activate();
1311
+ },
1312
+ // Step 6: the projected row already serves the route; setting it
1313
+ // active again is a no-op, so the entry stays disabled.
1314
+ disabled: busy || key.projected === true
1315
+ }, h(Icon, { name: "check", size: 15 }), t("accountActivate")),
1316
+ h("button", {
1317
+ type: "button",
1318
+ className: "km-menu-item",
1319
+ role: "menuitem",
1320
+ onClick: () => {
1321
+ setMenu(false);
1322
+ onReauthenticate();
1323
+ },
1324
+ disabled: busy
1325
+ }, h(Icon, { name: "refresh", size: 15 }), t("accountReauth")),
1326
+ h("div", { className: "km-menu-sep" }),
1327
+ h("button", { type: "button", className: "km-menu-item km-menu-danger", role: "menuitem", onClick: () => {
1328
+ setMenu(false);
1329
+ setConfirm(true);
1330
+ }, disabled: busy }, h(Icon, { name: "trash", size: 15 }), t("delete"))
1331
+ ) : null
1332
+ ),
1333
+ error !== null ? h("div", { className: "km-key-error", role: "alert" }, error) : null,
1334
+ confirm ? h(Modal, {
1335
+ // SF4: account-specific copy — the grant removal consequence, not a
1336
+ // key-deletion/provider-release promise. Step 8: the key-pool
1337
+ // fallback clause appears only when the route actually has key
1338
+ // support; an OAuth-only route states the real consequence instead.
1339
+ title: t("deleteAccountTitle", { alias: identity }),
1340
+ onClose: () => setConfirm(false),
1341
+ busy,
1342
+ footer: [
1343
+ h(Button, { key: "cancel", onClick: () => setConfirm(false), disabled: busy }, t("cancel")),
1344
+ h(Button, { key: "delete", variant: "danger", onClick: remove, disabled: busy }, busy ? t("deleting") : t("deleteConfirm"))
1345
+ ]
1346
+ }, h("p", { className: "km-modal-copy" }, t(provider.supported === true ? "deleteAccountBody" : "deleteAccountBodyNoKeys"))) : null
1347
+ );
1348
+ }
1093
1349
  function ProviderCard({ provider, expanded, onToggle, now }) {
1094
1350
  const runtime = useRuntime();
1095
1351
  const t = useT();
@@ -1097,6 +1353,8 @@ function ProviderCard({ provider, expanded, onToggle, now }) {
1097
1353
  const [adopting, setAdopting] = import_react2.default.useState(false);
1098
1354
  const [releasing, setReleasing] = import_react2.default.useState(false);
1099
1355
  const [confirmRelease, setConfirmRelease] = import_react2.default.useState(false);
1356
+ const [login, setLogin] = import_react2.default.useState(null);
1357
+ const [startingLogin, setStartingLogin] = import_react2.default.useState(false);
1100
1358
  const [error, setError] = import_react2.default.useState(null);
1101
1359
  const managed = provider.managed;
1102
1360
  const broken = managed && provider.bindingHealthy === false;
@@ -1128,6 +1386,35 @@ function ProviderCard({ provider, expanded, onToggle, now }) {
1128
1386
  }
1129
1387
  setConfirmRelease(false);
1130
1388
  }
1389
+ async function openLogin(targetAccountId, alias) {
1390
+ if (startingLogin) return;
1391
+ setStartingLogin(true);
1392
+ setError(null);
1393
+ const result = await runtime.store.beginAccountLogin(provider.provider, alias, targetAccountId);
1394
+ setStartingLogin(false);
1395
+ if (!result.ok) {
1396
+ setError(result.error);
1397
+ return;
1398
+ }
1399
+ if (result.value !== null) setLogin({ attemptId: result.value.id });
1400
+ }
1401
+ async function adoptThenLogin() {
1402
+ if (provider.managed) {
1403
+ await openLogin();
1404
+ return;
1405
+ }
1406
+ setAdopting(true);
1407
+ setError(null);
1408
+ const result = await runtime.store.call("adoptProvider", { provider: provider.provider });
1409
+ setAdopting(false);
1410
+ if (!result.ok) {
1411
+ setError(result.error);
1412
+ return;
1413
+ }
1414
+ await openLogin();
1415
+ }
1416
+ const accountAdoptable = oauthCapable(provider);
1417
+ const adoptable = provider.supported || accountAdoptable;
1131
1418
  return h(
1132
1419
  "div",
1133
1420
  { className: cx("km-card", "km-provider", expanded && "km-provider-open", managed && "km-provider-managed") },
@@ -1149,7 +1436,7 @@ function ProviderCard({ provider, expanded, onToggle, now }) {
1149
1436
  h("span", { className: "km-provider-name" }, provider.displayName || provider.provider),
1150
1437
  h("span", { className: "km-tag" }, originLabel),
1151
1438
  managed ? h("span", { className: "km-tag km-tag-accent" }, t("managedBadge")) : null,
1152
- !managed && !provider.supported ? h("span", { className: "km-tag km-tag-warn" }, t("unsupported")) : null
1439
+ !managed && !provider.supported && !accountAdoptable ? h("span", { className: "km-tag km-tag-warn" }, t("unsupported")) : null
1153
1440
  ),
1154
1441
  h(
1155
1442
  "span",
@@ -1163,7 +1450,7 @@ function ProviderCard({ provider, expanded, onToggle, now }) {
1163
1450
  h(
1164
1451
  "span",
1165
1452
  { className: "km-provider-side" },
1166
- managed ? h("span", { className: "km-count" }, countText(t, keyCount, "providerKeyOne", "providerKeys")) : h("span", { className: "km-count km-muted" }, provider.supported ? t("unmanaged") : t("unsupported")),
1453
+ managed ? h("span", { className: "km-count" }, countText(t, keyCount, "providerKeyOne", "providerKeys")) : h("span", { className: "km-count km-muted" }, adoptable ? t("unmanaged") : t("unsupported")),
1167
1454
  managed ? h(StatusPill, { status: provider.status, t }) : null,
1168
1455
  h(Icon, { name: "chevron", size: 16, className: cx("km-chevron", expanded && "km-chevron-open") })
1169
1456
  )
@@ -1179,7 +1466,15 @@ function ProviderCard({ provider, expanded, onToggle, now }) {
1179
1466
  h(
1180
1467
  "div",
1181
1468
  { className: "km-adopt-copy" },
1182
- h("div", { className: "km-adopt-title" }, provider.supported ? t(isExtra ? "adoptExtraHint" : "adoptHint") : t("unsupported")),
1469
+ // I-000063: an OAuth-only route is account-adoptable, so its
1470
+ // adopt panel headline must invite sign-in instead of claiming
1471
+ // the provider is unsupported. The unsupported headline remains
1472
+ // only for providers that are neither key- nor account-capable.
1473
+ h(
1474
+ "div",
1475
+ { className: "km-adopt-title" },
1476
+ provider.supported ? t(isExtra ? "adoptExtraHint" : "adoptHint") : accountAdoptable ? t("adoptAccountTitle") : t("unsupported")
1477
+ ),
1183
1478
  !provider.supported && provider.reason !== void 0 ? h("div", { className: "km-muted" }, provider.reason) : null,
1184
1479
  provider.apiKeyEnv !== null ? h("div", { className: "km-meta-line" }, h("span", { className: "km-muted" }, `${t("secret")}:`), h("span", { className: "km-mono" }, provider.apiKeyEnv)) : null,
1185
1480
  provider.baseURL !== null ? h("div", { className: "km-meta-line" }, h("span", { className: "km-muted" }, `${t("baseUrl")}:`), h("span", { className: "km-mono km-truncate" }, provider.baseURL)) : null,
@@ -1190,10 +1485,18 @@ function ProviderCard({ provider, expanded, onToggle, now }) {
1190
1485
  { className: "km-adopt-actions" },
1191
1486
  h(
1192
1487
  Button,
1193
- { variant: "primary", onClick: adopt, disabled: adopting || !provider.supported },
1488
+ { variant: "primary", onClick: adopt, disabled: adopting || !adoptable },
1194
1489
  adopting ? h(Icon, { name: "refresh", size: 14, className: "km-spin" }) : h(Icon, { name: "shield", size: 14 }),
1195
1490
  h("span", null, adopting ? t("adopting") : t("adopt"))
1196
- )
1491
+ ),
1492
+ // MF1: Add account is reachable from the unmanaged/adopt flow —
1493
+ // adopt (zero keys for OAuth-only routes) then sign in.
1494
+ oauthCapable(provider) ? h(
1495
+ Button,
1496
+ { variant: "secondary", onClick: () => void adoptThenLogin(), disabled: adopting || startingLogin },
1497
+ startingLogin ? h(Icon, { name: "refresh", size: 14, className: "km-spin" }) : h(Icon, { name: "user", size: 14 }),
1498
+ h("span", null, startingLogin ? t("adopting") : t("addAccount"))
1499
+ ) : null
1197
1500
  )
1198
1501
  ) : h(
1199
1502
  "div",
@@ -1212,10 +1515,16 @@ function ProviderCard({ provider, expanded, onToggle, now }) {
1212
1515
  { className: "km-managed-actions" },
1213
1516
  h(
1214
1517
  Button,
1215
- { variant: "secondary", size: "small", onClick: () => setAdding((current) => !current), disabled: releasing },
1518
+ { variant: "secondary", size: "small", onClick: () => setAdding((current) => !current), disabled: releasing || !provider.supported, title: provider.supported ? void 0 : provider.reason },
1216
1519
  h(Icon, { name: "plus", size: 14 }),
1217
1520
  h("span", null, t("addKey"))
1218
1521
  ),
1522
+ oauthCapable(provider) ? h(
1523
+ Button,
1524
+ { variant: "secondary", size: "small", onClick: () => void openLogin(), disabled: releasing || startingLogin },
1525
+ h(Icon, { name: "user", size: 14 }),
1526
+ h("span", null, t("addAccount"))
1527
+ ) : null,
1219
1528
  h(
1220
1529
  Button,
1221
1530
  { variant: "ghost", size: "small", onClick: () => setConfirmRelease(true), disabled: releasing },
@@ -1224,7 +1533,12 @@ function ProviderCard({ provider, expanded, onToggle, now }) {
1224
1533
  )
1225
1534
  ),
1226
1535
  adding ? h(KeyForm, { provider, onDone: () => setAdding(false), autoFocus: true }) : null,
1227
- provider.keys.length === 0 ? h("div", { className: "km-empty-keys" }, t("noKeys")) : h("div", { className: "km-keys" }, provider.keys.map((key) => h(KeyRow, { key: key.id, provider, keyRecord: key, now })))
1536
+ provider.keys.length === 0 ? h(
1537
+ "div",
1538
+ { className: "km-empty-keys" },
1539
+ h("div", null, provider.supported ? t("noKeys") : oauthCapable(provider) ? t("noKeysAccount") : t("unsupported")),
1540
+ !provider.supported && provider.reason !== void 0 ? h("div", { className: "km-muted" }, provider.reason) : null
1541
+ ) : h("div", { className: "km-keys" }, provider.keys.map((key) => key.kind === "oauth" ? h(AccountRow, { key: key.id, provider, keyRecord: key, now, onReauthenticate: () => void openLogin(key.id, key.alias) }) : h(KeyRow, { key: key.id, provider, keyRecord: key, now })))
1228
1542
  ),
1229
1543
  h(
1230
1544
  "div",
@@ -1241,9 +1555,207 @@ function ProviderCard({ provider, expanded, onToggle, now }) {
1241
1555
  h(Button, { key: "cancel", onClick: () => setConfirmRelease(false), disabled: releasing }, t("cancel")),
1242
1556
  h(Button, { key: "release", variant: "danger", onClick: release, disabled: releasing }, releasing ? t("releasing") : t("releaseConfirm"))
1243
1557
  ]
1244
- }, h("p", { className: "km-modal-copy" }, t(provider.createdProfile === true ? "releaseBodyCreated" : "releaseBody"))) : null
1558
+ }, h("p", { className: "km-modal-copy" }, t(provider.createdProfile === true ? "releaseBodyCreated" : "releaseBody"))) : null,
1559
+ login !== null ? h(LoginDialog, {
1560
+ provider,
1561
+ attemptId: login.attemptId,
1562
+ onClose: () => setLogin(null)
1563
+ }) : null
1245
1564
  );
1246
1565
  }
1566
+ var LOGIN_POLL_MS = 1e3;
1567
+ function safeNoticeUrl(url) {
1568
+ if (url === void 0 || url.length === 0) return null;
1569
+ try {
1570
+ const parsed = new URL(url);
1571
+ return parsed.protocol === "http:" || parsed.protocol === "https:" ? url : null;
1572
+ } catch {
1573
+ return null;
1574
+ }
1575
+ }
1576
+ var LOGIN_POLL_FAILURE_LIMIT = 3;
1577
+ function LoginDialog({ provider, attemptId, onClose }) {
1578
+ const runtime = useRuntime();
1579
+ const t = useT();
1580
+ const [attempt, setAttempt] = import_react2.default.useState(null);
1581
+ const [flowEnded, setFlowEnded] = import_react2.default.useState(false);
1582
+ const [pollError, setPollError] = import_react2.default.useState(null);
1583
+ const [answer, setAnswer] = import_react2.default.useState("");
1584
+ const [answering, setAnswering] = import_react2.default.useState(false);
1585
+ const [error, setError] = import_react2.default.useState(null);
1586
+ const pollNowRef = import_react2.default.useRef(null);
1587
+ const onCloseRef = import_react2.default.useRef(onClose);
1588
+ onCloseRef.current = onClose;
1589
+ const prompt = attempt?.prompt;
1590
+ const promptKey = prompt === void 0 ? "" : `${prompt.kind}\0${prompt.message}\0${(prompt.options ?? []).map((option) => option.id).join("\0")}`;
1591
+ import_react2.default.useEffect(() => {
1592
+ const options = prompt !== void 0 && prompt.kind === "select" ? prompt.options : void 0;
1593
+ if (options !== void 0 && options.length > 0) {
1594
+ setAnswer((current) => options.some((option) => option.id === current) ? current : options[0].id);
1595
+ return;
1596
+ }
1597
+ setAnswer("");
1598
+ }, [promptKey]);
1599
+ import_react2.default.useEffect(() => {
1600
+ let stopped = false;
1601
+ let timer;
1602
+ let failures = 0;
1603
+ let pending = 0;
1604
+ let latestUpdatedAt = 0;
1605
+ const isTerminal = (status) => status === "ready" || status === "failed" || status === "cancelled";
1606
+ const poll = async () => {
1607
+ if (stopped) return;
1608
+ pending += 1;
1609
+ try {
1610
+ const result = await runtime.store.accountAttempt(attemptId);
1611
+ if (stopped) return;
1612
+ if (!result.ok) {
1613
+ failures += 1;
1614
+ if (failures >= LOGIN_POLL_FAILURE_LIMIT) {
1615
+ if (timer !== void 0) clearInterval(timer);
1616
+ setPollError(result.error);
1617
+ }
1618
+ return;
1619
+ }
1620
+ failures = 0;
1621
+ if (result.value === null) {
1622
+ if (timer !== void 0) clearInterval(timer);
1623
+ setFlowEnded(true);
1624
+ return;
1625
+ }
1626
+ const view = result.value;
1627
+ if (view.updatedAt < latestUpdatedAt) return;
1628
+ latestUpdatedAt = view.updatedAt;
1629
+ setAttempt((current) => {
1630
+ if (current === null) return view;
1631
+ if (isTerminal(current.status) && !isTerminal(view.status)) return current;
1632
+ if (current.updatedAt > view.updatedAt) return current;
1633
+ if (current.updatedAt === view.updatedAt && current.status === view.status) return current;
1634
+ return view;
1635
+ });
1636
+ if (view.status === "ready") {
1637
+ if (timer !== void 0) clearInterval(timer);
1638
+ await runtime.store.load();
1639
+ if (!stopped) onCloseRef.current();
1640
+ return;
1641
+ }
1642
+ if (view.status === "failed" || view.status === "cancelled") {
1643
+ if (timer !== void 0) clearInterval(timer);
1644
+ }
1645
+ } finally {
1646
+ pending -= 1;
1647
+ }
1648
+ };
1649
+ const pollNow = () => {
1650
+ void poll();
1651
+ };
1652
+ pollNowRef.current = pollNow;
1653
+ void poll();
1654
+ timer = setInterval(() => {
1655
+ if (pending === 0) void poll();
1656
+ }, LOGIN_POLL_MS);
1657
+ return () => {
1658
+ stopped = true;
1659
+ if (timer !== void 0) clearInterval(timer);
1660
+ };
1661
+ }, [attemptId, runtime]);
1662
+ async function submitAnswer(event) {
1663
+ event.preventDefault();
1664
+ const prompt2 = attempt?.prompt;
1665
+ if (prompt2 === void 0 || answering) return;
1666
+ const value = answer.trim();
1667
+ if (value.length === 0) return;
1668
+ setAnswering(true);
1669
+ setError(null);
1670
+ const result = await runtime.store.answerAccountPrompt(attemptId, value);
1671
+ setAnswering(false);
1672
+ if (!result.ok) {
1673
+ setError(result.error);
1674
+ return;
1675
+ }
1676
+ setAnswer("");
1677
+ pollNowRef.current?.();
1678
+ }
1679
+ const lastNotice = attempt !== null && attempt.notices.length > 0 ? attempt.notices[attempt.notices.length - 1] : null;
1680
+ const safeUrl = attempt !== null ? attempt.notices.map((notice) => safeNoticeUrl(notice.url)).reverse().find((url) => url !== null) ?? null : null;
1681
+ const answerable = prompt !== void 0 && !answering && answer.trim().length > 0;
1682
+ let body;
1683
+ if (pollError !== null) {
1684
+ body = h(Notice, { tone: "error", icon: "warning", title: t("loginFailed") }, pollError);
1685
+ } else if (flowEnded) {
1686
+ body = h(Notice, { tone: "warn", icon: "warning" }, t("loginFlowEnded"));
1687
+ } else if (attempt === null || attempt.status === "pending") {
1688
+ body = h(
1689
+ "div",
1690
+ { className: "km-login-flow" },
1691
+ h("div", { className: "km-login-pending" }, h(Icon, { name: "refresh", size: 15, className: "km-spin" }), h("span", null, t("loginPending"))),
1692
+ lastNotice !== null ? h("div", { className: "km-login-notices" }, lastNotice.message) : null,
1693
+ safeUrl !== null ? h(
1694
+ "div",
1695
+ { className: "km-login-notices" },
1696
+ h("a", { className: "km-login-link", href: safeUrl, target: "_blank", rel: "noreferrer" }, t("loginBrowserOpen"))
1697
+ ) : null
1698
+ );
1699
+ } else if (attempt.status === "ready") {
1700
+ body = h("div", { className: "km-login-pending" }, h(Icon, { name: "check", size: 15 }), h("span", null, t("loginReady")));
1701
+ } else if (attempt.status === "failed" || attempt.status === "cancelled") {
1702
+ body = h(Notice, { tone: "error", icon: "warning", title: t("loginFailed") }, attempt.error ?? lastNotice?.message ?? provider.displayName);
1703
+ } else {
1704
+ body = h(
1705
+ "div",
1706
+ { className: "km-login-flow" },
1707
+ lastNotice !== null ? h("div", { className: "km-login-notices" }, lastNotice.message) : null,
1708
+ safeUrl !== null ? h(
1709
+ "div",
1710
+ { className: "km-login-notices" },
1711
+ h("a", { className: "km-login-link", href: safeUrl, target: "_blank", rel: "noreferrer" }, t("loginBrowserOpen"))
1712
+ ) : null,
1713
+ prompt !== void 0 ? h(
1714
+ "form",
1715
+ { className: "km-login-prompt", onSubmit: submitAnswer },
1716
+ h("div", { className: "km-login-prompt-title" }, t("loginPromptTitle")),
1717
+ h(
1718
+ "div",
1719
+ { className: "km-field" },
1720
+ h("span", { className: "km-field-label" }, prompt.message),
1721
+ prompt.kind === "select" && prompt.options !== void 0 ? h("select", {
1722
+ className: "km-input",
1723
+ value: answer,
1724
+ onChange: (event) => setAnswer(event.target.value),
1725
+ disabled: answering,
1726
+ autoFocus: true
1727
+ }, prompt.options.map((option) => h("option", { key: option.id, value: option.id }, option.label))) : h("input", {
1728
+ className: "km-input",
1729
+ type: prompt.kind === "secret" ? "password" : "text",
1730
+ value: answer,
1731
+ onChange: (event) => setAnswer(event.target.value),
1732
+ placeholder: prompt.placeholder,
1733
+ spellCheck: false,
1734
+ disabled: answering,
1735
+ autoFocus: true
1736
+ })
1737
+ ),
1738
+ error !== null ? h("div", { className: "km-inline-error", role: "alert" }, error) : null,
1739
+ h(
1740
+ "div",
1741
+ { className: "km-form-actions" },
1742
+ h(
1743
+ Button,
1744
+ { variant: "primary", type: "submit", disabled: !answerable },
1745
+ answering ? h(Icon, { name: "refresh", size: 14, className: "km-spin" }) : h(Icon, { name: "check", size: 14 }),
1746
+ h("span", null, answering ? t("loginPending") : t("loginPromptSubmit"))
1747
+ )
1748
+ )
1749
+ ) : null
1750
+ );
1751
+ }
1752
+ return h(Modal, {
1753
+ title: t("loginTitle"),
1754
+ onClose,
1755
+ busy: false,
1756
+ footer: h(Button, { onClick: onClose, disabled: answering }, t("close"))
1757
+ }, body);
1758
+ }
1247
1759
  function SettingsCard() {
1248
1760
  const runtime = useRuntime();
1249
1761
  const t = useT();