allow read for any valid did

daviddao 1de50e68 90cf6220

Changed files
+88 -42
app
api
profile
project
[id]
components
lib
+32 -15
app/api/profile/route.ts
··· 3 3 import { getGlobalOAuthClient } from '@/lib/auth/client' 4 4 import { Agent } from '@atproto/api' 5 5 6 - export async function GET() { 6 + export async function GET(request: Request) { 7 7 try { 8 + const { searchParams } = new URL(request.url) 9 + const did = searchParams.get('did') 10 + 8 11 const session = await getSession() 9 12 10 - if (!session.did) { 11 - return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }) 13 + // If a specific DID is requested, allow unauthenticated access for read-only 14 + const targetDid = did || session.did 15 + 16 + if (!targetDid) { 17 + return NextResponse.json({ error: 'DID parameter required or authentication needed' }, { status: 400 }) 12 18 } 13 19 14 20 const client = await getGlobalOAuthClient() 15 21 16 - // Restore the OAuth session 17 - const oauthSession = await client.restore(session.did) 22 + // Try to use authenticated session if available and valid 23 + let agent: Agent 24 + let usingAuthenticatedAccess = false 18 25 19 - if (!oauthSession) { 20 - // Clear invalid session 21 - await clearSession() 22 - return NextResponse.json({ error: 'Session expired' }, { status: 401 }) 26 + if (session.did && !did) { 27 + // Only use OAuth for own profile access 28 + const oauthSession = await client.restore(session.did) 29 + 30 + if (oauthSession) { 31 + agent = new Agent(oauthSession) 32 + usingAuthenticatedAccess = true 33 + } else { 34 + // Clear invalid session 35 + await clearSession() 36 + return NextResponse.json({ error: 'Session expired' }, { status: 401 }) 37 + } 38 + } else { 39 + // For public access or specific DID lookup, use public API 40 + agent = new Agent({ service: 'https://public.api.bsky.app' }) 41 + // Note: No authentication needed for public profile access via public API 23 42 } 24 - 25 - // Create agent and fetch profile using the getProfile method 26 - const agent = new Agent(oauthSession) 27 43 28 - // Use the simpler getProfile method like green_globe app 29 - const profileResponse = await agent.getProfile({ actor: session.did }) 44 + // Use the simpler getProfile method 45 + const profileResponse = await agent.getProfile({ actor: targetDid }) 30 46 31 47 if (!profileResponse.success) { 32 48 return NextResponse.json({ error: 'Profile not found' }, { status: 404 }) ··· 35 51 const profile = profileResponse.data 36 52 37 53 return NextResponse.json({ 38 - did: session.did, 54 + did: targetDid, 39 55 handle: profile.handle, 40 56 displayName: profile.displayName, 41 57 avatar: profile.avatar, ··· 43 59 followersCount: profile.followersCount, 44 60 followsCount: profile.followsCount, 45 61 postsCount: profile.postsCount, 62 + isOwner: usingAuthenticatedAccess && session.did === targetDid, // Indicate if the authenticated user owns this profile 46 63 }) 47 64 } catch (error) { 48 65 console.error('Profile fetch failed:', error)
+53 -16
app/project/[id]/page.tsx
··· 3 3 import { useParams } from 'next/navigation' 4 4 import { useEffect, useState } from 'react' 5 5 import Link from 'next/link' 6 - import { ArrowLeft, ExternalLink, Users, DollarSign, TrendingUp, MapPin, Plus, Edit2, Save, X } from 'lucide-react' 6 + import { ArrowLeft, ExternalLink, Users, DollarSign, TrendingUp, MapPin, Plus, Edit2, Save, X, Copy, Check } from 'lucide-react' 7 7 import { projects, formatCurrency } from '@/lib/data' 8 8 import FundingChart from '@/components/FundingChart' 9 9 ··· 11 11 const params = useParams() 12 12 const id = decodeURIComponent(params.id as string) 13 13 const [profile, setProfile] = useState<any>(null) 14 - const [userSession, setUserSession] = useState<any>(null) 15 14 const [loading, setLoading] = useState(true) 16 15 const [editingField, setEditingField] = useState<string | null>(null) 17 16 const [editValues, setEditValues] = useState<any>({}) 17 + const [copiedDID, setCopiedDID] = useState(false) 18 18 19 19 // Check if this is a DID 20 20 const isDID = id.startsWith('did:plc:') ··· 23 23 const staticProject = isDID ? null : projects.find(p => p.id === id) 24 24 25 25 // Check if this is the user's own project 26 - const isOwnProject = isDID && userSession?.did === id 26 + const isOwnProject = isDID && profile?.isOwner 27 27 28 28 useEffect(() => { 29 29 const fetchData = async () => { 30 30 try { 31 - // Check current user session 32 - const sessionResponse = await fetch('/api/status') 33 - if (sessionResponse.ok) { 34 - const sessionData = await sessionResponse.json() 35 - setUserSession(sessionData) 36 - } 37 31 38 32 if (isDID) { 39 - const profileResponse = await fetch('/api/profile') 33 + const profileResponse = await fetch(`/api/profile?did=${encodeURIComponent(id)}`) 40 34 if (profileResponse.ok) { 41 35 const profileData = await profileResponse.json() 42 36 setProfile(profileData) ··· 71 65 setEditingField(null) 72 66 } 73 67 68 + const handleCopyDID = async () => { 69 + if (isDID && id) { 70 + try { 71 + await navigator.clipboard.writeText(id) 72 + setCopiedDID(true) 73 + setTimeout(() => setCopiedDID(false), 2000) 74 + } catch (err) { 75 + console.error('Failed to copy DID:', err) 76 + } 77 + } 78 + } 79 + 80 + const truncateDID = (did: string) => { 81 + // Extract the part after "did:plc:" 82 + const didPart = did.replace('did:plc:', '') 83 + if (didPart.length <= 8) return did // If the DID part is short, show full DID 84 + 85 + // Show first 4 letters after "plc:" and last 4 letters 86 + const firstFour = didPart.substring(0, 4) 87 + const lastFour = didPart.substring(didPart.length - 4) 88 + return `did:plc:${firstFour}...${lastFour}` 89 + } 90 + 74 91 if (!isDID && !staticProject) { 75 92 return <div>Project not found</div> 76 93 } ··· 107 124 <div className="bg-surface border-subtle shadow-card p-4 sm:p-6 group"> 108 125 <div className="flex flex-col sm:flex-row sm:items-start justify-between mb-4 sm:mb-6 gap-4"> 109 126 <div className="flex flex-col sm:flex-row items-start sm:items-center space-y-3 sm:space-y-0 sm:space-x-4"> 110 - <div className="h-12 w-12 bg-surface-hover flex items-center justify-center flex-shrink-0"> 127 + <div className="h-20 w-20 bg-surface-hover flex items-center justify-center flex-shrink-0"> 111 128 {isDID && profile?.avatar ? ( 112 - <img 113 - src={profile.avatar} 129 + <img 130 + src={profile.avatar} 114 131 alt={displayName} 115 - className="h-12 w-12 rounded object-cover" 132 + className="h-20 w-20 rounded object-cover" 116 133 /> 117 134 ) : ( 118 - <span className="text-lg font-medium text-secondary"> 135 + <span className="text-xl font-medium text-secondary"> 119 136 {(project?.name || displayName).substring(0, 2).toUpperCase()} 120 137 </span> 121 138 )} ··· 148 165 ) : ( 149 166 <> 150 167 <h1 className="text-2xl sm:text-3xl font-serif font-semibold text-primary"> 151 - {editValues.name || project?.name || `${displayName}'s Environmental Project`} 168 + {editValues.name || project?.name || `${displayName}'s project`} 152 169 </h1> 153 170 {isOwnProject && ( 154 171 <button ··· 257 274 </div> 258 275 )} 259 276 </div> 277 + 278 + {/* DID Display */} 279 + {isDID && ( 280 + <div className="mt-2"> 281 + <button 282 + onClick={handleCopyDID} 283 + className="inline-flex items-center text-xs sm:text-sm text-muted hover:text-primary transition-colors group" 284 + title={`Click to copy: ${id}`} 285 + > 286 + <span className="font-mono bg-surface-hover px-2 py-1 rounded text-xs sm:text-sm mr-2 truncate"> 287 + {truncateDID(id)} 288 + </span> 289 + {copiedDID ? ( 290 + <Check className="h-3 w-3 sm:h-4 sm:w-4 text-accent flex-shrink-0" /> 291 + ) : ( 292 + <Copy className="h-3 w-3 sm:h-4 sm:w-4 opacity-60 group-hover:opacity-100 flex-shrink-0" /> 293 + )} 294 + </button> 295 + </div> 296 + )} 260 297 </div> 261 298 </div> 262 299 </div>
+2 -10
components/Sidebar.tsx
··· 68 68 <Link href="/about" className="text-sm font-medium text-secondary hover:text-primary transition-colors"> 69 69 About 70 70 </Link> 71 - <Link href="/submit" className="text-sm font-medium text-secondary hover:text-primary transition-colors"> 72 - Submit Project 73 - </Link> 71 + 74 72 </div> 75 73 76 74 {/* Profile and Controls */} ··· 136 134 > 137 135 About 138 136 </Link> 139 - <Link 140 - href="/submit" 141 - className="text-sm font-medium text-secondary hover:text-primary transition-colors py-2" 142 - onClick={() => setIsMobileMenuOpen(false)} 143 - > 144 - Submit Project 145 - </Link> 137 + 146 138 </div> 147 139 </div> 148 140 </div>
+1 -1
lib/env.ts
··· 2 2 3 3 export const env = cleanEnv(process.env, { 4 4 COOKIE_SECRET: str({ default: 'your-secret-key-change-this' }), 5 - PUBLIC_URL: str({ default: 'https://www.impactindexer.org' }), 5 + PUBLIC_URL: str({ default: 'localhost' }), 6 6 PORT: port({ default: 3000 }), 7 7 }) 8 8