diff --git a/.gitignore b/.gitignore index 693a705..51e048c 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,4 @@ count.txt .env .nitro .tanstack +public/client-metadata.json \ No newline at end of file diff --git a/README.md b/README.md index 86f662f..8e59dcc 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,16 @@ Red Dwarf is a Bluesky client that does not use any AppView servers, instead it huge thanks to [Microcosm](https://microcosm.blue/) for making this possible +## running dev and build +in the `vite.config.ts` file you should change these values +```ts +const PROD_URL = "https://reddwarf.whey.party" +const DEV_URL = "https://local3768forumtest.whey.party" +``` +the PROD_URL is what will compile your oauth client metadata so it is very important to change that. same for DEV_URL if you are using a tunnel for dev work + +run dev with `npm run dev` (port 3768) and build with `npm run build` (the output is the `dist` folder) + ## useQuery Red Dwarf has been upgraded from its original bespoke caching system to Tanstack Query (react query). this migration was done to achieve a more robust and maintainable approach to data fetching and caching and state synchronization. ive seen serious performance gains from this switch! @@ -22,8 +32,14 @@ the beating heart of Red Dwarf, the backlink index that provides contextual info ### Slingshot though Red Dwarf was made before Microcosm [Slingshot](https://slingshot.microcosm.blue) existed, it now uses Slingshot to reduce load from each respective PDS server. Slignshot -## PassAuthProvider -a really bad app-password auth provider, inherited from TestFront and used in all my projects from TestFront to ForumTest (im very good at naming things). in ForumTest, its been superseded by the [OAuthProvider](https://tangled.sh/@whey.party/forumtest/blob/main/src/providers/OAuthProvider.tsx). i havent backported it here and maybe soon, although oauth makes it slightly more annoying to do development because it requires a tunnel so maybe someday if i managed to merge the password and oauth logins to provide both options +## UnifiedAuthProvider +a merged auth provider with oauth and password based login. oauth makes it slightly more annoying to do development because it requires a tunnel, so so the password auth option is still here if you do prefer password login for whatever reason. + +### Pass Auth +a really bad app-password auth provider, inherited from TestFront and used in all my projects from TestFront to ForumTest (im very good at naming things). + +### OAuth +taken from ForumTest [OAuthProvider](https://tangled.sh/@whey.party/forumtest/blob/main/src/providers/OAuthProvider.tsx) ## Custom Feeds they work, but i havent implemented a simple way of viewing arbitraty feeds. currently it either loads discover (logged out) or your saved feeds (logged in) and its not a technical limitation i just havent implemented it yet diff --git a/index.html b/index.html index 91c6184..2efb19f 100644 --- a/index.html +++ b/index.html @@ -11,7 +11,6 @@ /> - Red Dwarf 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 b0ea903..3579f31 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,6 +7,7 @@ "name": "red-dwarf-tanstack", "dependencies": { "@atproto/api": "^0.16.6", + "@atproto/oauth-client-browser": "^0.3.33", "@tailwindcss/vite": "^4.0.6", "@tanstack/query-sync-storage-persister": "^5.85.6", "@tanstack/react-devtools": "^0.2.2", @@ -71,6 +72,79 @@ "dev": true, "license": "ISC" }, + "node_modules/@atproto-labs/did-resolver": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/@atproto-labs/did-resolver/-/did-resolver-0.2.2.tgz", + "integrity": "sha512-ca2B7xR43tVoQ8XxBvha58DXwIH8cIyKQl6lpOKGkPUrJuFoO4iCLlDiSDi2Ueh+yE1rMDPP/qveHdajgDX3WQ==", + "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.2.1", + "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.2", + "resolved": "https://registry.npmjs.org/@atproto-labs/handle-resolver/-/handle-resolver-0.3.2.tgz", + "integrity": "sha512-KIerCzh3qb+zZoqWbIvTlvBY0XPq0r56kwViaJY/LTe/3oPO2JaqlYKS/F4dByWBhHK6YoUOJ0sWrh6PMJl40A==", + "license": "MIT", + "dependencies": { + "@atproto-labs/simple-store": "0.3.0", + "@atproto-labs/simple-store-memory": "0.1.4", + "@atproto/did": "0.2.1", + "zod": "^3.23.8" + } + }, + "node_modules/@atproto-labs/identity-resolver": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/@atproto-labs/identity-resolver/-/identity-resolver-0.3.2.tgz", + "integrity": "sha512-MYxO9pe0WsFyi5HFdKAwqIqHfiF2kBPoVhAIuH/4PYHzGr799ED47xLhNMxR3ZUYrJm5+TQzWXypGZ0Btw1Ffw==", + "license": "MIT", + "dependencies": { + "@atproto-labs/did-resolver": "0.2.2", + "@atproto-labs/handle-resolver": "0.3.2" + } + }, + "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/api": { "version": "0.16.6", "resolved": "https://registry.npmjs.org/@atproto/api/-/api-0.16.6.tgz", @@ -88,9 +162,9 @@ } }, "node_modules/@atproto/common-web": { - "version": "0.4.2", - "resolved": "https://registry.npmjs.org/@atproto/common-web/-/common-web-0.4.2.tgz", - "integrity": "sha512-vrXwGNoFGogodjQvJDxAeP3QbGtawgZute2ed1XdRO0wMixLk3qewtikZm06H259QDJVu6voKC5mubml+WgQUw==", + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@atproto/common-web/-/common-web-0.4.3.tgz", + "integrity": "sha512-nRDINmSe4VycJzPo6fP/hEltBcULFxt9Kw7fQk6405FyAWZiTluYHlXOnU7GkQfeUK44OENG1qFTBcmCJ7e8pg==", "license": "MIT", "dependencies": { "graphemer": "^1.4.0", @@ -99,19 +173,108 @@ "zod": "^3.23.8" } }, + "node_modules/@atproto/did": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/@atproto/did/-/did-0.2.1.tgz", + "integrity": "sha512-1i5BTU2GnBaaeYWhxUOnuEKFVq9euT5+dQPFabHpa927BlJ54PmLGyBBaOI7/NbLmN5HWwBa18SBkMpg3jGZRA==", + "license": "MIT", + "dependencies": { + "zod": "^3.23.8" + } + }, + "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-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/lexicon": { - "version": "0.5.0", - "resolved": "https://registry.npmjs.org/@atproto/lexicon/-/lexicon-0.5.0.tgz", - "integrity": "sha512-3aAzEAy9EAPs3CxznzMhEcqDd7m3vz1eze/ya9/ThbB7yleqJIhz5GY2q76tCCwHPhn5qDDMhlA9kKV6fG23gA==", + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/@atproto/lexicon/-/lexicon-0.5.1.tgz", + "integrity": "sha512-y8AEtYmfgVl4fqFxqXAeGvhesiGkxiy3CWoJIfsFDDdTlZUC8DFnZrYhcqkIop3OlCkkljvpSJi1hbeC1tbi8A==", "license": "MIT", "dependencies": { - "@atproto/common-web": "^0.4.2", + "@atproto/common-web": "^0.4.3", "@atproto/syntax": "^0.4.1", "iso-datestring-validator": "^2.2.2", "multiformats": "^9.9.0", "zod": "^3.23.8" } }, + "node_modules/@atproto/oauth-client": { + "version": "0.5.7", + "resolved": "https://registry.npmjs.org/@atproto/oauth-client/-/oauth-client-0.5.7.tgz", + "integrity": "sha512-pDvbvy9DCxrAJv7bAbBUzWrHZKhFy091HvEMZhr+EyZA6gSCGYmmQJG/coDj0oICSVQeafAZd+IxR0YUCWwmEg==", + "license": "MIT", + "dependencies": { + "@atproto-labs/did-resolver": "0.2.2", + "@atproto-labs/fetch": "0.2.3", + "@atproto-labs/handle-resolver": "0.3.2", + "@atproto-labs/identity-resolver": "0.3.2", + "@atproto-labs/simple-store": "0.3.0", + "@atproto-labs/simple-store-memory": "0.1.4", + "@atproto/did": "0.2.1", + "@atproto/jwk": "0.6.0", + "@atproto/oauth-types": "0.4.2", + "@atproto/xrpc": "0.7.5", + "core-js": "^3", + "multiformats": "^9.9.0", + "zod": "^3.23.8" + } + }, + "node_modules/@atproto/oauth-client-browser": { + "version": "0.3.33", + "resolved": "https://registry.npmjs.org/@atproto/oauth-client-browser/-/oauth-client-browser-0.3.33.tgz", + "integrity": "sha512-IvHn/5W3e9GXFUGXQ4MV19E4HXY4zJFgu+eZRWexIXnZl4GwgTH7op8J1SosczdOK1Ngu+LnHE6npcNhUGGd6Q==", + "license": "MIT", + "dependencies": { + "@atproto-labs/did-resolver": "0.2.2", + "@atproto-labs/handle-resolver": "0.3.2", + "@atproto-labs/simple-store": "0.3.0", + "@atproto/did": "0.2.1", + "@atproto/jwk": "0.6.0", + "@atproto/jwk-webcrypto": "0.2.0", + "@atproto/oauth-client": "0.5.7", + "@atproto/oauth-types": "0.4.2", + "core-js": "^3" + } + }, + "node_modules/@atproto/oauth-types": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@atproto/oauth-types/-/oauth-types-0.4.2.tgz", + "integrity": "sha512-gcfNTyFsPJcYDf79M0iKHykWqzxloscioKoerdIN3MTS3htiNOSgZjm2p8ho7pdrElLzea3qktuhTQI39j1XFQ==", + "license": "MIT", + "dependencies": { + "@atproto/did": "0.2.1", + "@atproto/jwk": "0.6.0", + "zod": "^3.23.8" + } + }, "node_modules/@atproto/syntax": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/@atproto/syntax/-/syntax-0.4.1.tgz", @@ -119,12 +282,12 @@ "license": "MIT" }, "node_modules/@atproto/xrpc": { - "version": "0.7.4", - "resolved": "https://registry.npmjs.org/@atproto/xrpc/-/xrpc-0.7.4.tgz", - "integrity": "sha512-sDi68+QE1XHegTaNAndlX41Gp827pouSzSs8CyAwhrqZdsJUxE3P7TMtrA0z+zAjvxVyvzscRc0TsN/fGUGrhw==", + "version": "0.7.5", + "resolved": "https://registry.npmjs.org/@atproto/xrpc/-/xrpc-0.7.5.tgz", + "integrity": "sha512-MUYNn5d2hv8yVegRL0ccHvTHAVj5JSnW07bkbiaz96UH45lvYNRVwt44z+yYVnb0/mvBzyD3/ZQ55TRGt7fHkA==", "license": "MIT", "dependencies": { - "@atproto/lexicon": "^0.5.0", + "@atproto/lexicon": "^0.5.1", "zod": "^3.23.8" } }, @@ -2869,6 +3032,17 @@ "integrity": "sha512-+W7VmiVINB+ywl1HGXJXmrqkOhpKrIiVZV6tQuV54ZyQC7MMuBt81Vc336GMLoHBq5hV/F9eXgt5Mnx0Rha5Fg==", "license": "MIT" }, + "node_modules/core-js": { + "version": "3.46.0", + "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.46.0.tgz", + "integrity": "sha512-vDMm9B0xnqqZ8uSBpZ8sNtRtOdmfShrvT6h2TuQGLs0Is+cR0DYbj/KWP6ALVNbWPpqA/qPLoOuppJN07humpA==", + "hasInstallScript": true, + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/core-js" + } + }, "node_modules/cssstyle": { "version": "4.6.0", "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz", @@ -3429,6 +3603,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.1", "resolved": "https://registry.npmjs.org/jotai/-/jotai-2.13.1.tgz", diff --git a/package.json b/package.json index 5f79a12..3746350 100644 --- a/package.json +++ b/package.json @@ -3,14 +3,15 @@ "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.6", + "@atproto/oauth-client-browser": "^0.3.33", "@tailwindcss/vite": "^4.0.6", "@tanstack/query-sync-storage-persister": "^5.85.6", "@tanstack/react-devtools": "^0.2.2", diff --git a/src/components/InfiniteCustomFeed.tsx b/src/components/InfiniteCustomFeed.tsx index 92367c0..6795741 100644 --- a/src/components/InfiniteCustomFeed.tsx +++ b/src/components/InfiniteCustomFeed.tsx @@ -1,7 +1,7 @@ import * as React from "react"; //import { useInView } from "react-intersection-observer"; import { UniversalPostRendererATURILoader } from "~/components/UniversalPostRenderer"; -import { useAuth } from "~/providers/PassAuthProvider"; +import { useAuth } from "~/providers/UnifiedAuthProvider"; import { useQueryArbitrary, useQueryIdentity, @@ -19,7 +19,8 @@ export function InfiniteCustomFeed({ pdsUrl, feedServiceDid, }: InfiniteCustomFeedProps) { - const { agent, authed } = useAuth(); + const { agent } = useAuth(); + const authed = !!agent?.did; // const identityresultmaybe = useQueryIdentity(agent?.did); // const identity = identityresultmaybe?.data; diff --git a/src/components/Login.tsx b/src/components/Login.tsx index 642773d..238ead4 100644 --- a/src/components/Login.tsx +++ b/src/components/Login.tsx @@ -1,229 +1,233 @@ +// src/components/Login.tsx import React, { useEffect, useState, useRef } from "react"; -import { useAuth } from "~/providers/PassAuthProvider"; +import { useAuth } from "~/providers/UnifiedAuthProvider"; +import { Agent } from "@atproto/api"; -interface LoginProps { - compact?: boolean; -} - -export default function Login({ compact = false }: LoginProps) { - const { loginStatus, login, logout, loading, authed, agent } = useAuth(); - const [user, setUser] = useState(""); - const [password, setPassword] = useState(""); - const [serviceURL, setServiceURL] = useState("bsky.social"); - const [showLoginForm, setShowLoginForm] = useState(false); - const formRef = useRef(null); - - useEffect(() => { - function handleClickOutside(event: MouseEvent) { - if (formRef.current && !formRef.current.contains(event.target as Node)) { - setShowLoginForm(false); - } - } +// --- 1. The Main Component (Orchestrator with `compact` prop) --- +export default function Login({ compact = false }: { compact?: boolean }) { + const { status, agent, logout } = useAuth(); - if (showLoginForm) { - document.addEventListener("mousedown", handleClickOutside); - } - - return () => { - document.removeEventListener("mousedown", handleClickOutside); - }; - }, [showLoginForm]); - - if (loading) { + // Loading state can be styled differently based on the prop + if (status === "loading") { return ( -
- Loading... +
+
); } - if (compact) { - if (authed) { + // --- LOGGED IN STATE --- + if (status === "signedIn") { + // Large view + if (!compact) { return ( +
+
+

+ You are logged in! +

+ + +
+
+ ); + } + // Compact view + return ( +
+ - ); - } else { - return ( -
- - {showLoginForm && ( -
-
{ - e.preventDefault(); - login(user, password, `https://${serviceURL}`); - setShowLoginForm(false); - }} - className="flex flex-col gap-3" - > -

- sorry for the temporary login, -
- oauth will come soon enough i swear -

- setUser(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-sm focus:outline-none focus:ring-2 focus:ring-blue-500" - autoComplete="username" - /> - setPassword(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-sm focus:outline-none focus:ring-2 focus:ring-blue-500" - autoComplete="current-password" - /> - setServiceURL(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-sm focus:outline-none focus:ring-2 focus:ring-blue-500" - /> - -
-
- )} -
- ); - } +
+ ); + } + + // --- LOGGED OUT STATE --- + if (!compact) { + // Large view renders the form directly in the card + return ( +
+ +
+ ); } + // Compact view renders a button that toggles the form in a dropdown + return ; +} + +// --- 2. The Reusable, Self-Contained Login Form Component --- +export function UnifiedLoginForm() { + const [mode, setMode] = useState<"oauth" | "password">("oauth"); + return ( -
- {authed ? ( -
-

- You are logged in! -

- - -
- ) : ( -
{ - e.preventDefault(); - login(user, password, `https://${serviceURL}`); - }} - className="flex flex-col gap-4" - > -

- sorry for the temporary login, -
- oauth will come soon enough i swear -

- setUser(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="username" - /> - setPassword(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="current-password" - /> - setServiceURL(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" - /> - -
- )} +
+
+ setMode("oauth")} + /> + setMode("password")} + /> +
+ {mode === "oauth" ? : }
); } -export const ProfileThing = () => { - const { agent, loading, loginStatus, authed } = useAuth(); - const [response, setResponse] = useState(null); +// --- 3. Helper components for layouts, forms, and UI --- + +// A new component to contain the logic for the compact dropdown +const CompactLoginButton = () => { + const [showForm, setShowForm] = useState(false); + const formRef = useRef(null); useEffect(() => { - if (loginStatus && agent && !loading && authed) { - fetchUser(); + function handleClickOutside(event: MouseEvent) { + if (formRef.current && !formRef.current.contains(event.target as Node)) { + setShowForm(false); + } } - // eslint-disable-next-line - }, [loginStatus, agent, loading, authed]); - - const fetchUser = async () => { - if (!agent) { - console.error("Agent is null or undefined"); - return; + if (showForm) { + document.addEventListener("mousedown", handleClickOutside); } - const res = await agent.app.bsky.actor.getProfile({ - actor: agent.assertDid, - }); - setResponse(res.data); - }; + return () => { + document.removeEventListener("mousedown", handleClickOutside); + }; + }, [showForm]); - if (!authed) { - return - return ( -
- - Login - -
- ); - } + return ( +
+ + {showForm && ( +
+ +
+ )} +
+ ); +}; - if (!response) { - return ( -
- - Loading... -
- ); - } +const TabButton = ({ label, active, onClick }: { label: string; active: boolean; onClick: () => void; }) => ( + +); +const OAuthForm = () => { + const { loginWithOAuth } = useAuth(); + const [handle, setHandle] = useState(""); + const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); if (handle.trim()) loginWithOAuth(handle); }; return ( -
- avatar -
-
{response?.displayName}
-
@{response?.handle}
-
-
+
+

Sign in with AT. Your password is never shared.

+ 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-sm focus:outline-none focus:ring-2 focus:ring-gray-500" /> + +
+ ); +}; + +const PasswordForm = () => { + const { loginWithPassword } = useAuth(); + const [user, setUser] = useState(""); + const [password, setPassword] = useState(""); + const [serviceURL, setServiceURL] = useState("bsky.social"); + const [error, setError] = useState(null); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setError(null); + try { + await loginWithPassword(user, password, `https://${serviceURL}`); + } catch (err) { + setError("Login failed. Check your handle and App Password."); + } + }; + + return ( +
+

Warning: Less secure. Use an App Password.

+ setUser(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-sm focus:outline-none focus:ring-2 focus:ring-gray-500" autoComplete="username" /> + setPassword(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-sm focus:outline-none focus:ring-2 focus:ring-gray-500" autoComplete="current-password" /> + setServiceURL(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-sm focus:outline-none focus:ring-2 focus:ring-gray-500" /> + {error &&

{error}

} + +
); }; + +// --- Profile Component (now supports a `large` prop for styling) --- +export const ProfileThing = ({ agent, large = false }: { agent: Agent | null; large?: boolean }) => { + const [profile, setProfile] = useState(null); + + useEffect(() => { + const fetchUser = async () => { + const did = (agent as any)?.session?.did ?? (agent as any)?.assertDid; + if (!did) return; + try { + const res = await agent!.getProfile({ actor: did }); + setProfile(res.data); + } catch (e) { console.error("Failed to fetch profile", e); } + }; + if (agent) fetchUser(); + }, [agent]); + + if (!profile) { + return ( // Skeleton loader +
+
+
+
+
+
+
+ ); + } + + return ( +
+ avatar +
+
{profile?.displayName}
+
@{profile?.handle}
+
+
+ ); +}; \ No newline at end of file diff --git a/src/components/UniversalPostRenderer.tsx b/src/components/UniversalPostRenderer.tsx index 533a590..2767c79 100644 --- a/src/components/UniversalPostRenderer.tsx +++ b/src/components/UniversalPostRenderer.tsx @@ -954,7 +954,7 @@ import type { } from "@atproto/api/dist/client/types/app/bsky/feed/defs"; import { useEffect, useRef, useState } from "react"; import ReactPlayer from "react-player"; -import { useAuth } from "~/providers/PassAuthProvider"; +import { useAuth } from "~/providers/UnifiedAuthProvider"; // import type { OutputSchema } from "@atproto/api/dist/client/types/app/bsky/feed/getFeed"; // import type { // ViewRecord, diff --git a/src/providers/UnifiedAuthProvider.tsx b/src/providers/UnifiedAuthProvider.tsx new file mode 100644 index 0000000..094c335 --- /dev/null +++ b/src/providers/UnifiedAuthProvider.tsx @@ -0,0 +1,205 @@ +// src/providers/UnifiedAuthProvider.tsx +import React, { + createContext, + useState, + useEffect, + useContext, + useCallback, +} from "react"; +// Import both Agent and the (soon to be deprecated) AtpAgent +import { Agent, AtpAgent, type AtpSessionData } from "@atproto/api"; +import { oauthClient } from "../utils/oauthClient"; // Adjust path if needed +import { + type OAuthSession, + TokenInvalidError, + TokenRefreshError, + TokenRevokedError, +} from "@atproto/oauth-client-browser"; + +// Define the unified status and authentication method +type AuthStatus = "loading" | "signedIn" | "signedOut"; +type AuthMethod = "password" | "oauth" | null; + +interface AuthContextValue { + agent: Agent | null; // The agent is typed as the base class `Agent` + status: AuthStatus; + authMethod: AuthMethod; + loginWithPassword: ( + user: string, + password: string, + service?: string, + ) => Promise; + loginWithOAuth: (handleOrPdsUrl: string) => Promise; + logout: () => Promise; +} + +const AuthContext = createContext({} as AuthContextValue); + +export const UnifiedAuthProvider = ({ + children, +}: { + children: React.ReactNode; +}) => { + // The state is typed as the base class `Agent`, which accepts both `Agent` and `AtpAgent` instances. + const [agent, setAgent] = useState(null); + const [status, setStatus] = useState("loading"); + const [authMethod, setAuthMethod] = useState(null); + const [oauthSession, setOauthSession] = useState(null); + + // Unified Initialization Logic + const initialize = useCallback(async () => { + // --- 1. Try OAuth initialization first --- + try { + const oauthResult = await oauthClient.init(); + if (oauthResult) { + console.log("OAuth session restored."); + const apiAgent = new Agent(oauthResult.session); // Standard Agent + setAgent(apiAgent); + setOauthSession(oauthResult.session); + setAuthMethod("oauth"); + setStatus("signedIn"); + return; // Success + } + } catch (e) { + console.error("OAuth init failed, checking password session.", e); + } + + // --- 2. If no OAuth, try password-based session using AtpAgent --- + try { + const service = localStorage.getItem("service"); + const sessionString = localStorage.getItem("sess"); + + if (service && sessionString) { + console.log("Resuming password-based session using AtpAgent..."); + // Use the original, working AtpAgent logic + const apiAgent = new AtpAgent({ service }); + const session: AtpSessionData = JSON.parse(sessionString); + await apiAgent.resumeSession(session); + + console.log("Password-based session resumed successfully."); + setAgent(apiAgent); // This works because AtpAgent is a subclass of Agent + setAuthMethod("password"); + setStatus("signedIn"); + return; // Success + } + } catch (e) { + console.error("Failed to resume password-based session.", e); + localStorage.removeItem("sess"); + localStorage.removeItem("service"); + } + + // --- 3. If neither worked, user is signed out --- + console.log("No active session found."); + setStatus("signedOut"); + setAgent(null); + setAuthMethod(null); + }, []); + + useEffect(() => { + const handleOAuthSessionDeleted = ( + event: CustomEvent<{ sub: string; cause: TokenRefreshError | TokenRevokedError | TokenInvalidError }>, + ) => { + console.error(`OAuth Session for ${event.detail.sub} was deleted.`, event.detail.cause); + setAgent(null); + setOauthSession(null); + setAuthMethod(null); + setStatus("signedOut"); + }; + + oauthClient.addEventListener("deleted", handleOAuthSessionDeleted as EventListener); + initialize(); + + return () => { + oauthClient.removeEventListener("deleted", handleOAuthSessionDeleted as EventListener); + }; + }, [initialize]); + + // --- Login Methods --- + const loginWithPassword = async ( + user: string, + password: string, + service: string = "https://bsky.social", + ) => { + if (status !== "signedOut") return; + setStatus("loading"); + try { + let sessionData: AtpSessionData | undefined; + // Use the AtpAgent for its simple login and session persistence + const apiAgent = new AtpAgent({ + service, + persistSession: (_evt, sess) => { + sessionData = sess; + }, + }); + await apiAgent.login({ identifier: user, password }); + + if (sessionData) { + localStorage.setItem("service", service); + localStorage.setItem("sess", JSON.stringify(sessionData)); + setAgent(apiAgent); // Store the AtpAgent instance in our state + setAuthMethod("password"); + setStatus("signedIn"); + console.log("Successfully logged in with password."); + } else { + throw new Error("Session data not persisted after login."); + } + } catch (e) { + console.error("Password login failed:", e); + setStatus("signedOut"); + throw e; + } + }; + + const loginWithOAuth = useCallback(async (handleOrPdsUrl: string) => { + if (status !== "signedOut") return; + try { + sessionStorage.setItem("postLoginRedirect", window.location.pathname + window.location.search); + await oauthClient.signIn(handleOrPdsUrl); + } catch (err) { + console.error("OAuth sign-in aborted or failed:", err); + } + }, [status]); + + // --- Unified Logout --- + const logout = useCallback(async () => { + if (status !== "signedIn" || !agent) return; + setStatus("loading"); + + try { + if (authMethod === "oauth" && oauthSession) { + await oauthClient.revoke(oauthSession.sub); + console.log("OAuth session revoked."); + } else if (authMethod === "password") { + localStorage.removeItem("service"); + localStorage.removeItem("sess"); + // AtpAgent has its own logout methods + await (agent as AtpAgent).com.atproto.server.deleteSession(); + console.log("Password-based session deleted."); + } + } catch (e) { + console.error("Logout failed:", e); + } finally { + setAgent(null); + setAuthMethod(null); + setOauthSession(null); + setStatus("signedOut"); + } + }, [status, authMethod, agent, oauthSession]); + + 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 6cd7388..74bddf7 100644 --- a/src/routeTree.gen.ts +++ b/src/routeTree.gen.ts @@ -15,6 +15,7 @@ import { Route as NotificationsRouteImport } from './routes/notifications' import { Route as FeedsRouteImport } from './routes/feeds' import { Route as PathlessLayoutRouteImport } from './routes/_pathlessLayout' import { Route as IndexRouteImport } from './routes/index' +import { Route as CallbackIndexRouteImport } from './routes/callback/index' import { Route as PathlessLayoutNestedLayoutRouteImport } from './routes/_pathlessLayout/_nested-layout' import { Route as ProfileDidIndexRouteImport } from './routes/profile.$did/index' import { Route as PathlessLayoutNestedLayoutRouteBRouteImport } from './routes/_pathlessLayout/_nested-layout/route-b' @@ -50,6 +51,11 @@ const IndexRoute = IndexRouteImport.update({ path: '/', getParentRoute: () => rootRouteImport, } as any) +const CallbackIndexRoute = CallbackIndexRouteImport.update({ + id: '/callback/', + path: '/callback/', + getParentRoute: () => rootRouteImport, +} as any) const PathlessLayoutNestedLayoutRoute = PathlessLayoutNestedLayoutRouteImport.update({ id: '/_nested-layout', @@ -84,6 +90,7 @@ export interface FileRoutesByFullPath { '/notifications': typeof NotificationsRoute '/search': typeof SearchRoute '/settings': typeof SettingsRoute + '/callback': typeof CallbackIndexRoute '/route-a': typeof PathlessLayoutNestedLayoutRouteARoute '/route-b': typeof PathlessLayoutNestedLayoutRouteBRoute '/profile/$did': typeof ProfileDidIndexRoute @@ -95,6 +102,7 @@ export interface FileRoutesByTo { '/notifications': typeof NotificationsRoute '/search': typeof SearchRoute '/settings': typeof SettingsRoute + '/callback': typeof CallbackIndexRoute '/route-a': typeof PathlessLayoutNestedLayoutRouteARoute '/route-b': typeof PathlessLayoutNestedLayoutRouteBRoute '/profile/$did': typeof ProfileDidIndexRoute @@ -109,6 +117,7 @@ export interface FileRoutesById { '/search': typeof SearchRoute '/settings': typeof SettingsRoute '/_pathlessLayout/_nested-layout': typeof PathlessLayoutNestedLayoutRouteWithChildren + '/callback/': typeof CallbackIndexRoute '/_pathlessLayout/_nested-layout/route-a': typeof PathlessLayoutNestedLayoutRouteARoute '/_pathlessLayout/_nested-layout/route-b': typeof PathlessLayoutNestedLayoutRouteBRoute '/profile/$did/': typeof ProfileDidIndexRoute @@ -122,6 +131,7 @@ export interface FileRouteTypes { | '/notifications' | '/search' | '/settings' + | '/callback' | '/route-a' | '/route-b' | '/profile/$did' @@ -133,6 +143,7 @@ export interface FileRouteTypes { | '/notifications' | '/search' | '/settings' + | '/callback' | '/route-a' | '/route-b' | '/profile/$did' @@ -146,6 +157,7 @@ export interface FileRouteTypes { | '/search' | '/settings' | '/_pathlessLayout/_nested-layout' + | '/callback/' | '/_pathlessLayout/_nested-layout/route-a' | '/_pathlessLayout/_nested-layout/route-b' | '/profile/$did/' @@ -159,6 +171,7 @@ export interface RootRouteChildren { NotificationsRoute: typeof NotificationsRoute SearchRoute: typeof SearchRoute SettingsRoute: typeof SettingsRoute + CallbackIndexRoute: typeof CallbackIndexRoute ProfileDidIndexRoute: typeof ProfileDidIndexRoute ProfileDidPostRkeyRoute: typeof ProfileDidPostRkeyRoute } @@ -207,6 +220,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 + } '/_pathlessLayout/_nested-layout': { id: '/_pathlessLayout/_nested-layout' path: '' @@ -282,6 +302,7 @@ const rootRouteChildren: RootRouteChildren = { NotificationsRoute: NotificationsRoute, SearchRoute: SearchRoute, SettingsRoute: SettingsRoute, + CallbackIndexRoute: CallbackIndexRoute, ProfileDidIndexRoute: ProfileDidIndexRoute, ProfileDidPostRkeyRoute: ProfileDidPostRkeyRoute, } diff --git a/src/routes/__root.tsx b/src/routes/__root.tsx index 13983da..407c8f0 100644 --- a/src/routes/__root.tsx +++ b/src/routes/__root.tsx @@ -21,9 +21,9 @@ import Login from "~/components/Login"; import { NotFound } from "~/components/NotFound"; import appCss from "~/styles/app.css?url"; import { seo } from "~/utils/seo"; -import { AuthProvider, useAuth } from "~/providers/PassAuthProvider"; +import { UnifiedAuthProvider, useAuth } from "~/providers/UnifiedAuthProvider"; import { PersistentStoreProvider } from "~/providers/PersistentStoreProvider"; -import type AtpAgent from "@atproto/api"; +import type Agent from "@atproto/api"; import type { QueryClient } from "@tanstack/react-query"; export const Route = createRootRouteWithContext<{ @@ -44,7 +44,6 @@ export const Route = createRootRouteWithContext<{ }), ], links: [ - { rel: "stylesheet", href: appCss }, { rel: "apple-touch-icon", sizes: "180x180", @@ -79,20 +78,21 @@ export const Route = createRootRouteWithContext<{ function RootComponent() { return ( - + - + ); } function RootDocument({ children }: { children: React.ReactNode }) { const location = useLocation(); const navigate = useNavigate(); - const { agent, authed } = useAuth(); + const { agent } = useAuth(); + const authed = !!agent?.did; const isHome = location.pathname === "/"; const isNotifications = location.pathname.startsWith("/notifications"); const isProfile = agent && ((location.pathname === (`/profile/${agent?.did}`)) || (location.pathname === (`/profile/${encodeURIComponent(agent?.did??"")}`))); diff --git a/src/routes/callback/index.tsx b/src/routes/callback/index.tsx new file mode 100644 index 0000000..1743457 --- /dev/null +++ b/src/routes/callback/index.tsx @@ -0,0 +1,13 @@ +import { createFileRoute, useNavigate } from '@tanstack/react-router' + +export const Route = createFileRoute('/callback/')({ + component: RouteComponent, +}) + +function RouteComponent() { + const navigate = useNavigate() + const redirectPath = sessionStorage.getItem('postLoginRedirect') || '/'; + navigate({to:redirectPath}) + sessionStorage.removeItem('postLoginRedirect'); + return
Hello "/callback/"!
+} diff --git a/src/routes/index.tsx b/src/routes/index.tsx index 3f9c6ab..225019a 100644 --- a/src/routes/index.tsx +++ b/src/routes/index.tsx @@ -6,7 +6,7 @@ import { UniversalPostRendererATURILoader, } from "~/components/UniversalPostRenderer"; import * as React from "react"; -import { useAuth } from "~/providers/PassAuthProvider"; +import { useAuth } from "~/providers/UnifiedAuthProvider"; //import { usePersistentStore } from "~/providers/PersistentStoreProvider"; import { useQueryIdentity, @@ -99,12 +99,13 @@ function PendingHome() { function Home() { const { agent, - loginStatus, - login, + status, + authMethod, + loginWithPassword, + loginWithOAuth, logout, - loading: loadering, - authed, } = useAuth(); + const authed = !!agent?.did; useEffect(() => { if (agent?.did) { @@ -112,14 +113,15 @@ function Home() { } else { store.set(authedAtom, false); } - }, [loginStatus, agent, authed]); + }, [status, agent, authed]); useEffect(() => { if (agent) { + // is it just me or is the type really weird here it should be Agent not AtpAgent store.set(agentAtom, agent); } else { store.set(agentAtom, null); } - }, [loginStatus, agent, authed]); + }, [status, agent, authed]); //const { get, set } = usePersistentStore(); // const [feed, setFeed] = React.useState([]); diff --git a/src/utils/atoms.ts b/src/utils/atoms.ts index 5d86cbf..5f3a203 100644 --- a/src/utils/atoms.ts +++ b/src/utils/atoms.ts @@ -1,4 +1,4 @@ -import type AtpAgent from "@atproto/api"; +import type Agent from "@atproto/api"; import { atom, createStore } from "jotai"; import { atomWithStorage } from 'jotai/utils'; @@ -21,5 +21,5 @@ export const likedPostsAtom = atomWithStorage>( {} ); -export const agentAtom = atom(null); +export const agentAtom = atom(null); export const authedAtom = atom(false); diff --git a/src/utils/oauthClient.ts b/src/utils/oauthClient.ts new file mode 100644 index 0000000..494e1ed --- /dev/null +++ b/src/utils/oauthClient.ts @@ -0,0 +1,16 @@ +// src/helpers/oauthClient.ts +import { BrowserOAuthClient, type ClientMetadata } from '@atproto/oauth-client-browser'; + +// This is your app's PDS for resolving handles if not provided. +// You might need to host your own or use a public one. +const handleResolverPDS = 'https://bsky.social'; + +// This assumes your client-metadata.json is in the /public folder +// and will be served at the root of your domain. +import clientMetadata from '../../public/client-metadata.json' assert { type: 'json' }; + +export const oauthClient = new BrowserOAuthClient({ + // The type assertion is needed because the static import isn't strictly typed + clientMetadata: clientMetadata as ClientMetadata, + handleResolver: handleResolverPDS, +}); \ No newline at end of file diff --git a/src/utils/useQuery.ts b/src/utils/useQuery.ts index 5f12ccb..9400d67 100644 --- a/src/utils/useQuery.ts +++ b/src/utils/useQuery.ts @@ -332,7 +332,7 @@ type linksAllResponse = { export function constructFeedSkeletonQuery(options?: { feedUri: string; - agent?: ATPAPI.AtpAgent; + agent?: ATPAPI.Agent; isAuthed: boolean; pdsUrl?: string; feedServiceDid?: string; @@ -372,7 +372,7 @@ export function constructFeedSkeletonQuery(options?: { export function useQueryFeedSkeleton(options?: { feedUri: string; - agent?: ATPAPI.AtpAgent; + agent?: ATPAPI.Agent; isAuthed: boolean; pdsUrl?: string; feedServiceDid?: string; @@ -380,7 +380,7 @@ export function useQueryFeedSkeleton(options?: { return useQuery(constructFeedSkeletonQuery(options)); } -export function constructPreferencesQuery(agent?: ATPAPI.AtpAgent | undefined, pdsUrl?: string | undefined) { +export function constructPreferencesQuery(agent?: ATPAPI.Agent | undefined, pdsUrl?: string | undefined) { return queryOptions({ queryKey: ['preferences', agent?.did], queryFn: async () => { @@ -393,7 +393,7 @@ export function constructPreferencesQuery(agent?: ATPAPI.AtpAgent | undefined, p }); } export function useQueryPreferences(options: { - agent?: ATPAPI.AtpAgent | undefined, pdsUrl?: string | undefined + agent?: ATPAPI.Agent | undefined, pdsUrl?: string | undefined }) { return useQuery(constructPreferencesQuery(options.agent, options.pdsUrl)); } @@ -498,7 +498,7 @@ type FeedSkeletonPage = ATPAPI.AppBskyFeedGetFeedSkeleton.OutputSchema; export function constructInfiniteFeedSkeletonQuery(options: { feedUri: string; - agent?: ATPAPI.AtpAgent; + agent?: ATPAPI.Agent; isAuthed: boolean; pdsUrl?: string; feedServiceDid?: string; @@ -537,7 +537,7 @@ export function constructInfiniteFeedSkeletonQuery(options: { export function useInfiniteQueryFeedSkeleton(options: { feedUri: string; - agent?: ATPAPI.AtpAgent; + agent?: ATPAPI.Agent; isAuthed: boolean; pdsUrl?: string; feedServiceDid?: string; diff --git a/vite.config.ts b/vite.config.ts index e7e9ab6..4becafd 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,6 +1,14 @@ import { defineConfig } from "vite"; import viteReact from "@vitejs/plugin-react"; import tailwindcss from "@tailwindcss/vite"; +import { generateMetadataPlugin } from "./oauthdev.mts"; + +const PROD_URL = "https://reddwarf.whey.party" +const DEV_URL = "https://local3768forumtest.whey.party" + +function shp(url: string): string { + return url.replace(/^https?:\/\//, ''); +} import { TanStackRouterVite } from "@tanstack/router-plugin/vite"; import { resolve } from "node:path"; @@ -8,6 +16,10 @@ import { resolve } from "node:path"; // https://vitejs.dev/config/ export default defineConfig({ plugins: [ + generateMetadataPlugin({ + prod: PROD_URL, + dev: DEV_URL, + }), TanStackRouterVite({ autoCodeSplitting: true }), viteReact(), tailwindcss(), @@ -22,4 +34,10 @@ export default defineConfig({ "~": resolve(__dirname, "./src"), }, }, + server: { + allowedHosts: [shp(PROD_URL),shp(DEV_URL)], + }, + css: { + devSourcemap: true, + }, });