atproto pds in zig pds.zat.dev
pds atproto
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>zds account</title><meta name="description" content="Manage an account hosted on this ZDS PDS."><link rel="icon" href="/favicon.svg" type="image/svg+xml"><style>:root{color-scheme:dark;--bg:#070807;--panel:#18221b;--line:#627864;--text:#f1f2ec;--muted:#c2bcaf;--accent:#8fb0ff;--accent-text:#061021;--green:#37c978;--field:#0e1711;--bad:#ffb4a8;--warn:#f4d35e;--focus:#f4d35e}@media (prefers-color-scheme:light){:root{--bg:#f6f4ed;--panel:#fffdf7;--line:#847866;--text:#161412;--muted:#4f493f;--accent:#1d4ed8;--accent-text:#fffdf7;--green:#17683a;--field:#fffaf1;--bad:#9f1d1d;--warn:#735f00;--focus:#5a3fc0;color-scheme:light}}*{box-sizing:border-box}body{margin:0;min-height:100vh;background:var(--bg);color:var(--text);font:16px/1.55 ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono",monospace}.shell{width:min(100%,1000px);margin:0 auto;padding:28px 16px 42px}a{color:inherit}.brand{display:inline-flex;margin-bottom:24px;text-decoration:none;font-weight:800}h1{margin:0;font-size:clamp(32px,6vw,40px);line-height:1.15;letter-spacing:-.025em}h2{margin:0;font-size:1.8rem;letter-spacing:-.025em;line-height:1.2}h3{margin:0 0 8px;font-size:1.25rem;line-height:1.3}p{color:var(--muted);margin:12px 0 0;max-width:68ch}strong{color:var(--text)}code{overflow-wrap:anywhere;color:var(--accent)}.panel{margin-top:28px;border:1px solid var(--line);border-radius:16px;background:var(--panel);padding:28px}.panel:has(.resident.on){padding:0;border:0;background:none}label{display:block;margin:14px 0 6px;font-weight:760}input,select{min-height:48px;width:100%;font:inherit;color:var(--text);background:var(--field);border:1px solid var(--line);border-radius:9px;padding:12px}input:focus-visible,select:focus-visible,button:focus-visible{outline:3px solid var(--focus);outline-offset:2px}button{min-height:44px;border:1px solid var(--accent);border-radius:8px;background:var(--accent);color:var(--accent-text);font:inherit;font-weight:800;padding:12px;cursor:pointer}button.secondary{background:transparent;color:var(--text);border-color:var(--line)}button.danger{background:transparent;color:var(--bad);border-color:color-mix(in srgb,var(--bad) 55%,var(--line))}button:disabled{opacity:.62;cursor:not-allowed}.login button{width:100%;margin-top:22px}.status{color:var(--muted);border-left:3px solid currentColor;padding:10px 14px;margin:16px 0;max-width:none}.status:empty{display:none}.error{color:var(--bad)}.ok{color:var(--green)}.warn{color:var(--warn)}.hint{font-size:.92rem;color:var(--muted);margin:8px 0 0}.resident{display:none}.resident.on{display:block}.login.off{display:none}.hidden{display:none}.account-card{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:0;margin-top:12px;padding:20px 24px;border:1px solid var(--line);border-radius:12px;background:var(--panel)}.identity{grid-column:1/-1;padding-bottom:20px;margin-bottom:16px;border-bottom:1px solid var(--line)}.metric{padding-right:16px}.metric span,.identity span{display:block;color:var(--muted);font-size:.9rem}.metric strong,.identity strong{display:block;margin-top:4px;font-size:1.1rem;overflow-wrap:anywhere}.identity strong{font-size:1.35rem}.identity code{display:block;margin-top:4px;font-size:.85rem;overflow-wrap:anywhere}.nav{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:8px;margin:24px 0}.nav a{display:flex;justify-content:center;text-decoration:none;border:1px solid transparent;border-radius:10px;padding:12px;color:var(--muted);font-weight:650;min-width:0}.nav a[aria-current="page"]{background:var(--panel);border-color:var(--line);color:var(--text);box-shadow:inset 0 -3px var(--accent)}.view{display:none;padding-top:8px;margin-top:8px;scroll-margin-top:24px}.view.on{display:block}.section{border-top:1px solid var(--line);padding-top:28px;margin-top:28px}.row{display:grid;grid-template-columns:1fr auto;gap:10px;align-items:end}.grid{display:grid;grid-template-columns:1fr 1fr;gap:10px}.actions{display:flex;gap:8px;flex-wrap:wrap;margin-top:14px}.actions button{width:auto}.empty{border:1px dashed var(--line);border-radius:9px;padding:14px;color:var(--muted);margin-top:10px}.credential-row,.session-card,.item{border:1px solid var(--line);border-radius:8px;background:color-mix(in srgb,var(--field) 76%,transparent);padding:16px;margin-top:12px}.credential-row{display:flex;align-items:center;gap:10px;justify-content:space-between}.credential-name,.who,.what,.item-title{font-weight:800;overflow-wrap:anywhere}.credential-meta,.did,.scope,.item-meta{display:block;margin-top:2px;color:var(--muted);font-size:.9rem;overflow-wrap:anywhere}.credential-actions{display:flex;gap:8px;flex-wrap:wrap}.credential-actions button{padding:7px 10px}.session-card.active{border-color:color-mix(in srgb,var(--green) 44%,var(--line))}.session-card.inactive{opacity:.82}.card-top{display:flex;align-items:start;justify-content:space-between;gap:12px}.pill{display:inline-flex;border:1px solid var(--line);border-radius:999px;padding:3px 8px;font-size:.78rem;font-weight:800;white-space:nowrap}.pill.active{color:#061021;background:var(--green);border-color:var(--green)}.pill.inactive{color:var(--bad);border-color:color-mix(in srgb,var(--bad) 55%,var(--line));background:color-mix(in srgb,var(--bad) 7%,transparent)}.card-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:8px;margin-top:10px}.stamp{border-top:1px solid color-mix(in srgb,var(--line) 70%,transparent);padding-top:7px;color:var(--text)}.stamp span{display:block;color:var(--muted);font-size:.72rem}.tabs{display:flex;gap:8px;overflow-x:auto;margin-top:14px;padding-bottom:2px}.tab{width:auto;margin:0;text-align:left;background:transparent;color:var(--text);border-color:var(--line);white-space:nowrap}.tab[aria-selected="true"]{color:var(--accent-text);background:var(--accent);border-color:var(--accent)}.item{width:100%;text-align:left;color:var(--text)}.item[aria-selected="true"]{border-color:color-mix(in srgb,var(--accent) 72%,var(--line));background:linear-gradient(135deg,color-mix(in srgb,var(--accent) 17%,transparent),color-mix(in srgb,var(--field) 78%,transparent))}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:repeat(3,minmax(0,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{padding:18px;grid-template-columns:1fr;gap:12px}.metric{display:flex;justify-content:space-between;gap:12px;padding:0}.metric strong{margin:0;text-align:right}.identity{margin:0}}.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:28px 28px 28px 92px;min-width:0;border:1px solid var(--line);border-radius:14px;background:var(--panel);margin-top:24px}.account-section h3 .icon{position:absolute;left:24px;top:24px;width:44px;height:44px;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;border-top:1px solid var(--line);padding-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:inherit}.credential-row{flex-wrap:wrap}.account-card>*{min-width:0}@media(max-width:520px){.panel{padding:16px}.account-section{padding:22px 18px}.account-section h3{min-height:40px;display:flex;align-items:center;padding-left:52px;margin-bottom:16px}.account-section h3 .icon{top:20px;left:18px}.nav{grid-template-columns:repeat(2,minmax(0,1fr));gap:8px}.nav a{justify-content:flex-start;padding:10px;font-size:1rem;gap:10px}.nav .icon{width:18px;height:18px}}
.resident-actions{display:flex;justify-content:flex-end}.resident-actions button{padding:8px 16px;font-size:.95rem}/* 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:.9rem;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-size:.85rem;font-weight:600;line-height:1.5;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}}
.skip-link{position:fixed;top:8px;left:8px;z-index:10;padding:12px 18px;background:var(--accent);color:var(--accent-text);transform:translateY(-150%);border-radius:8px}.skip-link:focus{transform:none}button:not(:disabled):active{transform:translateY(1px);box-shadow:inset 0 2px 3px #0002}@media(hover:hover){button.secondary:hover,.nav a:hover{background:color-mix(in srgb,var(--accent) 12%,var(--panel));color:var(--text)}button.danger:hover{background:color-mix(in srgb,var(--bad) 10%,var(--panel))}}@media(prefers-reduced-motion:no-preference){button,.nav a{transition:background-color 120ms ease,border-color 120ms ease,transform 120ms ease}}@media(prefers-reduced-motion:reduce){button:not(:disabled):active{transform:none}}@media(forced-colors:active){.nav a[aria-current="page"]{outline:2px solid Highlight}.access-status::before{background:CanvasText}}#home-checks .session-card{border:0;border-bottom:1px solid var(--line);border-radius:0;background:none;padding:16px 0}.checkbox{min-height:44px}.checkbox input{width:20px;height:20px;min-height:0;accent-color:var(--accent)}</style></head><body><!-- Bundled Lucide icons.ISC License
Copyright (c) 2026 Lucide Icons and Contributors
Permission to use, copy, modify, and/or distribute this software for anypurpose with or without fee is hereby granted, provided that the abovecopyright notice and this permission notice appear in all copies.
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIESWITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OFMERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FORANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGESWHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN ANACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OFOR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
---
The following Lucide icons are derived from the Feather project:
airplay, alert-circle, alert-octagon, alert-triangle, aperture, arrow-down-circle, arrow-down-left, arrow-down-right, arrow-down, arrow-left-circle, arrow-left, arrow-right-circle, arrow-right, arrow-up-circle, arrow-up-left, arrow-up-right, arrow-up, at-sign, calendar, cast, check, chevron-down, chevron-left, chevron-right, chevron-up, chevrons-down, chevrons-left, chevrons-right, chevrons-up, circle, clipboard, clock, code, columns, command, compass, corner-down-left, corner-down-right, corner-left-down, corner-left-up, corner-right-down, corner-right-up, corner-up-left, corner-up-right, crosshair, database, divide-circle, divide-square, dollar-sign, download, external-link, feather, frown, hash, headphones, help-circle, info, italic, key, layout, life-buoy, link-2, link, loader, lock, log-in, log-out, maximize, meh, minimize, minimize-2, minus-circle, minus-square, minus, monitor, moon, more-horizontal, more-vertical, move, music, navigation-2, navigation, octagon, pause-circle, percent, plus-circle, plus-square, plus, power, radio, rss, search, server, share, shopping-bag, sidebar, smartphone, smile, square, table-2, tablet, target, terminal, trash-2, trash, triangle, tv, type, upload, x-circle, x-octagon, x-square, x, zoom-in, zoom-out
The MIT License (MIT) (for the icons listed above)
Copyright (c) 2013-present Cole Bemis
Permission is hereby granted, free of charge, to any person obtaining a copyof this software and associated documentation files (the "Software"), to dealin the Software without restriction, including without limitation the rightsto use, copy, modify, merge, publish, distribute, sublicense, and/or sellcopies of the Software, and to permit persons to whom the Software isfurnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in allcopies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS ORIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THEAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHERLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THESOFTWARE.--><a class="skip-link" href="#page-title">Skip to account content</a><main class="shell"><header class="account-masthead"><a class="brand" href="/" aria-label="zds home"><span data-icon-slot="zds.header.brand">zds</span></a><a class="home-return" href="/"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="m12 5-7 7 7 7M5 12h14"/></svg>Server home</a></header><h1 id="page-title" tabindex="-1">Your account</h1><p>Manage your sign-in methods and the apps connected to your account.</p><section class="panel"><form id="login-form" class="login"><label for="identifier">Account</label><input id="identifier" name="identifier" autocomplete="username" required><label for="password">Password</label><input id="password" name="password" type="password" autocomplete="current-password" required><button>Sign in</button></form><div class="session-toolbar"><p id="status" class="status" role="status" aria-live="polite" aria-atomic="true"></p><div class="resident-actions"><button id="sign-out" type="button" class="secondary">Sign out</button></div></div><section id="resident" class="resident"><div class="account-card"><div class="identity"><span>Signed in as</span><strong id="account-handle">-</strong><code id="account-did"></code></div><div class="metric"><span>Status</span><strong id="account-status">-</strong></div><div class="metric"><span>Email</span><strong id="account-email-state">-</strong></div><div class="metric"><span>Spaces</span><strong id="space-count">0</strong></div></div><nav class="nav" aria-label="account sections"><a href="/account" data-view="home"><svg data-icon-slot="zds.nav.home" class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="M15 21v-8a1 1 0 0 0-1-1h-4a1 1 0 0 0-1 1v8" /> <path d="M3 10a2 2 0 0 1 .709-1.528l7-6a2 2 0 0 1 2.582 0l7 6A2 2 0 0 1 21 10v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" /></svg>Home</a><a href="/account/manage" data-view="manage"><svg data-icon-slot="zds.nav.account" class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><circle cx="12" cy="8" r="5" /> <path d="M20 21a8 8 0 0 0-16 0" /></svg>Account</a><a href="/account/apps" data-view="sessions"><svg data-icon-slot="zds.nav.apps" class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><rect width="18" height="18" x="3" y="3" rx="2" /> <path d="M3 9h18" /> <path d="M9 21V9" /></svg>Apps</a><a href="/account/about" data-view="about"><svg data-icon-slot="zds.nav.about" class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><circle cx="12" cy="12" r="10" /> <path d="M12 16v-4" /> <path d="M12 8h.01" /></svg>About</a></nav><section id="view-home" class="view"><h2>Overview</h2><p>This PDS stores your public repo, private permissioned-space records, blobs, credentials, and app access state. Use the sections above to manage what this server can control.</p><p class="hint">Experimental data tools: <a href="/account/spaces" data-view="spaces">Browse permissioned spaces</a>.</p><div class="section"><h3>At a glance</h3><div id="home-checks"><article class="glance-item"><svg data-icon-slot="zds.account.email" class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="m22 7-8.991 5.727a2 2 0 0 1-2.009 0L2 7" /> <rect x="2" y="4" width="20" height="16" rx="2" /></svg><div><h4>Email</h4><span id="glance-email" class="hint">—</span></div></article><article class="glance-item"><svg data-icon-slot="zds.nav.apps" class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><rect width="18" height="18" x="3" y="3" rx="2" /> <path d="M3 9h18" /> <path d="M9 21V9" /></svg><div><h4>App access</h4><span id="glance-apps" class="hint">—</span></div></article><article class="glance-item"><svg data-icon-slot="zds.account.app-passwords" class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="M2.586 17.414A2 2 0 0 0 2 18.828V21a1 1 0 0 0 1 1h3a1 1 0 0 0 1-1v-1a1 1 0 0 1 1-1h1a1 1 0 0 0 1-1v-1a1 1 0 0 1 1-1h.172a2 2 0 0 0 1.414-.586l.814-.814a6.5 6.5 0 1 0-4-4z" /> <circle cx="16.5" cy="7.5" r=".5" fill="currentColor" /></svg><div><h4>Direct API tokens</h4><span id="glance-tokens" class="hint">—</span></div></article><article class="glance-item"><svg data-icon-slot="zds.account.spaces" class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><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><div><h4>Permissioned spaces</h4><span id="glance-spaces" class="hint">—</span></div></article></div></div></section><section id="view-sessions" class="view"><h2>App access</h2><p class="hint">See which apps can access your account and review their permissions.</p><form id="access-filters" class="access-filters"><div><label for="access-search">Find an app or credential</label><input id="access-search" type="search" maxlength="200" placeholder="App address or credential name"></div><div><label for="access-state">Show</label><select id="access-state"><option value="authorized">Authorized</option><option value="past">Past</option><option value="all">All</option></select></div><button type="submit">Apply</button></form><p id="access-search-status" class="hint" role="status"></p><p class="hint">Authorized means access has not expired or been revoked; it does not mean a connection is in use now.</p><section class="section access-section"><div class="access-section-heading"><h3>Apps</h3><span id="oauth-access-count" class="access-count"></span></div><div id="oauth-grant-items"></div></section><section class="section access-section"><div class="access-section-heading"><h3>Password sessions</h3><span id="direct-access-count" class="access-count"></span></div><p class="hint">Connections made with your password or an app password.</p><div id="session-items"></div></section><p class="hint access-timezone" id="timezone"></p></section><section id="view-spaces" class="view"><h2>Permissioned spaces</h2><p>View private records in permissioned spaces for this account.</p><div id="spaces" class="tabs" role="tablist" aria-label="permissioned spaces"></div><section class="section"><h3>Record set</h3><div class="grid"><div><label for="repo">Writer repo</label><input id="repo" spellcheck="false"></div><div><label for="collection">Collection, optional</label><input id="collection" placeholder="all collections" spellcheck="false"></div></div><div class="actions"><button id="load-records" type="button">Load records</button><button id="next-records" class="secondary" type="button" disabled>Next page</button></div><div id="records"></div></section><section class="section"><h3>Selected record</h3><div id="record-detail" class="empty">Choose a record.</div></section></section><section id="view-manage" class="view"><h2>Sign-in & account</h2><section class="section account-section"><h3><svg data-icon-slot="zds.account.passkeys" class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="M12 10a2 2 0 0 0-2 2c0 1.02-.1 2.51-.26 4" /> <path d="M14 13.12c0 2.38 0 6.38-1 8.88" /> <path d="M17.29 21.02c.12-.6.43-2.3.5-3.02" /> <path d="M2 12a10 10 0 0 1 18-6" /> <path d="M2 16h.01" /> <path d="M21.8 16c.2-2 .131-5.354 0-6" /> <path d="M5 19.5C5.5 18 6 15 6 12a6 6 0 0 1 .34-2" /> <path d="M8.65 22c.21-.66.45-1.32.57-2" /> <path d="M9 6.8a6 6 0 0 1 9 5.2v2" /></svg>Passkeys</h3><p class="hint">Use a passkey to sign in without typing this account password.</p><div id="passkey-items"></div><label for="friendly">New passkey name</label><input id="friendly" placeholder="this device"><button id="add-passkey" type="button" style="margin-top:12px">Add passkey</button></section><section class="section account-section"><h3><svg data-icon-slot="zds.account.app-passwords" class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="M2.586 17.414A2 2 0 0 0 2 18.828V21a1 1 0 0 0 1 1h3a1 1 0 0 0 1-1v-1a1 1 0 0 1 1-1h1a1 1 0 0 0 1-1v-1a1 1 0 0 1 1-1h.172a2 2 0 0 0 1.414-.586l.814-.814a6.5 6.5 0 1 0-4-4z" /> <circle cx="16.5" cy="7.5" r=".5" fill="currentColor" /></svg>App passwords</h3><p class="hint">Use an app password for clients that should not receive your main account password.</p><div id="app-password-items"></div><label for="app-password-name">New app password name</label><input id="app-password-name" placeholder="client or device name"><label class="checkbox"><input id="app-password-privileged" type="checkbox"> privileged app password</label><p class="hint">Most clients should leave this off.</p><button id="create-app-password" type="button" style="margin-top:12px">Create app password</button><div id="generated-app-password" class="generated" aria-live="polite"></div></section><section class="section account-section"><h3><svg data-icon-slot="zds.account.email" class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="m22 7-8.991 5.727a2 2 0 0 1-2.009 0L2 7" /> <rect x="2" y="4" width="20" height="16" rx="2" /></svg>Email</h3><p id="email-copy" class="hint"></p><div id="verify-email-block"><p class="hint">Send yourself a verification email, then enter the code from it.</p><div class="actions"><button id="verify-email-request" type="button">Send verification email</button></div><div class="row"><div><label for="verify-code">Verification code</label><input id="verify-code" autocomplete="one-time-code"></div><button id="verify-email-confirm" type="button">Confirm</button></div></div><div class="email-update"><h4>Change email</h4><p class="hint">Request an update code at your current address, then enter the new address and the code.</p><div class="actions"><button id="update-email-request" class="secondary" type="button">Send update code</button></div><div class="grid"><div><label for="new-email">New email</label><input id="new-email" type="email" autocomplete="email"></div><div><label for="update-code">Update code</label><input id="update-code" autocomplete="one-time-code"></div></div><div class="actions"><button id="update-email-confirm" type="button">Confirm new email</button></div></div></section><section class="section account-section account-status"><h3><svg data-icon-slot="zds.account.status" class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="M12 2v10" /> <path d="M18.4 6.6a9 9 0 1 1-12.77.04" /></svg>Account status</h3><p class="hint">Deactivation hides the account until it is reactivated. Takedowns and suspensions are operator actions, not resident controls.</p><div class="actions"><button id="activate-account" type="button">Reactivate account</button><button id="deactivate-account" class="danger" type="button">Deactivate account</button></div></section></section><section id="view-about" class="view"><h2>About this PDS</h2><p>ZDS is the PDS hosting this account. It stores repo data, blobs, account credentials, OAuth token state, and experimental permissioned-space data for residents on this server.</p><p>This page is local to the PDS. It is not a Bluesky app view, and it only manages things this PDS is responsible for.</p></section></section></section></main><script>const $=(id)=>document.getElementById(id);const status=$('status'),loginForm=$('login-form'),resident=$('resident'),generatedAppPassword=$('generated-app-password');let accessJwt=null,account=null,spaces=[],selectedSpace=null,cursor=null;$('timezone').textContent=`times shown in ${Intl.DateTimeFormat().resolvedOptions().timeZone || 'your local timezone'}`;const esc=(s)=>String(s??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));const setStatus=(text,cls='')=>{status.className='status '+cls;status.textContent=text};class RequestError extends Error{constructor(message,status,code){super(message);this.status=status;this.code=code}}async function fail(r,msg){if(r.ok){const t=await r.text();return t?JSON.parse(t):{}}let body={};try{body=await r.json()}catch{}throw new RequestError(body.message||body.error||msg,r.status,body.error)}const authed=(url,opts={})=>fetch(url,{...opts,headers:{...(opts.headers||{}),authorization:`Bearer ${accessJwt}`}});async function xrpc(path,params={},options={}){const qs=new URLSearchParams(params);return fail(await authed(path+(qs.size?'?'+qs:''),options),'request failed')}function viewName(){const p=location.pathname; if(p.endsWith('/security'))return'manage'; if(p.endsWith('/sessions')||p.endsWith('/apps'))return'sessions'; if(p.endsWith('/spaces'))return'spaces'; if(p.endsWith('/manage'))return'manage'; if(p.endsWith('/about'))return'about'; return'home'}function showView(name=viewName(),moveFocus=false){ for(const el of document.querySelectorAll('.view'))el.classList.toggle('on',el.id==='view-'+name); for(const a of document.querySelectorAll('.nav a')){if(a.dataset.view===name)a.setAttribute('aria-current','page');else a.removeAttribute('aria-current')} const heading=$('view-'+name).querySelector('h2'); document.title=heading.textContent+' · ZDS'; heading.tabIndex=-1; if(moveFocus)heading.focus();}document.addEventListener('click',e=>{ if(!(e.target instanceof Element)||e.button!==0||e.metaKey||e.ctrlKey||e.shiftKey||e.altKey)return; const a=e.target.closest('a[data-view]');if(!a)return; e.preventDefault();if(location.pathname!==a.pathname)history.pushState(null,'',a.href); showView(a.dataset.view,true);});addEventListener('popstate',()=>showView(viewName(),resident.classList.contains('on')));function updateHeader(){ $('account-handle').textContent=account.handle; $('account-did').textContent=account.did; $('account-status').textContent=account.status||'active'; $('account-email-state').textContent=account.emailConfirmed?'verified':'needs verification'; $('email-copy').textContent=`Current email: ${account.email}${account.emailConfirmed?' (verified)':' (not verified)'}`; $('verify-email-block').classList.toggle('hidden',!!account.emailConfirmed); }function quickChecks(){$('glance-email').textContent=account.emailConfirmed?'verified':'needs verification';$('glance-spaces').textContent=String(spaces.length)}const b64ToBuf=(v)=>{const b64=v.replace(/-/g,'+').replace(/_/g,'/');const bin=atob(b64+'==='.slice((b64.length+3)%4));const out=new Uint8Array(bin.length);for(let i=0;i<bin.length;i++)out[i]=bin.charCodeAt(i);return out.buffer};const bufToB64=(buf)=>btoa(String.fromCharCode(...new Uint8Array(buf))).replace(/\+/g,'-').replace(/\//g,'_').replace(/=+$/,'');const prepCreate=(o)=>{const p=o.publicKey;p.challenge=b64ToBuf(p.challenge);p.user.id=b64ToBuf(p.user.id);p.excludeCredentials=(p.excludeCredentials||[]).map(c=>({...c,id:b64ToBuf(c.id)}));return o};const serialize=(c)=>({id:c.id,rawId:bufToB64(c.rawId),type:c.type,response:{clientDataJSON:bufToB64(c.response.clientDataJSON),attestationObject:bufToB64(c.response.attestationObject)}});const time=(value)=>{if(!value)return{short:'never',full:'never'};const d=typeof value==='number'?new Date(value*1000):new Date(value);return{short:d.toLocaleString([], {month:'short',day:'numeric',hour:'2-digit',minute:'2-digit'}),full:d.toLocaleString([], {dateStyle:'full',timeStyle:'long'})}};const stamp=(label,value)=>{const t=time(value);return`<div class="stamp"><span>${esc(label)}</span><time title="${esc(t.full)}">${esc(t.short)}</time></div>`};const credentialRow=(name,meta)=>{const row=document.createElement('div');row.className='credential-row';const label=document.createElement('span');label.className='credential-name';label.textContent=name;const small=document.createElement('span');small.className='credential-meta';small.textContent=meta;label.append(small);const actions=document.createElement('span');actions.className='credential-actions';row.append(label,actions);return{row,actions}};function empty(root,text){root.textContent='';const p=document.createElement('div');p.className='empty';p.textContent=text;root.append(p)}function showPasskeys(items){const root=$('passkey-items');root.textContent='';if(!items.length)return empty(root,'No passkeys are saved for this account yet.');for(const item of items){const view=credentialRow(item.friendlyName||'unnamed passkey',`last used: ${time(item.lastUsed).short}`);const rename=document.createElement('button');rename.type='button';rename.className='secondary';rename.textContent='rename';rename.onclick=async()=>{const friendlyName=prompt('Passkey name',item.friendlyName||'');if(!friendlyName)return;await xrpc('/xrpc/com.atproto.server.updatePasskey',{}, {method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({id:item.id,friendlyName})});await loadAccountData()};const del=document.createElement('button');del.type='button';del.className='danger';del.textContent='delete';del.onclick=async()=>{if(!confirm('Delete this passkey from this PDS?'))return;await xrpc('/xrpc/com.atproto.server.deletePasskey',{}, {method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({id:item.id})});await loadAccountData()};view.actions.append(rename,del);root.append(view.row)}}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';// 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 `<time title="${esc(t.full)}">${esc(t.short)}</time>`}function accessField(label,value,technical=false){ if(!value)return ''; return `<div><dt>${esc(label)}</dt><dd>${technical?`<code>${esc(value)}</code>`:esc(value)}</dd></div>`;}function accessCard(item,isGrant){ const title=isGrant?clientLabel(item.clientId):(item.appPasswordName||'Account password'); const status=item.active?'Authorized':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?`<div class="access-permissions"><h5>Granted permissions</h5><ul>${scopes.map(scope=>`<li><code>${esc(scope)}</code></li>`).join('')}</ul></div>`:''; return `<article class="access-card"><div class="access-overview"><span class="access-avatar" aria-hidden="true">${esc(Array.from(title)[0].toUpperCase())}</span><div class="access-identity"><h4>${esc(title)}</h4><p class="access-description${broad?' broad':''}">${esc(description)}</p></div><span class="access-status ${item.active?'is-active':'is-inactive'}">${status}</span></div><div class="access-recency">${dateLabel} ${accessDate(date)}</div><details class="access-details"><summary>Connection details<span class="sr-only"> for ${esc(title)}</span></summary><div class="access-detail-body"><dl>${fields}</dl>${scopeDetails}<div class="card-grid">${dates}</div></div></details></article>`;}let accessVersion=0;function accessQuery(){return {search:$('access-search').value.trim(),state:$('access-state').value}}function accessRequest(kind,query,extra={}){return xrpc('/xrpc/dev.zat.account.listAccess',{kind,...query,...extra})}function accessGroup(group,kind,query){ const title=kind==='oauth'?clientLabel(group.name):group.name; const root=document.createElement('details');root.className='access-group'; root.innerHTML=`<summary><span class="access-avatar" aria-hidden="true">${esc(Array.from(title)[0].toUpperCase())}</span><span class="access-group-title"><strong>${esc(title)}</strong><span>${group.count} ${query.state==='authorized'?'authorized':query.state==='past'?'past':'total'} connection${group.count===1?'':'s'}</span></span><span class="access-group-date">Newest ${accessDate(group.newest)}</span></summary><div class="access-group-body"><div class="access-connections"></div><p class="hint" role="status"></p><button class="secondary" type="button">Load connections</button></div>`; const list=root.querySelector('.access-connections'),message=root.querySelector('[role="status"]'),more=root.querySelector('button'); let next=null,loaded=false,pending=false; async function load(){ if(pending)return;pending=true;more.disabled=true;message.textContent='Loading connections…'; try{ const page=await accessRequest(kind,query,{group:group.key,...(next?{cursor:next}:{})}); const first=list.children.length; list.insertAdjacentHTML('beforeend',page.connections.map(item=>accessCard(item,kind==='oauth')).join('')); next=page.cursor;loaded=true;more.hidden=!next;more.textContent='Load 20 more connections'; message.textContent=page.connections.length?'':'No connections match this filter.'; if(first&&list.children[first]){const target=list.children[first].querySelector('summary');target.focus();} }catch(error){message.textContent=error.message||'Could not load connections.';more.textContent='Retry loading connections';more.hidden=false} finally{pending=false;more.disabled=false} } more.addEventListener('click',load); root.addEventListener('toggle',()=>{if(root.open&&!loaded)load()}); return root;}function showAccessPage(kind,initial,query){ const root=$(kind==='oauth'?'oauth-grant-items':'session-items');root.replaceChildren(); const list=document.createElement('div');list.className='access-groups'; const message=document.createElement('p');message.className='hint';message.setAttribute('role','status'); const more=document.createElement('button');more.type='button';more.className='secondary access-more';more.textContent='Load more '+(kind==='oauth'?'apps':'credentials'); root.append(list,message,more);let next=null; function append(page){for(const group of page.groups)list.append(accessGroup(group,kind,query));next=page.cursor;more.hidden=!next;message.textContent=list.children.length?'':'No '+(kind==='oauth'?'apps':'credentials')+' match this filter.'} append(initial); more.addEventListener('click',async()=>{ more.disabled=true;message.textContent='Loading…'; try{const first=list.children.length;append(await accessRequest(kind,query,{cursor:next}));list.children[first]?.querySelector('summary').focus()} catch(error){message.textContent=error.message||'Could not load more results.'} finally{more.disabled=false} });}function showAccessTotals(oauth,password,query){ for(const [id,page] of [['oauth-access-count',oauth],['direct-access-count',password]]){const count=query.state==='past'?page.total-page.authorized:query.state==='all'?page.total:page.authorized;$(id).textContent=query.search?'Filtered':`${count} ${query.state==='all'?'total':query.state}`;} $('glance-apps').textContent=`${oauth.authorized} authorizations`; $('glance-tokens').textContent=`${password.authorized} authorized`;}async function searchAccess(){ const version=++accessVersion,query=accessQuery();$('access-search-status').textContent='Loading…'; try{const [oauth,password]=await Promise.all([accessRequest('oauth',query),accessRequest('password',query)]);if(version!==accessVersion)return;showAccessTotals(oauth,password,query);showAccessPage('oauth',oauth,query);showAccessPage('password',password,query);$('access-search-status').textContent='Results updated.'} catch(error){if(version===accessVersion)$('access-search-status').textContent='Could not update results. Previous results are still shown. Try Apply again.'}}$('access-filters').addEventListener('submit',event=>{event.preventDefault();searchAccess()});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='<div class="empty">No permissioned spaces found for this account.</div>';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}function renderRecords(records,append=false){const box=$('records');if(!append)box.innerHTML='';if(!records.length&&!append)box.innerHTML='<div class="empty">No records for that repo.</div>';for(const rec of records){const btn=document.createElement('button');btn.type='button';btn.className='item';btn.innerHTML='<span class="item-title">'+esc(rec.collection)+' / '+esc(rec.rkey)+'</span><span class="item-meta">'+esc(rec.cid)+'</span>';btn.onclick=()=>openRecord(rec);box.appendChild(btn)}}async function loadRecords(append=false){if(!selectedSpace)throw new Error('choose a space first');const repo=$('repo').value.trim(),collection=$('collection').value.trim();if(!repo)throw new Error('enter a writer repo DID');setStatus('loading records...');const params={space:selectedSpace,repo,limit:'50'};if(collection)params.collection=collection;if(append&&cursor)params.cursor=cursor;const out=await xrpc('/xrpc/com.atproto.space.listRecords',params);cursor=out.cursor||null;$('next-records').disabled=!cursor;renderRecords(out.records||[],append);setStatus('records loaded','ok')}function blobChips(value){const out=[],seen=new Set();function walk(v){if(!v||typeof v!=='object')return;if(v.ref&&v.ref.$link&&!seen.has(v.ref.$link)){seen.add(v.ref.$link);out.push(v.ref.$link)}for(const child of Array.isArray(v)?v:Object.values(v))walk(child)}walk(value);return out.map(cid=>'<span class="blob-chip">blob '+esc(cid)+'</span>').join(' ')}async function openRecord(rec){const detail=$('record-detail');detail.className='';detail.innerHTML='<p class="hint">loading record...</p>';const out=rec.value?rec:await xrpc('/xrpc/com.atproto.space.getRecord',{space:selectedSpace,repo:$('repo').value.trim(),collection:rec.collection,rkey:rec.rkey});detail.innerHTML='<div class="record-head"><div><strong>'+esc(rec.collection)+' / '+esc(rec.rkey)+'</strong><span class="item-meta">'+esc(out.cid)+'</span></div><button class="secondary" type="button" id="copy-json">copy JSON</button></div>'+blobChips(out.value)+'<pre>'+esc(JSON.stringify(out.value,null,2))+'</pre>';$('copy-json').onclick=()=>navigator.clipboard.writeText(JSON.stringify(out.value,null,2))}async function loadAccountData(){setStatus('loading account...');const query=accessQuery(),version=++accessVersion;const [session,passkeys,passwords,oauth,passwordAccess,spaceOut]=await Promise.all([xrpc('/xrpc/com.atproto.server.getSession'),xrpc('/xrpc/com.atproto.server.listPasskeys'),xrpc('/xrpc/com.atproto.server.listAppPasswords'),accessRequest('oauth',query),accessRequest('password',query),xrpc('/xrpc/com.atproto.space.listSpaces',{limit:'100'}).catch(()=>({spaces:[]}))]);account=session;spaces=spaceOut.spaces||[];updateHeader();showPasskeys(passkeys.passkeys||[]);showAppPasswords(passwords.passwords||[]);if(version===accessVersion){showAccessTotals(oauth,passwordAccess,query);showAccessPage('oauth',oauth,query);showAccessPage('password',passwordAccess,query);}renderSpaces();quickChecks();setStatus('signed in','ok')}loginForm.addEventListener('submit',async(e)=>{e.preventDefault();try{setStatus('signing in...');const f=e.currentTarget;const session=await fail(await fetch('/xrpc/com.atproto.server.createSession',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({identifier:f.identifier.value,password:f.password.value})}),'sign in failed');accessJwt=session.accessJwt;rememberSignIn();account=session;loginForm.classList.add('off');resident.classList.add('on');showView();await loadAccountData()}catch(err){setStatus(err.message||String(err),'error')}})$('add-passkey').onclick=async()=>{try{setStatus('opening browser passkey prompt...');const friendlyName=$('friendly').value;const start=await xrpc('/xrpc/com.atproto.server.startPasskeyRegistration',{}, {method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({friendlyName})});const credential=await navigator.credentials.create(prepCreate(start.options));await xrpc('/xrpc/com.atproto.server.finishPasskeyRegistration',{}, {method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({friendlyName,credential:serialize(credential)})});setStatus('passkey saved','ok');await loadAccountData()}catch(err){setStatus((err.name?err.name+': ':'')+(err.message||String(err)),'error')}}$('create-app-password').onclick=async()=>{try{const name=$('app-password-name').value.trim(),privileged=$('app-password-privileged').checked;if(!name)throw new Error('Choose a name for this app password.');setStatus('creating app password...');const created=await xrpc('/xrpc/com.atproto.server.createAppPassword',{}, {method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({name,privileged})});generatedAppPassword.classList.add('on');generatedAppPassword.innerHTML='Copy this app password now. It will not be shown again.<code></code>';generatedAppPassword.querySelector('code').textContent=created.password;$('app-password-name').value='';setStatus('app password created','ok');await loadAccountData()}catch(err){setStatus(err.message||String(err),'error')}}$('load-records').onclick=()=>loadRecords(false).catch(err=>setStatus(err.message,'error'));$('next-records').onclick=()=>loadRecords(true).catch(err=>setStatus(err.message,'error'));$('verify-email-request').onclick=async()=>{try{await xrpc('/xrpc/com.atproto.server.requestEmailConfirmation',{}, {method:'POST'});setStatus('verification email sent','ok')}catch(err){setStatus(err.message,'error')}}$('verify-email-confirm').onclick=async()=>{try{const token=$('verify-code').value.trim();if(!token)throw new Error('Enter the code from the verification email.');await xrpc('/xrpc/com.atproto.server.confirmEmail',{}, {method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({email:account.email,token})});$('verify-code').value='';setStatus('email verified','ok');await loadAccountData()}catch(err){setStatus(err.message,'error')}}$('update-email-request').onclick=async()=>{try{const out=await xrpc('/xrpc/com.atproto.server.requestEmailUpdate',{}, {method:'POST'});setStatus(out.tokenRequired?'update code sent to your current email':'no code needed — enter the new email and confirm','ok')}catch(err){setStatus(err.message,'error')}}$('update-email-confirm').onclick=async()=>{try{const email=$('new-email').value.trim();if(!email)throw new Error('Enter the new email address.');const body={email};if($('update-code').value.trim())body.token=$('update-code').value.trim();await xrpc('/xrpc/com.atproto.server.updateEmail',{}, {method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(body)});$('new-email').value='';$('update-code').value='';setStatus('email updated','ok');await loadAccountData()}catch(err){setStatus(err.message,'error')}}$('activate-account').onclick=async()=>{try{await xrpc('/xrpc/com.atproto.server.activateAccount',{}, {method:'POST'});setStatus('account reactivated','ok');await loadAccountData()}catch(err){setStatus(err.message,'error')}}$('deactivate-account').onclick=async()=>{try{if(!confirm('Deactivate this account? You can reactivate by signing back in.'))return;await xrpc('/xrpc/com.atproto.server.deactivateAccount',{}, {method:'POST'});setStatus('account deactivated','warn');await loadAccountData()}catch(err){setStatus(err.message,'error')}}showView();// Preserve this tab's sign-in across document loads; the server still enforces expiry.const signInKey='zds-account-access';function rememberSignIn(){try{sessionStorage.setItem(signInKey,accessJwt)}catch{}}function forgetSignIn(){try{sessionStorage.removeItem(signInKey);sessionStorage.removeItem('zds-signup-session')}catch{}}$('sign-out').addEventListener('click',()=>{forgetSignIn();location.reload()});async function restoreSignIn(){ try{ const handoff=sessionStorage.getItem('zds-signup-session'); if(handoff){ sessionStorage.removeItem('zds-signup-session'); const session=JSON.parse(handoff); if(session&&typeof session.accessJwt==='string'&&session.accessJwt){accessJwt=session.accessJwt;rememberSignIn()} } if(!accessJwt)accessJwt=sessionStorage.getItem(signInKey); }catch{} if(!accessJwt)return; loginForm.classList.add('off'); setStatus('restoring sign-in...'); try{ await loadAccountData(); resident.classList.add('on'); showView(); }catch(err){ if(err instanceof RequestError&&(err.status===401||err.code==='ExpiredToken')){ forgetSignIn();accessJwt=null;loginForm.classList.remove('off'); setStatus('Your sign-in has expired. Please sign in again.'); }else{ setStatus('Could not load your account. Reload to try again.','error'); // Keep the saved sign-in when the network or another endpoint is unavailable. loginForm.classList.remove('off'); } }}restoreSignIn();</script></body></html>