From 16379e595b930af4bcda882958a3a9f1d8b29457 Mon Sep 17 00:00:00 2001 From: Trezy Date: Sat, 14 Feb 2026 18:04:38 +0000 Subject: [PATCH] feat: add ATProto OAuth login via handle --- web/package-lock.json | 366 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ web/package.json | 1 + web/src/components/login-form.tsx | 42 ++++++++++++++++++++++++++---------------- web/src/lib/api.ts | 66 +++++++++++++++++++++++++++++++++++++++--------------------------- web/src/lib/auth-context.tsx | 147 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------------------- web/src/app/(dashboard)/layout.tsx | 8 ++++---- web/src/app/(dashboard)/page.tsx | 7 +++---- web/src/app/login/page.tsx | 8 ++++---- web/src/app/(dashboard)/admins/page.tsx | 18 ++++++++---------- web/src/app/(dashboard)/backfill/page.tsx | 15 +++++++-------- web/src/app/(dashboard)/lexicons/page.tsx | 21 +++++++++------------ web/src/app/(dashboard)/network-lexicons/page.tsx | 18 ++++++++---------- 12 file(s) changed, 601 insertion(s)(+), 116 deletion(s)(-) diff --git a/web/package-lock.json b/web/package-lock.json --- a/web/package-lock.json +++ b/web/package-lock.json @@ -8,6 +8,7 @@ "name": "web", "version": "0.1.0", "dependencies": { + "@atproto/oauth-client-browser": "^0.3.40", "@dnd-kit/core": "^6.3.1", "@dnd-kit/modifiers": "^9.0.0", "@dnd-kit/sortable": "^10.0.0", @@ -75,6 +76,333 @@ "nr": "bin/nr.mjs", "nun": "bin/nun.mjs", "nup": "bin/nup.mjs" + } + }, + "node_modules/@atproto-labs/did-resolver": { + "version": "0.2.6", + "resolved": "https://registry.npmjs.org/@atproto-labs/did-resolver/-/did-resolver-0.2.6.tgz", + "integrity": "sha512-2K1bC04nI2fmgNcvof+yA28IhGlpWn2JKYlPa7To9JTKI45FINCGkQSGiL2nyXlyzDJJ34fZ1aq6/IRFIOIiqg==", + "license": "MIT", + "dependencies": { + "@atproto-labs/fetch": "0.2.3", + "@atproto-labs/pipe": "0.1.1", + "@atproto-labs/simple-store": "0.3.0", + "@atproto-labs/simple-store-memory": "0.1.4", + "@atproto/did": "0.3.0", + "zod": "^3.23.8" + } + }, + "node_modules/@atproto-labs/did-resolver/node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/@atproto-labs/fetch": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/@atproto-labs/fetch/-/fetch-0.2.3.tgz", + "integrity": "sha512-NZtbJOCbxKUFRFKMpamT38PUQMY0hX0p7TG5AEYOPhZKZEP7dHZ1K2s1aB8MdVH0qxmqX7nQleNrrvLf09Zfdw==", + "license": "MIT", + "dependencies": { + "@atproto-labs/pipe": "0.1.1" + } + }, + "node_modules/@atproto-labs/handle-resolver": { + "version": "0.3.6", + "resolved": "https://registry.npmjs.org/@atproto-labs/handle-resolver/-/handle-resolver-0.3.6.tgz", + "integrity": "sha512-qnSTXvOBNj1EHhp2qTWSX8MS5q3AwYU5LKlt5fBvSbCjgmTr2j0URHCv+ydrwO55KvsojIkTMgeMOh4YuY4fCA==", + "license": "MIT", + "dependencies": { + "@atproto-labs/simple-store": "0.3.0", + "@atproto-labs/simple-store-memory": "0.1.4", + "@atproto/did": "0.3.0", + "zod": "^3.23.8" + } + }, + "node_modules/@atproto-labs/handle-resolver/node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/@atproto-labs/identity-resolver": { + "version": "0.3.6", + "resolved": "https://registry.npmjs.org/@atproto-labs/identity-resolver/-/identity-resolver-0.3.6.tgz", + "integrity": "sha512-qoWqBDRobln0NR8L8dQjSp79E0chGkBhibEgxQa2f9WD+JbJdjQ0YvwwO5yeQn05pJoJmAwmI2wyJ45zjU7aWg==", + "license": "MIT", + "dependencies": { + "@atproto-labs/did-resolver": "0.2.6", + "@atproto-labs/handle-resolver": "0.3.6" + } + }, + "node_modules/@atproto-labs/pipe": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/@atproto-labs/pipe/-/pipe-0.1.1.tgz", + "integrity": "sha512-hdNw2oUs2B6BN1lp+32pF7cp8EMKuIN5Qok2Vvv/aOpG/3tNSJ9YkvfI0k6Zd188LeDDYRUpYpxcoFIcGH/FNg==", + "license": "MIT" + }, + "node_modules/@atproto-labs/simple-store": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@atproto-labs/simple-store/-/simple-store-0.3.0.tgz", + "integrity": "sha512-nOb6ONKBRJHRlukW1sVawUkBqReLlLx6hT35VS3imaNPwiXDxLnTK7lxw3Lrl9k5yugSBDQAkZAq3MPTEFSUBQ==", + "license": "MIT" + }, + "node_modules/@atproto-labs/simple-store-memory": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/@atproto-labs/simple-store-memory/-/simple-store-memory-0.1.4.tgz", + "integrity": "sha512-3mKY4dP8I7yKPFj9VKpYyCRzGJOi5CEpOLPlRhoJyLmgs3J4RzDrjn323Oakjz2Aj2JzRU/AIvWRAZVhpYNJHw==", + "license": "MIT", + "dependencies": { + "@atproto-labs/simple-store": "0.3.0", + "lru-cache": "^10.2.0" + } + }, + "node_modules/@atproto-labs/simple-store-memory/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "license": "ISC" + }, + "node_modules/@atproto/common-web": { + "version": "0.4.16", + "resolved": "https://registry.npmjs.org/@atproto/common-web/-/common-web-0.4.16.tgz", + "integrity": "sha512-Ufvaff5JgxUyUyTAG0/3o7ltpy3lnZ1DvLjyAnvAf+hHfiK7OMQg+8byr+orN+KP9MtIQaRTsCgYPX+PxMKUoA==", + "license": "MIT", + "dependencies": { + "@atproto/lex-data": "^0.0.11", + "@atproto/lex-json": "^0.0.11", + "@atproto/syntax": "^0.4.3", + "zod": "^3.23.8" + } + }, + "node_modules/@atproto/common-web/node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/@atproto/did": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@atproto/did/-/did-0.3.0.tgz", + "integrity": "sha512-raUPzUGegtW/6OxwCmM8bhZvuIMzxG5t9oWsth6Tp91Kb5fTnHV2h/KKNF1C82doeA4BdXCErTyg7ISwLbQkzA==", + "license": "MIT", + "dependencies": { + "zod": "^3.23.8" + } + }, + "node_modules/@atproto/did/node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/@atproto/jwk": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/@atproto/jwk/-/jwk-0.6.0.tgz", + "integrity": "sha512-bDoJPvt7TrQVi/rBfBrSSpGykhtIriKxeYCYQTiPRKFfyRhbgpElF0wPXADjIswnbzZdOwbY63az4E/CFVT3Tw==", + "license": "MIT", + "dependencies": { + "multiformats": "^9.9.0", + "zod": "^3.23.8" + } + }, + "node_modules/@atproto/jwk-jose": { + "version": "0.1.11", + "resolved": "https://registry.npmjs.org/@atproto/jwk-jose/-/jwk-jose-0.1.11.tgz", + "integrity": "sha512-i4Fnr2sTBYmMmHXl7NJh8GrCH+tDQEVWrcDMDnV5DjJfkgT17wIqvojIw9SNbSL4Uf0OtfEv6AgG0A+mgh8b5Q==", + "license": "MIT", + "dependencies": { + "@atproto/jwk": "0.6.0", + "jose": "^5.2.0" + } + }, + "node_modules/@atproto/jwk-jose/node_modules/jose": { + "version": "5.10.0", + "resolved": "https://registry.npmjs.org/jose/-/jose-5.10.0.tgz", + "integrity": "sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/@atproto/jwk-webcrypto": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/@atproto/jwk-webcrypto/-/jwk-webcrypto-0.2.0.tgz", + "integrity": "sha512-UmgRrrEAkWvxwhlwe30UmDOdTEFidlIzBC7C3cCbeJMcBN1x8B3KH+crXrsTqfWQBG58mXgt8wgSK3Kxs2LhFg==", + "license": "MIT", + "dependencies": { + "@atproto/jwk": "0.6.0", + "@atproto/jwk-jose": "0.1.11", + "zod": "^3.23.8" + } + }, + "node_modules/@atproto/jwk-webcrypto/node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/@atproto/jwk/node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/@atproto/lex-data": { + "version": "0.0.11", + "resolved": "https://registry.npmjs.org/@atproto/lex-data/-/lex-data-0.0.11.tgz", + "integrity": "sha512-4+KTtHdqwlhiTKA7D4SACea4jprsNpCQsNALW09wsZ6IHhCDGO5tr1cmV+QnLYe3G3mu1E1yXHXbPUHrUUDT/A==", + "license": "MIT", + "dependencies": { + "multiformats": "^9.9.0", + "tslib": "^2.8.1", + "uint8arrays": "3.0.0", + "unicode-segmenter": "^0.14.0" + } + }, + "node_modules/@atproto/lex-json": { + "version": "0.0.11", + "resolved": "https://registry.npmjs.org/@atproto/lex-json/-/lex-json-0.0.11.tgz", + "integrity": "sha512-2IExAoQ4KsR5fyPa1JjIvtR316PvdgRH/l3BVGLBd3cSxM3m5MftIv1B6qZ9HjNiK60SgkWp0mi9574bTNDhBQ==", + "license": "MIT", + "dependencies": { + "@atproto/lex-data": "^0.0.11", + "tslib": "^2.8.1" + } + }, + "node_modules/@atproto/lexicon": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/@atproto/lexicon/-/lexicon-0.6.1.tgz", + "integrity": "sha512-/vI1kVlY50Si+5MXpvOucelnYwb0UJ6Qto5mCp+7Q5C+Jtp+SoSykAPVvjVtTnQUH2vrKOFOwpb3C375vSKzXw==", + "license": "MIT", + "dependencies": { + "@atproto/common-web": "^0.4.13", + "@atproto/syntax": "^0.4.3", + "iso-datestring-validator": "^2.2.2", + "multiformats": "^9.9.0", + "zod": "^3.23.8" + } + }, + "node_modules/@atproto/lexicon/node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/@atproto/oauth-client": { + "version": "0.5.14", + "resolved": "https://registry.npmjs.org/@atproto/oauth-client/-/oauth-client-0.5.14.tgz", + "integrity": "sha512-sPH+vcdq9maTEAhJI0HzmFcFAMrkCS19np+RUssNkX6kS8Xr3OYr57tvYRCbkcnIyYTfYcxKQgpwHKx3RVEaYw==", + "license": "MIT", + "dependencies": { + "@atproto-labs/did-resolver": "0.2.6", + "@atproto-labs/fetch": "0.2.3", + "@atproto-labs/handle-resolver": "0.3.6", + "@atproto-labs/identity-resolver": "0.3.6", + "@atproto-labs/simple-store": "0.3.0", + "@atproto-labs/simple-store-memory": "0.1.4", + "@atproto/did": "0.3.0", + "@atproto/jwk": "0.6.0", + "@atproto/oauth-types": "0.6.2", + "@atproto/xrpc": "0.7.7", + "core-js": "^3", + "multiformats": "^9.9.0", + "zod": "^3.23.8" + } + }, + "node_modules/@atproto/oauth-client-browser": { + "version": "0.3.40", + "resolved": "https://registry.npmjs.org/@atproto/oauth-client-browser/-/oauth-client-browser-0.3.40.tgz", + "integrity": "sha512-AlvHf1DYFRHw+J8uALUMhpclTbUTTkvLqzQeGBdAXxSGP8OefsOXXaSiY5Mh6zAYxek95/ypxYzNYncasgnMWg==", + "license": "MIT", + "dependencies": { + "@atproto-labs/did-resolver": "0.2.6", + "@atproto-labs/handle-resolver": "0.3.6", + "@atproto-labs/simple-store": "0.3.0", + "@atproto/did": "0.3.0", + "@atproto/jwk": "0.6.0", + "@atproto/jwk-webcrypto": "0.2.0", + "@atproto/oauth-client": "0.5.14", + "@atproto/oauth-types": "0.6.2", + "core-js": "^3" + } + }, + "node_modules/@atproto/oauth-client/node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/@atproto/oauth-types": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/@atproto/oauth-types/-/oauth-types-0.6.2.tgz", + "integrity": "sha512-2cuboM4RQBCYR8NQC5uGRkW6KgCgKyq/B5/+tnMmWZYtZGVUQvsUWQHK/ZiMCnVXbcDNtc/RIEJQJDZ8FXMoxg==", + "license": "MIT", + "dependencies": { + "@atproto/did": "0.3.0", + "@atproto/jwk": "0.6.0", + "zod": "^3.23.8" + } + }, + "node_modules/@atproto/oauth-types/node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/@atproto/syntax": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@atproto/syntax/-/syntax-0.4.3.tgz", + "integrity": "sha512-YoZUz40YAJr5nPwvCDWgodEOlt5IftZqPJvA0JDWjuZKD8yXddTwSzXSaKQAzGOpuM+/A3uXRtPzJJqlScc+iA==", + "license": "MIT", + "dependencies": { + "tslib": "^2.8.1" + } + }, + "node_modules/@atproto/xrpc": { + "version": "0.7.7", + "resolved": "https://registry.npmjs.org/@atproto/xrpc/-/xrpc-0.7.7.tgz", + "integrity": "sha512-K1ZyO/BU8JNtXX5dmPp7b5UrkLMMqpsIa/Lrj5D3Su+j1Xwq1m6QJ2XJ1AgjEjkI1v4Muzm7klianLE6XGxtmA==", + "license": "MIT", + "dependencies": { + "@atproto/lexicon": "^0.6.0", + "zod": "^3.23.8" + } + }, + "node_modules/@atproto/xrpc/node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" } }, "node_modules/@babel/code-frame": { @@ -5554,6 +5882,17 @@ "node": ">=6.6.0" } }, + "node_modules/core-js": { + "version": "3.48.0", + "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.48.0.tgz", + "integrity": "sha512-zpEHTy1fjTMZCKLHUZoVeylt9XrzaIN2rbPXEt0k+q7JE5CkCZdo6bNq55bn24a69CH7ErAVLKijxJja4fw+UQ==", + "hasInstallScript": true, + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/core-js" + } + }, "node_modules/cors": { "version": "2.8.6", "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", @@ -8345,6 +8684,12 @@ "dev": true, "license": "ISC" }, + "node_modules/iso-datestring-validator": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/iso-datestring-validator/-/iso-datestring-validator-2.2.2.tgz", + "integrity": "sha512-yLEMkBbLZTlVQqOnQ4FiMujR6T4DEcCb1xizmvXS+OxuhwcbtynoosRzdMA69zZCShCNAbi+gJ71FxZBBXx1SA==", + "license": "MIT" + }, "node_modules/iterator.prototype": { "version": "1.1.5", "resolved": "https://registry.npmjs.org/iterator.prototype/-/iterator.prototype-1.1.5.tgz", @@ -9145,6 +9490,12 @@ "type": "opencollective", "url": "https://opencollective.com/express" } + }, + "node_modules/multiformats": { + "version": "9.9.0", + "resolved": "https://registry.npmjs.org/multiformats/-/multiformats-9.9.0.tgz", + "integrity": "sha512-HoMUjhH9T8DDBNT+6xzkrd9ga/XiBI4xLr58LJACwK6G3HTOPeMz4nB4KJs33L2BelrIJa7P0VuNaVF3hMYfjg==", + "license": "(Apache-2.0 AND MIT)" }, "node_modules/mute-stream": { "version": "2.0.0", @@ -11680,6 +12031,15 @@ "typescript": ">=4.8.4 <6.0.0" } }, + "node_modules/uint8arrays": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/uint8arrays/-/uint8arrays-3.0.0.tgz", + "integrity": "sha512-HRCx0q6O9Bfbp+HHSfQQKD7wU70+lydKVt4EghkdOvlK/NlrF90z+eXV34mUd48rNvVJXwkrMSPpCATkct8fJA==", + "license": "MIT", + "dependencies": { + "multiformats": "^9.4.2" + } + }, "node_modules/unbox-primitive": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz", @@ -11704,6 +12064,12 @@ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", "dev": true, + "license": "MIT" + }, + "node_modules/unicode-segmenter": { + "version": "0.14.5", + "resolved": "https://registry.npmjs.org/unicode-segmenter/-/unicode-segmenter-0.14.5.tgz", + "integrity": "sha512-jHGmj2LUuqDcX3hqY12Ql+uhUTn8huuxNZGq7GvtF6bSybzH3aFgedYu/KTzQStEgt1Ra2F3HxadNXsNjb3m3g==", "license": "MIT" }, "node_modules/unicorn-magic": { diff --git a/web/package.json b/web/package.json --- a/web/package.json +++ b/web/package.json @@ -9,6 +9,7 @@ "lint": "eslint" }, "dependencies": { + "@atproto/oauth-client-browser": "^0.3.40", "@dnd-kit/core": "^6.3.1", "@dnd-kit/modifiers": "^9.0.0", "@dnd-kit/sortable": "^10.0.0", diff --git a/web/src/components/login-form.tsx b/web/src/components/login-form.tsx --- a/web/src/components/login-form.tsx +++ b/web/src/components/login-form.tsx @@ -1,7 +1,6 @@ "use client" import { useState } from "react" -import { useRouter } from "next/navigation" import { cn } from "@/lib/utils" import { useAuth } from "@/lib/auth-context" @@ -18,15 +17,22 @@ className, ...props }: React.ComponentProps<"div">) { - const [token, setToken] = useState("") + const [handle, setHandle] = useState("") + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) const { login } = useAuth() - const router = useRouter() - function handleSubmit(e: React.FormEvent) { + async function handleSubmit(e: React.FormEvent) { e.preventDefault() - if (!token.trim()) return - login(token.trim()) - router.push("/") + if (!handle.trim()) return + setLoading(true) + setError(null) + try { + await login(handle.trim()) + } catch (e: unknown) { + setError(e instanceof Error ? e.message : "Login failed") + setLoading(false) + } } return ( @@ -36,23 +42,27 @@

HappyView Admin

- Enter your access token to manage your AppView. + Sign in with your ATProto account to manage your AppView.
+ {error && ( +

{error}

+ )} - Access Token + Handle setToken(e.target.value)} + id="handle" + type="text" + placeholder="you.bsky.social" + value={handle} + onChange={(e) => setHandle(e.target.value)} required + disabled={loading} /> - diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -8,9 +8,12 @@ async function apiFetch( path: string, - token: string, + getToken: () => Promise, options?: RequestInit ): Promise { + const token = await getToken() + if (!token) throw new ApiError(401, "Not authenticated") + const headers: Record = { Authorization: `Bearer ${token}`, } @@ -46,8 +49,8 @@ collections: CollectionStat[] } -export function getStats(token: string) { - return apiFetch("/admin/stats", token) +export function getStats(getToken: () => Promise) { + return apiFetch("/admin/stats", getToken) } // Lexicons @@ -65,16 +68,19 @@ lexicon_json: Record } -export function getLexicons(token: string) { - return apiFetch("/admin/lexicons", token) +export function getLexicons(getToken: () => Promise) { + return apiFetch("/admin/lexicons", getToken) } -export function getLexicon(token: string, id: string) { - return apiFetch(`/admin/lexicons/${encodeURIComponent(id)}`, token) +export function getLexicon(getToken: () => Promise, id: string) { + return apiFetch( + `/admin/lexicons/${encodeURIComponent(id)}`, + getToken + ) } export function uploadLexicon( - token: string, + getToken: () => Promise, body: { lexicon_json: unknown backfill?: boolean @@ -82,14 +88,14 @@ action?: string } ) { - return apiFetch<{ id: string; revision: number }>("/admin/lexicons", token, { + return apiFetch<{ id: string; revision: number }>("/admin/lexicons", getToken, { method: "POST", body: JSON.stringify(body), }) } -export function deleteLexicon(token: string, id: string) { - return apiFetch(`/admin/lexicons/${encodeURIComponent(id)}`, token, { +export function deleteLexicon(getToken: () => Promise, id: string) { + return apiFetch(`/admin/lexicons/${encodeURIComponent(id)}`, getToken, { method: "DELETE", }) } @@ -103,25 +109,28 @@ created_at: string } -export function getNetworkLexicons(token: string) { - return apiFetch("/admin/network-lexicons", token) +export function getNetworkLexicons(getToken: () => Promise) { + return apiFetch("/admin/network-lexicons", getToken) } export function addNetworkLexicon( - token: string, + getToken: () => Promise, body: { nsid: string; target_collection?: string } ) { return apiFetch<{ nsid: string; authority_did: string; revision: number }>( "/admin/network-lexicons", - token, + getToken, { method: "POST", body: JSON.stringify(body) } ) } -export function deleteNetworkLexicon(token: string, nsid: string) { +export function deleteNetworkLexicon( + getToken: () => Promise, + nsid: string +) { return apiFetch( `/admin/network-lexicons/${encodeURIComponent(nsid)}`, - token, + getToken, { method: "DELETE" } ) } @@ -141,15 +150,15 @@ created_at: string } -export function getBackfillJobs(token: string) { - return apiFetch("/admin/backfill/status", token) +export function getBackfillJobs(getToken: () => Promise) { + return apiFetch("/admin/backfill/status", getToken) } export function createBackfillJob( - token: string, + getToken: () => Promise, body: { collection?: string; did?: string } ) { - return apiFetch<{ id: string; status: string }>("/admin/backfill", token, { + return apiFetch<{ id: string; status: string }>("/admin/backfill", getToken, { method: "POST", body: JSON.stringify(body), }) @@ -163,19 +172,22 @@ last_used_at: string | null } -export function getAdmins(token: string) { - return apiFetch("/admin/admins", token) +export function getAdmins(getToken: () => Promise) { + return apiFetch("/admin/admins", getToken) } -export function addAdmin(token: string, body: { did: string }) { - return apiFetch<{ id: string; did: string }>("/admin/admins", token, { +export function addAdmin( + getToken: () => Promise, + body: { did: string } +) { + return apiFetch<{ id: string; did: string }>("/admin/admins", getToken, { method: "POST", body: JSON.stringify(body), }) } -export function deleteAdmin(token: string, id: string) { - return apiFetch(`/admin/admins/${encodeURIComponent(id)}`, token, { +export function deleteAdmin(getToken: () => Promise, id: string) { + return apiFetch(`/admin/admins/${encodeURIComponent(id)}`, getToken, { method: "DELETE", }) } diff --git a/web/src/lib/auth-context.tsx b/web/src/lib/auth-context.tsx --- a/web/src/lib/auth-context.tsx +++ b/web/src/lib/auth-context.tsx @@ -1,43 +1,148 @@ "use client" -import { createContext, useCallback, useContext, useEffect, useState } from "react" +import { + createContext, + useCallback, + useContext, + useEffect, + useRef, + useState, +} from "react" +import type { BrowserOAuthClient, OAuthSession } from "@atproto/oauth-client-browser" interface AuthContextType { - token: string | null - login: (token: string) => void - logout: () => void + did: string | null + getToken: () => Promise + login: (handle: string) => Promise + logout: () => Promise + loading: boolean + error: string | null } const AuthContext = createContext({ - token: null, - login: () => {}, - logout: () => {}, + did: null, + getToken: async () => null, + login: async () => {}, + logout: async () => {}, + loading: true, + error: null, }) export function AuthProvider({ children }: { children: React.ReactNode }) { - const [token, setToken] = useState(null) - const [loaded, setLoaded] = useState(false) + const [session, setSession] = useState(null) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + const clientRef = useRef(null) useEffect(() => { - const stored = localStorage.getItem("happyview_token") - if (stored) setToken(stored) - setLoaded(true) + let cancelled = false + + async function init() { + try { + const { + BrowserOAuthClient: Client, + atprotoLoopbackClientMetadata, + buildAtprotoLoopbackClientId, + } = await import("@atproto/oauth-client-browser") + + const isLocalhost = + window.location.hostname === "localhost" || + window.location.hostname === "127.0.0.1" + + let client: InstanceType + + if (isLocalhost) { + const port = window.location.port + ? `:${window.location.port}` + : "" + const clientId = buildAtprotoLoopbackClientId({ + redirect_uris: [`http://127.0.0.1${port}/`], + }) + client = new Client({ + handleResolver: "https://bsky.social", + clientMetadata: atprotoLoopbackClientMetadata(clientId), + }) + } else { + client = await Client.load({ + clientId: `${window.location.origin}/oauth/client-metadata.json`, + handleResolver: "https://bsky.social", + }) + } + + clientRef.current = client + + const result = await client.init() + if (!cancelled && result?.session) { + setSession(result.session) + } + } catch (e) { + if (!cancelled) { + console.error("OAuth init error:", e) + setError(e instanceof Error ? e.message : String(e)) + } + } finally { + if (!cancelled) setLoading(false) + } + } + + init() + return () => { + cancelled = true + } }, []) - const login = useCallback((t: string) => { - localStorage.setItem("happyview_token", t) - setToken(t) + const getToken = useCallback(async (): Promise => { + if (!session) return null + try { + // Access the protected getTokenSet method to extract the raw access + // token. The admin API validates tokens via AIP's userinfo endpoint + // using plain Bearer auth, so we need the raw JWT. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const tokenSet = await (session as any).getTokenSet("auto") + return tokenSet.access_token + } catch { + return null + } + }, [session]) + + const login = useCallback(async (handle: string) => { + const client = clientRef.current + if (!client) return + setError(null) + try { + await client.signIn(handle, { + scope: "atproto", + }) + } catch (e) { + setError(e instanceof Error ? e.message : String(e)) + throw e + } }, []) - const logout = useCallback(() => { - localStorage.removeItem("happyview_token") - setToken(null) - }, []) + const logout = useCallback(async () => { + if (session) { + try { + await session.signOut() + } catch { + // Ignore sign-out errors + } + } + setSession(null) + }, [session]) - if (!loaded) return null + if (loading) return null return ( - + {children} ) diff --git a/web/src/app/(dashboard)/layout.tsx b/web/src/app/(dashboard)/layout.tsx --- a/web/src/app/(dashboard)/layout.tsx +++ b/web/src/app/(dashboard)/layout.tsx @@ -12,16 +12,16 @@ }: { children: React.ReactNode }) { - const { token } = useAuth() + const { did } = useAuth() const router = useRouter() useEffect(() => { - if (!token) { + if (!did) { router.replace("/login") } - }, [token, router]) + }, [did, router]) - if (!token) return null + if (!did) return null return ( (null) const [error, setError] = useState(null) useEffect(() => { - if (!token) return - getStats(token).then(setStats).catch((e) => setError(e.message)) - }, [token]) + getStats(getToken).then(setStats).catch((e) => setError(e.message)) + }, [getToken]) return ( <> diff --git a/web/src/app/login/page.tsx b/web/src/app/login/page.tsx --- a/web/src/app/login/page.tsx +++ b/web/src/app/login/page.tsx @@ -6,14 +6,14 @@ import { useAuth } from "@/lib/auth-context" export default function LoginPage() { - const { token } = useAuth() + const { did } = useAuth() const router = useRouter() useEffect(() => { - if (token) router.replace("/") - }, [token, router]) + if (did) router.replace("/") + }, [did, router]) - if (token) return null + if (did) return null return (
diff --git a/web/src/app/(dashboard)/admins/page.tsx b/web/src/app/(dashboard)/admins/page.tsx --- a/web/src/app/(dashboard)/admins/page.tsx +++ b/web/src/app/(dashboard)/admins/page.tsx @@ -28,23 +28,21 @@ } from "@/components/ui/table" export default function AdminsPage() { - const { token } = useAuth() + const { getToken } = useAuth() const [admins, setAdmins] = useState([]) const [error, setError] = useState(null) const load = useCallback(() => { - if (!token) return - getAdmins(token).then(setAdmins).catch((e) => setError(e.message)) - }, [token]) + getAdmins(getToken).then(setAdmins).catch((e) => setError(e.message)) + }, [getToken]) useEffect(() => { load() }, [load]) async function handleDelete(id: string) { - if (!token) return try { - await deleteAdmin(token, id) + await deleteAdmin(getToken, id) load() } catch (e: unknown) { setError(e instanceof Error ? e.message : String(e)) @@ -59,7 +57,7 @@

Admin Users

- +
@@ -116,10 +114,10 @@ } function AddAdminDialog({ - token, + getToken, onSuccess, }: { - token: string + getToken: () => Promise onSuccess: () => void }) { const [did, setDid] = useState("") @@ -129,7 +127,7 @@ async function handleAdd() { setError(null) try { - await addAdmin(token, { did }) + await addAdmin(getToken, { did }) setDid("") setOpen(false) onSuccess() diff --git a/web/src/app/(dashboard)/backfill/page.tsx b/web/src/app/(dashboard)/backfill/page.tsx --- a/web/src/app/(dashboard)/backfill/page.tsx +++ b/web/src/app/(dashboard)/backfill/page.tsx @@ -46,14 +46,13 @@ } export default function BackfillPage() { - const { token } = useAuth() + const { getToken } = useAuth() const [jobs, setJobs] = useState([]) const [error, setError] = useState(null) const load = useCallback(() => { - if (!token) return - getBackfillJobs(token).then(setJobs).catch((e) => setError(e.message)) - }, [token]) + getBackfillJobs(getToken).then(setJobs).catch((e) => setError(e.message)) + }, [getToken]) useEffect(() => { load() @@ -77,7 +76,7 @@

Backfill Jobs

- +
@@ -144,10 +143,10 @@ } function CreateDialog({ - token, + getToken, onSuccess, }: { - token: string + getToken: () => Promise onSuccess: () => void }) { const [collection, setCollection] = useState("") @@ -158,7 +157,7 @@ async function handleCreate() { setError(null) try { - await createBackfillJob(token, { + await createBackfillJob(getToken, { collection: collection || undefined, did: did || undefined, }) diff --git a/web/src/app/(dashboard)/lexicons/page.tsx b/web/src/app/(dashboard)/lexicons/page.tsx --- a/web/src/app/(dashboard)/lexicons/page.tsx +++ b/web/src/app/(dashboard)/lexicons/page.tsx @@ -38,24 +38,22 @@ import { Textarea } from "@/components/ui/textarea" export default function LexiconsPage() { - const { token } = useAuth() + const { getToken } = useAuth() const [lexicons, setLexicons] = useState([]) const [error, setError] = useState(null) const [viewLexicon, setViewLexicon] = useState(null) const load = useCallback(() => { - if (!token) return - getLexicons(token).then(setLexicons).catch((e) => setError(e.message)) - }, [token]) + getLexicons(getToken).then(setLexicons).catch((e) => setError(e.message)) + }, [getToken]) useEffect(() => { load() }, [load]) async function handleView(id: string) { - if (!token) return try { - const detail = await getLexicon(token, id) + const detail = await getLexicon(getToken, id) setViewLexicon(detail) } catch (e: unknown) { setError(e instanceof Error ? e.message : String(e)) @@ -63,9 +61,8 @@ } async function handleDelete(id: string) { - if (!token) return try { - await deleteLexicon(token, id) + await deleteLexicon(getToken, id) load() } catch (e: unknown) { setError(e instanceof Error ? e.message : String(e)) @@ -80,7 +77,7 @@

Uploaded Lexicons

- +
@@ -157,10 +154,10 @@ } function UploadDialog({ - token, + getToken, onSuccess, }: { - token: string + getToken: () => Promise onSuccess: () => void }) { const [json, setJson] = useState("") @@ -174,7 +171,7 @@ setError(null) try { const lexiconJson = JSON.parse(json) - await uploadLexicon(token, { + await uploadLexicon(getToken, { lexicon_json: lexiconJson, backfill, target_collection: targetCollection || undefined, diff --git a/web/src/app/(dashboard)/network-lexicons/page.tsx b/web/src/app/(dashboard)/network-lexicons/page.tsx --- a/web/src/app/(dashboard)/network-lexicons/page.tsx +++ b/web/src/app/(dashboard)/network-lexicons/page.tsx @@ -33,23 +33,21 @@ } from "@/components/ui/table" export default function NetworkLexiconsPage() { - const { token } = useAuth() + const { getToken } = useAuth() const [items, setItems] = useState([]) const [error, setError] = useState(null) const load = useCallback(() => { - if (!token) return - getNetworkLexicons(token).then(setItems).catch((e) => setError(e.message)) - }, [token]) + getNetworkLexicons(getToken).then(setItems).catch((e) => setError(e.message)) + }, [getToken]) useEffect(() => { load() }, [load]) async function handleDelete(nsid: string) { - if (!token) return try { - await deleteNetworkLexicon(token, nsid) + await deleteNetworkLexicon(getToken, nsid) load() } catch (e: unknown) { setError(e instanceof Error ? e.message : String(e)) @@ -64,7 +62,7 @@

Tracked Network Lexicons

- +
@@ -125,10 +123,10 @@ } function AddDialog({ - token, + getToken, onSuccess, }: { - token: string + getToken: () => Promise onSuccess: () => void }) { const [nsid, setNsid] = useState("") @@ -139,7 +137,7 @@ async function handleAdd() { setError(null) try { - await addNetworkLexicon(token, { + await addNetworkLexicon(getToken, { nsid, target_collection: targetCollection || undefined, }) -- tangled.sh