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 ? (
+ <>
+
+
+
+
+
+
+ @{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 = (
+
+ );
+
+ 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)],
+ },
+});