From 2ff6b79adb285cbbc5d843bc30926cee003602db Mon Sep 17 00:00:00 2001 From: rimar1337 <132627503+rimar1337@users.noreply.github.com> Date: Sat, 9 Aug 2025 23:24:36 +0700 Subject: [PATCH] oauth (public client) --- oauthdev.mts | 48 +++ package-lock.json | 296 +++++++++++++++++- package.json | 9 +- public/client-metadata.json | 22 ++ src/components/Header.tsx | 61 +++- src/components/OAuthLogin.tsx | 124 ++++++++ src/components/{Login.tsx => PassLogin.tsx} | 0 src/components/PostError.tsx | 2 +- src/helpers/oauthClient.ts | 38 +++ src/main.tsx | 6 +- src/providers/OAuthProvider.tsx | 98 ++++++ src/routeTree.gen.ts | 21 ++ src/routes/callback/index.tsx | 11 + src/routes/f/$forumHandle/index.tsx | 6 +- .../$forumHandle/t/$userHandle/$topicRKey.tsx | 10 +- src/routes/search.tsx | 9 +- vite.config.ts | 40 ++- 17 files changed, 759 insertions(+), 42 deletions(-) create mode 100644 oauthdev.mts create mode 100644 public/client-metadata.json create mode 100644 src/components/OAuthLogin.tsx rename src/components/{Login.tsx => PassLogin.tsx} (100%) create mode 100644 src/helpers/oauthClient.ts create mode 100644 src/providers/OAuthProvider.tsx create mode 100644 src/routes/callback/index.tsx diff --git a/oauthdev.mts b/oauthdev.mts new file mode 100644 index 0000000..73382f5 --- /dev/null +++ b/oauthdev.mts @@ -0,0 +1,48 @@ +import fs from 'fs'; +import path from 'path'; +//import { generateClientMetadata } from './src/helpers/oauthClient' +export const generateClientMetadata = (appOrigin: string) => { + const callbackPath = '/callback'; + + return { + "client_id": `${appOrigin}/client-metadata.json`, + "client_name": "ForumTest", + "client_uri": appOrigin, + "logo_uri": `${appOrigin}/logo192.png`, + "tos_uri": `${appOrigin}/terms-of-service`, + "policy_uri": `${appOrigin}/privacy-policy`, + "redirect_uris": [`${appOrigin}${callbackPath}`] as [string, ...string[]], + "scope": "atproto transition:generic", + "grant_types": ["authorization_code", "refresh_token"] as ["authorization_code", "refresh_token"], + "response_types": ["code"] as ["code"], + "token_endpoint_auth_method": "none" as "none", + "application_type": "web" as "web", + "dpop_bound_access_tokens": true + }; +} + + +export function generateMetadataPlugin({prod, dev}:{prod: string, dev: string}) { + return { + name: 'vite-plugin-generate-metadata', + config(_config: any, { mode }: any) { + let appOrigin; + if (mode === 'production') { + appOrigin = prod + if (!appOrigin || !appOrigin.startsWith('https://')) { + throw new Error('VITE_APP_ORIGIN environment variable must be set to a valid HTTPS URL for production build.'); + } + } else { + appOrigin = dev; + } + + + const metadata = generateClientMetadata(appOrigin); + const outputPath = path.resolve(process.cwd(), 'public', 'client-metadata.json'); + + fs.writeFileSync(outputPath, JSON.stringify(metadata, null, 2)); + + console.log(`✅ Generated client-metadata.json for ${appOrigin}`); + }, + }; +} \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 2ae9453..f3791d2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6,8 +6,10 @@ "": { "name": "forumtest", "dependencies": { - "@atproto/api": "^0.16.0", + "@atproto/api": "^0.16.2", + "@atproto/oauth-client-browser": "^0.3.27", "@radix-ui/react-dialog": "^1.1.14", + "@radix-ui/react-dropdown-menu": "^2.1.15", "@radix-ui/react-icons": "^1.3.2", "@radix-ui/react-popover": "^1.1.14", "@radix-ui/react-select": "^2.2.5", @@ -26,6 +28,7 @@ "devDependencies": { "@testing-library/dom": "^10.4.0", "@testing-library/react": "^16.2.0", + "@types/node": "^24.2.1", "@types/react": "^19.0.8", "@types/react-dom": "^19.0.3", "@vitejs/plugin-react": "^4.3.4", @@ -70,10 +73,83 @@ "dev": true, "license": "ISC" }, + "node_modules/@atproto-labs/did-resolver": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/@atproto-labs/did-resolver/-/did-resolver-0.2.0.tgz", + "integrity": "sha512-y9GOx2gUETynDKmANnBrU5DTf+u0AwKBJpGns1vDDOYMdLdRCFIeYy3UH+TI8YOkcEazjgF5Q3m+LjwriE1KqQ==", + "license": "MIT", + "dependencies": { + "@atproto-labs/fetch": "0.2.3", + "@atproto-labs/pipe": "0.1.1", + "@atproto-labs/simple-store": "0.2.0", + "@atproto-labs/simple-store-memory": "0.1.3", + "@atproto/did": "0.1.5", + "zod": "^3.23.8" + } + }, + "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.0", + "resolved": "https://registry.npmjs.org/@atproto-labs/handle-resolver/-/handle-resolver-0.3.0.tgz", + "integrity": "sha512-TREelvXB6P2eHxx6QjINRkBzUZu/aXWrdY9iN57shQe3C8rzsHNEHHuTVvRa33Hc7vFdQbZN0TnCgKveoyiL/A==", + "license": "MIT", + "dependencies": { + "@atproto-labs/simple-store": "0.2.0", + "@atproto-labs/simple-store-memory": "0.1.3", + "@atproto/did": "0.1.5", + "zod": "^3.23.8" + } + }, + "node_modules/@atproto-labs/identity-resolver": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@atproto-labs/identity-resolver/-/identity-resolver-0.3.0.tgz", + "integrity": "sha512-ZmmRV6m17kIaX4WllYrFIa7d23lNng0fIk6pLyepRGZobQhM5d4wDezICTESAG+RoD0e5fisWs+Tamdvx3mx/Q==", + "license": "MIT", + "dependencies": { + "@atproto-labs/did-resolver": "0.2.0", + "@atproto-labs/handle-resolver": "0.3.0" + } + }, + "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.2.0", + "resolved": "https://registry.npmjs.org/@atproto-labs/simple-store/-/simple-store-0.2.0.tgz", + "integrity": "sha512-0bRbAlI8Ayh03wRwncAMEAyUKtZ+AuTS1jgPrfym1WVOAOiottI/ZmgccqLl6w5MbxVcClNQF7WYGKvGwGoIhA==", + "license": "MIT" + }, + "node_modules/@atproto-labs/simple-store-memory": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@atproto-labs/simple-store-memory/-/simple-store-memory-0.1.3.tgz", + "integrity": "sha512-jkitT9+AtU+0b28DoN92iURLaCt/q/q4yX8q6V+9LSwYlUTqKoj/5NFKvF7x6EBuG+gpUdlcycbH7e60gjOhRQ==", + "license": "MIT", + "dependencies": { + "@atproto-labs/simple-store": "0.2.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/api": { - "version": "0.16.0", - "resolved": "https://registry.npmjs.org/@atproto/api/-/api-0.16.0.tgz", - "integrity": "sha512-PQHeae6mz/L1YirUslfci7bknfg3RrSZjXpYwzLICxIOvqGKIkOi0+qukC2Py238RhXRo8YZ9dCuole9HQBXDw==", + "version": "0.16.2", + "resolved": "https://registry.npmjs.org/@atproto/api/-/api-0.16.2.tgz", + "integrity": "sha512-sSTg31J8ws8DNaoiizp+/uJideRxRaJsq+Nyl8rnSxGw0w3oCvoeRU19iRWh2t0jZEmiRJAGkveGu23NKmPYEQ==", "license": "MIT", "dependencies": { "@atproto/common-web": "^0.4.2", @@ -98,6 +174,46 @@ "zod": "^3.23.8" } }, + "node_modules/@atproto/did": { + "version": "0.1.5", + "resolved": "https://registry.npmjs.org/@atproto/did/-/did-0.1.5.tgz", + "integrity": "sha512-8+1D08QdGE5TF0bB0vV8HLVrVZJeLNITpRTUVEoABNMRaUS7CoYSVb0+JNQDeJIVmqMjOL8dOjvCUDkp3gEaGQ==", + "license": "MIT", + "dependencies": { + "zod": "^3.23.8" + } + }, + "node_modules/@atproto/jwk": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@atproto/jwk/-/jwk-0.4.0.tgz", + "integrity": "sha512-tvp4iZrzqEzKCeTOKz50/o6WdsZzOuWmWjF6On5QAp04fLwLpsFu2Hixgx/lA1KBO0O4sns7YSGcAqSSX6Rdog==", + "license": "MIT", + "dependencies": { + "multiformats": "^9.9.0", + "zod": "^3.23.8" + } + }, + "node_modules/@atproto/jwk-jose": { + "version": "0.1.9", + "resolved": "https://registry.npmjs.org/@atproto/jwk-jose/-/jwk-jose-0.1.9.tgz", + "integrity": "sha512-HT9GcUe6htDxI5OSYXWdeS6QZ9lpuDDvJk508ppi8a48E/1f8eumoM0QhgbFRF9IKAnnFrtnZDOAvljQzFKwwQ==", + "license": "MIT", + "dependencies": { + "@atproto/jwk": "0.4.0", + "jose": "^5.2.0" + } + }, + "node_modules/@atproto/jwk-webcrypto": { + "version": "0.1.9", + "resolved": "https://registry.npmjs.org/@atproto/jwk-webcrypto/-/jwk-webcrypto-0.1.9.tgz", + "integrity": "sha512-ecciePHT0JEDZNAbMKSkdqoBYsjvhwuVno0jsS600SZmuvi2fAMhGraDZ5ZOO5M0hHHBiDbN7Ar/qcnIwyoxsA==", + "license": "MIT", + "dependencies": { + "@atproto/jwk": "0.4.0", + "@atproto/jwk-jose": "0.1.9", + "zod": "^3.23.8" + } + }, "node_modules/@atproto/lexicon": { "version": "0.4.12", "resolved": "https://registry.npmjs.org/@atproto/lexicon/-/lexicon-0.4.12.tgz", @@ -111,6 +227,52 @@ "zod": "^3.23.8" } }, + "node_modules/@atproto/oauth-client": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/@atproto/oauth-client/-/oauth-client-0.5.1.tgz", + "integrity": "sha512-wNC9RdfH1LGyZKF+UOmY+z4TFNx1gBur3fx91MCCrNaU0aTHBzgEH9UquL2031J7VNXhBsKJnHfEB5ZYy0AEHQ==", + "license": "MIT", + "dependencies": { + "@atproto-labs/did-resolver": "0.2.0", + "@atproto-labs/fetch": "0.2.3", + "@atproto-labs/handle-resolver": "0.3.0", + "@atproto-labs/identity-resolver": "0.3.0", + "@atproto-labs/simple-store": "0.2.0", + "@atproto-labs/simple-store-memory": "0.1.3", + "@atproto/did": "0.1.5", + "@atproto/jwk": "0.4.0", + "@atproto/oauth-types": "0.4.0", + "@atproto/xrpc": "0.7.1", + "multiformats": "^9.9.0", + "zod": "^3.23.8" + } + }, + "node_modules/@atproto/oauth-client-browser": { + "version": "0.3.27", + "resolved": "https://registry.npmjs.org/@atproto/oauth-client-browser/-/oauth-client-browser-0.3.27.tgz", + "integrity": "sha512-sUZP27KjlS3qJVPMC+RgWNARQZo7n6CWCXN55+QqLnHTfh+dLCXDS9jMUreXUGMQkVETEogDZ/v0Pb0xHQwBsg==", + "license": "MIT", + "dependencies": { + "@atproto-labs/did-resolver": "0.2.0", + "@atproto-labs/handle-resolver": "0.3.0", + "@atproto-labs/simple-store": "0.2.0", + "@atproto/did": "0.1.5", + "@atproto/jwk": "0.4.0", + "@atproto/jwk-webcrypto": "0.1.9", + "@atproto/oauth-client": "0.5.1", + "@atproto/oauth-types": "0.4.0" + } + }, + "node_modules/@atproto/oauth-types": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@atproto/oauth-types/-/oauth-types-0.4.0.tgz", + "integrity": "sha512-FrRH9JsPw9H4JxfPDrbrI+pB102tbHTygajfHay7xwz78HPOjSbWPRgWW2hYS4w8vDYdB3PYbBj1jPoKetW7LA==", + "license": "MIT", + "dependencies": { + "@atproto/jwk": "0.4.0", + "zod": "^3.23.8" + } + }, "node_modules/@atproto/syntax": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/@atproto/syntax/-/syntax-0.4.0.tgz", @@ -1359,6 +1521,35 @@ } } }, + "node_modules/@radix-ui/react-dropdown-menu": { + "version": "2.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dropdown-menu/-/react-dropdown-menu-2.1.15.tgz", + "integrity": "sha512-mIBnOjgwo9AH3FyKaSWoSu/dYj6VdhJ7frEPiGTeXCdUFHjl9h3mFh2wwhEtINOmYXWhdpf1rY2minFsmaNgVQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.2", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-menu": "2.1.15", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-focus-guards": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.2.tgz", @@ -1426,6 +1617,46 @@ } } }, + "node_modules/@radix-ui/react-menu": { + "version": "2.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-menu/-/react-menu-2.1.15.tgz", + "integrity": "sha512-tVlmA3Vb9n8SZSd+YSbuFR66l87Wiy4du+YE+0hzKQEANA+7cWKH1WgqcEX4pXqxUFQKrWQGHdvEfw00TjFiew==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.2", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-dismissable-layer": "1.1.10", + "@radix-ui/react-focus-guards": "1.1.2", + "@radix-ui/react-focus-scope": "1.1.7", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-popper": "1.2.7", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.4", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-roving-focus": "1.1.10", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.6.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-popover": { "version": "1.1.14", "resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.1.14.tgz", @@ -1566,6 +1797,37 @@ } } }, + "node_modules/@radix-ui/react-roving-focus": { + "version": "1.1.10", + "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.10.tgz", + "integrity": "sha512-dT9aOXUen9JSsxnMPv/0VqySQf5eDQ6LCk5Sw28kamz8wSOW2bJdlX2Bg5VUIIcV+6XlHpWTIuTPCf/UNIyq8Q==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.2", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-select": { "version": "2.2.5", "resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.2.5.tgz", @@ -2745,6 +3007,16 @@ "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", "license": "MIT" }, + "node_modules/@types/node": { + "version": "24.2.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.2.1.tgz", + "integrity": "sha512-DRh5K+ka5eJic8CjH7td8QpYEV6Zo10gfRkjHCO3weqZHWDtAaSTFtl4+VMqOJ4N5jcuhZ9/l+yy8rVgw7BQeQ==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.10.0" + } + }, "node_modules/@types/react": { "version": "19.1.9", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.9.tgz", @@ -3650,6 +3922,15 @@ "jiti": "lib/jiti-cli.mjs" } }, + "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/jotai": { "version": "2.13.0", "resolved": "https://registry.npmjs.org/jotai/-/jotai-2.13.0.tgz", @@ -4802,6 +5083,13 @@ "multiformats": "^9.4.2" } }, + "node_modules/undici-types": { + "version": "7.10.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.10.0.tgz", + "integrity": "sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag==", + "devOptional": true, + "license": "MIT" + }, "node_modules/unplugin": { "version": "2.3.5", "resolved": "https://registry.npmjs.org/unplugin/-/unplugin-2.3.5.tgz", diff --git a/package.json b/package.json index 0167e4f..bf77f02 100644 --- a/package.json +++ b/package.json @@ -3,15 +3,17 @@ "private": true, "type": "module", "scripts": { - "dev": "vite --port 3000", - "start": "vite --port 3000", + "dev": "vite --port 3768", + "start": "vite --port 3768", "build": "vite build && tsc", "serve": "vite preview", "test": "vitest run" }, "dependencies": { - "@atproto/api": "^0.16.0", + "@atproto/api": "^0.16.2", + "@atproto/oauth-client-browser": "^0.3.27", "@radix-ui/react-dialog": "^1.1.14", + "@radix-ui/react-dropdown-menu": "^2.1.15", "@radix-ui/react-icons": "^1.3.2", "@radix-ui/react-popover": "^1.1.14", "@radix-ui/react-select": "^2.2.5", @@ -30,6 +32,7 @@ "devDependencies": { "@testing-library/dom": "^10.4.0", "@testing-library/react": "^16.2.0", + "@types/node": "^24.2.1", "@types/react": "^19.0.8", "@types/react-dom": "^19.0.3", "@vitejs/plugin-react": "^4.3.4", diff --git a/public/client-metadata.json b/public/client-metadata.json new file mode 100644 index 0000000..4d51977 --- /dev/null +++ b/public/client-metadata.json @@ -0,0 +1,22 @@ +{ + "client_id": "https://forumtest.whey.party/client-metadata.json", + "client_name": "ForumTest", + "client_uri": "https://forumtest.whey.party", + "logo_uri": "https://forumtest.whey.party/logo192.png", + "tos_uri": "https://forumtest.whey.party/terms-of-service", + "policy_uri": "https://forumtest.whey.party/privacy-policy", + "redirect_uris": [ + "https://forumtest.whey.party/callback" + ], + "scope": "atproto transition:generic", + "grant_types": [ + "authorization_code", + "refresh_token" + ], + "response_types": [ + "code" + ], + "token_endpoint_auth_method": "none", + "application_type": "web", + "dpop_bound_access_tokens": true +} \ No newline at end of file diff --git a/src/components/Header.tsx b/src/components/Header.tsx index 5bb68f6..dfc3b0e 100644 --- a/src/components/Header.tsx +++ b/src/components/Header.tsx @@ -1,16 +1,57 @@ import { Link } from "@tanstack/react-router"; -import Login from "./Login"; +import Login from "./OAuthLogin"; import { SearchBox } from "./Search"; +import { useCachedProfileJotai } from "@/esav/hooks"; +import { useAuth } from "@/providers/OAuthProvider"; +import { + DropdownMenu, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuItem, +} from "@radix-ui/react-dropdown-menu"; +export default function Header() { + const { agent, status } = useAuth(); + const did = agent && agent.did && status === "signedIn" ? agent.did : null; + const [profile, profileloading] = useCachedProfileJotai(did); + const avatarUrl = + profile?.profile.avatar?.ref.$link && profile?.pdsUrl + ? `${profile.pdsUrl}/xrpc/com.atproto.sync.getBlob?did=${profile.did}&cid=${profile?.profile.avatar?.ref.$link}` + : undefined; -export default function Header(){ + return ( +
+ + ForumTest + + {/*
*/} + - - return
- ForumTest - {/*
*/} - - -
-} \ No newline at end of file + {profile && !profileloading ? ( + <> + + + {`Avatar + + + + @{profile.handle} + + + + + + + ) : ( + + )} +
+ ); +} diff --git a/src/components/OAuthLogin.tsx b/src/components/OAuthLogin.tsx new file mode 100644 index 0000000..10d84f9 --- /dev/null +++ b/src/components/OAuthLogin.tsx @@ -0,0 +1,124 @@ +import React, { useState, useRef, useEffect } from 'react'; +import { useAuth } from '@/providers/OAuthProvider'; +interface AuthButtonProps { + compact?: boolean; +} + +export default function Login({ compact = false }: AuthButtonProps) { + // 1. Get state and functions from the new OAuth context + const { status, startLogin, logout } = useAuth(); + + // State for the handle input and the dropdown visibility + const [handle, setHandle] = useState(''); + const [showLoginForm, setShowLoginForm] = useState(false); + const formRef = useRef(null); + + useEffect(() => { + // This logic for closing the dropdown on outside click is still useful + function handleClickOutside(event: MouseEvent) { + if (formRef.current && !formRef.current.contains(event.target as Node)) { + setShowLoginForm(false); + } + } + if (showLoginForm) { + document.addEventListener('mousedown', handleClickOutside); + } + return () => { + document.removeEventListener('mousedown', handleClickOutside); + }; + }, [showLoginForm]); + + // Handle the form submission + const handleLogin = async (e: React.FormEvent) => { + e.preventDefault(); + if (!handle.trim()) { + alert('Please enter your handle (e.g., name.example.com)'); + return; + } + // This will redirect the user, so no need to manage loading states here + await startLogin(handle); + }; + + // Render loading state if the provider is initializing + if (status === 'loading') { + return ( +
+ Loading... +
+ ); + } + + // If logged in, show a logout button + if (status === 'signedIn') { + const buttonClass = compact + ? "text-sm bg-gray-600 hover:bg-gray-700 text-white rounded px-3 py-1 font-medium transition-colors" + : "bg-gray-600 hover:bg-gray-700 text-white rounded px-6 py-2 font-semibold text-base transition-colors"; + + const loggedInContent = ( + + ); + + if (compact) { + return loggedInContent; + } + + return ( +
+
+

You are logged in!

+ {loggedInContent} +
+
+ ); + } + + // If logged out, show a login button/form + const loginButtonClass = compact + ? "text-sm bg-gray-600 hover:bg-gray-700 text-white rounded px-3 py-1 font-medium transition-colors" + : "bg-gray-600 hover:bg-gray-700 text-white rounded px-6 py-2 font-semibold text-base transition-colors mt-2"; + + const loginForm = ( +
+

+ Login with your AT Protocol (Bluesky) handle +

+ setHandle(e.target.value)} + className="px-3 py-2 rounded border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 text-base focus:outline-none focus:ring-2 focus:ring-blue-500" + autoComplete="webauthn" // Hint for password managers + /> + +
+ ); + + if (compact) { + return ( +
+ + {showLoginForm && ( +
+ {loginForm} +
+ )} +
+ ); + } + + return ( +
+ {loginForm} +
+ ); +} \ No newline at end of file diff --git a/src/components/Login.tsx b/src/components/PassLogin.tsx similarity index 100% rename from src/components/Login.tsx rename to src/components/PassLogin.tsx diff --git a/src/components/PostError.tsx b/src/components/PostError.tsx index 3573f46..2a27d8e 100644 --- a/src/components/PostError.tsx +++ b/src/components/PostError.tsx @@ -1,4 +1,4 @@ -import { ErrorComponent, ErrorComponentProps } from '@tanstack/react-router' +import { ErrorComponent, type ErrorComponentProps } from '@tanstack/react-router' export function PostErrorComponent({ error }: ErrorComponentProps) { return diff --git a/src/helpers/oauthClient.ts b/src/helpers/oauthClient.ts new file mode 100644 index 0000000..61247f3 --- /dev/null +++ b/src/helpers/oauthClient.ts @@ -0,0 +1,38 @@ +import { BrowserOAuthClient, type ClientMetadata } from '@atproto/oauth-client-browser'; + +// const domain = location.origin ? location.origin : ((import.meta?.env?.DEV) ? 'https://local3768forumtest.whey.party' : 'https://forumtest.whey.party') +const handleResolverPDS = 'https://pds-nd.whey.party' + +// export const generateClientMetadata = (appOrigin: string) => { +// const callbackPath = '/callback'; + +// return { +// "client_id": `${appOrigin}/client-metadata.json`, +// "client_name": "ForumTest", +// "client_uri": appOrigin, +// "logo_uri": `${appOrigin}/logo192.png`, +// "tos_uri": `${appOrigin}/terms-of-service`, +// "policy_uri": `${appOrigin}/privacy-policy`, +// "redirect_uris": [`${appOrigin}${callbackPath}`] as [string, ...string[]], +// "scope": "atproto transition:generic", +// "grant_types": ["authorization_code", "refresh_token"] as ["authorization_code", "refresh_token"], +// "response_types": ["code"] as ["code"], +// "token_endpoint_auth_method": "none" as "none", +// "application_type": "web" as "web", +// "dpop_bound_access_tokens": true +// }; +// } + +// IF ERROR: you need to build it first, either npm run dev or npm run build +import clientMetadata from '../../public/client-metadata.json' assert { type: 'json' }; + +// async function loadClientMetadata(): Promise { +// const res = await fetch('/client-metadata.json'); +// if (!res.ok) throw new Error('Failed to load client metadata'); +// return res.json(); +// } + +export const oauthClient = new BrowserOAuthClient({ + clientMetadata: clientMetadata as ClientMetadata, + handleResolver: handleResolverPDS, +}); \ No newline at end of file diff --git a/src/main.tsx b/src/main.tsx index 7d345d6..68f2a3a 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -7,7 +7,7 @@ import { routeTree } from "./routeTree.gen"; import "./styles.css"; import reportWebVitals from "./reportWebVitals.ts"; -import { AuthProvider } from "./providers/PassAuthProvider.tsx"; +import { OAuthProvider } from "./providers/OAuthProvider.tsx"; import { PersistentStoreProvider } from "./providers/PersistentStoreProvider.tsx"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { ESAVLiveProvider } from "./esav/ESAVLiveProvider.tsx"; @@ -42,12 +42,12 @@ if (rootElement && !rootElement.innerHTML) { // - + {/* Pass the router instance with the context to the provider */} - + // diff --git a/src/providers/OAuthProvider.tsx b/src/providers/OAuthProvider.tsx new file mode 100644 index 0000000..50c50f9 --- /dev/null +++ b/src/providers/OAuthProvider.tsx @@ -0,0 +1,98 @@ +import React, { createContext, useState, useEffect, useContext, useCallback } from 'react'; +import { Agent } from '@atproto/api'; +import { oauthClient } from '../helpers/oauthClient'; +import { type OAuthSession, TokenInvalidError, TokenRefreshError, TokenRevokedError } from '@atproto/oauth-client-browser'; + +type Session = OAuthSession; + +interface AuthContextValue { + agent: Agent | null; + session: Session | null; + status: 'loading' | 'signedIn' | 'signedOut'; + startLogin: (handle: string) => Promise; + logout: () => Promise; +} + +const AuthContext = createContext({} as AuthContextValue); + +export const OAuthProvider = ({ children }: { children: React.ReactNode }) => { + const [agent, setAgent] = useState(null); + const [session, setSession] = useState(null); + const [status, setStatus] = useState<'loading' | 'signedIn' | 'signedOut'>('loading'); + + useEffect(() => { + const initialize = async () => { + try { + const result = await oauthClient.init(); + + if (result) { + const { session: oauthSession } = result; + const apiAgent = new Agent(oauthSession); + + setAgent(apiAgent); + setSession(oauthSession); + setStatus('signedIn'); + if ('state' in result && result.state) { + console.log(`Successfully authenticated ${oauthSession.sub} (state: ${result.state})`); + } else { + console.log(`Session for ${oauthSession.sub} was restored`); + } + } else { + setStatus('signedOut'); + console.log('No active session found.'); + } + } catch (e) { + console.error('Auth initialization failed:', e); + setStatus('signedOut'); + } + }; + + const handleSessionDeleted = ( + event: CustomEvent<{ sub: string; cause: TokenRefreshError | TokenRevokedError | TokenInvalidError }> + ) => { + console.error(`Session for ${event.detail.sub} was deleted. Logging out.`, event.detail.cause); + setAgent(null); + setSession(null); + setStatus('signedOut'); + }; + + oauthClient.addEventListener('deleted', handleSessionDeleted as EventListener); + initialize(); + + return () => { + oauthClient.removeEventListener('deleted', handleSessionDeleted as EventListener); + }; + }, []); + + const startLogin = useCallback(async (handleOrPdsUrl: string) => { + if (status !== 'signedOut') return; + try { + await oauthClient.signIn(handleOrPdsUrl); + } catch (err) { + console.error('Sign-in process aborted or failed:', err); + } + }, [status]); + + const logout = useCallback(async () => { + if (!session) return; + setStatus('loading'); + try { + await oauthClient.revoke(session.sub); + console.log('Successfully logged out.'); + } catch (e) { + console.error("Logout failed:", e); + } finally { + setAgent(null); + setSession(null); + setStatus('signedOut'); + } + }, [session]); + + return ( + + {children} + + ); +}; + +export const useAuth = () => useContext(AuthContext); \ No newline at end of file diff --git a/src/routeTree.gen.ts b/src/routeTree.gen.ts index 947e354..36b6a77 100644 --- a/src/routeTree.gen.ts +++ b/src/routeTree.gen.ts @@ -11,6 +11,7 @@ import { Route as rootRouteImport } from './routes/__root' import { Route as SearchRouteImport } from './routes/search' import { Route as IndexRouteImport } from './routes/index' +import { Route as CallbackIndexRouteImport } from './routes/callback/index' import { Route as FForumHandleRouteImport } from './routes/f/$forumHandle' import { Route as FForumHandleIndexRouteImport } from './routes/f/$forumHandle/index' import { Route as FForumHandleTUserHandleTopicRKeyRouteImport } from './routes/f/$forumHandle/t/$userHandle/$topicRKey' @@ -25,6 +26,11 @@ const IndexRoute = IndexRouteImport.update({ path: '/', getParentRoute: () => rootRouteImport, } as any) +const CallbackIndexRoute = CallbackIndexRouteImport.update({ + id: '/callback/', + path: '/callback/', + getParentRoute: () => rootRouteImport, +} as any) const FForumHandleRoute = FForumHandleRouteImport.update({ id: '/f/$forumHandle', path: '/f/$forumHandle', @@ -46,12 +52,14 @@ export interface FileRoutesByFullPath { '/': typeof IndexRoute '/search': typeof SearchRoute '/f/$forumHandle': typeof FForumHandleRouteWithChildren + '/callback': typeof CallbackIndexRoute '/f/$forumHandle/': typeof FForumHandleIndexRoute '/f/$forumHandle/t/$userHandle/$topicRKey': typeof FForumHandleTUserHandleTopicRKeyRoute } export interface FileRoutesByTo { '/': typeof IndexRoute '/search': typeof SearchRoute + '/callback': typeof CallbackIndexRoute '/f/$forumHandle': typeof FForumHandleIndexRoute '/f/$forumHandle/t/$userHandle/$topicRKey': typeof FForumHandleTUserHandleTopicRKeyRoute } @@ -60,6 +68,7 @@ export interface FileRoutesById { '/': typeof IndexRoute '/search': typeof SearchRoute '/f/$forumHandle': typeof FForumHandleRouteWithChildren + '/callback/': typeof CallbackIndexRoute '/f/$forumHandle/': typeof FForumHandleIndexRoute '/f/$forumHandle/t/$userHandle/$topicRKey': typeof FForumHandleTUserHandleTopicRKeyRoute } @@ -69,12 +78,14 @@ export interface FileRouteTypes { | '/' | '/search' | '/f/$forumHandle' + | '/callback' | '/f/$forumHandle/' | '/f/$forumHandle/t/$userHandle/$topicRKey' fileRoutesByTo: FileRoutesByTo to: | '/' | '/search' + | '/callback' | '/f/$forumHandle' | '/f/$forumHandle/t/$userHandle/$topicRKey' id: @@ -82,6 +93,7 @@ export interface FileRouteTypes { | '/' | '/search' | '/f/$forumHandle' + | '/callback/' | '/f/$forumHandle/' | '/f/$forumHandle/t/$userHandle/$topicRKey' fileRoutesById: FileRoutesById @@ -90,6 +102,7 @@ export interface RootRouteChildren { IndexRoute: typeof IndexRoute SearchRoute: typeof SearchRoute FForumHandleRoute: typeof FForumHandleRouteWithChildren + CallbackIndexRoute: typeof CallbackIndexRoute } declare module '@tanstack/react-router' { @@ -108,6 +121,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof IndexRouteImport parentRoute: typeof rootRouteImport } + '/callback/': { + id: '/callback/' + path: '/callback' + fullPath: '/callback' + preLoaderRoute: typeof CallbackIndexRouteImport + parentRoute: typeof rootRouteImport + } '/f/$forumHandle': { id: '/f/$forumHandle' path: '/f/$forumHandle' @@ -150,6 +170,7 @@ const rootRouteChildren: RootRouteChildren = { IndexRoute: IndexRoute, SearchRoute: SearchRoute, FForumHandleRoute: FForumHandleRouteWithChildren, + CallbackIndexRoute: CallbackIndexRoute, } export const routeTree = rootRouteImport ._addFileChildren(rootRouteChildren) diff --git a/src/routes/callback/index.tsx b/src/routes/callback/index.tsx new file mode 100644 index 0000000..bde268c --- /dev/null +++ b/src/routes/callback/index.tsx @@ -0,0 +1,11 @@ +import { createFileRoute, useNavigate } from '@tanstack/react-router' + +export const Route = createFileRoute('/callback/')({ + component: RouteComponent, +}) + +function RouteComponent() { + const navigate = useNavigate() + navigate({to:"/"}) + return
Hello "/callback/"!
+} diff --git a/src/routes/f/$forumHandle/index.tsx b/src/routes/f/$forumHandle/index.tsx index 5940a2c..90217e0 100644 --- a/src/routes/f/$forumHandle/index.tsx +++ b/src/routes/f/$forumHandle/index.tsx @@ -13,7 +13,7 @@ import { esavQuery } from "@/helpers/esquery"; import * as Select from "@radix-ui/react-select"; import * as Dialog from "@radix-ui/react-dialog"; import { ChevronDownIcon, CheckIcon, Cross2Icon } from "@radix-ui/react-icons"; -import { useAuth } from "@/providers/PassAuthProvider"; +import { useAuth } from "@/providers/OAuthProvider"; import { AtUri, BskyAgent } from "@atproto/api"; import { useQuery, useQueryClient, QueryClient } from "@tanstack/react-query"; import { @@ -215,7 +215,9 @@ export function Forum() { ); const navigate = useNavigate(); - const { agent, loading: authLoading } = useAuth(); + const { agent, status } = useAuth(); + + const authLoading = status === 'loading' const queryClient = useQueryClient(); diff --git a/src/routes/f/$forumHandle/t/$userHandle/$topicRKey.tsx b/src/routes/f/$forumHandle/t/$userHandle/$topicRKey.tsx index 8a8bf73..73d7412 100644 --- a/src/routes/f/$forumHandle/t/$userHandle/$topicRKey.tsx +++ b/src/routes/f/$forumHandle/t/$userHandle/$topicRKey.tsx @@ -1,12 +1,12 @@ import { createFileRoute, Link, useParams } from "@tanstack/react-router"; import { useMemo, useState } from "react"; -import { useAuth } from "@/providers/PassAuthProvider"; +import { useAuth } from "@/providers/OAuthProvider"; import { esavQuery } from "@/helpers/esquery"; import { resolveIdentity, type ResolvedIdentity, } from "@/helpers/cachedidentityresolver"; -import AtpAgent from "@atproto/api"; +import AtpAgent, { Agent } from "@atproto/api"; import { ArrowLeftIcon, ChatBubbleIcon, @@ -345,7 +345,7 @@ export function PostCard({ isCreatingReaction, }: { forumdid: string; - agent: AtpAgent | null; + agent: Agent | null; post: PostDoc; //author: AuthorInfo | null; //reactions: ReactionDoc[]; @@ -491,7 +491,9 @@ export function ForumTopic() { const uri = useMemo(() => { return `at://${op?.did}/party.whey.ft.topic.post/${topicRKey}`; }, [op?.did]); - const { agent, loading: authLoading } = useAuth(); + const { agent, status } = useAuth(); + const authLoading = status === 'loading' + //const topic = useEsavDocument(uri); //const parsed = parseAtUri(uri); diff --git a/src/routes/search.tsx b/src/routes/search.tsx index 3ca9a98..81f6cf4 100644 --- a/src/routes/search.tsx +++ b/src/routes/search.tsx @@ -5,14 +5,14 @@ import { Link, } from "@tanstack/react-router"; import { useEffect, useState, useCallback, useMemo } from "react"; -import { useAuth } from "@/providers/PassAuthProvider"; +import { useAuth } from "@/providers/OAuthProvider"; import { usePersistentStore } from "@/providers/PersistentStoreProvider"; import { esavQuery } from "@/helpers/esquery"; import { cachedResolveIdentity, type ResolvedIdentity, } from "@/helpers/cachedidentityresolver"; -import AtpAgent, { AtUri } from "@atproto/api"; +import AtpAgent, { Agent, AtUri } from "@atproto/api"; import { ArrowRightIcon } from "@radix-ui/react-icons"; import { PostCard, @@ -60,7 +60,7 @@ export const Route = createFileRoute("/search")({ }); interface SearchResultCardProps { - agent: AtpAgent | null; + agent: Agent | null; post: PostDoc; author: AuthorInfo | null; reactions: ReactionDoc[]; @@ -174,7 +174,8 @@ let authors: Record = {}; export function SearchPage() { const { q } = useSearch({ from: "/search" }); - const { agent, loading: authLoading } = useAuth(); + const { agent, status } = useAuth(); + const authLoading = status === 'loading' const { get, set } = usePersistentStore(); const [results, setResults] = useState([]); diff --git a/vite.config.ts b/vite.config.ts index c25eebf..541e0d3 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,19 +1,37 @@ -import { defineConfig } from 'vite' -import viteReact from '@vitejs/plugin-react' -import { TanStackRouterVite } from '@tanstack/router-plugin/vite' -import { resolve } from 'node:path' +import { defineConfig } from "vite"; +import viteReact from "@vitejs/plugin-react"; +import { tanstackRouter } from "@tanstack/router-plugin/vite"; +import { resolve } from "node:path"; import tailwindcss from "@tailwindcss/vite"; +import { generateMetadataPlugin } from "./oauthdev.mts"; +const prodURL = "https://forumtest.whey.party" +const devURL = "https://local3768forumtest.whey.party" + +function shp(url: string): string { + return url.replace(/^https?:\/\//, ''); +} // https://vitejs.dev/config/ export default defineConfig({ - plugins: [tailwindcss(),TanStackRouterVite({ autoCodeSplitting: true }), viteReact()], - test: { - globals: true, - environment: 'jsdom', - }, + plugins: [ + generateMetadataPlugin({ + prod: prodURL, + dev: devURL, + }), + tailwindcss(), + tanstackRouter({ autoCodeSplitting: true }), + viteReact(), + ], + // test: { + // globals: true, + // environment: 'jsdom', + // }, resolve: { alias: { - '@': resolve(__dirname, './src'), + "@": resolve(__dirname, "./src"), }, }, -}) + server: { + allowedHosts: [shp(prodURL),shp(devURL)], + }, +}); -- 2.51.2