The recipes.blue monorepo recipes.blue
recipes appview atproto

feat: updating the setup in web

hayden.moe 13adfcbd e97c46b0

verified
+477 -389
+2 -1
apps/web/package.json
··· 12 12 "dependencies": { 13 13 "@atcute/atproto": "^3.1.9", 14 14 "@atcute/client": "catalog:", 15 + "@atcute/identity-resolver": "^1.1.4", 15 16 "@atcute/lexicons": "catalog:", 16 - "@atcute/oauth-browser-client": "^1.0.7", 17 + "@atcute/oauth-browser-client": "^2.0.1", 17 18 "@atproto/common": "^0.4.5", 18 19 "@atproto/common-web": "^0.3.1", 19 20 "@dnd-kit/core": "^6.3.1",
-12
apps/web/public/client-metadata.json
··· 1 - { 2 - "client_id": "https://recipes.blue/client-metadata.json", 3 - "client_name": "Recipes", 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 - }
+9
apps/web/public/oauth-client-metadata.json
··· 1 + { 2 + "client_id": "https://recipes.blue/oauth-client-metadata.json", 3 + "client_name": "Recipes.blue", 4 + "redirect_uris": ["https://recipes.blue/"], 5 + "scope": "atproto transition:generic", 6 + "token_endpoint_auth_method": "none", 7 + "application_type": "web", 8 + "dpop_bound_access_tokens": true 9 + }
-9
apps/web/src/forms/recipe.ts
··· 1 - import { RecipeRecord } from "@cookware/lexicons"; 2 - import { z } from "zod"; 3 - 4 - export const recipeSchema = RecipeRecord.extend({ 5 - time: z.coerce.number(), 6 - image: z 7 - .instanceof(FileList) 8 - .or(z.null()), 9 - });
+10 -10
apps/web/src/hooks/use-xrpc.tsx
··· 1 1 import { SERVER_URL } from "@/lib/utils"; 2 2 import { useAuth } from "@/state/auth"; 3 - import { Client, simpleFetchHandler } from "@atcute/client"; 3 + import { Client, FetchHandler, FetchHandlerObject, ServiceProxyOptions, simpleFetchHandler } from "@atcute/client"; 4 4 5 5 export function useXrpc() { 6 6 const { agent } = useAuth(); 7 + 8 + let handler: FetchHandler | FetchHandlerObject = simpleFetchHandler({ service: `https://${SERVER_URL}` }); 9 + let proxy: ServiceProxyOptions | null = null; 7 10 8 11 if (agent) { 9 - return new Client({ 10 - handler: agent, 11 - proxy: { 12 - did: `did:web:${SERVER_URL}`, 13 - serviceId: '#recipes_blue', 14 - }, 15 - }); 12 + handler = agent; 13 + proxy = { 14 + did: `did:web:${SERVER_URL}`, 15 + serviceId: '#recipes_blue', 16 + }; 16 17 } 17 18 18 - const handler = simpleFetchHandler({ service: `https://${SERVER_URL}` }); 19 - return new Client({ handler }); 19 + return new Client({ handler, proxy }); 20 20 }
+20 -9
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 { configureOAuth } from '@atcute/oauth-browser-client'; 7 + import { configureOAuth, defaultIdentityResolver } from '@atcute/oauth-browser-client'; 8 8 import './index.css' 9 9 import { AuthProvider, useAuth } from './state/auth'; 10 10 import { ThemeProvider } from './components/theme-provider'; 11 + import { CompositeDidDocumentResolver, PlcDidDocumentResolver, WebDidDocumentResolver, XrpcHandleResolver } from '@atcute/identity-resolver'; 12 + import { SessionProvider } from './state/auth/session'; 11 13 12 14 const router = createRouter({ 13 15 routeTree, ··· 27 29 client_id: import.meta.env.VITE_OAUTH_CLIENT_ID, 28 30 redirect_uri: import.meta.env.VITE_OAUTH_REDIRECT_URL, 29 31 }, 32 + identityResolver: defaultIdentityResolver({ 33 + handleResolver: new XrpcHandleResolver({ serviceUrl: 'https://slingshot.microcosm.blue' }), 34 + didDocumentResolver: new CompositeDidDocumentResolver({ 35 + methods: { 36 + plc: new PlcDidDocumentResolver(), 37 + web: new WebDidDocumentResolver(), 38 + }, 39 + }), 40 + }), 30 41 }); 31 42 32 43 const queryClient = new QueryClient({ ··· 46 57 47 58 createRoot(document.getElementById('root')!).render( 48 59 <StrictMode> 49 - <ThemeProvider defaultTheme="dark" storageKey="recipes-theme"> 50 - <AuthProvider> 51 - <QueryClientProvider client={queryClient}> 52 - <InnerApp /> 53 - <ReactQueryDevtools initialIsOpen={false} /> 54 - </QueryClientProvider> 55 - </AuthProvider> 56 - </ThemeProvider> 60 + <SessionProvider> 61 + <ThemeProvider defaultTheme="dark" storageKey="recipes-theme"> 62 + <QueryClientProvider client={queryClient}> 63 + <InnerApp /> 64 + <ReactQueryDevtools initialIsOpen={false} /> 65 + </QueryClientProvider> 66 + </ThemeProvider> 67 + </SessionProvider> 57 68 </StrictMode>, 58 69 )
+288 -288
apps/web/src/routes/_.(app)/recipes/new.tsx
··· 40 40 import { Label } from "@/components/ui/label"; 41 41 import { TrashIcon } from "lucide-react"; 42 42 import { useNewRecipeMutation } from "@/queries/recipe"; 43 - import { recipeSchema } from "@/forms/recipe"; 44 43 45 44 export const Route = createFileRoute("/_/(app)/recipes/new")({ 46 45 beforeLoad: async ({ context }) => { ··· 54 53 }); 55 54 56 55 function RouteComponent() { 57 - const form = useForm<z.infer<typeof recipeSchema>>({ 58 - resolver: zodResolver(recipeSchema), 59 - defaultValues: { 60 - title: "", 61 - time: 0, 62 - image: null, 63 - description: "", 64 - ingredients: [{ name: "" }], 65 - steps: [{ text: "" }], 66 - }, 67 - }); 68 - 69 - const { mutate, isPending } = useNewRecipeMutation(form); 70 - 71 - const onSubmit = (values: z.infer<typeof recipeSchema>) => { 72 - mutate({ recipe: values }); 73 - }; 74 - 75 - const imageRef = form.register("image"); 76 - 77 - const ingredients = useFieldArray({ 78 - control: form.control, 79 - name: "ingredients", 80 - }); 81 - 82 - const steps = useFieldArray({ 83 - control: form.control, 84 - name: "steps", 85 - }); 86 - 87 - return ( 88 - <> 89 - <Breadcrumbs /> 90 - <div className="flex-1 flex-col p-4 pt-0 max-w-xl w-full mx-auto"> 91 - <Card> 92 - <CardHeader> 93 - <CardTitle>New recipe</CardTitle> 94 - <CardDescription>Share your recipe with the world!</CardDescription> 95 - </CardHeader> 96 - <CardContent> 97 - <Form {...form}> 98 - <form 99 - onSubmit={form.handleSubmit(onSubmit)} 100 - className="space-y-8" 101 - > 102 - <FormField 103 - name="title" 104 - control={form.control} 105 - render={({ field }) => ( 106 - <FormItem> 107 - <FormLabel>Title</FormLabel> 108 - <FormControl> 109 - <Input placeholder="My awesome recipe!" {...field} /> 110 - </FormControl> 111 - <FormDescription> 112 - This is your recipe's name. 113 - </FormDescription> 114 - <FormMessage /> 115 - </FormItem> 116 - )} 117 - /> 118 - 119 - <FormField 120 - name="description" 121 - control={form.control} 122 - render={({ field: { value, ...field } }) => ( 123 - <FormItem> 124 - <FormLabel>Description</FormLabel> 125 - <FormControl> 126 - <Textarea 127 - className="resize-none" 128 - value={value || ""} 129 - {...field} 130 - /> 131 - </FormControl> 132 - <FormDescription>Describe your recipe, maybe tell the world how tasty it is? (Optional)</FormDescription> 133 - <FormMessage /> 134 - </FormItem> 135 - )} 136 - /> 137 - 138 - <FormField 139 - name="image" 140 - control={form.control} 141 - render={(_props) => ( 142 - <FormItem> 143 - <FormLabel>Image</FormLabel> 144 - <FormControl> 145 - <Input 146 - type="file" 147 - className="resize-none" 148 - {...imageRef} 149 - /> 150 - </FormControl> 151 - <FormMessage /> 152 - </FormItem> 153 - )} 154 - /> 155 - 156 - <FormField 157 - name="time" 158 - control={form.control} 159 - render={({ field: { value, ...field } }) => ( 160 - <FormItem> 161 - <FormLabel>Time</FormLabel> 162 - <FormControl> 163 - <Input 164 - type="number" 165 - className="resize-none" 166 - value={value || ""} 167 - {...field} 168 - /> 169 - </FormControl> 170 - <FormDescription>How long (in minutes) does your recipe take to complete?</FormDescription> 171 - <FormMessage /> 172 - </FormItem> 173 - )} 174 - /> 175 - 176 - <div className="grid gap-2"> 177 - <Label>Ingredients</Label> 178 - <Sortable 179 - value={ingredients.fields} 180 - onMove={({ activeIndex, overIndex }) => 181 - ingredients.move(activeIndex, overIndex)} 182 - > 183 - <div className="flex w-full flex-col gap-2"> 184 - {ingredients.fields.map((field, index) => ( 185 - <SortableItem key={field.id} value={field.id} asChild> 186 - <div className="grid grid-cols-[2rem_0.3fr_1fr_2rem] items-center gap-2"> 187 - <SortableDragHandle 188 - type="button" 189 - variant="outline" 190 - size="icon" 191 - className="size-8 shrink-0" 192 - > 193 - <DragHandleDots2Icon 194 - className="size-4" 195 - aria-hidden="true" 196 - /> 197 - </SortableDragHandle> 198 - 199 - <FormField 200 - control={form.control} 201 - name={`ingredients.${index}.amount`} 202 - render={({ field: { value, ...field } }) => ( 203 - <FormItem> 204 - <FormControl> 205 - <Input 206 - placeholder="Amount" 207 - value={value || ""} 208 - className="h-8" 209 - {...field} 210 - /> 211 - </FormControl> 212 - <FormMessage /> 213 - </FormItem> 214 - )} 215 - /> 216 - 217 - <FormField 218 - control={form.control} 219 - name={`ingredients.${index}.name`} 220 - render={({ field }) => ( 221 - <FormItem> 222 - <FormControl> 223 - <Input 224 - placeholder="Ingredient" 225 - className="h-8" 226 - {...field} 227 - /> 228 - </FormControl> 229 - <FormMessage /> 230 - </FormItem> 231 - )} 232 - /> 233 - 234 - <Button 235 - type="button" 236 - variant="destructive" 237 - className="size-8" 238 - onClick={(e) => { 239 - e.preventDefault(); 240 - ingredients.remove(index); 241 - }} 242 - > 243 - <TrashIcon /> 244 - </Button> 245 - </div> 246 - </SortableItem> 247 - ))} 248 - </div> 249 - </Sortable> 250 - <Button 251 - type="button" 252 - variant="secondary" 253 - onClick={(e) => { 254 - e.preventDefault(); 255 - ingredients.append({ 256 - name: "", 257 - amount: "", 258 - }); 259 - }} 260 - > 261 - Add 262 - </Button> 263 - </div> 264 - 265 - <div className="grid gap-2"> 266 - <Label>Steps</Label> 267 - <Sortable 268 - value={steps.fields} 269 - onMove={({ activeIndex, overIndex }) => 270 - steps.move(activeIndex, overIndex)} 271 - > 272 - <div className="flex w-full flex-col gap-2"> 273 - {steps.fields.map((field, index) => ( 274 - <SortableItem key={field.id} value={field.id} asChild> 275 - <div className="grid grid-cols-[2rem_auto_2rem] items-center gap-2"> 276 - <SortableDragHandle 277 - type="button" 278 - variant="outline" 279 - size="icon" 280 - className="size-8 shrink-0" 281 - > 282 - <DragHandleDots2Icon 283 - className="size-4" 284 - aria-hidden="true" 285 - /> 286 - </SortableDragHandle> 287 - <FormField 288 - control={form.control} 289 - name={`steps.${index}.text`} 290 - render={({ field }) => ( 291 - <FormItem> 292 - <FormControl> 293 - <Input className="h-8" {...field} /> 294 - </FormControl> 295 - <FormMessage /> 296 - </FormItem> 297 - )} 298 - /> 299 - 300 - <Button 301 - type="button" 302 - variant="destructive" 303 - className="size-8" 304 - onClick={(e) => { 305 - e.preventDefault(); 306 - steps.remove(index); 307 - }} 308 - > 309 - <TrashIcon /> 310 - </Button> 311 - </div> 312 - </SortableItem> 313 - ))} 314 - </div> 315 - </Sortable> 316 - <Button 317 - type="button" 318 - variant="secondary" 319 - onClick={(e) => { 320 - e.preventDefault(); 321 - steps.append({ text: "" }); 322 - }} 323 - > 324 - Add 325 - </Button> 326 - </div> 327 - 328 - <div className="grid justify-end"> 329 - <Button 330 - type="submit" 331 - className="ml-auto" 332 - disabled={isPending} 333 - > 334 - Submit 335 - </Button> 336 - </div> 337 - </form> 338 - </Form> 339 - </CardContent> 340 - </Card> 341 - </div> 342 - </> 343 - ); 56 + return (<></>); 57 + // const form = useForm<z.infer<typeof recipeSchema>>({ 58 + // resolver: zodResolver(recipeSchema), 59 + // defaultValues: { 60 + // title: "", 61 + // time: 0, 62 + // image: null, 63 + // description: "", 64 + // ingredients: [{ name: "" }], 65 + // steps: [{ text: "" }], 66 + // }, 67 + // }); 68 + // 69 + // const { mutate, isPending } = useNewRecipeMutation(form); 70 + // 71 + // const onSubmit = (values: z.infer<typeof recipeSchema>) => { 72 + // mutate({ recipe: values }); 73 + // }; 74 + // 75 + // const imageRef = form.register("image"); 76 + // 77 + // const ingredients = useFieldArray({ 78 + // control: form.control, 79 + // name: "ingredients", 80 + // }); 81 + // 82 + // const steps = useFieldArray({ 83 + // control: form.control, 84 + // name: "steps", 85 + // }); 86 + // 87 + // return ( 88 + // <> 89 + // <Breadcrumbs /> 90 + // <div className="flex-1 flex-col p-4 pt-0 max-w-xl w-full mx-auto"> 91 + // <Card> 92 + // <CardHeader> 93 + // <CardTitle>New recipe</CardTitle> 94 + // <CardDescription>Share your recipe with the world!</CardDescription> 95 + // </CardHeader> 96 + // <CardContent> 97 + // <Form {...form}> 98 + // <form 99 + // onSubmit={form.handleSubmit(onSubmit)} 100 + // className="space-y-8" 101 + // > 102 + // <FormField 103 + // name="title" 104 + // control={form.control} 105 + // render={({ field }) => ( 106 + // <FormItem> 107 + // <FormLabel>Title</FormLabel> 108 + // <FormControl> 109 + // <Input placeholder="My awesome recipe!" {...field} /> 110 + // </FormControl> 111 + // <FormDescription> 112 + // This is your recipe's name. 113 + // </FormDescription> 114 + // <FormMessage /> 115 + // </FormItem> 116 + // )} 117 + // /> 118 + // 119 + // <FormField 120 + // name="description" 121 + // control={form.control} 122 + // render={({ field: { value, ...field } }) => ( 123 + // <FormItem> 124 + // <FormLabel>Description</FormLabel> 125 + // <FormControl> 126 + // <Textarea 127 + // className="resize-none" 128 + // value={value || ""} 129 + // {...field} 130 + // /> 131 + // </FormControl> 132 + // <FormDescription>Describe your recipe, maybe tell the world how tasty it is? (Optional)</FormDescription> 133 + // <FormMessage /> 134 + // </FormItem> 135 + // )} 136 + // /> 137 + // 138 + // <FormField 139 + // name="image" 140 + // control={form.control} 141 + // render={(_props) => ( 142 + // <FormItem> 143 + // <FormLabel>Image</FormLabel> 144 + // <FormControl> 145 + // <Input 146 + // type="file" 147 + // className="resize-none" 148 + // {...imageRef} 149 + // /> 150 + // </FormControl> 151 + // <FormMessage /> 152 + // </FormItem> 153 + // )} 154 + // /> 155 + // 156 + // <FormField 157 + // name="time" 158 + // control={form.control} 159 + // render={({ field: { value, ...field } }) => ( 160 + // <FormItem> 161 + // <FormLabel>Time</FormLabel> 162 + // <FormControl> 163 + // <Input 164 + // type="number" 165 + // className="resize-none" 166 + // value={value || ""} 167 + // {...field} 168 + // /> 169 + // </FormControl> 170 + // <FormDescription>How long (in minutes) does your recipe take to complete?</FormDescription> 171 + // <FormMessage /> 172 + // </FormItem> 173 + // )} 174 + // /> 175 + // 176 + // <div className="grid gap-2"> 177 + // <Label>Ingredients</Label> 178 + // <Sortable 179 + // value={ingredients.fields} 180 + // onMove={({ activeIndex, overIndex }) => 181 + // ingredients.move(activeIndex, overIndex)} 182 + // > 183 + // <div className="flex w-full flex-col gap-2"> 184 + // {ingredients.fields.map((field, index) => ( 185 + // <SortableItem key={field.id} value={field.id} asChild> 186 + // <div className="grid grid-cols-[2rem_0.3fr_1fr_2rem] items-center gap-2"> 187 + // <SortableDragHandle 188 + // type="button" 189 + // variant="outline" 190 + // size="icon" 191 + // className="size-8 shrink-0" 192 + // > 193 + // <DragHandleDots2Icon 194 + // className="size-4" 195 + // aria-hidden="true" 196 + // /> 197 + // </SortableDragHandle> 198 + // 199 + // <FormField 200 + // control={form.control} 201 + // name={`ingredients.${index}.amount`} 202 + // render={({ field: { value, ...field } }) => ( 203 + // <FormItem> 204 + // <FormControl> 205 + // <Input 206 + // placeholder="Amount" 207 + // value={value || ""} 208 + // className="h-8" 209 + // {...field} 210 + // /> 211 + // </FormControl> 212 + // <FormMessage /> 213 + // </FormItem> 214 + // )} 215 + // /> 216 + // 217 + // <FormField 218 + // control={form.control} 219 + // name={`ingredients.${index}.name`} 220 + // render={({ field }) => ( 221 + // <FormItem> 222 + // <FormControl> 223 + // <Input 224 + // placeholder="Ingredient" 225 + // className="h-8" 226 + // {...field} 227 + // /> 228 + // </FormControl> 229 + // <FormMessage /> 230 + // </FormItem> 231 + // )} 232 + // /> 233 + // 234 + // <Button 235 + // type="button" 236 + // variant="destructive" 237 + // className="size-8" 238 + // onClick={(e) => { 239 + // e.preventDefault(); 240 + // ingredients.remove(index); 241 + // }} 242 + // > 243 + // <TrashIcon /> 244 + // </Button> 245 + // </div> 246 + // </SortableItem> 247 + // ))} 248 + // </div> 249 + // </Sortable> 250 + // <Button 251 + // type="button" 252 + // variant="secondary" 253 + // onClick={(e) => { 254 + // e.preventDefault(); 255 + // ingredients.append({ 256 + // name: "", 257 + // amount: "", 258 + // }); 259 + // }} 260 + // > 261 + // Add 262 + // </Button> 263 + // </div> 264 + // 265 + // <div className="grid gap-2"> 266 + // <Label>Steps</Label> 267 + // <Sortable 268 + // value={steps.fields} 269 + // onMove={({ activeIndex, overIndex }) => 270 + // steps.move(activeIndex, overIndex)} 271 + // > 272 + // <div className="flex w-full flex-col gap-2"> 273 + // {steps.fields.map((field, index) => ( 274 + // <SortableItem key={field.id} value={field.id} asChild> 275 + // <div className="grid grid-cols-[2rem_auto_2rem] items-center gap-2"> 276 + // <SortableDragHandle 277 + // type="button" 278 + // variant="outline" 279 + // size="icon" 280 + // className="size-8 shrink-0" 281 + // > 282 + // <DragHandleDots2Icon 283 + // className="size-4" 284 + // aria-hidden="true" 285 + // /> 286 + // </SortableDragHandle> 287 + // <FormField 288 + // control={form.control} 289 + // name={`steps.${index}.text`} 290 + // render={({ field }) => ( 291 + // <FormItem> 292 + // <FormControl> 293 + // <Input className="h-8" {...field} /> 294 + // </FormControl> 295 + // <FormMessage /> 296 + // </FormItem> 297 + // )} 298 + // /> 299 + // 300 + // <Button 301 + // type="button" 302 + // variant="destructive" 303 + // className="size-8" 304 + // onClick={(e) => { 305 + // e.preventDefault(); 306 + // steps.remove(index); 307 + // }} 308 + // > 309 + // <TrashIcon /> 310 + // </Button> 311 + // </div> 312 + // </SortableItem> 313 + // ))} 314 + // </div> 315 + // </Sortable> 316 + // <Button 317 + // type="button" 318 + // variant="secondary" 319 + // onClick={(e) => { 320 + // e.preventDefault(); 321 + // steps.append({ text: "" }); 322 + // }} 323 + // > 324 + // Add 325 + // </Button> 326 + // </div> 327 + // 328 + // <div className="grid justify-end"> 329 + // <Button 330 + // type="submit" 331 + // className="ml-auto" 332 + // disabled={isPending} 333 + // > 334 + // Submit 335 + // </Button> 336 + // </div> 337 + // </form> 338 + // </Form> 339 + // </CardContent> 340 + // </Card> 341 + // </div> 342 + // </> 343 + // ); 344 344 } 345 345 346 346 const Breadcrumbs = () => (
+6 -26
apps/web/src/routes/_.(auth)/login.tsx
··· 18 18 import { Separator } from '@/components/ui/separator' 19 19 import { SidebarTrigger } from '@/components/ui/sidebar' 20 20 import { sleep } from '@/lib/utils' 21 - import { 22 - createAuthorizationUrl, 23 - resolveFromIdentity, 24 - } from '@atcute/oauth-browser-client' 21 + import { useSession } from '@/state/auth/session' 22 + import { isHandle } from '@atcute/lexicons/syntax' 23 + import { createAuthorizationUrl } from '@atcute/oauth-browser-client' 25 24 import { useMutation } from '@tanstack/react-query' 26 25 import { createFileRoute } from '@tanstack/react-router' 27 26 import { useState } from 'react' ··· 31 30 }) 32 31 33 32 function RouteComponent() { 33 + const { signIn } = useSession(); 34 34 const [handle, setHandle] = useState('') 35 35 36 36 const { mutate, isPending, error } = useMutation({ 37 37 mutationKey: ['login'], 38 38 mutationFn: async () => { 39 - const { identity, metadata } = await resolveFromIdentity(handle) 40 - 41 - const authUrl = await createAuthorizationUrl({ 42 - metadata: metadata, 43 - identity: identity, 44 - scope: 'atproto transition:generic', 45 - }) 46 - 47 - await sleep(200) 48 - 49 - return authUrl 50 - }, 51 - onSuccess: async (authUrl: URL) => { 52 - window.location.assign(authUrl) 53 - 54 - await new Promise((_resolve, reject) => { 55 - const listener = () => { 56 - reject(new Error(`user aborted the login request`)) 57 - } 58 - 59 - window.addEventListener('pageshow', listener, { once: true }) 60 - }) 39 + await signIn(handle); 40 + return; 61 41 }, 62 42 }) 63 43
+9 -4
apps/web/src/state/auth.tsx
··· 1 1 import { Did } from "@atcute/lexicons"; 2 - import { finalizeAuthorization, getSession, OAuthUserAgent } from "@atcute/oauth-browser-client"; 2 + import { finalizeAuthorization, getSession, OAuthUserAgent, Session } from "@atcute/oauth-browser-client"; 3 3 import { createContext, PropsWithChildren, useContext, useEffect, useState } from "react"; 4 4 5 5 export type AuthContextType = { 6 6 isLoggedIn: boolean; 7 + hasProfile: boolean; 7 8 agent?: OAuthUserAgent; 8 9 logOut: () => Promise<void>; 9 10 }; 10 11 11 12 const AuthContext = createContext<AuthContextType>({ 12 13 isLoggedIn: false, 14 + hasProfile: false, 13 15 logOut: async () => {}, 14 16 }); 15 17 16 18 export const AuthProvider = ({ children }: PropsWithChildren) => { 17 19 const [isReady, setIsReady] = useState(false); 18 20 const [isLoggedIn, setIsLoggedIn] = useState(false); 21 + const [hasProfile, setHasProfile] = useState(false); 19 22 const [agent, setAgent] = useState<OAuthUserAgent | undefined>(undefined); 20 23 21 24 useEffect(() => { 22 25 const init = async () => { 23 26 const params = new URLSearchParams(location.hash.slice(1)); 27 + let session: Session | null = null; 24 28 25 29 if (params.has("state") && (params.has("code") || params.has("error"))) { 26 30 history.replaceState(null, "", location.pathname + location.search); ··· 29 33 const did = session.info.sub; 30 34 31 35 localStorage.setItem("lastSignedIn", did); 32 - return session; 33 - 34 36 } else { 35 37 const lastSignedIn = localStorage.getItem("lastSignedIn"); 36 38 ··· 43 45 } 44 46 } 45 47 } 48 + 49 + setAgent(new OAuthUserAgent(session!)); 46 50 }; 47 51 48 52 init() ··· 62 66 return ( 63 67 <AuthContext.Provider value={{ 64 68 isLoggedIn, 69 + hasProfile, 65 70 agent, 66 71 logOut: async () => { 72 + await agent?.signOut(); 67 73 setIsLoggedIn(false); 68 - await agent?.signOut(); 69 74 }, 70 75 }}> 71 76 {children}
apps/web/src/state/auth/client.tsx

This is a binary file and will not be displayed.

+2
apps/web/src/state/auth/index.ts
··· 1 + export * from './client'; 2 + export * from './session';
+104
apps/web/src/state/auth/session.tsx
··· 1 + import { Did, isActorIdentifier, isHandle } from "@atcute/lexicons/syntax"; 2 + import { createAuthorizationUrl, deleteStoredSession, finalizeAuthorization, getSession, OAuthUserAgent, Session } from "@atcute/oauth-browser-client"; 3 + import { SessionState } from "node:http2"; 4 + import { createContext, PropsWithChildren, useContext, useEffect, useState } from "react"; 5 + 6 + export type SessionContext = { 7 + session: null | Session; 8 + isLoading: boolean; 9 + isLoggedIn: boolean; 10 + signIn: (handle: string) => Promise<void>; 11 + signOut: () => Promise<void>; 12 + }; 13 + 14 + const sessionContext = createContext<SessionContext>({ 15 + session: null, 16 + isLoading: false, 17 + isLoggedIn: false, 18 + signIn: async () => { 19 + throw new Error("AuthContext not initialized"); 20 + }, 21 + signOut: async () => { 22 + throw new Error("AuthContext not initialized"); 23 + }, 24 + }); 25 + 26 + export const SessionProvider = ({ children }: PropsWithChildren<{}>) => { 27 + const [initialized, setInitialized] = useState(false); 28 + const [loading, setLoading] = useState(true); 29 + const [session, setSession] = useState<null | Session>(null); 30 + const [agent, setAgent] = useState<null | OAuthUserAgent>(null); 31 + 32 + useEffect(() => { 33 + setInitialized(false); 34 + setSession(null); 35 + setAgent(null); 36 + 37 + const params = new URLSearchParams(location.hash.slice(1)); 38 + if (params.has("state") && params.has("iss") && params.has("code")) { 39 + // If there is an active auth attempt: 40 + history.replaceState(null, "", location.pathname + location.search); 41 + finalizeAuthorization(params) 42 + .then(val => { 43 + setSession(val.session); 44 + setAgent(new OAuthUserAgent(val.session)); 45 + }) 46 + .catch(err => { 47 + console.error("Failed to initialize session:", err); 48 + }) 49 + .finally(() => { 50 + setLoading(false); 51 + setInitialized(true); 52 + }); 53 + } else { 54 + setLoading(false); 55 + setInitialized(true); 56 + } 57 + }, []); 58 + 59 + const signIn = async (handle: string) => { 60 + if (!isActorIdentifier(handle)) throw new Error("Invalid handle or DID!"); 61 + const authUrl = await createAuthorizationUrl({ 62 + target: { type: 'account', identifier: handle }, 63 + scope: 'atproto transition:generic', 64 + }); 65 + window.location.assign(authUrl); 66 + }; 67 + 68 + const signOut = async () => { 69 + if (!agent || !session) return; 70 + 71 + const did = session.info.sub; 72 + try { 73 + const session = await getSession(did, { allowStale: true }); 74 + const agent = new OAuthUserAgent(session); 75 + 76 + await agent.signOut(); 77 + setSession(null); 78 + } catch(err) { 79 + deleteStoredSession(did); 80 + } 81 + }; 82 + 83 + if (!initialized) return ( 84 + <p>Loading...</p> 85 + ); 86 + 87 + return ( 88 + <sessionContext.Provider value={{ 89 + isLoading: loading, 90 + isLoggedIn: session !== null, 91 + session, 92 + signIn, 93 + signOut, 94 + }}> 95 + {children} 96 + </sessionContext.Provider> 97 + ); 98 + }; 99 + 100 + export const useSession = () => { 101 + const ctx = useContext(sessionContext); 102 + if (!ctx) throw new Error("useSession() must be called inside a <SessionProvider />!"); 103 + return ctx; 104 + };
-2
apps/web/src/vite-env.d.ts
··· 1 1 /// <reference types="vite/client" /> 2 - /// <reference types="@cookware/lexicons" /> 3 - /// <reference types="@atcute/bluesky/lexicons" /> 4 2 5 3 interface ImportMetaEnv { 6 4 readonly VITE_API_SERVICE: string;
+24 -26
apps/web/vite.config.ts
··· 2 2 import react from '@vitejs/plugin-react-swc' 3 3 import { tanstackRouter } from '@tanstack/router-plugin/vite' 4 4 import path from 'path' 5 - import metadata from "./public/client-metadata.json"; 5 + import metadata from "./public/oauth-client-metadata.json" with { type: 'json' }; 6 6 7 7 const SERVER_HOST = "127.0.0.1"; 8 8 const SERVER_PORT = 5173; ··· 12 12 plugins: [ 13 13 tanstackRouter(), 14 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 `https://local.recipes.blue${url.pathname}`; 26 - })(); 15 + { 16 + name: '_config', 17 + config(_conf, { command }) { 18 + if (command === 'build') { 19 + process.env.VITE_OAUTH_CLIENT_ID = metadata.client_id; 20 + process.env.VITE_OAUTH_REDIRECT_URI = metadata.redirect_uris[0]; 21 + } else { 22 + const redirectUri = (() => { 23 + const url = new URL(metadata.redirect_uris[0]); 24 + return `http://${SERVER_HOST}:${SERVER_PORT}${url.pathname}`; 25 + })(); 27 26 28 - const clientId = 29 - `https://local.recipes.blue` + 30 - `?redirect_uri=${encodeURIComponent(redirectUri)}` + 31 - `&scope=${encodeURIComponent(metadata.scope)}`; 27 + const clientId = 28 + `http://localhost` + 29 + `?redirect_uri=${encodeURIComponent(redirectUri)}` + 30 + `&scope=${encodeURIComponent(metadata.scope)}`; 32 31 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 - } 32 + process.env.VITE_DEV_SERVER_PORT = '' + SERVER_PORT; 33 + process.env.VITE_OAUTH_CLIENT_ID = clientId; 34 + process.env.VITE_OAUTH_REDIRECT_URI = redirectUri; 35 + } 37 36 38 - process.env.VITE_CLIENT_URI = metadata.client_uri; 39 - process.env.VITE_OAUTH_SCOPE = metadata.scope; 40 - }, 41 - }, 37 + process.env.VITE_CLIENT_URI = metadata.client_uri; 38 + process.env.VITE_OAUTH_SCOPE = metadata.scope; 39 + }, 40 + }, 42 41 ], 43 42 server: { 44 - allowedHosts: ["local.recipes.blue"], 45 43 host: SERVER_HOST, 46 44 port: SERVER_PORT, 47 45 },
+3 -2
bun.lock
··· 61 61 "dependencies": { 62 62 "@atcute/atproto": "^3.1.9", 63 63 "@atcute/client": "catalog:", 64 + "@atcute/identity-resolver": "^1.1.4", 64 65 "@atcute/lexicons": "catalog:", 65 - "@atcute/oauth-browser-client": "^1.0.7", 66 + "@atcute/oauth-browser-client": "^2.0.1", 66 67 "@atproto/common": "^0.4.5", 67 68 "@atproto/common-web": "^0.3.1", 68 69 "@dnd-kit/core": "^6.3.1", ··· 198 199 199 200 "@atcute/multibase": ["@atcute/multibase@1.1.6", "", { "dependencies": { "@atcute/uint8array": "^1.0.5" } }, "sha512-HBxuCgYLKPPxETV0Rot4VP9e24vKl8JdzGCZOVsDaOXJgbRZoRIF67Lp0H/OgnJeH/Xpva8Z5ReoTNJE5dn3kg=="], 200 201 201 - "@atcute/oauth-browser-client": ["@atcute/oauth-browser-client@1.0.27", "", { "dependencies": { "@atcute/client": "^4.0.4", "@atcute/identity": "^1.1.1", "@atcute/lexicons": "^1.2.2", "@atcute/multibase": "^1.1.6", "@atcute/uint8array": "^1.0.5", "nanoid": "^5.1.5" } }, "sha512-Ng1tCOTMLgFHHoIHXTtCZR1/ND62an1qxPX2kBoUzkxxd7iCP7IBYYqOiKyJYT5n1R4zS+s29hFS4t9mxXa5kQ=="], 202 + "@atcute/oauth-browser-client": ["@atcute/oauth-browser-client@2.0.1", "", { "dependencies": { "@atcute/client": "^4.0.5", "@atcute/identity": "^1.1.1", "@atcute/identity-resolver": "^1.1.4", "@atcute/lexicons": "^1.2.2", "@atcute/multibase": "^1.1.6", "@atcute/uint8array": "^1.0.5", "nanoid": "^5.1.5" } }, "sha512-lG021GkeORG06zfFf4bH85egObjBEKHNgAWHvbtY/E2dX4wxo88hf370pJDx8acdnuUJLJ2VKPikJtZwo4Heeg=="], 202 203 203 204 "@atcute/uint8array": ["@atcute/uint8array@1.0.5", "", {}, "sha512-XLWWxoR2HNl2qU+FCr0rp1APwJXci7HnzbOQLxK55OaMNBXZ19+xNC5ii4QCsThsDxa4JS/JTzuiQLziITWf2Q=="], 204 205