diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index 016b15b..ec19b3d 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -94,6 +94,7 @@ func main() { r.Get("/auth/login", oauthHandler.HandleLogin) r.Post("/auth/start", oauthHandler.HandleStart) + r.Post("/auth/signup", oauthHandler.HandleSignup) r.Get("/auth/callback", oauthHandler.HandleCallback) r.Post("/auth/logout", oauthHandler.HandleLogout) r.Get("/auth/session", oauthHandler.HandleSession) diff --git a/backend/internal/oauth/client.go b/backend/internal/oauth/client.go index 991fa3a..cc3234a 100644 --- a/backend/internal/oauth/client.go +++ b/backend/internal/oauth/client.go @@ -208,6 +208,25 @@ func (c *Client) GetAuthServerMetadata(ctx context.Context, pds string) (*AuthSe return &meta, nil } +func (c *Client) GetAuthServerMetadataForSignup(ctx context.Context, url string) (*AuthServerMetadata, error) { + url = strings.TrimSuffix(url, "/") + + metaURL := fmt.Sprintf("%s/.well-known/oauth-authorization-server", url) + metaResp, err := http.Get(metaURL) + if err == nil && metaResp.StatusCode == 200 { + defer metaResp.Body.Close() + var meta AuthServerMetadata + if err := json.NewDecoder(metaResp.Body).Decode(&meta); err == nil && meta.Issuer != "" { + return &meta, nil + } + } + if metaResp != nil { + metaResp.Body.Close() + } + + return c.GetAuthServerMetadata(ctx, url) +} + func (c *Client) GeneratePKCE() (verifier, challenge string) { b := make([]byte, 32) rand.Read(b) diff --git a/backend/internal/oauth/handler.go b/backend/internal/oauth/handler.go index a64d0bc..02260f6 100644 --- a/backend/internal/oauth/handler.go +++ b/backend/internal/oauth/handler.go @@ -283,6 +283,86 @@ func (h *Handler) HandleStart(w http.ResponseWriter, r *http.Request) { }) } +func (h *Handler) HandleSignup(w http.ResponseWriter, r *http.Request) { + if r.Method != "POST" { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + var req struct { + PdsURL string `json:"pds_url"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "Invalid request body", http.StatusBadRequest) + return + } + + if req.PdsURL == "" { + http.Error(w, "PDS URL is required", http.StatusBadRequest) + return + } + + client := h.getDynamicClient(r) + ctx := r.Context() + + meta, err := client.GetAuthServerMetadataForSignup(ctx, req.PdsURL) + if err != nil { + log.Printf("Failed to get auth metadata for signup from %s: %v", req.PdsURL, err) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode(map[string]string{"error": "Failed to connect to PDS"}) + return + } + + dpopKey, err := client.GenerateDPoPKey() + if err != nil { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusInternalServerError) + json.NewEncoder(w).Encode(map[string]string{"error": "Internal error"}) + return + } + + pkceVerifier, pkceChallenge := client.GeneratePKCE() + scope := "atproto offline_access blob:* include:at.margin.authFull" + + parResp, state, dpopNonce, err := client.SendPAR(meta, "", scope, dpopKey, pkceChallenge) + if err != nil { + log.Printf("PAR request failed for signup: %v", err) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusInternalServerError) + json.NewEncoder(w).Encode(map[string]string{"error": "Failed to initiate signup"}) + return + } + + pending := &PendingAuth{ + State: state, + DID: "", + Handle: "", + PDS: req.PdsURL, + AuthServer: meta.TokenEndpoint, + Issuer: meta.Issuer, + PKCEVerifier: pkceVerifier, + DPoPKey: dpopKey, + DPoPNonce: dpopNonce, + CreatedAt: time.Now(), + } + + h.pendingMu.Lock() + h.pending[state] = pending + h.pendingMu.Unlock() + + authURL, _ := url.Parse(meta.AuthorizationEndpoint) + q := authURL.Query() + q.Set("client_id", client.ClientID) + q.Set("request_uri", parResp.RequestURI) + authURL.RawQuery = q.Encode() + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]string{ + "authorizationUrl": authURL.String(), + }) +} + func (h *Handler) HandleCallback(w http.ResponseWriter, r *http.Request) { client := h.getDynamicClient(r) @@ -318,8 +398,9 @@ func (h *Handler) HandleCallback(w http.ResponseWriter, r *http.Request) { } ctx := r.Context() - meta, err := client.GetAuthServerMetadata(ctx, pending.PDS) + meta, err := client.GetAuthServerMetadataForSignup(ctx, pending.PDS) if err != nil { + log.Printf("Failed to get auth metadata in callback for %s: %v", pending.PDS, err) http.Error(w, fmt.Sprintf("Failed to get auth metadata: %v", err), http.StatusInternalServerError) return } @@ -330,7 +411,7 @@ func (h *Handler) HandleCallback(w http.ResponseWriter, r *http.Request) { return } - if tokenResp.Sub != pending.DID { + if pending.DID != "" && tokenResp.Sub != pending.DID { log.Printf("Security: OAuth sub mismatch, expected %s, got %s", pending.DID, tokenResp.Sub) http.Error(w, "Account identity mismatch, authorization returned different account", http.StatusBadRequest) return diff --git a/web/src/api/client.js b/web/src/api/client.js index 71b822a..c43ce3d 100644 --- a/web/src/api/client.js +++ b/web/src/api/client.js @@ -452,6 +452,13 @@ export async function startLogin(handle, inviteCode) { body: JSON.stringify({ handle, invite_code: inviteCode }), }); } + +export async function startSignup(pdsUrl) { + return request(`${AUTH_BASE}/signup`, { + method: "POST", + body: JSON.stringify({ pds_url: pdsUrl }), + }); +} export async function getTrendingTags(limit = 10) { return request(`${API_BASE}/tags/trending?limit=${limit}`); } diff --git a/web/src/components/SignUpModal.jsx b/web/src/components/SignUpModal.jsx index 127c0a0..0c8c90a 100644 --- a/web/src/components/SignUpModal.jsx +++ b/web/src/components/SignUpModal.jsx @@ -1,9 +1,19 @@ import { useState, useEffect } from "react"; import { X, ChevronRight, Loader2, AlertCircle } from "lucide-react"; import { BlackskyIcon, NorthskyIcon, BlueskyIcon, TopphieIcon } from "./Icons"; -import { describeServer, createAccount, startLogin } from "../api/client"; +import { startSignup } from "../api/client"; +import logo from "../assets/logo.svg"; -const PROVIDERS = [ +const RECOMMENDED_PROVIDER = { + id: "margin", + name: "Margin", + service: "https://pds.margin.at", + Icon: null, + description: "Hosted by Margin, the easiest way to get started", + isMargin: true, +}; + +const OTHER_PROVIDERS = [ { id: "bluesky", name: "Bluesky", @@ -24,7 +34,6 @@ const PROVIDERS = [ service: "https://northsky.social", Icon: NorthskyIcon, description: "A Canadian-based worker-owned cooperative", - inviteUrl: "https://northskysocial.com/join", }, { id: "topphie", @@ -41,28 +50,21 @@ const PROVIDERS = [ description: "An independent, self-hosted PDS instance", }, { - id: "selfhosted", - name: "Self-Hosted", + id: "custom", + name: "Custom", service: "", custom: true, Icon: null, - description: "Connect to your own Personal Data Server", + description: "Connect to your own or another custom PDS", }, ]; export default function SignUpModal({ onClose }) { - const [step, setStep] = useState(1); - const [selectedProvider, setSelectedProvider] = useState(null); + const [showOtherProviders, setShowOtherProviders] = useState(false); + const [showCustomInput, setShowCustomInput] = useState(false); const [customService, setCustomService] = useState(""); - const [formData, setFormData] = useState({ - handle: "", - email: "", - password: "", - inviteCode: "", - }); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); - const [serverInfo, setServerInfo] = useState(null); useEffect(() => { document.body.style.overflow = "hidden"; @@ -71,79 +73,47 @@ export default function SignUpModal({ onClose }) { }; }, []); - const handleProviderSelect = (provider) => { - setSelectedProvider(provider); - if (!provider.custom) { - checkServer(provider.service); - } else { - setStep(1.5); + const handleProviderSelect = async (provider) => { + if (provider.custom) { + setShowCustomInput(true); + return; } - }; - const checkServer = async (url) => { setLoading(true); setError(null); - try { - let serviceUrl = url.trim(); - if (!serviceUrl.startsWith("http")) { - serviceUrl = `https://${serviceUrl}`; - } - const info = await describeServer(serviceUrl); - setServerInfo({ - ...info, - service: serviceUrl, - inviteCodeRequired: info.inviteCodeRequired ?? true, - }); - - if (selectedProvider?.custom) { - setSelectedProvider({ ...selectedProvider, service: serviceUrl }); + try { + const result = await startSignup(provider.service); + if (result.authorizationUrl) { + window.location.href = result.authorizationUrl; } - - setStep(2); } catch (err) { console.error(err); - setError("Could not connect to this PDS. Please check the URL."); - } finally { + setError("Could not connect to this provider. Please try again."); setLoading(false); } }; - const handleCreateAccount = async (e) => { + const handleCustomSubmit = async (e) => { e.preventDefault(); - if (!serverInfo) return; + if (!customService.trim()) return; setLoading(true); setError(null); - let domain = - serverInfo.selectedDomain || serverInfo.availableUserDomains[0]; - if (!domain.startsWith(".")) { - domain = "." + domain; + let serviceUrl = customService.trim(); + if (!serviceUrl.startsWith("http")) { + serviceUrl = `https://${serviceUrl}`; } - const cleanHandle = formData.handle.trim().replace(/^@/, ""); - const fullHandle = cleanHandle.endsWith(domain) - ? cleanHandle - : `${cleanHandle}${domain}`; - try { - await createAccount(serverInfo.service, { - handle: fullHandle, - email: formData.email, - password: formData.password, - inviteCode: formData.inviteCode, - }); - - const result = await startLogin(fullHandle); + const result = await startSignup(serviceUrl); if (result.authorizationUrl) { window.location.href = result.authorizationUrl; - } else { - onClose(); - alert("Account created! Please sign in."); } } catch (err) { - setError(err.message || "Failed to create account"); + console.error(err); + setError("Could not connect to this PDS. Please check the URL."); setLoading(false); } }; @@ -155,237 +125,129 @@ export default function SignUpModal({ onClose }) { - {step === 1 && ( -
-

Choose a Provider

-

- Where would you like to host your account? + {loading ? ( +

+ +

+ Connecting to provider...

-
- {PROVIDERS.map((p) => ( - - ))} -
- )} - - {step === 1.5 && ( + ) : showCustomInput ? (

Custom Provider

-
{ - e.preventDefault(); - checkServer(customService); - }} - > +
setCustomService(e.target.value)} - placeholder="example.com" + placeholder="pds.example.com" autoFocus />
+ {error && (
- {error} + + {error}
)} +
- )} - - {step === 2 && serverInfo && ( + ) : (
-
- -

- Create Account on {selectedProvider?.name || "Custom PDS"} -

-
- -
- {serverInfo.inviteCodeRequired && ( -
- - - setFormData({ ...formData, inviteCode: e.target.value }) - } - placeholder="bsky-social-xxxxx" - required - /> - {selectedProvider?.inviteUrl && ( -

- Need an invite code?{" "} - - Get one here - -

- )} -
- )} - -
- - - setFormData({ ...formData, email: e.target.value }) - } - placeholder="you@example.com" - required - /> -
+

Create your account

+

+ Margin uses the AT Protocol — the same decentralized network that + powers Bluesky. Your account will be hosted on a server of your + choice. +

-
- - - setFormData({ ...formData, password: e.target.value }) - } - required - /> + {error && ( +
+ + {error}
+ )} -
- -
- - setFormData({ ...formData, handle: e.target.value }) - } - placeholder="username" - required - style={{ flex: 1 }} +
+
Recommended
+
- - {error && ( -
- {error} +
+

{RECOMMENDED_PROVIDER.name}

+ {RECOMMENDED_PROVIDER.description}
- )} - - +
-

- By creating an account, you agree to {selectedProvider?.name} - 's{" "} - {serverInfo.links?.termsOfService ? ( - setShowOtherProviders(!showOtherProviders)} + > + {showOtherProviders ? "Hide other options" : "More options"} + + + + {showOtherProviders && ( +

+ {OTHER_PROVIDERS.map((p) => ( + + ))} +
+ )}
)}
diff --git a/web/src/css/modals.css b/web/src/css/modals.css index 85eb466..41ebb5f 100644 --- a/web/src/css/modals.css +++ b/web/src/css/modals.css @@ -10,6 +10,20 @@ animation: fadeIn 0.15s ease-out; } +.spinner { + animation: spin 1s linear infinite; +} + +@keyframes spin { + from { + transform: rotate(0deg); + } + + to { + transform: rotate(360deg); + } +} + .modal-container { background: var(--bg-secondary); border-radius: var(--radius-lg); @@ -74,6 +88,7 @@ from { opacity: 0; } + to { opacity: 1; } @@ -84,6 +99,7 @@ opacity: 0; transform: scale(0.96) translateY(-8px); } + to { opacity: 1; transform: scale(1) translateY(0); @@ -381,6 +397,64 @@ color: var(--text-tertiary); } +.signup-recommended { + position: relative; + margin-bottom: var(--spacing-md); +} + +.signup-recommended-badge { + position: absolute; + top: -8px; + left: 12px; + background: var(--accent); + color: white; + font-size: 0.7rem; + font-weight: 600; + padding: 2px 8px; + border-radius: var(--radius-sm); + text-transform: uppercase; + letter-spacing: 0.5px; + z-index: 1; +} + +.provider-card-featured { + border-color: var(--accent); + background: var(--accent-subtle); +} + +.provider-card-featured:hover { + border-color: var(--accent); + background: var(--bg-tertiary); +} + +.signup-toggle-others { + display: flex; + align-items: center; + justify-content: center; + gap: 6px; + width: 100%; + padding: 10px; + background: transparent; + border: none; + color: var(--text-secondary); + font-size: 0.85rem; + cursor: pointer; + transition: color 0.15s; +} + +.signup-toggle-others:hover { + color: var(--text-primary); +} + +.toggle-chevron { + transition: transform 0.2s ease; + transform: rotate(90deg); +} + +.toggle-chevron.open { + transform: rotate(-90deg); +} + .signup-form { display: flex; flex-direction: column; diff --git a/web/src/pages/Feed.jsx b/web/src/pages/Feed.jsx index 75088c4..e16aac1 100644 --- a/web/src/pages/Feed.jsx +++ b/web/src/pages/Feed.jsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useMemo } from "react"; +import { useState, useEffect, useMemo, useCallback } from "react"; import { useSearchParams } from "react-router-dom"; import AnnotationCard, { HighlightCard } from "../components/AnnotationCard"; import BookmarkCard from "../components/BookmarkCard"; @@ -45,68 +45,71 @@ export default function Feed() { const { user } = useAuth(); - const fetchFeed = async (isLoadMore = false) => { - try { - if (isLoadMore) { - setLoadingMore(true); - } else { - setLoading(true); - } + const fetchFeed = useCallback( + async (isLoadMore = false) => { + try { + if (isLoadMore) { + setLoadingMore(true); + } else { + setLoading(true); + } - let creatorDid = ""; + let creatorDid = ""; + + if (feedType === "my-feed") { + if (user?.did) { + creatorDid = user.did; + } else { + setAnnotations([]); + setLoading(false); + setLoadingMore(false); + return; + } + } - if (feedType === "my-feed") { - if (user?.did) { - creatorDid = user.did; + const motivationMap = { + commenting: "commenting", + highlighting: "highlighting", + bookmarking: "bookmarking", + }; + const motivation = motivationMap[filter] || ""; + const limit = 50; + const offset = isLoadMore ? annotations.length : 0; + + const data = await getAnnotationFeed( + limit, + offset, + tagFilter || "", + creatorDid, + feedType, + motivation, + ); + + const newItems = data.items || []; + if (newItems.length < limit) { + setHasMore(false); } else { - setAnnotations([]); - setLoading(false); - setLoadingMore(false); - return; + setHasMore(true); } - } - const motivationMap = { - commenting: "commenting", - highlighting: "highlighting", - bookmarking: "bookmarking", - }; - const motivation = motivationMap[filter] || ""; - const limit = 50; - const offset = isLoadMore ? annotations.length : 0; - - const data = await getAnnotationFeed( - limit, - offset, - tagFilter || "", - creatorDid, - feedType, - motivation, - ); - - const newItems = data.items || []; - if (newItems.length < limit) { - setHasMore(false); - } else { - setHasMore(true); - } - - if (isLoadMore) { - setAnnotations((prev) => [...prev, ...newItems]); - } else { - setAnnotations(newItems); + if (isLoadMore) { + setAnnotations((prev) => [...prev, ...newItems]); + } else { + setAnnotations(newItems); + } + } catch (err) { + setError(err.message); + } finally { + setLoading(false); + setLoadingMore(false); } - } catch (err) { - setError(err.message); - } finally { - setLoading(false); - setLoadingMore(false); - } - }; + }, + [tagFilter, feedType, filter, user, annotations.length], + ); useEffect(() => { fetchFeed(false); - }, [tagFilter, feedType, filter, user]); + }, [fetchFeed]); const deduplicatedAnnotations = useMemo(() => { const inCollectionUris = new Set();