Barazo default frontend barazo.forum
2
fork

Configure Feed

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

Merge pull request #49 from barazo-forum/feat/oauth-scope-refinement

feat(auth): add cross-post scope authorization UI

authored by

Guido X Jansen and committed by
GitHub
d3fdbe0d c9ca21e2

+276 -48
+3 -1
src/app/settings/page.test.tsx
··· 27 27 }, 28 28 isAuthenticated: true, 29 29 isLoading: false, 30 + crossPostScopesGranted: true, 30 31 getAccessToken: () => 'mock-access-token', 31 32 login: vi.fn(), 32 33 logout: vi.fn(), 33 34 setSessionFromCallback: vi.fn(), 35 + requestCrossPostAuth: vi.fn(), 34 36 authFetch: vi.fn(), 35 37 }), 36 38 })) ··· 90 92 it('renders cross-posting section', async () => { 91 93 render(<SettingsPage />) 92 94 await waitFor(() => { 93 - expect(screen.getByText(/cross-posting/i)).toBeInTheDocument() 95 + expect(screen.getByText('Cross-Posting')).toBeInTheDocument() 94 96 }) 95 97 expect(screen.getByLabelText(/bluesky/i)).toBeInTheDocument() 96 98 expect(screen.getByLabelText(/frontpage/i)).toBeInTheDocument()
+60 -25
src/app/settings/page.tsx
··· 14 14 import { ForumLayout } from '@/components/layout/forum-layout' 15 15 import { Breadcrumbs } from '@/components/breadcrumbs' 16 16 import { AgeGateDialog } from '@/components/age-gate-dialog' 17 + import { CrossPostAuthDialog } from '@/components/crosspost-auth-dialog' 17 18 import { CommunityProfileSettings } from '@/components/community-profile-settings' 18 19 import { cn } from '@/lib/utils' 19 20 import { ··· 46 47 } 47 48 48 49 export default function SettingsPage() { 49 - const { getAccessToken } = useAuth() 50 + const { getAccessToken, crossPostScopesGranted, requestCrossPostAuth } = useAuth() 51 + const [showCrossPostAuthDialog, setShowCrossPostAuthDialog] = useState(false) 50 52 const [values, setValues] = useState<SettingsValues>({ 51 53 maturityLevel: 'sfw', 52 54 mutedWords: '', ··· 383 385 {/* Cross-Posting */} 384 386 <fieldset className="space-y-4 rounded-lg border border-border p-4"> 385 387 <legend className="px-2 text-sm font-semibold text-foreground">Cross-Posting</legend> 386 - <div className="space-y-3"> 387 - <label className="flex items-center gap-2"> 388 - <input 389 - type="checkbox" 390 - checked={values.crossPostBluesky} 391 - onChange={(e) => setValues({ ...values, crossPostBluesky: e.target.checked })} 392 - className="h-4 w-4 rounded border-border text-primary focus-visible:ring-2 focus-visible:ring-ring" 393 - /> 394 - <span className="text-sm text-foreground"> 395 - Share new topics on Bluesky by default 396 - </span> 397 - </label> 398 - <label className="flex items-center gap-2"> 399 - <input 400 - type="checkbox" 401 - checked={values.crossPostFrontpage} 402 - onChange={(e) => setValues({ ...values, crossPostFrontpage: e.target.checked })} 403 - className="h-4 w-4 rounded border-border text-primary focus-visible:ring-2 focus-visible:ring-ring" 404 - /> 405 - <span className="text-sm text-foreground"> 406 - Share new topics on Frontpage by default 407 - </span> 408 - </label> 409 - </div> 388 + {crossPostScopesGranted ? ( 389 + <div className="space-y-3"> 390 + <p className="text-xs text-muted-foreground"> 391 + Cross-posting authorized. You can share topics on Bluesky and Frontpage. 392 + </p> 393 + <label className="flex items-center gap-2"> 394 + <input 395 + type="checkbox" 396 + checked={values.crossPostBluesky} 397 + onChange={(e) => setValues({ ...values, crossPostBluesky: e.target.checked })} 398 + className="h-4 w-4 rounded border-border text-primary focus-visible:ring-2 focus-visible:ring-ring" 399 + /> 400 + <span className="text-sm text-foreground"> 401 + Share new topics on Bluesky by default 402 + </span> 403 + </label> 404 + <label className="flex items-center gap-2"> 405 + <input 406 + type="checkbox" 407 + checked={values.crossPostFrontpage} 408 + onChange={(e) => 409 + setValues({ ...values, crossPostFrontpage: e.target.checked }) 410 + } 411 + className="h-4 w-4 rounded border-border text-primary focus-visible:ring-2 focus-visible:ring-ring" 412 + /> 413 + <span className="text-sm text-foreground"> 414 + Share new topics on Frontpage by default 415 + </span> 416 + </label> 417 + </div> 418 + ) : ( 419 + <div className="space-y-3"> 420 + <p className="text-sm text-muted-foreground"> 421 + To share topics on Bluesky and Frontpage, Barazo needs permission to create 422 + posts on your behalf. 423 + </p> 424 + <button 425 + type="button" 426 + onClick={() => setShowCrossPostAuthDialog(true)} 427 + className={cn( 428 + 'rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors', 429 + 'hover:bg-primary-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2' 430 + )} 431 + > 432 + Authorize cross-posting 433 + </button> 434 + </div> 435 + )} 410 436 </fieldset> 411 437 412 438 {/* Notifications */} ··· 477 503 </form> 478 504 )} 479 505 </div> 506 + 507 + <CrossPostAuthDialog 508 + open={showCrossPostAuthDialog} 509 + onAuthorize={() => { 510 + setShowCrossPostAuthDialog(false) 511 + void requestCrossPostAuth() 512 + }} 513 + onCancel={() => setShowCrossPostAuthDialog(false)} 514 + /> 480 515 481 516 <AgeGateDialog 482 517 open={showAgeGate}
+109
src/components/crosspost-auth-dialog.tsx
··· 1 + 'use client' 2 + 3 + import { useEffect, useRef } from 'react' 4 + import { cn } from '@/lib/utils' 5 + 6 + interface CrossPostAuthDialogProps { 7 + open: boolean 8 + onAuthorize: () => void 9 + onCancel: () => void 10 + } 11 + 12 + export function CrossPostAuthDialog({ open, onAuthorize, onCancel }: CrossPostAuthDialogProps) { 13 + const dialogRef = useRef<HTMLDialogElement>(null) 14 + const cancelRef = useRef<HTMLButtonElement>(null) 15 + 16 + useEffect(() => { 17 + const dialog = dialogRef.current 18 + if (!dialog) return 19 + 20 + if (open) { 21 + dialog.showModal() 22 + cancelRef.current?.focus() 23 + } else { 24 + dialog.close() 25 + } 26 + }, [open]) 27 + 28 + if (!open) return null 29 + 30 + return ( 31 + // Native <dialog> handles Escape key via onClose; no extra keyboard handler needed. 32 + <dialog 33 + ref={dialogRef} 34 + onClose={onCancel} 35 + aria-labelledby="crosspost-auth-title" 36 + aria-describedby="crosspost-auth-description" 37 + className={cn( 38 + 'w-full max-w-md rounded-lg border border-border bg-background p-0 shadow-lg', 39 + 'backdrop:bg-black/50' 40 + )} 41 + > 42 + <div className="space-y-4 p-6"> 43 + <h2 id="crosspost-auth-title" className="text-lg font-semibold text-foreground"> 44 + Cross-posting permissions 45 + </h2> 46 + 47 + <p id="crosspost-auth-description" className="text-sm text-muted-foreground"> 48 + To share topics on Bluesky and Frontpage, Barazo needs permission to create posts on your 49 + behalf. You will be redirected to your AT Protocol identity provider to approve these 50 + permissions. 51 + </p> 52 + 53 + <ul className="space-y-1 text-sm text-foreground" aria-label="Requested permissions"> 54 + <li className="flex items-start gap-2"> 55 + <span className="mt-0.5 text-muted-foreground" aria-hidden="true"> 56 + - 57 + </span> 58 + <span> 59 + Create posts on Bluesky (<code className="text-xs">app.bsky.feed.post</code>) 60 + </span> 61 + </li> 62 + <li className="flex items-start gap-2"> 63 + <span className="mt-0.5 text-muted-foreground" aria-hidden="true"> 64 + - 65 + </span> 66 + <span> 67 + Create posts on Frontpage (<code className="text-xs">fyi.frontpage.post</code>) 68 + </span> 69 + </li> 70 + <li className="flex items-start gap-2"> 71 + <span className="mt-0.5 text-muted-foreground" aria-hidden="true"> 72 + - 73 + </span> 74 + <span>Upload images for post thumbnails</span> 75 + </li> 76 + </ul> 77 + 78 + <p className="text-xs text-muted-foreground"> 79 + Barazo will only use these permissions when you explicitly choose to cross-post a topic. 80 + You can revoke access at any time from your identity provider. 81 + </p> 82 + 83 + <div className="flex justify-end gap-3 pt-2"> 84 + <button 85 + ref={cancelRef} 86 + type="button" 87 + onClick={onCancel} 88 + className={cn( 89 + 'rounded-md border border-border px-4 py-2 text-sm font-medium text-foreground transition-colors', 90 + 'hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2' 91 + )} 92 + > 93 + Cancel 94 + </button> 95 + <button 96 + type="button" 97 + onClick={onAuthorize} 98 + className={cn( 99 + 'rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors', 100 + 'hover:bg-primary-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2' 101 + )} 102 + > 103 + Authorize 104 + </button> 105 + </div> 106 + </div> 107 + </dialog> 108 + ) 109 + }
+7
src/components/topic-form.test.tsx
··· 10 10 import { handlers } from '@/mocks/handlers' 11 11 import { TopicForm } from './topic-form' 12 12 13 + vi.mock('@/hooks/use-auth', () => ({ 14 + useAuth: () => ({ 15 + crossPostScopesGranted: true, 16 + requestCrossPostAuth: vi.fn(), 17 + }), 18 + })) 19 + 13 20 const server = setupServer(...handlers) 14 21 15 22 beforeAll(() => server.listen({ onUnhandledRequest: 'error' }))
+52 -20
src/components/topic-form.tsx
··· 12 12 import { cn } from '@/lib/utils' 13 13 import { MarkdownEditor } from './markdown-editor' 14 14 import { MarkdownPreview } from './markdown-preview' 15 + import { CrossPostAuthDialog } from './crosspost-auth-dialog' 16 + import { useAuth } from '@/hooks/use-auth' 15 17 16 18 interface TopicFormValues { 17 19 title: string ··· 88 90 ) 89 91 const [errors, setErrors] = useState<FormErrors>({}) 90 92 const [activeTab, setActiveTab] = useState<'write' | 'preview'>('write') 93 + const [showCrossPostAuthDialog, setShowCrossPostAuthDialog] = useState(false) 94 + const { crossPostScopesGranted, requestCrossPostAuth } = useAuth() 91 95 92 96 const handleSubmit = useCallback( 93 97 (e: React.FormEvent) => { ··· 272 276 {mode === 'create' && ( 273 277 <fieldset className="space-y-3"> 274 278 <legend className="text-sm font-medium text-foreground">Cross-post</legend> 275 - <div className="flex flex-col gap-2"> 276 - <label className="flex items-center gap-2"> 277 - <input 278 - type="checkbox" 279 - checked={crossPostBluesky} 280 - onChange={(e) => setCrossPostBluesky(e.target.checked)} 281 - className="h-4 w-4 rounded border-border text-primary focus-visible:ring-2 focus-visible:ring-ring" 282 - /> 283 - <span className="text-sm text-foreground">Share on Bluesky</span> 284 - </label> 285 - <label className="flex items-center gap-2"> 286 - <input 287 - type="checkbox" 288 - checked={crossPostFrontpage} 289 - onChange={(e) => setCrossPostFrontpage(e.target.checked)} 290 - className="h-4 w-4 rounded border-border text-primary focus-visible:ring-2 focus-visible:ring-ring" 291 - /> 292 - <span className="text-sm text-foreground">Share on Frontpage</span> 293 - </label> 294 - </div> 279 + {crossPostScopesGranted ? ( 280 + <div className="flex flex-col gap-2"> 281 + <label className="flex items-center gap-2"> 282 + <input 283 + type="checkbox" 284 + checked={crossPostBluesky} 285 + onChange={(e) => setCrossPostBluesky(e.target.checked)} 286 + className="h-4 w-4 rounded border-border text-primary focus-visible:ring-2 focus-visible:ring-ring" 287 + /> 288 + <span className="text-sm text-foreground">Share on Bluesky</span> 289 + </label> 290 + <label className="flex items-center gap-2"> 291 + <input 292 + type="checkbox" 293 + checked={crossPostFrontpage} 294 + onChange={(e) => setCrossPostFrontpage(e.target.checked)} 295 + className="h-4 w-4 rounded border-border text-primary focus-visible:ring-2 focus-visible:ring-ring" 296 + /> 297 + <span className="text-sm text-foreground">Share on Frontpage</span> 298 + </label> 299 + </div> 300 + ) : ( 301 + <div className="space-y-2"> 302 + <p className="text-sm text-muted-foreground"> 303 + Cross-posting requires additional permissions. 304 + </p> 305 + <button 306 + type="button" 307 + onClick={() => setShowCrossPostAuthDialog(true)} 308 + className={cn( 309 + 'text-sm font-medium text-primary transition-colors', 310 + 'hover:text-primary-hover underline underline-offset-4', 311 + 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring' 312 + )} 313 + > 314 + Authorize cross-posting 315 + </button> 316 + </div> 317 + )} 295 318 </fieldset> 296 319 )} 320 + 321 + <CrossPostAuthDialog 322 + open={showCrossPostAuthDialog} 323 + onAuthorize={() => { 324 + setShowCrossPostAuthDialog(false) 325 + void requestCrossPostAuth() 326 + }} 327 + onCancel={() => setShowCrossPostAuthDialog(false)} 328 + /> 297 329 298 330 {/* Submit */} 299 331 <div className="flex justify-end">
+34 -2
src/context/auth-context.tsx
··· 10 10 import { createContext, useCallback, useEffect, useMemo, useRef, useState } from 'react' 11 11 import type { ReactNode } from 'react' 12 12 import type { AuthSession, AuthUser } from '@/lib/api/types' 13 - import { initiateLogin, refreshSession, logout as apiLogout } from '@/lib/api/client' 13 + import { 14 + initiateLogin, 15 + initiateCrossPostAuth, 16 + refreshSession, 17 + logout as apiLogout, 18 + } from '@/lib/api/client' 14 19 import { createAuthFetch } from '@/lib/api/auth-fetch' 15 20 16 21 export interface AuthContextValue { ··· 20 25 isAuthenticated: boolean 21 26 /** Whether auth state is still loading (initial refresh) */ 22 27 isLoading: boolean 28 + /** Whether the user has authorized cross-post scopes */ 29 + crossPostScopesGranted: boolean 23 30 /** Get the current access token (stable function ref) */ 24 31 getAccessToken: () => string | null 25 32 /** Initiate login flow -- redirects to PDS OAuth */ ··· 28 35 logout: () => Promise<void> 29 36 /** Set session from OAuth callback (stores token in memory) */ 30 37 setSessionFromCallback: (session: AuthSession) => void 38 + /** Initiate cross-post authorization flow (redirects to PDS OAuth with expanded scopes) */ 39 + requestCrossPostAuth: () => Promise<void> 31 40 /** Auth-aware fetch that auto-refreshes on 401 */ 32 41 authFetch: <T>( 33 42 path: string, ··· 49 58 export function AuthProvider({ children }: AuthProviderProps) { 50 59 const [user, setUser] = useState<AuthUser | null>(null) 51 60 const [isLoading, setIsLoading] = useState(true) 61 + const [crossPostScopesGranted, setCrossPostScopesGranted] = useState(false) 52 62 const tokenRef = useRef<string | null>(null) 53 63 54 64 const getAccessToken = useCallback(() => tokenRef.current, []) ··· 61 71 displayName: session.displayName, 62 72 avatarUrl: session.avatarUrl, 63 73 }) 74 + setCrossPostScopesGranted(session.crossPostScopesGranted ?? false) 64 75 }, []) 65 76 66 77 const clearSession = useCallback(() => { 67 78 tokenRef.current = null 68 79 setUser(null) 80 + setCrossPostScopesGranted(false) 69 81 }, []) 70 82 71 83 const handleAuthFailure = useCallback(() => { ··· 124 136 clearSession() 125 137 }, [clearSession]) 126 138 139 + const requestCrossPostAuth = useCallback(async () => { 140 + const token = tokenRef.current 141 + if (!token) return 142 + sessionStorage.setItem('auth_returnTo', window.location.href) 143 + const { url } = await initiateCrossPostAuth(token) 144 + window.location.href = url 145 + }, []) 146 + 127 147 const value = useMemo<AuthContextValue>( 128 148 () => ({ 129 149 user, 130 150 isAuthenticated: user !== null, 131 151 isLoading, 152 + crossPostScopesGranted, 132 153 getAccessToken, 133 154 login, 134 155 logout, 135 156 setSessionFromCallback: setSession, 157 + requestCrossPostAuth, 136 158 authFetch, 137 159 }), 138 - [user, isLoading, getAccessToken, login, logout, setSession, authFetch] 160 + [ 161 + user, 162 + isLoading, 163 + crossPostScopesGranted, 164 + getAccessToken, 165 + login, 166 + logout, 167 + setSession, 168 + requestCrossPostAuth, 169 + authFetch, 170 + ] 139 171 ) 140 172 141 173 return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>
+6
src/lib/api/client.ts
··· 106 106 return apiFetch<{ url: string }>(`/api/auth/login${query}`) 107 107 } 108 108 109 + export function initiateCrossPostAuth(token: string): Promise<{ url: string }> { 110 + return apiFetch<{ url: string }>('/api/auth/crosspost-authorize', { 111 + headers: { Authorization: `Bearer ${token}` }, 112 + }) 113 + } 114 + 109 115 export function handleCallback(code: string, state: string): Promise<AuthSession> { 110 116 const query = buildQuery({ code, state }) 111 117 return apiFetch<AuthSession>(`/api/auth/callback${query}`)
+1
src/lib/api/types.ts
··· 210 210 handle: string 211 211 displayName: string | null 212 212 avatarUrl: string | null 213 + crossPostScopesGranted?: boolean 213 214 } 214 215 215 216 export interface AuthUser {
+4
src/test/mock-auth.tsx
··· 20 20 user: mockUser, 21 21 isAuthenticated: true, 22 22 isLoading: false, 23 + crossPostScopesGranted: false, 23 24 getAccessToken: vi.fn(() => 'mock-access-token'), 24 25 login: vi.fn(), 25 26 logout: vi.fn(), 26 27 setSessionFromCallback: vi.fn(), 28 + requestCrossPostAuth: vi.fn(), 27 29 authFetch: vi.fn(), 28 30 ...overrides, 29 31 } ··· 36 38 user: null, 37 39 isAuthenticated: false, 38 40 isLoading: false, 41 + crossPostScopesGranted: false, 39 42 getAccessToken: vi.fn(() => null), 40 43 login: vi.fn(), 41 44 logout: vi.fn(), 42 45 setSessionFromCallback: vi.fn(), 46 + requestCrossPostAuth: vi.fn(), 43 47 authFetch: vi.fn(), 44 48 ...overrides, 45 49 }