The recipes.blue monorepo recipes.blue
recipes appview atproto
2
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: move auth clientside

+222 -81
-4
Caddyfile
··· 31 31 rewrite * /xrpc{uri} 32 32 reverse_proxy http://host.docker.internal:8080 33 33 } 34 - handle_path /oauth/* { 35 - rewrite * /oauth{uri} 36 - reverse_proxy http://host.docker.internal:8080 37 - } 38 34 handle_path /api/* { 39 35 rewrite * /api{uri} 40 36 reverse_proxy http://host.docker.internal:8080
+12
apps/web/public/client-metadata.json
··· 1 + { 2 + "client_id": "https://recipes.blue/client-metadata.json", 3 + "client_name": "pdsls", 4 + "client_uri": "https://recipes.blue", 5 + "redirect_uris": ["https://recipes.blue/"], 6 + "scope": "atproto transition:generic", 7 + "grant_types": ["authorization_code", "refresh_token"], 8 + "response_types": ["code"], 9 + "token_endpoint_auth_method": "none", 10 + "application_type": "web", 11 + "dpop_bound_access_tokens": true 12 + }
+41 -34
apps/web/src/components/nav-user.tsx
··· 6 6 LogOut, 7 7 } from "lucide-react" 8 8 import { 9 - Avatar, 10 - AvatarFallback, 11 - AvatarImage, 12 - } from "@/components/ui/avatar" 13 - import { 14 9 DropdownMenu, 15 10 DropdownMenuContent, 16 11 DropdownMenuGroup, ··· 25 20 SidebarMenuItem, 26 21 useSidebar, 27 22 } from "@/components/ui/sidebar" 28 - import { useUserQuery } from "@/queries/self" 29 23 import { Button } from "./ui/button" 30 24 import { Link } from "@tanstack/react-router" 25 + import { useAuth } from "@/state/auth" 26 + import { useXrpc } from "@/hooks/use-xrpc" 27 + import { useQuery } from "@tanstack/react-query" 28 + import { At } from "@atcute/client/lexicons" 31 29 import { Skeleton } from "./ui/skeleton" 30 + import { Avatar, AvatarFallback, AvatarImage } from "./ui/avatar" 32 31 33 32 export function NavUser() { 34 33 const { isMobile } = useSidebar() 34 + const { isLoggedIn, agent } = useAuth(); 35 + const rpc = useXrpc(agent); 35 36 36 - const userQuery = useUserQuery(); 37 + const userQuery = useQuery({ 38 + queryKey: ['self'], 39 + queryFn: async () => rpc 40 + .get('com.atproto.repo.getRecord', { 41 + params: { 42 + repo: agent?.sub as At.DID, 43 + collection: 'app.bsky.actor.profile', 44 + rkey: 'self', 45 + }, 46 + }), 47 + enabled: isLoggedIn, 48 + }); 37 49 38 - if (userQuery.isLoading) { 39 - return ( 40 - <SidebarMenu> 41 - <SidebarMenuItem> 42 - <SidebarMenuButton 43 - size="lg" 44 - className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground" 45 - > 46 - <Skeleton className="h-8 w-8 rounded-lg" /> 47 - <div className="grid flex-1 text-left text-sm leading-tight"> 48 - <Skeleton className="h-2 w-20 rounded-lg" /> 49 - </div> 50 - </SidebarMenuButton> 51 - </SidebarMenuItem> 52 - </SidebarMenu> 53 - ) 54 - } 55 - 56 - if (userQuery.isError || !userQuery.data) { 50 + if (!isLoggedIn || !agent || userQuery.isError || !userQuery.data) { 57 51 return ( 58 52 <SidebarMenu> 59 53 <SidebarMenuItem> ··· 70 64 ); 71 65 } 72 66 67 + if (userQuery.isFetching) return ( 68 + <SidebarMenu> 69 + <SidebarMenuItem> 70 + <SidebarMenuButton 71 + size="lg" 72 + className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground" 73 + > 74 + <Skeleton className="h-8 w-8 rounded-lg" /> 75 + <div className="grid flex-1 text-left text-sm leading-tight"> 76 + <Skeleton className="h-2 w-20 rounded-lg" /> 77 + </div> 78 + </SidebarMenuButton> 79 + </SidebarMenuItem> 80 + </SidebarMenu> 81 + ); 82 + 73 83 return ( 74 84 <SidebarMenu> 75 85 <SidebarMenuItem> ··· 80 90 className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground" 81 91 > 82 92 <Avatar className="h-8 w-8 rounded-lg"> 83 - <AvatarImage src={userQuery.data.avatar} alt={userQuery.data.displayName ?? `@${userQuery.data.handle}`} /> 84 - <AvatarFallback className="rounded-lg">{userQuery.data.handle.substring(2)}</AvatarFallback> 93 + <AvatarImage src={`https://cdn.bsky.app/img/avatar_thumbnail/plain/${agent.sub}/${userQuery.data.data.value.avatar.ref.$link}@jpeg`} alt={userQuery.data.data.value.displayName ?? `@${userQuery.data.data.value.handle}`} /> 94 + <AvatarFallback className="rounded-lg">{userQuery.data.data.value.handle}</AvatarFallback> 85 95 </Avatar> 86 96 <div className="grid flex-1 text-left text-sm leading-tight"> 87 - <span className="truncate font-semibold">{userQuery.data.displayName ?? `@${userQuery.data.handle}`}</span> 97 + <span className="truncate font-semibold">{userQuery.data.data.value.displayName ?? `@${userQuery.data.data.value.handle}`}</span> 88 98 </div> 89 99 <ChevronsUpDown className="ml-auto size-4" /> 90 100 </SidebarMenuButton> ··· 98 108 <DropdownMenuLabel className="p-0 font-normal"> 99 109 <div className="flex items-center gap-2 px-1 py-1.5 text-left text-sm"> 100 110 <Avatar className="h-8 w-8 rounded-lg"> 101 - <AvatarImage src={userQuery.data.avatar} alt={userQuery.data.displayName ?? `@${userQuery.data.handle}`} /> 102 - <AvatarFallback className="rounded-lg">{userQuery.data.handle.substring(2)}</AvatarFallback> 111 + <AvatarImage src={`https://cdn.bsky.app/img/avatar_thumbnail/plain/${agent.sub}/${userQuery.data.data.value.avatar.ref.$link}@jpeg`} alt={userQuery.data.data.value.displayName ?? `@${userQuery.data.data.value.handle}`} /> 112 + <AvatarFallback className="rounded-lg">{userQuery.data.data.value.handle}</AvatarFallback> 103 113 </Avatar> 104 - <div className="grid flex-1 text-left text-sm leading-tight"> 105 - <span className="truncate font-semibold">{userQuery.data.displayName ?? `@${userQuery.data.handle}`}</span> 106 - </div> 107 114 </div> 108 115 </DropdownMenuLabel> 109 116 <DropdownMenuSeparator />
+8 -8
apps/web/src/hooks/use-xrpc.tsx
··· 1 1 import { SERVER_URL } from "@/lib/utils"; 2 2 import { CredentialManager, XRPC } from "@atcute/client" 3 - import { createContext, useContext } from "react"; 3 + import { OAuthUserAgent } from "@atcute/oauth-browser-client"; 4 4 5 - export const creds = new CredentialManager({ service: `https://${SERVER_URL}` }); 6 - export const rpc = new XRPC({ handler: creds }); 7 - export const XrpcContext = createContext<{ rpc: XRPC; creds: CredentialManager; } | null>(null); 5 + export function useXrpc(agent?: OAuthUserAgent) { 6 + let handler; 7 + if (agent) handler = agent; 8 + else handler = new CredentialManager({ service: `https://${SERVER_URL}` }); 8 9 9 - export function useXrpc() { 10 - const xrpc = useContext(XrpcContext); 11 - if (!xrpc) throw new Error('useXrpc() must be used within <XrpcContext.Provider>!'); 12 - return xrpc; 10 + const rpc = new XRPC({ handler }); 11 + 12 + return rpc; 13 13 }
+2
apps/web/src/lib/utils.ts
··· 5 5 return twMerge(clsx(inputs)) 6 6 } 7 7 8 + export const sleep = (ms: number): Promise<void> => new Promise(resolve => setTimeout(resolve, ms)) 9 + 8 10 export const SERVER_URL = import.meta.env.VITE_API_SERVICE;
+8 -7
apps/web/src/main.tsx
··· 4 4 import { createRouter, RouterProvider } from '@tanstack/react-router'; 5 5 import { QueryClientProvider, QueryClient } from '@tanstack/react-query' 6 6 import { ReactQueryDevtools } from '@tanstack/react-query-devtools' 7 - import { XrpcContext } from './hooks/use-xrpc'; 8 - import { CredentialManager, XRPC } from '@atcute/client'; 9 - import { SERVER_URL } from './lib/utils'; 7 + import { configureOAuth } from '@atcute/oauth-browser-client'; 10 8 import './index.css' 11 9 12 10 const router = createRouter({ routeTree }); ··· 17 15 } 18 16 } 19 17 20 - const creds = new CredentialManager({ service: `https://${SERVER_URL}` }); 21 - const rpc = new XRPC({ handler: creds }); 18 + configureOAuth({ 19 + metadata: { 20 + client_id: import.meta.env.VITE_OAUTH_CLIENT_ID, 21 + redirect_uri: import.meta.env.VITE_OAUTH_REDIRECT_URL, 22 + }, 23 + }); 24 + 22 25 const queryClient = new QueryClient({ 23 26 defaultOptions: { 24 27 queries: { ··· 31 34 32 35 createRoot(document.getElementById('root')!).render( 33 36 <StrictMode> 34 - <XrpcContext.Provider value={{ creds, rpc }}> 35 37 <QueryClientProvider client={queryClient}> 36 38 <RouterProvider router={router} /> 37 39 <ReactQueryDevtools initialIsOpen={false} /> 38 40 </QueryClientProvider> 39 - </XrpcContext.Provider> 40 41 </StrictMode>, 41 42 )
+5 -5
apps/web/src/routes/_.(app)/recipes/$author/$rkey.tsx
··· 11 11 import { SidebarTrigger } from '@/components/ui/sidebar' 12 12 import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' 13 13 import { recipeQueryOptions } from '@/queries/recipe' 14 - import { queryClient } from '@/lib/react-query' 15 14 import { useSuspenseQuery } from '@tanstack/react-query' 16 - import { rpc } from '@/hooks/use-xrpc' 15 + import { useXrpc } from '@/hooks/use-xrpc' 17 16 18 17 export const Route = createFileRoute('/_/(app)/recipes/$author/$rkey')({ 19 - loader: ({ params: { author, rkey } }) => { 20 - queryClient.ensureQueryData(recipeQueryOptions(rpc, author, rkey)) 21 - }, 18 + //loader: ({ params: { author, rkey } }) => { 19 + // queryClient.ensureQueryData(recipeQueryOptions(rpc, author, rkey)) 20 + //}, 22 21 component: RouteComponent, 23 22 }) 24 23 25 24 function RouteComponent() { 25 + const rpc = useXrpc(); 26 26 const { author, rkey } = Route.useParams() 27 27 const { 28 28 data: { recipe },
+23 -13
apps/web/src/routes/_.(auth)/login.lazy.tsx
··· 17 17 import { Label } from '@/components/ui/label' 18 18 import { Separator } from '@/components/ui/separator' 19 19 import { SidebarTrigger } from '@/components/ui/sidebar' 20 - import { SERVER_URL } from '@/lib/utils' 20 + import { sleep } from '@/lib/utils' 21 + import { createAuthorizationUrl, resolveFromIdentity } from '@atcute/oauth-browser-client' 21 22 import { useMutation } from '@tanstack/react-query' 22 23 import { createLazyFileRoute } from '@tanstack/react-router' 23 24 import { useState } from 'react' ··· 32 33 const { mutate, isPending, error } = useMutation({ 33 34 mutationKey: ['login'], 34 35 mutationFn: async () => { 35 - const res = await fetch(`https://${SERVER_URL}/oauth/login`, { 36 - method: 'POST', 37 - body: JSON.stringify({ actor: handle }), 38 - redirect: 'manual', 39 - headers: { 40 - 'Content-Type': 'application/json', 41 - Accept: 'application/json', 42 - }, 43 - }) 44 - return res.json() 36 + const { identity, metadata } = await resolveFromIdentity(handle); 37 + 38 + const authUrl = await createAuthorizationUrl({ 39 + metadata: metadata, 40 + identity: identity, 41 + scope: 'atproto transition:generic', 42 + }); 43 + 44 + await sleep(200); 45 + 46 + return authUrl; 45 47 }, 46 - onSuccess: (resp: { url: string }) => { 47 - document.location.href = resp.url 48 + onSuccess: async (authUrl: URL) => { 49 + window.location.assign(authUrl); 50 + 51 + await new Promise((_resolve, reject) => { 52 + const listener = () => { 53 + reject(new Error(`user aborted the login request`)); 54 + }; 55 + 56 + window.addEventListener('pageshow', listener, { once: true }); 57 + }); 48 58 }, 49 59 }) 50 60
+9 -6
apps/web/src/routes/__root.tsx
··· 3 3 SidebarInset, 4 4 SidebarProvider, 5 5 } from '@/components/ui/sidebar' 6 + import { AuthProvider } from '@/state/auth'; 6 7 import { Outlet, createRootRoute } from '@tanstack/react-router' 7 8 8 9 export const Route = createRootRoute({ ··· 11 12 12 13 function RootComponent() { 13 14 return ( 14 - <SidebarProvider> 15 - <AppSidebar /> 16 - <SidebarInset> 17 - <Outlet /> 18 - </SidebarInset> 19 - </SidebarProvider> 15 + <AuthProvider> 16 + <SidebarProvider> 17 + <AppSidebar /> 18 + <SidebarInset> 19 + <Outlet /> 20 + </SidebarInset> 21 + </SidebarProvider> 22 + </AuthProvider> 20 23 ) 21 24 }
+68
apps/web/src/state/auth.tsx
··· 1 + import { At } from "@atcute/client/lexicons"; 2 + import { finalizeAuthorization, getSession, OAuthUserAgent } from "@atcute/oauth-browser-client"; 3 + import { createContext, PropsWithChildren, useContext, useEffect, useState } from "react"; 4 + 5 + type AuthContextType = { 6 + isLoggedIn: boolean; 7 + agent?: OAuthUserAgent; 8 + }; 9 + 10 + const AuthContext = createContext<AuthContextType>({ 11 + isLoggedIn: false, 12 + }); 13 + 14 + export const AuthProvider = ({ children }: PropsWithChildren) => { 15 + const [isReady, setIsReady] = useState(false); 16 + const [isLoggedIn, setIsLoggedIn] = useState(false); 17 + const [agent, setAgent] = useState<OAuthUserAgent | undefined>(undefined); 18 + 19 + useEffect(() => { 20 + const init = async () => { 21 + const params = new URLSearchParams(location.hash.slice(1)); 22 + 23 + if (params.has("state") && (params.has("code") || params.has("error"))) { 24 + history.replaceState(null, "", location.pathname + location.search); 25 + 26 + const session = await finalizeAuthorization(params); 27 + const did = session.info.sub; 28 + 29 + localStorage.setItem("lastSignedIn", did); 30 + return session; 31 + } else { 32 + const lastSignedIn = localStorage.getItem("lastSignedIn"); 33 + 34 + if (lastSignedIn) { 35 + try { 36 + return await getSession(lastSignedIn as At.DID); 37 + } catch (err) { 38 + localStorage.removeItem("lastSignedIn"); 39 + throw err; 40 + } 41 + } 42 + } 43 + }; 44 + 45 + init() 46 + .then(session => { 47 + if (session) { 48 + setAgent(new OAuthUserAgent(session)); 49 + setIsLoggedIn(true); 50 + } 51 + 52 + setIsReady(true) 53 + }) 54 + .catch(() => {}); 55 + }, []); 56 + 57 + if (!isReady) return null; 58 + 59 + return ( 60 + <AuthContext.Provider value={{ isLoggedIn, agent }}> 61 + {children} 62 + </AuthContext.Provider> 63 + ); 64 + }; 65 + 66 + export const useAuth = () => { 67 + return useContext(AuthContext); 68 + };
+13
apps/web/src/vite-env.d.ts
··· 1 1 /// <reference types="vite/client" /> 2 2 /// <reference types="@cookware/lexicons" /> 3 3 /// <reference types="@atcute/bluesky/lexicons" /> 4 + 5 + interface ImportMetaEnv { 6 + readonly VITE_API_SERVICE: string; 7 + readonly VITE_DEV_SERVER_PORT?: string; 8 + readonly VITE_CLIENT_URI: string; 9 + readonly VITE_OAUTH_CLIENT_ID: string; 10 + readonly VITE_OAUTH_REDIRECT_URL: string; 11 + readonly VITE_OAUTH_SCOPE: string; 12 + } 13 + 14 + interface ImportMeta { 15 + readonly env: ImportMetaEnv; 16 + }
+29
apps/web/vite.config.ts
··· 2 2 import react from '@vitejs/plugin-react-swc' 3 3 import { TanStackRouterVite } from '@tanstack/router-plugin/vite' 4 4 import path from 'path' 5 + import metadata from "./public/client-metadata.json"; 6 + 5 7 const SERVER_HOST = "127.0.0.1"; 6 8 const SERVER_PORT = 5173; 7 9 ··· 10 12 plugins: [ 11 13 TanStackRouterVite(), 12 14 react(), 15 + 16 + { 17 + name: "oauth", 18 + config(_conf, { command }) { 19 + if (command === "build") { 20 + process.env.VITE_OAUTH_CLIENT_ID = metadata.client_id; 21 + process.env.VITE_OAUTH_REDIRECT_URL = metadata.redirect_uris[0]; 22 + } else { 23 + const redirectUri = ((): string => { 24 + const url = new URL(metadata.redirect_uris[0]); 25 + return `http://${SERVER_HOST}:${SERVER_PORT}${url.pathname}`; 26 + })(); 27 + 28 + const clientId = 29 + `http://localhost` + 30 + `?redirect_uri=${encodeURIComponent(redirectUri)}` + 31 + `&scope=${encodeURIComponent(metadata.scope)}`; 32 + 33 + process.env.VITE_DEV_SERVER_PORT = "" + SERVER_PORT; 34 + process.env.VITE_OAUTH_CLIENT_ID = clientId; 35 + process.env.VITE_OAUTH_REDIRECT_URL = redirectUri; 36 + } 37 + 38 + process.env.VITE_CLIENT_URI = metadata.client_uri; 39 + process.env.VITE_OAUTH_SCOPE = metadata.scope; 40 + }, 41 + }, 13 42 ], 14 43 server: { 15 44 host: SERVER_HOST,
+4 -4
pnpm-lock.yaml
··· 49 49 version: 4.0.8 50 50 drizzle-orm: 51 51 specifier: ^0.37.0 52 - version: 0.37.0(@libsql/client@0.14.0)(@opentelemetry/api@1.9.0)(@types/pg@8.6.1)(@types/react@19.0.1)(react@19.0.0) 52 + version: 0.37.0(@libsql/client@0.14.0(bufferutil@4.0.8))(@opentelemetry/api@1.9.0)(@types/pg@8.6.1)(@types/react@19.0.1)(react@19.0.0) 53 53 hono: 54 54 specifier: ^4.6.12 55 55 version: 4.6.12 ··· 125 125 version: 4.0.8 126 126 drizzle-orm: 127 127 specifier: ^0.37.0 128 - version: 0.37.0(@libsql/client@0.14.0)(@opentelemetry/api@1.9.0)(@types/pg@8.6.1)(@types/react@19.0.1)(react@19.0.0) 128 + version: 0.37.0(@libsql/client@0.14.0(bufferutil@4.0.8))(@opentelemetry/api@1.9.0)(@types/pg@8.6.1)(@types/react@19.0.1)(react@19.0.0) 129 129 pino: 130 130 specifier: ^9.5.0 131 131 version: 9.5.0 ··· 328 328 version: 0.14.0(bufferutil@4.0.8) 329 329 drizzle-orm: 330 330 specifier: ^0.37.0 331 - version: 0.37.0(@libsql/client@0.14.0)(@opentelemetry/api@1.9.0)(@types/pg@8.6.1)(@types/react@19.0.1)(react@19.0.0) 331 + version: 0.37.0(@libsql/client@0.14.0(bufferutil@4.0.8))(@opentelemetry/api@1.9.0)(@types/pg@8.6.1)(@types/react@19.0.1)(react@19.0.0) 332 332 zod: 333 333 specifier: ^3.23.8 334 334 version: 3.23.8 ··· 6624 6624 transitivePeerDependencies: 6625 6625 - supports-color 6626 6626 6627 - drizzle-orm@0.37.0(@libsql/client@0.14.0)(@opentelemetry/api@1.9.0)(@types/pg@8.6.1)(@types/react@19.0.1)(react@19.0.0): 6627 + drizzle-orm@0.37.0(@libsql/client@0.14.0(bufferutil@4.0.8))(@opentelemetry/api@1.9.0)(@types/pg@8.6.1)(@types/react@19.0.1)(react@19.0.0): 6628 6628 optionalDependencies: 6629 6629 '@libsql/client': 0.14.0(bufferutil@4.0.8) 6630 6630 '@opentelemetry/api': 1.9.0