fix(dashboard): read connections and secrets as one answer

A key saved under a provider's name is told from a connection by comparing the
two lists, and they were fetched at different times: after a disconnect the
removed connection's secret came back as a saved key, a team switch showed the
previous team's saved keys, and a slow /connections showed every pasted-key
connection as saved for a moment. loadConnections now fetches /secrets in the
same Promise.all and replaces both together.

- A failed Remove of a saved key reported to secretErr, which only Secrets
  showed; Connections and a provider's page show it now.
- A grant with no catalog provider (an own-app OAuth connect) got a card whose
  Manage opened a blank page; it stays on Secrets.
- Verify and connect opened the paste dialog and never used the saved key; it
  is gone. A saved key's card gets Manage like any other.
This commit is contained in:
SToneX
2026-09-30 11:51:36 +08:00
parent 82873601fc
commit 22747e0ea7
7 changed files with 25 additions and 16 deletions
+6 -3
View File
@@ -704,8 +704,11 @@ inline-confirms. The nav's **Connections** entry carries the count of cards need
as a secret named for the provider (`namedKeys`: `treg secret add apollo …`, or a Secrets row) is a
row of `connAccounts` too, on Connections and on its provider's page, and counts in `connCount` — the
credential ladder treats it as that provider's key — marked *Saved*, or *Not in use* when a connected
credential for the same provider outranks it, with **Verify and connect** to run it through the
connect probe. **Add a connection** is every provider this server can connect
credential for the same provider outranks it (pasting a key through the provider's button does
exactly that). `loadConnections` fetches `/secrets` in the same `Promise.all` as `/connections`, because
a named key is told from a connection by comparing the two: one list older than the other showed a
removed key, or the previous team's, as saved. A grant with no catalog provider (an own-app OAuth
connect) has no card; it stays on Secrets. A failed Remove reports in the page's banner (`secretErr`). **Add a connection** is every provider this server can connect
(`connectable`: `configured` ones only — a provider it holds no client credentials for could only
show a dead button; an account already connected to one still shows above), in `providerGroups`
(grouped by the registry's category, filtered by `connQ` and by `connKind` — the **All · Your
@@ -755,7 +758,7 @@ flag = whether *this* deployment can run at least one connect flow) and **`GET /
(`list_connections` — the org's existing grants). Each authorization method also has its own
`configured` flag. For a multi-method provider, the registry sets the provider flag when any one method
is available, so a configured secondary grant cannot be hidden by an unavailable primary grant.
`go('connections')` also loads **`GET /secrets`**, which `namedKeys` reads.
`loadConnections` also reads **`GET /secrets`** for a signed-in member, which `namedKeys` reads.
**Consent disclosure.** A provider row may carry a **`consent_notice`**, rendered as a `.mk-notice` panel
in two places: under the Connect button on the integration page and inside the `capAsk` modal,
+2 -3
View File
@@ -48,11 +48,10 @@ export default {
<template v-if="owner"> · added by {{short(owner)}}</template>
</p>
<!-- A named key: check it and connect it like any other, or remove it. -->
<!-- A named key: remove it. Pasting one through the provider's button replaces it in use. -->
<div v-if="a.s" class="cn-acts">
<button v-if="a.st.tone!=='quiet'" class="pl-btn sm ghost" :disabled="connBusy" @click="startConnect(a.p)"
title="Check the key against the provider and connect it like any other">Verify and connect</button>
<span class="cn-links">
<a v-if="manage" :href="'/app/marketplace/'+encodeURIComponent(a.service)" @click.prevent="openProvider(a.service)">Manage</a>
<button class="cn-del" :class="{armed:confirmDelSecret===a.s.id}" @click="deleteSecret(a.s)">
{{confirmDelSecret===a.s.id ? 'Click again to remove' : 'Remove'}}</button>
</span>
+1 -1
View File
@@ -34,7 +34,7 @@ export default {
agents make to that provider. Your key always wins over treg's, and those calls are never metered.</p>
</header>
<div v-if="connErr" class="banner cn-banner"><span>{{connErr}}</span><button class="btn sm ico" @click="connErr=''" aria-label="Dismiss">✕</button></div>
<div v-if="connErr || secretErr" class="banner cn-banner"><span>{{connErr || secretErr}}</span><button class="btn sm ico" @click="connErr=''; secretErr=''" aria-label="Dismiss">✕</button></div>
<section v-if="connAccounts.length" class="pl-sec">
<h2 class="pl-h"><span>Connected</span><i></i><em>{{connAccounts.length}}</em></h2>
+1 -1
View File
@@ -25,7 +25,7 @@ export default { components: { ToolDrawer, ProviderLogo, ConnectionCard }, setup
<p v-if="mkProvider.consent_notice" class="mk-notice">{{mkProvider.consent_notice}}</p>
</header>
<div v-if="connErr" class="banner cn-banner"><span>{{connErr}}</span><button class="btn sm ico" @click="connErr=''" aria-label="Dismiss">✕</button></div>
<div v-if="connErr || secretErr" class="banner cn-banner"><span>{{connErr || secretErr}}</span><button class="btn sm ico" @click="connErr=''; secretErr=''" aria-label="Dismiss">✕</button></div>
<div v-if="!mkProvider.configured" class="banner cn-banner">
This server holds no client credentials for {{mkProvider.display_name}}, so the connect flow can't run here.
</div>
+7 -1
View File
@@ -6,12 +6,18 @@ export default {
this.loadPlatforms(); // fire-and-forget, and first: the catalog must neither hold up nor wait for the connect UI
try{
// The provider list is the deployment's, fixed for the session: fetched once, not per view.
const [ps, cs]=await Promise.all([
// Secrets come in the same breath: a key saved under a provider's name is told apart from a
// connection by comparing the two lists, so one fetched later than the other (a team switch,
// a disconnect, a slow first answer) would show a removed or foreign key as saved.
const member=this.authed && !this.publicCatalog;
const [ps, cs, ss]=await Promise.all([
this.providers.length ? this.providers : fetch('/oauth/providers').then(r=>r.json()).catch(()=>[]),
this.api('/connections').catch(()=>[]),
member ? this.api('/secrets').catch(()=>null) : null,
]);
if(!live()) return;
this.providers=ps||[]; this.connections=cs||[];
if(ss) this.secrets=ss;
}catch(e){ if(live()) this.connErr=String(e.message||e); }
},
authorizationMethodSpec(providerName, methodName){
+6 -4
View File
@@ -17,16 +17,18 @@ providerIndex(){ return new Map(this.providers.map(p=>[p.service,p])); },
},
// What the Secrets page lists: the credentials the team's own tools use, and nothing Connections shows.
ownSecrets(){
const shown=new Set([...this.connections.map(c=>c.id), ...this.namedKeys.map(k=>k.s.id)]);
const shown=new Set(this.connAccounts.map(a=>(a.c||a.s).id));
return this.secrets.filter(s=>!shown.has(s.id));
},
// Every credential the team holds for a catalog provider, as one kind of row whichever way it was
// added: a connection (`c`) or a named key (`s`). `pasted` is computed once here because every
// card asks it several times.
connAccounts(){
const conns=this.connections.map(c=>{ const p=this.providerIndex.get(c.provider)||null;
return {id:'c'+c.id, service:c.provider, c, p, pasted:this.pastedCredential(p),
name:(p&&p.display_name)||c.provider||c.name, st:this.connState(c)}; });
// A grant with no catalog provider (an own-app OAuth connect) has no provider page to manage it
// from: it stays among the team's own secrets.
const conns=this.connections.flatMap(c=>{ const p=this.providerIndex.get(c.provider);
return p ? [{id:'c'+c.id, service:c.provider, c, p, pasted:this.pastedCredential(p),
name:p.display_name, st:this.connState(c)}] : []; });
const named=this.namedKeys.map(({s, p, shadowed})=>({id:'s'+s.id, service:p.service, s, p, pasted:true,
name:p.display_name, st:shadowed
? {key:'ok', tone:'quiet', label:'Not in use', title:'The connected '+p.display_name+' credential is used instead'}
+2 -3
View File
@@ -11,7 +11,7 @@ go(v, fromPop){ this.resetConfirms(); this.mobileNav=false; this.drawerTool=null
// working so an existing /app#usage link, and the balance card's deep link, still land right.
if(v==='usage'){ this.actTab='usage'; v='activity'; this.loadUsage(); }
else if(v==='activity'){ this.actTab='feed'; }
this.detail=null; this.view=v; if(v==='activity')this.loadCalls(); if(v==='admin')this.loadAdmin(); if(v==='orgs'){this.loadOrgAdmin(); this.loadMyUsage(); this.loadBilling();} if(v==='usage')this.loadUsage(); if(v==='secrets'){this.loadSecrets(); if(!this.providers.length)this.loadConnections();} if(v==='resources')this.loadTeamResources(); if(v==='catalog')this.loadConnections(); if(v==='connections'){this.loadConnections(); this.loadSecrets();} if(v==='referrals')this.loadReferrals(); if(v==='hub')this.loadHub();
this.detail=null; this.view=v; if(v==='activity')this.loadCalls(); if(v==='admin')this.loadAdmin(); if(v==='orgs'){this.loadOrgAdmin(); this.loadMyUsage(); this.loadBilling();} if(v==='usage')this.loadUsage(); if(v==='secrets'){this.loadSecrets(); if(!this.providers.length)this.loadConnections();} if(v==='resources')this.loadTeamResources(); if(v==='catalog')this.loadConnections(); if(v==='connections')this.loadConnections(); if(v==='referrals')this.loadReferrals(); if(v==='hub')this.loadHub();
// push history so browser Back navigates BETWEEN views instead of leaving the app; the '/app'
// pathname also walks back from a /app/skills/<x> detail URL so reload doesn't reopen the detail
if(!fromPop) history.pushState({view:v}, '',
@@ -46,8 +46,7 @@ openProvider(service, fromPop){ this.resetConfirms();
if(!fromPop) history.pushState({mk:service}, '', '/app/marketplace/'+encodeURIComponent(service));
// The consent popup can return before /connections has been re-read, and a deep link may
// arrive before the first load — either way the page needs the data it renders from.
if(!this.connections.length || !this.providers.length) this.loadConnections();
this.loadSecrets(); // a key saved as a secret named for this provider is one of its accounts
this.loadConnections(); // its accounts, a key saved under its name included
this.loadPlatforms();
window.scrollTo(0,0); }
}