This repository has no description
0

Configure Feed

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

implement authentication flow with login and logout functionality; update routing and styles for auth components

+762 -39
+138 -4
package-lock.json
··· 11 11 "@atcute/client": "^2.0.6", 12 12 "@atcute/oauth-browser-client": "^1.0.7", 13 13 "@atproto/api": "^0.13.22", 14 + "@atproto/oauth-client-browser": "^0.3.12", 14 15 "@atproto/oauth-client-node": "^0.2.4", 15 16 "@fortawesome/free-solid-svg-icons": "^6.7.2", 16 17 "@fortawesome/react-fontawesome": "^0.2.2", ··· 252 253 } 253 254 }, 254 255 "node_modules/@atproto/lexicon": { 255 - "version": "0.4.7", 256 - "resolved": "https://registry.npmjs.org/@atproto/lexicon/-/lexicon-0.4.7.tgz", 257 - "integrity": "sha512-/x6h3tAiDNzSi4eXtC8ke65B7UzsagtlGRHmUD95698x5lBRpDnpizj0fZWTZVYed5qnOmz/ZEue+v3wDmO61g==", 256 + "version": "0.4.9", 257 + "resolved": "https://registry.npmjs.org/@atproto/lexicon/-/lexicon-0.4.9.tgz", 258 + "integrity": "sha512-/tmEuHQFr51V2V7EAVJzaA40sqJ7ylAZpR962VbOsPtmcdOHvezbjVHYEMXgfb927hS+xqbVyzBTbu5w9v8prA==", 258 259 "license": "MIT", 259 260 "dependencies": { 260 261 "@atproto/common-web": "^0.4.0", 261 - "@atproto/syntax": "^0.3.3", 262 + "@atproto/syntax": "^0.4.0", 262 263 "iso-datestring-validator": "^2.2.2", 263 264 "multiformats": "^9.9.0", 264 265 "zod": "^3.23.8" 265 266 } 266 267 }, 268 + "node_modules/@atproto/lexicon/node_modules/@atproto/syntax": { 269 + "version": "0.4.0", 270 + "resolved": "https://registry.npmjs.org/@atproto/syntax/-/syntax-0.4.0.tgz", 271 + "integrity": "sha512-b9y5ceHS8YKOfP3mdKmwAx5yVj9294UN7FG2XzP6V5aKUdFazEYRnR9m5n5ZQFKa3GNvz7de9guZCJ/sUTcOAA==", 272 + "license": "MIT" 273 + }, 267 274 "node_modules/@atproto/oauth-client": { 268 275 "version": "0.3.10", 269 276 "resolved": "https://registry.npmjs.org/@atproto/oauth-client/-/oauth-client-0.3.10.tgz", ··· 281 288 "@atproto/oauth-types": "0.2.3", 282 289 "@atproto/xrpc": "0.6.9", 283 290 "multiformats": "^9.9.0", 291 + "zod": "^3.23.8" 292 + } 293 + }, 294 + "node_modules/@atproto/oauth-client-browser": { 295 + "version": "0.3.12", 296 + "resolved": "https://registry.npmjs.org/@atproto/oauth-client-browser/-/oauth-client-browser-0.3.12.tgz", 297 + "integrity": "sha512-VmpBoNIMOzfRZyAlimhBZ5WZftIf/Ysyi77cytxL9gHKIyB/tTUgB1oB4zSv3Oid01bPQHx73sMMIVfYEZ1Fpw==", 298 + "license": "MIT", 299 + "dependencies": { 300 + "@atproto-labs/did-resolver": "0.1.11", 301 + "@atproto-labs/handle-resolver": "0.1.7", 302 + "@atproto-labs/simple-store": "0.1.2", 303 + "@atproto/did": "0.1.5", 304 + "@atproto/jwk": "0.1.4", 305 + "@atproto/jwk-webcrypto": "0.1.5", 306 + "@atproto/oauth-client": "0.3.12", 307 + "@atproto/oauth-types": "0.2.4" 308 + } 309 + }, 310 + "node_modules/@atproto/oauth-client-browser/node_modules/@atproto-labs/did-resolver": { 311 + "version": "0.1.11", 312 + "resolved": "https://registry.npmjs.org/@atproto-labs/did-resolver/-/did-resolver-0.1.11.tgz", 313 + "integrity": "sha512-qXNzIX2GPQnxT1gl35nv/8ErDdc4Fj/+RlJE7oyE7JGkFAPUyuY03TvKJ79SmWFsWE8wyTXEpLuphr9Da1Vhkw==", 314 + "license": "MIT", 315 + "dependencies": { 316 + "@atproto-labs/fetch": "0.2.2", 317 + "@atproto-labs/pipe": "0.1.0", 318 + "@atproto-labs/simple-store": "0.1.2", 319 + "@atproto-labs/simple-store-memory": "0.1.2", 320 + "@atproto/did": "0.1.5", 321 + "zod": "^3.23.8" 322 + } 323 + }, 324 + "node_modules/@atproto/oauth-client-browser/node_modules/@atproto-labs/fetch": { 325 + "version": "0.2.2", 326 + "resolved": "https://registry.npmjs.org/@atproto-labs/fetch/-/fetch-0.2.2.tgz", 327 + "integrity": "sha512-QyafkedbFeVaN20DYUpnY2hcArYxjdThPXbYMqOSoZhcvkrUqaw4xDND4wZB5TBD9cq2yqe9V6mcw9P4XQKQuQ==", 328 + "license": "MIT", 329 + "dependencies": { 330 + "@atproto-labs/pipe": "0.1.0" 331 + } 332 + }, 333 + "node_modules/@atproto/oauth-client-browser/node_modules/@atproto-labs/identity-resolver": { 334 + "version": "0.1.15", 335 + "resolved": "https://registry.npmjs.org/@atproto-labs/identity-resolver/-/identity-resolver-0.1.15.tgz", 336 + "integrity": "sha512-3ABob5iUDoFL85I8/pJE4wncz3148fADoxNVAdksyACxxjpH1GNhSYNyIpRpdMCJ/kjj69DM9rggumTHqnD/Xg==", 337 + "license": "MIT", 338 + "dependencies": { 339 + "@atproto-labs/did-resolver": "0.1.11", 340 + "@atproto-labs/handle-resolver": "0.1.7", 341 + "@atproto/syntax": "0.4.0" 342 + } 343 + }, 344 + "node_modules/@atproto/oauth-client-browser/node_modules/@atproto/jwk": { 345 + "version": "0.1.4", 346 + "resolved": "https://registry.npmjs.org/@atproto/jwk/-/jwk-0.1.4.tgz", 347 + "integrity": "sha512-dSRuEi0FbxL5ln6hEFHp5ZW01xbQH9yJi5odZaEYpcA6beZHf/bawlU12CQy/CDsbC3FxSqrBw7Q2t7mvdSBqw==", 348 + "license": "MIT", 349 + "dependencies": { 350 + "multiformats": "^9.9.0", 351 + "zod": "^3.23.8" 352 + } 353 + }, 354 + "node_modules/@atproto/oauth-client-browser/node_modules/@atproto/jwk-jose": { 355 + "version": "0.1.5", 356 + "resolved": "https://registry.npmjs.org/@atproto/jwk-jose/-/jwk-jose-0.1.5.tgz", 357 + "integrity": "sha512-piYZ3ohKhRiGlD6/bZCV/Ed3lIi7CVd6txbofEHik22EkYWK0nWKoEriCUSTssSylwFzeOq2r31Ut16WcJoghw==", 358 + "license": "MIT", 359 + "dependencies": { 360 + "@atproto/jwk": "0.1.4", 361 + "jose": "^5.2.0" 362 + } 363 + }, 364 + "node_modules/@atproto/oauth-client-browser/node_modules/@atproto/jwk-webcrypto": { 365 + "version": "0.1.5", 366 + "resolved": "https://registry.npmjs.org/@atproto/jwk-webcrypto/-/jwk-webcrypto-0.1.5.tgz", 367 + "integrity": "sha512-xsX8cJO6rBakLz8zNKenuKIbjoTeaiMi/ETOFFYGtlMlk1grdxDOe6OGpCmUDXaOiYWu3x5K5Hc4J9ooI/4nRg==", 368 + "license": "MIT", 369 + "dependencies": { 370 + "@atproto/jwk": "0.1.4", 371 + "@atproto/jwk-jose": "0.1.5", 372 + "zod": "^3.23.8" 373 + } 374 + }, 375 + "node_modules/@atproto/oauth-client-browser/node_modules/@atproto/oauth-client": { 376 + "version": "0.3.12", 377 + "resolved": "https://registry.npmjs.org/@atproto/oauth-client/-/oauth-client-0.3.12.tgz", 378 + "integrity": "sha512-tDQzq/6PNspWeHBrYGZ+LU7t7PM/+0Ealk8V2PUfncGObLz0iPDS4RbWAzy0GH1foee1qIfRdiiKiEymMGIZDw==", 379 + "license": "MIT", 380 + "dependencies": { 381 + "@atproto-labs/did-resolver": "0.1.11", 382 + "@atproto-labs/fetch": "0.2.2", 383 + "@atproto-labs/handle-resolver": "0.1.7", 384 + "@atproto-labs/identity-resolver": "0.1.15", 385 + "@atproto-labs/simple-store": "0.1.2", 386 + "@atproto-labs/simple-store-memory": "0.1.2", 387 + "@atproto/did": "0.1.5", 388 + "@atproto/jwk": "0.1.4", 389 + "@atproto/oauth-types": "0.2.4", 390 + "@atproto/xrpc": "0.6.11", 391 + "multiformats": "^9.9.0", 392 + "zod": "^3.23.8" 393 + } 394 + }, 395 + "node_modules/@atproto/oauth-client-browser/node_modules/@atproto/oauth-types": { 396 + "version": "0.2.4", 397 + "resolved": "https://registry.npmjs.org/@atproto/oauth-types/-/oauth-types-0.2.4.tgz", 398 + "integrity": "sha512-V2LnlXi1CSmBQWTQgDm8l4oN7xYxlftVwM7hrvYNP+Jxo3Ozfe0QLK1Wy/CH6/ZqzrBBhYvcbf4DJYTUwPA+hw==", 399 + "license": "MIT", 400 + "dependencies": { 401 + "@atproto/jwk": "0.1.4", 402 + "zod": "^3.23.8" 403 + } 404 + }, 405 + "node_modules/@atproto/oauth-client-browser/node_modules/@atproto/syntax": { 406 + "version": "0.4.0", 407 + "resolved": "https://registry.npmjs.org/@atproto/syntax/-/syntax-0.4.0.tgz", 408 + "integrity": "sha512-b9y5ceHS8YKOfP3mdKmwAx5yVj9294UN7FG2XzP6V5aKUdFazEYRnR9m5n5ZQFKa3GNvz7de9guZCJ/sUTcOAA==", 409 + "license": "MIT" 410 + }, 411 + "node_modules/@atproto/oauth-client-browser/node_modules/@atproto/xrpc": { 412 + "version": "0.6.11", 413 + "resolved": "https://registry.npmjs.org/@atproto/xrpc/-/xrpc-0.6.11.tgz", 414 + "integrity": "sha512-J2cZP8FjoDN0UkyTYBlCvKvxwBbDm4dld47u6FQK30RJy9YpSiUkdxJJ10NYqpi7JVny3M0qWQgpWJDV94+PdA==", 415 + "license": "MIT", 416 + "dependencies": { 417 + "@atproto/lexicon": "^0.4.9", 284 418 "zod": "^3.23.8" 285 419 } 286 420 },
+1
package.json
··· 6 6 "@atcute/client": "^2.0.6", 7 7 "@atcute/oauth-browser-client": "^1.0.7", 8 8 "@atproto/api": "^0.13.22", 9 + "@atproto/oauth-client-browser": "^0.3.12", 9 10 "@atproto/oauth-client-node": "^0.2.4", 10 11 "@fortawesome/free-solid-svg-icons": "^6.7.2", 11 12 "@fortawesome/react-fontawesome": "^0.2.2",
+17
public/client-metadata.json
··· 1 + { 2 + "client_id": "https://cred.blue/client-metadata.json", 3 + "client_name": "Cred.blue", 4 + "client_uri": "https://cred.blue", 5 + "redirect_uris": [ 6 + "https://cred.blue/login/callback", 7 + "https://testing.cred.blue/login/callback", 8 + "http://localhost:3000/login/callback" 9 + ], 10 + "logo_uri": "https://cred.blue/favicon.ico", 11 + "scope": "atproto", 12 + "grant_types": ["authorization_code", "refresh_token"], 13 + "response_types": ["code"], 14 + "token_endpoint_auth_method": "none", 15 + "application_type": "web", 16 + "dpop_bound_access_tokens": true 17 + }
+60 -34
src/App.jsx
··· 20 20 import CompareScores from './components/CompareScores/CompareScores'; 21 21 import CollectionsFeed from './components/CollectionsFeed/CollectionsFeed'; 22 22 import AdminRoute from './components/Admin/AdminRoute'; 23 + import ProtectedRoute from './components/ProtectedRoute'; 24 + import Login from './components/Login/Login'; 25 + import LoginCallback from './components/Login/LoginCallback'; 26 + import { AuthProvider } from './contexts/AuthContext'; 23 27 import "./App.css"; 24 28 25 29 const App = () => { 26 30 return ( 27 31 <> 28 - <Router> 29 - <div className="app-container" style={{ display: 'flex', flexDirection: 'column', minHeight: '100vh' }}> 30 - <Navbar /> 31 - <div className="main-container" style={{ flex: 1 }}> 32 - <Routes> 33 - {/* All routes are now public */} 34 - <Route path="/home" element={<Home />} /> 35 - <Route path="/compare/:username1/:username2" element={<CompareScores />} /> 36 - <Route path="/compare" element={<CompareScores />} /> 37 - <Route path="/omnifeed/:username" element={<CollectionsFeed />} /> 38 - <Route path="/omnifeed" element={<CollectionsFeed />} /> 39 - <Route path="/alt-text" element={<AltTextRatingTool />} /> 40 - <Route path="/about" element={<About />} /> 41 - <Route path="/privacy" element={<Privacy />} /> 42 - <Route path="/terms" element={<Terms />} /> 43 - <Route path="/newsletter" element={<Newsletter />} /> 44 - <Route path="/supporter" element={<Supporter />} /> 45 - <Route path="/definitions" element={<Definitions />} /> 46 - <Route path="/leaderboard" element={<Leaderboard />} /> 47 - <Route path="/resources" element={<Resources />} /> 48 - <Route path="/shortcut" element={<Shortcut />} /> 49 - <Route path="/zen" element={<ZenPage />} /> 50 - <Route path="/methodology" element={<ScoringMethodology />} /> 51 - 52 - {/* Handle both DIDs and regular usernames */} 53 - <Route path="/:username" element={<UserProfile />} /> 54 - 55 - {/* Default routes */} 56 - <Route path="/" element={<Navigate to="/home" replace />} /> 57 - <Route path="*" element={<Navigate to="/home" replace />} /> 58 - </Routes> 32 + <AuthProvider> 33 + <Router> 34 + <div className="app-container" style={{ display: 'flex', flexDirection: 'column', minHeight: '100vh' }}> 35 + <Navbar /> 36 + <div className="main-container" style={{ flex: 1 }}> 37 + <Routes> 38 + {/* Auth Routes */} 39 + <Route path="/login" element={<Login />} /> 40 + <Route path="/login/callback" element={<LoginCallback />} /> 41 + 42 + {/* Public Routes */} 43 + <Route path="/home" element={<Home />} /> 44 + <Route path="/compare/:username1/:username2" element={<CompareScores />} /> 45 + <Route path="/compare" element={<CompareScores />} /> 46 + <Route path="/alt-text" element={<AltTextRatingTool />} /> 47 + <Route path="/about" element={<About />} /> 48 + <Route path="/privacy" element={<Privacy />} /> 49 + <Route path="/terms" element={<Terms />} /> 50 + <Route path="/newsletter" element={<Newsletter />} /> 51 + <Route path="/supporter" element={<Supporter />} /> 52 + <Route path="/definitions" element={<Definitions />} /> 53 + <Route path="/leaderboard" element={<Leaderboard />} /> 54 + <Route path="/resources" element={<Resources />} /> 55 + <Route path="/shortcut" element={<Shortcut />} /> 56 + <Route path="/zen" element={<ZenPage />} /> 57 + <Route path="/methodology" element={<ScoringMethodology />} /> 58 + 59 + {/* Protected Routes - Require Authentication */} 60 + <Route 61 + path="/omnifeed/:username" 62 + element={ 63 + <ProtectedRoute> 64 + <CollectionsFeed /> 65 + </ProtectedRoute> 66 + } 67 + /> 68 + <Route 69 + path="/omnifeed" 70 + element={ 71 + <ProtectedRoute> 72 + <CollectionsFeed /> 73 + </ProtectedRoute> 74 + } 75 + /> 76 + 77 + {/* Handle both DIDs and regular usernames */} 78 + <Route path="/:username" element={<UserProfile />} /> 79 + 80 + {/* Default routes */} 81 + <Route path="/" element={<Navigate to="/home" replace />} /> 82 + <Route path="*" element={<Navigate to="/home" replace />} /> 83 + </Routes> 84 + </div> 85 + <Footer /> 59 86 </div> 60 - <Footer /> 61 - </div> 62 - </Router> 87 + </Router> 88 + </AuthProvider> 63 89 <Analytics /> 64 90 </> 65 91 );
+36
src/components/Loading/Loading.css
··· 1 + .loading-container { 2 + display: flex; 3 + flex-direction: column; 4 + align-items: center; 5 + justify-content: center; 6 + padding: 2rem; 7 + min-height: 200px; 8 + } 9 + 10 + .loading-spinner { 11 + width: 40px; 12 + height: 40px; 13 + border: 4px solid rgba(0, 0, 0, 0.1); 14 + border-left-color: #0070f3; 15 + border-radius: 50%; 16 + animation: spin 1s linear infinite; 17 + margin-bottom: 1rem; 18 + } 19 + 20 + @keyframes spin { 21 + to { 22 + transform: rotate(360deg); 23 + } 24 + } 25 + 26 + .loading-message { 27 + color: var(--text-color); 28 + font-size: 1rem; 29 + text-align: center; 30 + } 31 + 32 + /* Dark mode adjustments */ 33 + .dark-mode .loading-spinner { 34 + border-color: rgba(255, 255, 255, 0.1); 35 + border-left-color: #0070f3; 36 + }
+13
src/components/Loading/Loading.js
··· 1 + import React from 'react'; 2 + import './Loading.css'; 3 + 4 + const Loading = ({ message = 'Loading...' }) => { 5 + return ( 6 + <div className="loading-container"> 7 + <div className="loading-spinner"></div> 8 + <p className="loading-message">{message}</p> 9 + </div> 10 + ); 11 + }; 12 + 13 + export default Loading;
+102
src/components/Login/Login.css
··· 1 + .login-container { 2 + display: flex; 3 + justify-content: center; 4 + align-items: center; 5 + min-height: 70vh; 6 + padding: 2rem; 7 + } 8 + 9 + .login-card { 10 + background-color: var(--background-color); 11 + border-radius: 8px; 12 + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); 13 + padding: 2rem; 14 + width: 100%; 15 + max-width: 480px; 16 + text-align: center; 17 + } 18 + 19 + .login-card h2 { 20 + color: var(--text-color); 21 + margin-bottom: 1rem; 22 + font-size: 1.8rem; 23 + } 24 + 25 + .login-card p { 26 + color: var(--secondary-text-color); 27 + margin-bottom: 1.5rem; 28 + } 29 + 30 + .login-error { 31 + background-color: rgba(255, 73, 73, 0.1); 32 + color: #ff4949; 33 + padding: 0.75rem; 34 + border-radius: 4px; 35 + margin-bottom: 1.5rem; 36 + } 37 + 38 + .login-form { 39 + display: flex; 40 + flex-direction: column; 41 + gap: 1.5rem; 42 + margin-bottom: 1.5rem; 43 + } 44 + 45 + .form-group { 46 + display: flex; 47 + flex-direction: column; 48 + text-align: left; 49 + } 50 + 51 + .form-group label { 52 + margin-bottom: 0.5rem; 53 + color: var(--text-color); 54 + font-weight: 500; 55 + } 56 + 57 + .form-group input { 58 + padding: 0.75rem; 59 + border: 1px solid var(--border-color); 60 + border-radius: 4px; 61 + background-color: var(--input-background-color, var(--background-color)); 62 + color: var(--text-color); 63 + font-size: 1rem; 64 + transition: border-color 0.2s; 65 + } 66 + 67 + .form-group input:focus { 68 + border-color: #0070f3; 69 + outline: none; 70 + } 71 + 72 + .login-button { 73 + background-color: #0070f3; 74 + color: white; 75 + border: none; 76 + border-radius: 4px; 77 + padding: 0.75rem 1.5rem; 78 + font-size: 1rem; 79 + font-weight: 500; 80 + cursor: pointer; 81 + transition: background-color 0.2s; 82 + } 83 + 84 + .login-button:hover { 85 + background-color: #0060df; 86 + } 87 + 88 + .login-button:disabled { 89 + background-color: #8bb3f0; 90 + cursor: not-allowed; 91 + } 92 + 93 + .login-info { 94 + border-top: 1px solid var(--border-color); 95 + padding-top: 1.5rem; 96 + margin-top: 1rem; 97 + } 98 + 99 + .login-info p { 100 + font-size: 0.9rem; 101 + color: var(--secondary-text-color); 102 + }
+82
src/components/Login/Login.js
··· 1 + import React, { useState } from 'react'; 2 + import { useNavigate } from 'react-router-dom'; 3 + import { useAuth } from '../../contexts/AuthContext'; 4 + import './Login.css'; 5 + 6 + const Login = () => { 7 + const [handle, setHandle] = useState(''); 8 + const [isLoading, setIsLoading] = useState(false); 9 + const [error, setError] = useState(''); 10 + const { login, isAuthenticated } = useAuth(); 11 + const navigate = useNavigate(); 12 + 13 + // Redirect if already authenticated 14 + React.useEffect(() => { 15 + if (isAuthenticated) { 16 + navigate('/'); 17 + } 18 + }, [isAuthenticated, navigate]); 19 + 20 + const handleSubmit = async (e) => { 21 + e.preventDefault(); 22 + 23 + if (!handle) { 24 + setError('Please enter your Bluesky handle'); 25 + return; 26 + } 27 + 28 + setIsLoading(true); 29 + setError(''); 30 + 31 + try { 32 + await login(handle); 33 + // Note: This code won't run because login redirects to Bluesky OAuth page 34 + } catch (err) { 35 + setError('Authentication failed. Please try again.'); 36 + setIsLoading(false); 37 + } 38 + }; 39 + 40 + return ( 41 + <div className="login-container"> 42 + <div className="login-card"> 43 + <h2>Login with Bluesky</h2> 44 + <p>Sign in with your Bluesky handle to access protected features.</p> 45 + 46 + {error && <div className="login-error">{error}</div>} 47 + 48 + <form onSubmit={handleSubmit} className="login-form"> 49 + <div className="form-group"> 50 + <label htmlFor="handle">Bluesky Handle</label> 51 + <input 52 + id="handle" 53 + type="text" 54 + value={handle} 55 + onChange={(e) => setHandle(e.target.value)} 56 + placeholder="yourhandle.bsky.social" 57 + disabled={isLoading} 58 + autoFocus 59 + /> 60 + </div> 61 + 62 + <button 63 + type="submit" 64 + className="login-button" 65 + disabled={isLoading} 66 + > 67 + {isLoading ? 'Connecting...' : 'Login with Bluesky'} 68 + </button> 69 + </form> 70 + 71 + <div className="login-info"> 72 + <p> 73 + We use Bluesky's authentication service to verify your identity. 74 + No passwords are stored by cred.blue. 75 + </p> 76 + </div> 77 + </div> 78 + </div> 79 + ); 80 + }; 81 + 82 + export default Login;
+46
src/components/Login/LoginCallback.js
··· 1 + import React, { useEffect, useState } from 'react'; 2 + import { Navigate } from 'react-router-dom'; 3 + import { useAuth } from '../../contexts/AuthContext'; 4 + import Loading from '../Loading/Loading'; 5 + 6 + // This component handles the callback redirect from the Bluesky OAuth process 7 + const LoginCallback = () => { 8 + const { loading } = useAuth(); 9 + const [error, setError] = useState(null); 10 + 11 + useEffect(() => { 12 + // The actual callback handling is done in the AuthContext.js 13 + // through the client.init() method that automatically processes 14 + // the URL params when the page loads 15 + 16 + // We just check if there are any errors in the URL 17 + const urlParams = new URLSearchParams(window.location.search); 18 + const errorParam = urlParams.get('error'); 19 + const errorDescription = urlParams.get('error_description'); 20 + 21 + if (errorParam) { 22 + setError(errorDescription || errorParam); 23 + } 24 + }, []); 25 + 26 + if (loading) { 27 + return <Loading message="Processing login..." />; 28 + } 29 + 30 + if (error) { 31 + return ( 32 + <div className="login-callback"> 33 + <div className="error"> 34 + <h3>Authentication Error</h3> 35 + <p>{error}</p> 36 + <a href="/login">Return to login</a> 37 + </div> 38 + </div> 39 + ); 40 + } 41 + 42 + // Redirect to the home page if no errors 43 + return <Navigate to="/" replace />; 44 + }; 45 + 46 + export default LoginCallback;
src/components/Login/oauth-spec.md

This is a binary file and will not be displayed.

+49
src/components/Navbar/Navbar.css
··· 580 580 width: 90vw; 581 581 max-width: 90vw; 582 582 } 583 + } 584 + 585 + /* Auth buttons */ 586 + .navbar-auth-container { 587 + margin-right: 0.5rem; 588 + } 589 + 590 + .login-button { 591 + background-color: #0070f3; 592 + color: white; 593 + border: none; 594 + border-radius: 4px; 595 + padding: 0.5rem 1rem; 596 + font-size: 0.875rem; 597 + font-weight: 500; 598 + cursor: pointer; 599 + transition: background-color 0.2s; 600 + } 601 + 602 + .login-button:hover { 603 + background-color: #0060df; 604 + } 605 + 606 + .user-profile-button { 607 + display: flex; 608 + align-items: center; 609 + gap: 0.5rem; 610 + background-color: var(--navbar-background-color); 611 + color: var(--text-color); 612 + padding: 0.5rem; 613 + border-radius: 4px; 614 + font-size: 0.875rem; 615 + } 616 + 617 + .logout-button { 618 + background-color: transparent; 619 + color: var(--text-color); 620 + border: 1px solid var(--border-color); 621 + border-radius: 4px; 622 + padding: 0.25rem 0.5rem; 623 + font-size: 0.75rem; 624 + cursor: pointer; 625 + transition: background-color 0.2s; 626 + } 627 + 628 + .logout-button:hover { 629 + background-color: rgba(255, 73, 73, 0.1); 630 + border-color: #ff4949; 631 + color: #ff4949; 583 632 }
+47 -1
src/components/Navbar/Navbar.js
··· 2 2 import React, { useContext, useState, useRef, useEffect } from 'react'; 3 3 import { Link, useNavigate } from 'react-router-dom'; 4 4 import { ThemeContext } from '../../contexts/ThemeContext'; 5 + import { useAuth } from '../../contexts/AuthContext'; 5 6 import './Navbar.css'; 6 7 7 8 // Dropdown Menu Component ··· 109 110 110 111 const Navbar = () => { 111 112 const { isDarkMode, toggleDarkMode } = useContext(ThemeContext); 113 + const { isAuthenticated, session, logout } = useAuth(); 112 114 const navigate = useNavigate(); 113 115 114 116 // Define dropdown menus structure ··· 143 145 ] 144 146 }; 145 147 148 + // Handle logout 149 + const handleLogout = async () => { 150 + await logout(); 151 + navigate('/'); 152 + }; 153 + 154 + // Get shortened display name from session 155 + const getDisplayName = () => { 156 + if (!session) return ''; 157 + 158 + // Try to get the handle from the session 159 + const handle = session.handle || ''; 160 + 161 + // If it's a DID, show a shortened version 162 + if (session.sub.startsWith('did:')) { 163 + return session.sub.substring(0, 15) + '...'; 164 + } 165 + 166 + return handle; 167 + }; 168 + 146 169 return ( 147 170 <header className="navbar"> 148 171 <div className="navbar-container"> ··· 210 233 <use href={`/icons/icons-sprite.svg#icon-${isDarkMode ? 'sun' : 'moon'}`} /> 211 234 </svg> 212 235 </button> 236 + 237 + {/* Auth Button - Show Login or User Profile based on auth state */} 238 + {isAuthenticated ? ( 239 + <div className="navbar-auth-container"> 240 + <div className="user-profile-button"> 241 + <span>{getDisplayName()}</span> 242 + <button onClick={handleLogout} className="logout-button"> 243 + Logout 244 + </button> 245 + </div> 246 + </div> 247 + ) : ( 248 + <div className="navbar-auth-container"> 249 + <button 250 + className="login-button" 251 + type="button" 252 + onClick={() => navigate('/login')} 253 + > 254 + login with bluesky 255 + </button> 256 + </div> 257 + )} 258 + 213 259 <div className="navbar-support-button-container"> 214 260 <button 215 261 className="navbar-support-button" 216 262 type="button" 217 263 onClick={() => navigate(`/supporter`)} 218 264 > 219 - become a supporter 265 + Upgrade 220 266 </button> 221 267 </div> 222 268 </div>
+24
src/components/ProtectedRoute.js
··· 1 + import React from 'react'; 2 + import { Navigate } from 'react-router-dom'; 3 + import { useAuth } from '../contexts/AuthContext'; 4 + import Loading from './Loading/Loading'; 5 + 6 + // Component to protect routes that require authentication 7 + const ProtectedRoute = ({ children }) => { 8 + const { isAuthenticated, loading } = useAuth(); 9 + 10 + // Show loading state while authentication is being checked 11 + if (loading) { 12 + return <Loading message="Checking authentication..." />; 13 + } 14 + 15 + // Redirect to login if not authenticated 16 + if (!isAuthenticated) { 17 + return <Navigate to="/login" replace />; 18 + } 19 + 20 + // Render children if authenticated 21 + return children; 22 + }; 23 + 24 + export default ProtectedRoute;
+132
src/contexts/AuthContext.js
··· 1 + import React, { createContext, useContext, useState, useEffect } from 'react'; 2 + import { BrowserOAuthClient } from '@atproto/oauth-client-browser'; 3 + 4 + // Create auth context 5 + export const AuthContext = createContext(null); 6 + 7 + // Determine environment 8 + const isDevelopment = process.env.NODE_ENV === 'development'; 9 + const hostname = window.location.hostname; 10 + 11 + // Set the appropriate domain based on the current hostname 12 + let domain; 13 + if (isDevelopment) { 14 + domain = 'http://localhost:3000'; 15 + } else if (hostname === 'testing.cred.blue') { 16 + domain = 'https://testing.cred.blue'; 17 + } else { 18 + domain = 'https://cred.blue'; 19 + } 20 + 21 + // Use the production client metadata URL for all environments 22 + // This ensures we don't need separate metadata files 23 + const metadataUrl = 'https://cred.blue/client-metadata.json'; 24 + 25 + // Client metadata for Bluesky OAuth 26 + const clientMetadata = { 27 + client_id: metadataUrl, 28 + client_name: "Cred.blue", 29 + client_uri: domain, 30 + redirect_uris: [`${domain}/login/callback`], 31 + logo_uri: `${domain}/favicon.ico`, 32 + scope: "atproto", 33 + grant_types: ["authorization_code", "refresh_token"], 34 + response_types: ["code"], 35 + token_endpoint_auth_method: "none", 36 + application_type: "web", 37 + dpop_bound_access_tokens: true 38 + }; 39 + 40 + export const AuthProvider = ({ children }) => { 41 + const [client, setClient] = useState(null); 42 + const [session, setSession] = useState(null); 43 + const [loading, setLoading] = useState(true); 44 + const [error, setError] = useState(null); 45 + 46 + // Initialize the OAuth client 47 + useEffect(() => { 48 + const initializeAuth = async () => { 49 + try { 50 + // Create the OAuth client 51 + const oauthClient = new BrowserOAuthClient({ 52 + clientMetadata, 53 + handleResolver: 'https://bsky.social', // Using bsky.social as handle resolver 54 + }); 55 + 56 + // Initialize the client and check for existing sessions 57 + const result = await oauthClient.init(); 58 + setClient(oauthClient); 59 + 60 + if (result?.session) { 61 + setSession(result.session); 62 + } 63 + 64 + // Listen for session deletion events 65 + oauthClient.addEventListener('deleted', (event) => { 66 + if (event.data.did === session?.sub) { 67 + setSession(null); 68 + } 69 + }); 70 + 71 + setLoading(false); 72 + } catch (err) { 73 + console.error('Auth initialization error:', err); 74 + setError(err.message); 75 + setLoading(false); 76 + } 77 + }; 78 + 79 + initializeAuth(); 80 + }, []); 81 + 82 + // Initiate the login process 83 + const login = async (handle) => { 84 + if (!client) return; 85 + 86 + try { 87 + // The signIn method will redirect the user to the OAuth server 88 + await client.signIn(handle); 89 + // This code won't execute as the page will be redirected 90 + } catch (err) { 91 + console.error('Login failed:', err); 92 + setError(err.message); 93 + } 94 + }; 95 + 96 + // Logout the user 97 + const logout = async () => { 98 + if (!client || !session) return; 99 + 100 + try { 101 + await client.logout(session.sub); 102 + setSession(null); 103 + } catch (err) { 104 + console.error('Logout failed:', err); 105 + setError(err.message); 106 + } 107 + }; 108 + 109 + return ( 110 + <AuthContext.Provider 111 + value={{ 112 + session, 113 + loading, 114 + error, 115 + isAuthenticated: !!session, 116 + login, 117 + logout 118 + }} 119 + > 120 + {children} 121 + </AuthContext.Provider> 122 + ); 123 + }; 124 + 125 + // Custom hook to use the auth context 126 + export const useAuth = () => { 127 + const context = useContext(AuthContext); 128 + if (context === null) { 129 + throw new Error('useAuth must be used within an AuthProvider'); 130 + } 131 + return context; 132 + };
+15
vercel.json
··· 1 + { 2 + "rewrites": [ 3 + { "source": "/(.*)", "destination": "/index.html" } 4 + ], 5 + "headers": [ 6 + { 7 + "source": "/client-metadata.json", 8 + "headers": [ 9 + { "key": "Access-Control-Allow-Origin", "value": "*" }, 10 + { "key": "Content-Type", "value": "application/json" }, 11 + { "key": "Cache-Control", "value": "public, max-age=300" } 12 + ] 13 + } 14 + ] 15 + }