From bce29888cdb28350f9b2ca5669b858fdc4e2c5b1 Mon Sep 17 00:00:00 2001 From: zzstoatzz Date: Sun, 6 Sep 2026 00:48:16 -0500 Subject: [PATCH] Clarify app access cards and disclose connection details on demand --- src/internal/web/account.html | 75 ++++++++++++++++++++++++++++++++--- 1 file changed, 69 insertions(+), 6 deletions(-) diff --git a/src/internal/web/account.html b/src/internal/web/account.html index 5516696..5d1d853 100644 --- a/src/internal/web/account.html +++ b/src/internal/web/account.html @@ -23,6 +23,33 @@ label{display:block;margin:14px 0 6px;font-weight:760}input,select{width:100%;fo pre{margin:12px 0 0;white-space:pre-wrap;overflow-wrap:anywhere;border:1px solid var(--line);border-radius:9px;background:color-mix(in srgb,#000 24%,var(--field));padding:12px;color:var(--text);max-height:56vh;overflow:auto}.record-head{display:flex;gap:10px;align-items:flex-start;justify-content:space-between;margin-top:14px}.record-head button{width:auto}.blob-chip{display:inline-flex;margin-top:8px;margin-right:6px;border:1px solid var(--line);border-radius:999px;padding:4px 8px;color:var(--accent);overflow-wrap:anywhere}.generated{display:none;margin-top:14px;border:1px solid color-mix(in srgb,var(--green) 42%,var(--line));border-radius:9px;padding:12px;background:color-mix(in srgb,var(--green) 9%,transparent)}.generated.on{display:block}.generated code{display:block;margin-top:6px;font-size:1.1rem;color:var(--text);overflow-wrap:anywhere}.checkbox{display:flex;gap:8px;align-items:center;margin:10px 0 2px;color:var(--muted);font-weight:400}.checkbox input{width:auto} @media (max-width:760px){.account-card{grid-template-columns:1fr 1fr}.row,.grid,.card-grid{grid-template-columns:1fr}.actions button{width:100%}.record-head{display:block}.record-head button{width:100%;margin-top:8px}}@media (max-width:520px){.account-card{grid-template-columns:1fr}} .icon{width:22px;height:22px;flex:none;display:inline-block;vertical-align:middle}.nav a{align-items:center;gap:8px;min-height:44px}.account-section{position:relative;padding-left:60px;min-width:0}.account-section h3 .icon{position:absolute;left:0;top:24px;width:40px;height:40px;padding:7px;border-radius:12px;background:color-mix(in srgb,var(--accent) 12%,var(--panel));color:var(--accent)}.account-status h3 .icon{color:var(--bad);background:color-mix(in srgb,var(--bad) 10%,var(--panel))}.email-update{margin-top:24px}h4{font-size:1rem;margin:0 0 8px}a:focus-visible{outline:3px solid var(--focus);outline-offset:3px}code,pre,.did{font-family:ui-monospace,SFMono-Regular,Menlo,monospace}.credential-row{flex-wrap:wrap}.account-card>*{min-width:0}@media(max-width:520px){.panel{padding:16px}.account-section{padding-left:0}.account-section h3{min-height:40px;display:flex;align-items:center;padding-left:52px;margin-bottom:16px}.account-section h3 .icon{top:28px}.nav{gap:4px}.nav a{padding:8px;font-size:.9rem;gap:5px}.nav .icon{width:18px;height:18px}} + +/* App access: identity first, audit information on demand. */ +#view-sessions{--access-surface:#1b251e;--access-text:#f4f7f0;--access-muted:#bac8bd;--access-line:#435749} +.access-section-heading{display:flex;align-items:baseline;justify-content:space-between;gap:12px;margin-bottom:16px} +.access-section-heading h3{margin:0}.access-count{font-size:.85rem;color:var(--access-muted);white-space:nowrap} +.access-card{margin-top:14px;border:1px solid var(--access-line);border-radius:14px;background:var(--access-surface);color:var(--access-text);overflow:hidden} +.access-overview{display:flex;align-items:center;gap:14px;padding:20px 20px 0}.access-identity{flex:1;min-width:0} +.access-identity h4{font-size:1.2rem;line-height:1.3;margin:0;font-weight:750;overflow-wrap:anywhere} +.access-avatar{display:grid;place-items:center;flex:none;width:44px;height:44px;border-radius:12px;background:var(--accent);color:var(--accent-text);font-size:1.35rem;font-weight:800} +.access-identity .access-description{color:var(--access-muted);font-size:.9rem;margin:5px 0 0}.access-identity .broad{color:var(--warn)} +.access-status{display:inline-flex;align-items:center;gap:6px;font-size:.8rem;font-weight:700;white-space:nowrap;color:var(--access-muted)} +.access-status.is-active{color:var(--green)}.access-status::before{content:"";width:6px;height:6px;border-radius:50%;background:currentColor} +.access-recency{margin:14px 20px 18px 78px;font-size:.82rem;color:var(--access-muted)}.access-recency time{color:var(--access-text)} +.access-details{border-top:1px solid var(--access-line)}.access-details summary,.access-history>summary{cursor:pointer;min-height:44px;display:flex;align-items:center;gap:10px;list-style:none;font-size:.88rem;font-weight:650;padding:12px 20px;color:var(--access-text)} +.access-details summary::-webkit-details-marker,.access-history>summary::-webkit-details-marker{display:none} +.access-details summary::before,.access-history>summary::before{content:"";width:7px;height:7px;border-right:2px solid currentColor;border-bottom:2px solid currentColor;transform:rotate(-45deg);flex:none} +.access-details[open]>summary::before,.access-history[open]>summary::before{transform:rotate(45deg)} +.access-details summary:hover{background:color-mix(in srgb,var(--accent) 9%,transparent)}summary:focus-visible{outline:3px solid var(--focus);outline-offset:-3px} +.access-detail-body{padding:4px 20px 20px;background:color-mix(in srgb,var(--panel) 55%,var(--access-surface))} +.access-detail-body dl{margin:0}.access-detail-body dl>div{padding-top:14px}.access-detail-body dt,.access-permissions h5{font:600 .85rem/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;color:var(--access-muted);margin:0 0 5px} +.access-detail-body dd{margin:0;overflow-wrap:anywhere;font-size:.92rem}.access-detail-body code{font-size:.84rem;color:var(--access-text)} +.access-permissions{margin-top:20px}.access-permissions ul{margin:0;padding-left:20px}.access-permissions li{padding:4px 0;overflow-wrap:anywhere} +.access-detail-body .card-grid{gap:16px;margin-top:20px}.access-detail-body .stamp{font-size:.85rem;border-color:var(--access-line)}.access-detail-body .stamp span{color:var(--access-muted);font-size:.8rem;margin-bottom:5px} +.access-history{margin-top:18px}.access-history>summary{padding-left:0;color:var(--access-muted)}.access-history>summary span{font-weight:400}.hint.access-timezone{margin-top:28px;font-size:.8rem} +.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0} +@media(prefers-color-scheme:light){#view-sessions{--access-surface:#fff;--access-text:#142319;--access-muted:#485c4d;--access-line:#a8baac}} +@media(max-width:520px){.access-overview{padding:16px 16px 0;gap:10px;flex-wrap:wrap}.access-avatar{width:38px;height:38px}.access-identity{flex-basis:calc(100% - 48px)}.access-identity h4{font-size:1.1rem}.access-status{margin-left:48px}.access-recency{margin:10px 16px 16px 64px}.access-details summary{padding:12px 16px}.access-detail-body{padding:4px 16px 16px}} @@ -104,10 +131,11 @@ SOFTWARE.

quick checks

-

apps

-

-

app access

OAuth app sign-ins. Active means the current refresh token has not been revoked.

-

direct API tokens

Token families from com.atproto.server.createSession, including app-password clients.

+

App access

+

See which apps can access your account and review their permissions.

+

Connected apps

+

Password sessions

Connections made with your password or an app password.

+

spaces

@@ -159,8 +187,43 @@ function showPasskeys(items){const root=$('passkey-items');root.textContent='';i function showAppPasswords(items){const root=$('app-password-items');root.textContent='';if(!items.length)return empty(root,'No app passwords are active for this account.');for(const item of items){const view=credentialRow(item.name,`created ${item.createdAt}${item.privileged?' · privileged':''}`);const revoke=document.createElement('button');revoke.type='button';revoke.className='danger';revoke.textContent='revoke';revoke.onclick=async()=>{if(!confirm(`Revoke app password "${item.name}"?`))return;await xrpc('/xrpc/com.atproto.server.revokeAppPassword',{}, {method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({name:item.name})});generatedAppPassword.classList.remove('on');generatedAppPassword.textContent='';await loadAccountData()};view.actions.append(revoke);root.append(view.row)}} const methodLabel=(s)=>s.appPasswordName?`app password: ${s.appPasswordName}`:s.authMethod==='password'?'account password token':s.authMethod==='app_password'?'app password token':s.authMethod==='app_password_privileged'?'privileged app password token':s.authMethod; const oauthAuthMethod=(g)=>g.authMethod==='passkey'?'passkey':g.authMethod==='password'?'password':'unknown method'; -function card(item,isGrant){const active=item.active?'active':'inactive';const title=isGrant?item.clientId:methodLabel(item);const sub=isGrant?`${oauthAuthMethod(item)} authorization · ${item.scope}`:(item.controllerDid?`controller ${item.controllerDid}`:'com.atproto.server.createSession token family');const grid=isGrant?stamp('authorized',item.createdAt)+stamp('access token expires',item.expiresAt)+stamp('revoked',item.revokedAt):stamp('created',item.createdAt)+stamp('last used',item.lastUsedAt)+stamp('refresh token expires',item.refreshExpiresAt);return`
${esc(title)}
${esc(sub)}
${active}
${grid}
`} -function showSessions(data){const grants=data.oauthGrants||[],sessions=data.sessions||[];$('oauth-grant-items').innerHTML=grants.length?grants.map(g=>card(g,true)).join(''):'
No OAuth app access rows.
';$('session-items').innerHTML=sessions.length?sessions.map(s=>card(s,false)).join(''):'
No direct API token rows.
'} +// Access-card presentation stays separate from session/authentication behavior. +function clientLabel(clientId){ + try { const url=new URL(clientId); if(url.protocol==='https:'||url.protocol==='http:')return url.host; } catch {} + return clientId||'Unknown app'; +} +function accessDate(value){const t=time(value);return ``} +function accessField(label,value,technical=false){ + if(!value)return ''; + return `
${esc(label)}
${technical?`${esc(value)}`:esc(value)}
`; +} +function accessCard(item,isGrant){ + const title=isGrant?clientLabel(item.clientId):(item.appPasswordName||'Account password'); + const status=item.active?'Active':item.revokedAt?'Revoked':'Expired'; + const scopes=isGrant?item.scope.split(/\s+/).filter(Boolean):[]; + const permissions=scopes.filter(scope=>scope!=='atproto'); + const broad=permissions.includes('transition:generic'); + const description=isGrant?(broad?'Broad account access':permissions.length?`${permissions.length} permission${permissions.length===1?'':'s'}`:'Account identity'):(item.appPasswordName?'App password':'Password sign-in'); + const dateLabel=item.revokedAt?'Revoked':!isGrant&&item.lastUsedAt?'Last used':'Connected'; + const date=item.revokedAt||(!isGrant&&item.lastUsedAt)||item.createdAt; + const fields=isGrant?accessField('Client ID',item.clientId,true)+accessField('Signed in with',oauthAuthMethod(item)): + accessField('Sign-in method',methodLabel(item))+accessField('Controller',item.controllerDid,true)+accessField('Session ID',item.id,true); + const dates=stamp('Connected',item.createdAt)+(isGrant?stamp('Connection expires',item.expiresAt):stamp('Last used',item.lastUsedAt)+stamp('Refresh token expires',item.refreshExpiresAt))+(item.revokedAt?stamp('Revoked',item.revokedAt):''); + const scopeDetails=isGrant?`
Granted permissions
    ${scopes.map(scope=>`
  • ${esc(scope)}
  • `).join('')}
`:''; + return `

${esc(title)}

${esc(description)}

${status}
${dateLabel} ${accessDate(date)}
Connection details for ${esc(title)}
${fields}
${scopeDetails}
${dates}
`; +} +function accessList(items,isGrant){ + const active=items.filter(item=>item.active),past=items.filter(item=>!item.active); + const current=active.length?active.map(item=>accessCard(item,isGrant)).join(''):`
${isGrant?'No apps currently have access.':'No active password sessions.'}
`; + return current+(past.length?`
Past connections ${past.length}${past.map(item=>accessCard(item,isGrant)).join('')}
`:''); +} +function showSessions(data){ + const grants=data.oauthGrants||[],sessions=data.sessions||[]; + $('oauth-access-count').textContent=`${grants.filter(item=>item.active).length} active`; + $('direct-access-count').textContent=`${sessions.filter(item=>item.active).length} active`; + $('oauth-grant-items').innerHTML=accessList(grants,true); + $('session-items').innerHTML=accessList(sessions,false); +} function spaceParts(uri){const parts=String(uri).split('/');return {type:parts[3]||uri,skey:parts[4]||''}} function renderSpaces(){ $('space-count').textContent=spaces.length; const box=$('spaces'); box.innerHTML=''; if(!spaces.length){box.innerHTML='
No permissioned spaces found for this account.
';return} spaces.forEach((space,idx)=>{const p=spaceParts(space.uri);const btn=document.createElement('button');btn.type='button';btn.className='tab';btn.role='tab';btn.ariaSelected=idx===0?'true':'false';btn.textContent=p.type+' / '+p.skey;btn.onclick=()=>selectSpace(space.uri);box.appendChild(btn)});selectSpace(spaces[0].uri)} function selectSpace(uri){selectedSpace=uri;cursor=null;$('records').innerHTML='';$('record-detail').className='empty';$('record-detail').textContent='Choose a record.';for(const btn of $('spaces').querySelectorAll('.tab'))btn.ariaSelected=btn.textContent===spaceParts(uri).type+' / '+spaceParts(uri).skey?'true':'false';$('repo').value=account.did} -- 2.51.2