(READ ONLY) Margin is an open annotation layer for the internet. Powered by the AT Protocol. margin.at
extension web atproto comments
99
fork

Configure Feed

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

Sign up form with multiple PDS choices and fix granular oauth

scanash00 419a4460 15484017

+845 -38
+34 -3
backend/internal/oauth/client.go
··· 86 86 } 87 87 88 88 func (c *Client) ResolveHandle(ctx context.Context, handle string) (string, error) { 89 - url := fmt.Sprintf("https://bsky.social/xrpc/com.atproto.identity.resolveHandle?handle=%s", url.QueryEscape(handle)) 90 - resp, err := http.Get(url) 89 + did, err := c.resolveHandleAt(ctx, handle, "https://public.api.bsky.app") 90 + if err == nil { 91 + return did, nil 92 + } 93 + 94 + parts := strings.Split(handle, ".") 95 + if len(parts) >= 2 { 96 + if len(parts) > 2 { 97 + domain := strings.Join(parts[1:], ".") 98 + did, err := c.resolveHandleAt(ctx, handle, fmt.Sprintf("https://%s", domain)) 99 + if err == nil { 100 + return did, nil 101 + } 102 + } 103 + 104 + did, err := c.resolveHandleAt(ctx, handle, fmt.Sprintf("https://%s", handle)) 105 + if err == nil { 106 + return did, nil 107 + } 108 + } 109 + 110 + return "", fmt.Errorf("failed to resolve handle %s: %v", handle, err) 111 + } 112 + 113 + func (c *Client) resolveHandleAt(ctx context.Context, handle, service string) (string, error) { 114 + endpoint := fmt.Sprintf("%s/xrpc/com.atproto.identity.resolveHandle?handle=%s", strings.TrimSuffix(service, "/"), url.QueryEscape(handle)) 115 + 116 + req, err := http.NewRequestWithContext(ctx, "GET", endpoint, nil) 117 + if err != nil { 118 + return "", err 119 + } 120 + 121 + resp, err := http.DefaultClient.Do(req) 91 122 if err != nil { 92 123 return "", err 93 124 } 94 125 defer resp.Body.Close() 95 126 96 127 if resp.StatusCode != 200 { 97 - return "", fmt.Errorf("failed to resolve handle: %d", resp.StatusCode) 128 + return "", fmt.Errorf("status %d from %s", resp.StatusCode, service) 98 129 } 99 130 100 131 var result struct {
+13 -34
backend/internal/oauth/handler.go
··· 140 140 141 141 pkceVerifier, pkceChallenge := client.GeneratePKCE() 142 142 143 - scope := "atproto " + 144 - "at.margin.annotation " + 145 - "at.margin.highlight " + 146 - "at.margin.bookmark " + 147 - "at.margin.reply " + 148 - "at.margin.like " + 149 - "at.margin.collection " + 150 - "at.margin.collectionItem" 143 + scope := "atproto offline_access blob:* include:at.margin.authFull" 151 144 152 145 parResp, state, dpopNonce, err := client.SendPAR(meta, handle, scope, dpopKey, pkceChallenge) 153 146 if err != nil { ··· 218 211 if err != nil { 219 212 w.Header().Set("Content-Type", "application/json") 220 213 w.WriteHeader(http.StatusBadRequest) 221 - json.NewEncoder(w).Encode(map[string]string{"error": "Could not find that Bluesky account"}) 214 + json.NewEncoder(w).Encode(map[string]string{"error": "Could not find that account. Please check the handle."}) 222 215 return 223 216 } 224 217 ··· 247 240 } 248 241 249 242 pkceVerifier, pkceChallenge := client.GeneratePKCE() 250 - scope := "atproto " + 251 - "at.margin.annotation " + 252 - "at.margin.highlight " + 253 - "at.margin.bookmark " + 254 - "at.margin.reply " + 255 - "at.margin.like " + 256 - "at.margin.collection " + 257 - "at.margin.collectionItem" 243 + scope := "atproto offline_access blob:* include:at.margin.authFull" 258 244 259 245 parResp, state, dpopNonce, err := client.SendPAR(meta, req.Handle, scope, dpopKey, pkceChallenge) 260 246 if err != nil { ··· 495 481 496 482 w.Header().Set("Content-Type", "application/json") 497 483 json.NewEncoder(w).Encode(map[string]interface{}{ 498 - "client_id": client.ClientID, 499 - "client_name": "Margin", 500 - "client_uri": baseURL, 501 - "logo_uri": baseURL + "/logo.svg", 502 - "tos_uri": baseURL + "/terms", 503 - "policy_uri": baseURL + "/privacy", 504 - "redirect_uris": []string{client.RedirectURI}, 505 - "grant_types": []string{"authorization_code", "refresh_token"}, 506 - "response_types": []string{"code"}, 507 - "scope": "atproto " + 508 - "at.margin.annotation " + 509 - "at.margin.highlight " + 510 - "at.margin.bookmark " + 511 - "at.margin.reply " + 512 - "at.margin.like " + 513 - "at.margin.collection " + 514 - "at.margin.collectionItem", 484 + "client_id": client.ClientID, 485 + "client_name": "Margin", 486 + "client_uri": baseURL, 487 + "logo_uri": baseURL + "/logo.svg", 488 + "tos_uri": baseURL + "/terms", 489 + "policy_uri": baseURL + "/privacy", 490 + "redirect_uris": []string{client.RedirectURI}, 491 + "grant_types": []string{"authorization_code", "refresh_token"}, 492 + "response_types": []string{"code"}, 493 + "scope": "atproto offline_access blob:* include:at.margin.authFull", 515 494 "token_endpoint_auth_method": "private_key_jwt", 516 495 "token_endpoint_auth_signing_alg": "ES256", 517 496 "dpop_bound_access_tokens": true,
+29
lexicons/at.margin.authFull.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "at.margin.authFull", 4 + "defs": { 5 + "main": { 6 + "type": "permission-set", 7 + "title": "Margin", 8 + "title:langs": {}, 9 + "detail": "Full access to Margin features including annotations, highlights, bookmarks, and collections.", 10 + "detail:langs": {}, 11 + "permissions": [ 12 + { 13 + "type": "permission", 14 + "resource": "repo", 15 + "action": ["create", "update", "delete"], 16 + "collection": [ 17 + "at.margin.annotation", 18 + "at.margin.highlight", 19 + "at.margin.bookmark", 20 + "at.margin.reply", 21 + "at.margin.like", 22 + "at.margin.collection", 23 + "at.margin.collectionItem" 24 + ] 25 + } 26 + ] 27 + } 28 + } 29 + }
+30
web/src/api/client.js
··· 451 451 export async function deleteAPIKey(id) { 452 452 return request(`${API_BASE}/keys/${id}`, { method: "DELETE" }); 453 453 } 454 + 455 + export async function describeServer(service) { 456 + const res = await fetch(`${service}/xrpc/com.atproto.server.describeServer`); 457 + if (!res.ok) throw new Error("Failed to describe server"); 458 + return res.json(); 459 + } 460 + 461 + export async function createAccount( 462 + service, 463 + { handle, email, password, inviteCode }, 464 + ) { 465 + const res = await fetch(`${service}/xrpc/com.atproto.server.createAccount`, { 466 + method: "POST", 467 + headers: { 468 + "Content-Type": "application/json", 469 + }, 470 + body: JSON.stringify({ 471 + handle, 472 + email, 473 + password, 474 + inviteCode, 475 + }), 476 + }); 477 + 478 + const data = await res.json(); 479 + if (!res.ok) { 480 + throw new Error(data.message || data.error || "Failed to create account"); 481 + } 482 + return data; 483 + }
+111
web/src/components/Icons.jsx
··· 351 351 </svg> 352 352 ); 353 353 } 354 + 355 + export function BlackskyIcon({ size = 18 }) { 356 + return ( 357 + <svg viewBox="0 0 285 285" width={size} height={size}> 358 + <path 359 + fill="currentColor" 360 + d="M148.846 144.562C148.846 159.75 161.158 172.062 176.346 172.062H207.012V185.865H176.346C161.158 185.865 148.846 198.177 148.846 213.365V243.045H136.029V213.365C136.029 198.177 123.717 185.865 108.529 185.865H77.8633V172.062H108.529C123.717 172.062 136.029 159.75 136.029 144.562V113.896H148.846V144.562Z" 361 + /> 362 + <path 363 + fill="currentColor" 364 + d="M170.946 31.8766C160.207 42.616 160.207 60.0281 170.946 70.7675L192.631 92.4516L182.871 102.212L161.186 80.5275C150.447 69.7881 133.035 69.7881 122.296 80.5275L101.309 101.514L92.2456 92.4509L113.232 71.4642C123.972 60.7248 123.972 43.3128 113.232 32.5733L91.5488 10.8899L101.309 1.12988L122.993 22.814C133.732 33.5533 151.144 33.5534 161.884 22.814L183.568 1.12988L192.631 10.1925L170.946 31.8766Z" 365 + /> 366 + <path 367 + fill="currentColor" 368 + d="M79.0525 75.3259C75.1216 89.9962 83.8276 105.076 98.498 109.006L128.119 116.943L124.547 130.275L94.9267 122.338C80.2564 118.407 65.1772 127.113 61.2463 141.784L53.5643 170.453L41.1837 167.136L48.8654 138.467C52.7963 123.797 44.0902 108.718 29.4199 104.787L-0.201172 96.8497L3.37124 83.5173L32.9923 91.4542C47.6626 95.3851 62.7419 86.679 66.6728 72.0088L74.6098 42.3877L86.9895 45.7048L79.0525 75.3259Z" 369 + /> 370 + <path 371 + fill="currentColor" 372 + d="M218.413 71.4229C222.344 86.093 237.423 94.7992 252.094 90.8683L281.715 82.9313L285.287 96.2628L255.666 104.2C240.995 108.131 232.29 123.21 236.22 137.88L243.902 166.55L231.522 169.867L223.841 141.198C219.91 126.528 204.831 117.822 190.16 121.753L160.539 129.69L156.967 116.357L186.588 108.42C201.258 104.49 209.964 89.4103 206.033 74.74L198.096 45.1189L210.476 41.8018L218.413 71.4229Z" 373 + /> 374 + </svg> 375 + ); 376 + } 377 + 378 + export function NorthskyIcon({ size = 18 }) { 379 + return ( 380 + <svg viewBox="0 0 1024 1024" width={size} height={size}> 381 + <defs> 382 + <linearGradient 383 + id="north_a" 384 + x1="564.17" 385 + y1="22.4" 386 + x2="374.54" 387 + y2="1187.29" 388 + gradientUnits="userSpaceOnUse" 389 + gradientTransform="matrix(1 0 0 1.03 31.9 91.01)" 390 + > 391 + <stop offset="0" stopColor="#2affba" /> 392 + <stop offset="0.02" stopColor="#31f4bd" /> 393 + <stop offset="0.14" stopColor="#53bccc" /> 394 + <stop offset="0.25" stopColor="#718ada" /> 395 + <stop offset="0.37" stopColor="#8a5fe5" /> 396 + <stop offset="0.49" stopColor="#9f3def" /> 397 + <stop offset="0.61" stopColor="#af22f6" /> 398 + <stop offset="0.74" stopColor="#bb0ffb" /> 399 + <stop offset="0.87" stopColor="#c204fe" /> 400 + <stop offset="1" stopColor="#c400ff" /> 401 + </linearGradient> 402 + <linearGradient 403 + id="north_b" 404 + x1="554.29" 405 + y1="20.79" 406 + x2="364.65" 407 + y2="1185.68" 408 + xlinkHref="#north_a" 409 + /> 410 + <linearGradient 411 + id="north_c" 412 + x1="561.1" 413 + y1="21.9" 414 + x2="371.47" 415 + y2="1186.79" 416 + xlinkHref="#north_a" 417 + /> 418 + <linearGradient 419 + id="north_d" 420 + x1="530.57" 421 + y1="16.93" 422 + x2="340.93" 423 + y2="1181.82" 424 + xlinkHref="#north_a" 425 + /> 426 + </defs> 427 + <path 428 + d="m275.87 880.64 272-184.16 120.79 114 78.55-56.88 184.6 125.1a485.5 485.5 0 0 0 55.81-138.27c-64.41-21.42-127-48.15-185.92-73.32-97-41.44-188.51-80.52-253.69-80.52-59.57 0-71.53 18.85-89.12 55-16.89 34.55-37.84 77.6-139.69 77.6-81.26 0-159.95-29.93-243.27-61.61-17.07-6.5-34.57-13.14-52.49-19.69A486.06 486.06 0 0 0 95.19 884l91.29-62.16Z" 429 + fill="url(#north_a)" 430 + /> 431 + <path 432 + d="M295.26 506.52c53.69 0 64.49-17.36 80.41-50.63 15.46-32.33 34.7-72.56 128.36-72.56 75 0 154.6 33.2 246.78 71.64 74.85 31.21 156.89 65.34 241 81.63a485.6 485.6 0 0 0-64.23-164.85c-108.88-6-201.82-43.35-284.6-76.69-66.77-26.89-129.69-52.22-182.84-52.22-46.88 0-56.43 15.74-70.55 45.89-13.41 28.65-31.79 67.87-118.24 67.87-44.25 0-90.68-13.48-141-33.11A488.3 488.3 0 0 0 62.86 435.7c8.3 3.38 16.55 6.74 24.68 10.08 76.34 31.22 148.3 60.74 207.72 60.74" 433 + fill="url(#north_b)" 434 + /> 435 + <path 436 + d="M319.2 687.81c61.24 0 73.38-19.09 91.18-55.66 16.7-34.28 37.48-76.95 137.58-76.95 81.4 0 174.78 39.89 282.9 86.09 52.19 22.29 107.38 45.84 163.42 65.43a483 483 0 0 0 2.72-136.5C898.41 554.4 806 516 722.27 481.05c-81.88-34.14-159.08-66.33-218.27-66.33-53.25 0-64 17.29-79.84 50.42-15.51 32.42-34.8 72.77-128.93 72.77-75.08 0-153.29-32-236.08-66l-8.91-3.64A487 487 0 0 0 24 601.68c27.31 9.55 53.55 19.52 79 29.19 80.24 30.55 149.61 56.94 216.2 56.94" 437 + fill="url(#north_c)" 438 + /> 439 + <path 440 + d="M341 279.65c13.49-28.78 31.95-68.19 119.16-68.19 68.59 0 137.73 27.84 210.92 57.32 70.14 28.22 148.13 59.58 233.72 69.37C815.77 218 673 140 511.88 140c-141.15 0-268.24 59.92-357.45 155.62 44 17.32 84.15 29.6 116.89 29.6 46.24 0 55.22-14.79 69.68-45.57" 441 + fill="url(#north_d)" 442 + /> 443 + </svg> 444 + ); 445 + } 446 + 447 + export function TopphieIcon({ size = 18 }) { 448 + return ( 449 + <svg 450 + width={size} 451 + height={size} 452 + viewBox="0 0 344 538" 453 + fill="none" 454 + xmlns="http://www.w3.org/2000/svg" 455 + > 456 + <ellipse cx="268.5" cy="455.5" rx="34.5" ry="35.5" fill="currentColor" /> 457 + <ellipse cx="76" cy="75.5" rx="35" ry="35.5" fill="currentColor" /> 458 + <circle cx="268.5" cy="75.5" r="75.5" fill="currentColor" /> 459 + <ellipse cx="76" cy="274.5" rx="76" ry="75.5" fill="currentColor" /> 460 + <ellipse cx="76" cy="462.5" rx="76" ry="75.5" fill="currentColor" /> 461 + <circle cx="268.5" cy="269.5" r="75.5" fill="currentColor" /> 462 + </svg> 463 + ); 464 + }
+393
web/src/components/SignUpModal.jsx
··· 1 + import { useState, useEffect } from "react"; 2 + import { X, ChevronRight, Loader2, AlertCircle } from "lucide-react"; 3 + import { BlackskyIcon, NorthskyIcon, BlueskyIcon, TopphieIcon } from "./Icons"; 4 + import { describeServer, createAccount, startLogin } from "../api/client"; 5 + 6 + const PROVIDERS = [ 7 + { 8 + id: "bluesky", 9 + name: "Bluesky", 10 + service: "https://bsky.social", 11 + Icon: BlueskyIcon, 12 + description: "The main network", 13 + }, 14 + { 15 + id: "blacksky", 16 + name: "Blacksky", 17 + service: "https://blacksky.app", 18 + Icon: BlackskyIcon, 19 + description: "For the Culture. A safe space for Black users and allies", 20 + }, 21 + { 22 + id: "northsky", 23 + name: "Northsky", 24 + service: "https://northsky.social", 25 + Icon: NorthskyIcon, 26 + description: "A Canadian-based worker-owned cooperative", 27 + inviteUrl: "https://northskysocial.com/join", 28 + }, 29 + { 30 + id: "topphie", 31 + name: "Topphie", 32 + service: "https://tophhie.social", 33 + Icon: TopphieIcon, 34 + description: "A welcoming and friendly community", 35 + }, 36 + { 37 + id: "altq", 38 + name: "AltQ", 39 + service: "https://altq.net", 40 + Icon: null, 41 + description: "An independent, self-hosted PDS instance", 42 + }, 43 + { 44 + id: "selfhosted", 45 + name: "Self-Hosted", 46 + service: "", 47 + custom: true, 48 + Icon: null, 49 + description: "Connect to your own Personal Data Server", 50 + }, 51 + ]; 52 + 53 + export default function SignUpModal({ onClose }) { 54 + const [step, setStep] = useState(1); 55 + const [selectedProvider, setSelectedProvider] = useState(null); 56 + const [customService, setCustomService] = useState(""); 57 + const [formData, setFormData] = useState({ 58 + handle: "", 59 + email: "", 60 + password: "", 61 + inviteCode: "", 62 + }); 63 + const [loading, setLoading] = useState(false); 64 + const [error, setError] = useState(null); 65 + const [serverInfo, setServerInfo] = useState(null); 66 + 67 + useEffect(() => { 68 + document.body.style.overflow = "hidden"; 69 + return () => { 70 + document.body.style.overflow = "unset"; 71 + }; 72 + }, []); 73 + 74 + const handleProviderSelect = (provider) => { 75 + setSelectedProvider(provider); 76 + if (!provider.custom) { 77 + checkServer(provider.service); 78 + } else { 79 + setStep(1.5); 80 + } 81 + }; 82 + 83 + const checkServer = async (url) => { 84 + setLoading(true); 85 + setError(null); 86 + try { 87 + let serviceUrl = url.trim(); 88 + if (!serviceUrl.startsWith("http")) { 89 + serviceUrl = `https://${serviceUrl}`; 90 + } 91 + 92 + const info = await describeServer(serviceUrl); 93 + setServerInfo({ 94 + ...info, 95 + service: serviceUrl, 96 + inviteCodeRequired: info.inviteCodeRequired ?? true, 97 + }); 98 + 99 + if (selectedProvider?.custom) { 100 + setSelectedProvider({ ...selectedProvider, service: serviceUrl }); 101 + } 102 + 103 + setStep(2); 104 + } catch (err) { 105 + console.error(err); 106 + setError("Could not connect to this PDS. Please check the URL."); 107 + } finally { 108 + setLoading(false); 109 + } 110 + }; 111 + 112 + const handleCreateAccount = async (e) => { 113 + e.preventDefault(); 114 + if (!serverInfo) return; 115 + 116 + setLoading(true); 117 + setError(null); 118 + 119 + let domain = 120 + serverInfo.selectedDomain || serverInfo.availableUserDomains[0]; 121 + if (!domain.startsWith(".")) { 122 + domain = "." + domain; 123 + } 124 + 125 + const fullHandle = formData.handle.endsWith(domain) 126 + ? formData.handle 127 + : `${formData.handle}${domain}`; 128 + 129 + try { 130 + await createAccount(serverInfo.service, { 131 + handle: fullHandle, 132 + email: formData.email, 133 + password: formData.password, 134 + inviteCode: formData.inviteCode, 135 + }); 136 + 137 + const result = await startLogin(fullHandle); 138 + if (result.authorizationUrl) { 139 + window.location.href = result.authorizationUrl; 140 + } else { 141 + onClose(); 142 + alert("Account created! Please sign in."); 143 + } 144 + } catch (err) { 145 + setError(err.message || "Failed to create account"); 146 + setLoading(false); 147 + } 148 + }; 149 + 150 + return ( 151 + <div className="modal-overlay"> 152 + <div className="modal-content signup-modal"> 153 + <button className="modal-close" onClick={onClose}> 154 + <X size={20} /> 155 + </button> 156 + 157 + {step === 1 && ( 158 + <div className="signup-step"> 159 + <h2>Choose a Provider</h2> 160 + <p className="signup-subtitle"> 161 + Where would you like to host your account? 162 + </p> 163 + <div className="provider-grid"> 164 + {PROVIDERS.map((p) => ( 165 + <button 166 + key={p.id} 167 + className="provider-card" 168 + onClick={() => handleProviderSelect(p)} 169 + > 170 + <div className={`provider-icon ${p.wide ? "wide" : ""}`}> 171 + {p.Icon ? ( 172 + <p.Icon size={p.wide ? 32 : 32} /> 173 + ) : ( 174 + <span className="provider-initial">{p.name[0]}</span> 175 + )} 176 + </div> 177 + <div className="provider-info"> 178 + <h3>{p.name}</h3> 179 + <span>{p.description}</span> 180 + </div> 181 + <ChevronRight size={16} className="provider-arrow" /> 182 + </button> 183 + ))} 184 + </div> 185 + </div> 186 + )} 187 + 188 + {step === 1.5 && ( 189 + <div className="signup-step"> 190 + <h2>Custom Provider</h2> 191 + <form 192 + onSubmit={(e) => { 193 + e.preventDefault(); 194 + checkServer(customService); 195 + }} 196 + > 197 + <div className="form-group"> 198 + <label>PDS address (e.g. pds.example.com)</label> 199 + <input 200 + type="text" 201 + className="login-input" 202 + value={customService} 203 + onChange={(e) => setCustomService(e.target.value)} 204 + placeholder="example.com" 205 + autoFocus 206 + /> 207 + </div> 208 + {error && ( 209 + <div className="error-message"> 210 + <AlertCircle size={14} /> {error} 211 + </div> 212 + )} 213 + <div className="modal-actions"> 214 + <button 215 + type="button" 216 + className="btn btn-ghost" 217 + onClick={() => setStep(1)} 218 + > 219 + Back 220 + </button> 221 + <button 222 + type="submit" 223 + className="btn btn-primary" 224 + disabled={!customService || loading} 225 + > 226 + {loading ? <Loader2 className="animate-spin" /> : "Next"} 227 + </button> 228 + </div> 229 + </form> 230 + </div> 231 + )} 232 + 233 + {step === 2 && serverInfo && ( 234 + <div className="signup-step"> 235 + <div className="step-header"> 236 + <button className="btn-back" onClick={() => setStep(1)}> 237 + ← Back 238 + </button> 239 + <h2> 240 + Create Account on {selectedProvider?.name || "Custom PDS"} 241 + </h2> 242 + </div> 243 + 244 + <form onSubmit={handleCreateAccount} className="signup-form"> 245 + {serverInfo.inviteCodeRequired && ( 246 + <div className="form-group"> 247 + <label>Invite Code *</label> 248 + <input 249 + type="text" 250 + className="login-input" 251 + value={formData.inviteCode} 252 + onChange={(e) => 253 + setFormData({ ...formData, inviteCode: e.target.value }) 254 + } 255 + placeholder="bsky-social-xxxxx" 256 + required 257 + /> 258 + {selectedProvider?.inviteUrl && ( 259 + <p 260 + className="legal-text" 261 + style={{ textAlign: "left", marginTop: "4px" }} 262 + > 263 + Need an invite code?{" "} 264 + <a 265 + href={selectedProvider.inviteUrl} 266 + target="_blank" 267 + rel="noopener noreferrer" 268 + style={{ color: "var(--accent)" }} 269 + > 270 + Get one here 271 + </a> 272 + </p> 273 + )} 274 + </div> 275 + )} 276 + 277 + <div className="form-group"> 278 + <label>Email Address</label> 279 + <input 280 + type="email" 281 + className="login-input" 282 + value={formData.email} 283 + onChange={(e) => 284 + setFormData({ ...formData, email: e.target.value }) 285 + } 286 + placeholder="you@example.com" 287 + required 288 + /> 289 + </div> 290 + 291 + <div className="form-group"> 292 + <label>Password</label> 293 + <input 294 + type="password" 295 + className="login-input" 296 + value={formData.password} 297 + onChange={(e) => 298 + setFormData({ ...formData, password: e.target.value }) 299 + } 300 + required 301 + /> 302 + </div> 303 + 304 + <div className="form-group"> 305 + <label>Handle</label> 306 + <div className="handle-input-group"> 307 + <input 308 + type="text" 309 + className="login-input" 310 + value={formData.handle} 311 + onChange={(e) => 312 + setFormData({ ...formData, handle: e.target.value }) 313 + } 314 + placeholder="username" 315 + required 316 + style={{ flex: 1 }} 317 + /> 318 + {serverInfo.availableUserDomains && 319 + serverInfo.availableUserDomains.length > 1 ? ( 320 + <select 321 + className="login-input" 322 + style={{ 323 + width: "auto", 324 + flex: "0 0 auto", 325 + paddingRight: "24px", 326 + }} 327 + onChange={(e) => { 328 + setServerInfo({ 329 + ...serverInfo, 330 + selectedDomain: e.target.value, 331 + }); 332 + }} 333 + value={ 334 + serverInfo.selectedDomain || 335 + serverInfo.availableUserDomains[0] 336 + } 337 + > 338 + {serverInfo.availableUserDomains.map((d) => ( 339 + <option key={d} value={d}> 340 + .{d.startsWith(".") ? d.substring(1) : d} 341 + </option> 342 + ))} 343 + </select> 344 + ) : ( 345 + <span className="handle-suffix"> 346 + {(() => { 347 + const d = 348 + serverInfo.availableUserDomains?.[0] || "bsky.social"; 349 + return d.startsWith(".") ? d : `.${d}`; 350 + })()} 351 + </span> 352 + )} 353 + </div> 354 + </div> 355 + 356 + {error && ( 357 + <div className="error-message"> 358 + <AlertCircle size={14} /> {error} 359 + </div> 360 + )} 361 + 362 + <button 363 + type="submit" 364 + className="btn btn-primary full-width" 365 + disabled={loading} 366 + > 367 + {loading ? "Creating Account..." : "Create Account"} 368 + </button> 369 + 370 + <p className="legal-text"> 371 + By creating an account, you agree to {selectedProvider?.name} 372 + &apos;s{" "} 373 + {serverInfo.links?.termsOfService ? ( 374 + <a 375 + href={serverInfo.links.termsOfService} 376 + target="_blank" 377 + rel="noopener noreferrer" 378 + style={{ color: "var(--accent)" }} 379 + > 380 + Terms of Service 381 + </a> 382 + ) : ( 383 + "Terms of Service" 384 + )} 385 + . 386 + </p> 387 + </form> 388 + </div> 389 + )} 390 + </div> 391 + </div> 392 + ); 393 + }
+41
web/src/css/login.css
··· 335 335 height: 48px; 336 336 } 337 337 } 338 + 339 + .login-divider { 340 + display: flex; 341 + align-items: center; 342 + text-align: center; 343 + margin: 24px 0; 344 + color: var(--text-tertiary); 345 + font-size: 13px; 346 + font-weight: 500; 347 + text-transform: uppercase; 348 + letter-spacing: 0.5px; 349 + } 350 + 351 + .login-divider::before, 352 + .login-divider::after { 353 + content: ""; 354 + flex: 1; 355 + border-bottom: 1px solid var(--border); 356 + } 357 + 358 + .login-divider::before { 359 + margin-right: 16px; 360 + } 361 + 362 + .login-divider::after { 363 + margin-left: 16px; 364 + } 365 + 366 + .login-signup-btn { 367 + width: 100%; 368 + border: 1px solid var(--border); 369 + background: transparent; 370 + color: var(--text-primary); 371 + transition: all 0.2s; 372 + } 373 + 374 + .login-signup-btn:hover { 375 + border-color: var(--accent); 376 + background: var(--bg-hover); 377 + color: var(--accent); 378 + }
+178
web/src/css/modals.css
··· 260 260 cursor: pointer; 261 261 opacity: 0; 262 262 } 263 + 264 + .signup-modal { 265 + background: var(--bg-card); 266 + width: 100%; 267 + max-width: 480px; 268 + border-radius: 16px; 269 + padding: 24px; 270 + border: 1px solid var(--border); 271 + position: relative; 272 + max-height: 85vh; 273 + overflow-y: auto; 274 + overscroll-behavior: contain; 275 + box-shadow: 0 10px 40px -10px rgba(0, 0, 0, 0.5); 276 + } 277 + 278 + .modal-close { 279 + position: absolute; 280 + top: 16px; 281 + right: 16px; 282 + background: none; 283 + border: none; 284 + color: var(--text-secondary); 285 + cursor: pointer; 286 + padding: 4px; 287 + border-radius: 50%; 288 + } 289 + 290 + .modal-close:hover { 291 + background: var(--bg-hover); 292 + color: var(--text-primary); 293 + } 294 + 295 + .signup-step h2 { 296 + font-size: 24px; 297 + margin-bottom: 8px; 298 + font-weight: 700; 299 + } 300 + 301 + .signup-subtitle { 302 + color: var(--text-secondary); 303 + margin-bottom: 24px; 304 + } 305 + 306 + .provider-grid { 307 + display: grid; 308 + grid-template-columns: 1fr; 309 + gap: 12px; 310 + } 311 + 312 + .provider-card { 313 + display: flex; 314 + align-items: center; 315 + gap: 16px; 316 + padding: 16px; 317 + border: 1px solid var(--border); 318 + border-radius: 12px; 319 + background: var(--bg-element); 320 + cursor: pointer; 321 + text-align: left; 322 + transition: all 0.2s ease; 323 + } 324 + 325 + .provider-card:hover { 326 + border-color: var(--accent); 327 + background: var(--bg-hover); 328 + transform: translateY(-1px); 329 + } 330 + 331 + .provider-icon { 332 + width: 48px; 333 + height: 48px; 334 + border-radius: 10px; 335 + background: var(--bg-card); 336 + display: flex; 337 + align-items: center; 338 + justify-content: center; 339 + border: 1px solid var(--border); 340 + color: var(--text-primary); 341 + flex-shrink: 0; 342 + } 343 + 344 + .provider-icon.wide { 345 + width: auto; 346 + padding: 0 12px; 347 + border: none; 348 + background: transparent; 349 + } 350 + 351 + .provider-icon.wide img { 352 + max-height: 40px !important; 353 + height: 40px !important; 354 + width: auto !important; 355 + } 356 + 357 + .provider-initial { 358 + font-size: 20px; 359 + font-weight: 700; 360 + } 361 + 362 + .provider-info { 363 + flex: 1; 364 + } 365 + 366 + .provider-info h3 { 367 + font-weight: 600; 368 + font-size: 16px; 369 + margin-bottom: 2px; 370 + } 371 + 372 + .provider-info span { 373 + color: var(--text-secondary); 374 + font-size: 13px; 375 + } 376 + 377 + .provider-arrow { 378 + color: var(--text-tertiary); 379 + } 380 + 381 + .signup-form { 382 + display: flex; 383 + flex-direction: column; 384 + gap: 16px; 385 + } 386 + 387 + .handle-input-group { 388 + display: flex; 389 + align-items: center; 390 + gap: 8px; 391 + } 392 + 393 + .handle-suffix { 394 + color: var(--text-tertiary); 395 + font-size: 14px; 396 + white-space: nowrap; 397 + } 398 + 399 + .error-message { 400 + color: #ff4444; 401 + background: rgba(255, 68, 68, 0.1); 402 + padding: 12px; 403 + border-radius: 8px; 404 + font-size: 13px; 405 + display: flex; 406 + align-items: center; 407 + gap: 8px; 408 + } 409 + 410 + .step-header { 411 + display: flex; 412 + align-items: center; 413 + gap: 12px; 414 + margin-bottom: 24px; 415 + } 416 + 417 + .step-header h2 { 418 + margin: 0; 419 + font-size: 20px; 420 + } 421 + 422 + .btn-back { 423 + background: none; 424 + border: none; 425 + color: var(--text-secondary); 426 + cursor: pointer; 427 + font-size: 14px; 428 + padding: 0; 429 + } 430 + 431 + .btn-back:hover { 432 + color: var(--text-primary); 433 + } 434 + 435 + .legal-text { 436 + font-size: 12px; 437 + color: var(--text-tertiary); 438 + text-align: center; 439 + margin-top: 8px; 440 + }
+16 -1
web/src/pages/Login.jsx
··· 4 4 import { searchActors, startLogin } from "../api/client"; 5 5 import { AtSign } from "lucide-react"; 6 6 import logo from "../assets/logo.svg"; 7 + import SignUpModal from "../components/SignUpModal"; 7 8 8 9 export default function Login() { 9 10 const { isAuthenticated, user, logout } = useAuth(); 11 + const [showSignUp, setShowSignUp] = useState(false); 10 12 const [handle, setHandle] = useState(""); 11 13 const [inviteCode, setInviteCode] = useState(""); 12 14 const [showInviteInput, setShowInviteInput] = useState(false); ··· 26 28 "Bluesky", 27 29 "Blacksky", 28 30 "Tangled", 29 - "selfhosted.social", 30 31 "Northsky", 31 32 "witchcraft.systems", 32 33 "topphie.social", ··· 291 292 <Link to="/terms">Terms of Service</Link> and{" "} 292 293 <Link to="/privacy">Privacy Policy</Link>. 293 294 </p> 295 + 296 + <div className="login-divider"> 297 + <span>or</span> 298 + </div> 299 + 300 + <button 301 + type="button" 302 + className="btn btn-secondary login-signup-btn" 303 + onClick={() => setShowSignUp(true)} 304 + > 305 + Create New Account 306 + </button> 294 307 </form> 308 + 309 + {showSignUp && <SignUpModal onClose={() => setShowSignUp(false)} />} 295 310 </div> 296 311 ); 297 312 }