diff --git a/backend/internal/oauth/client.go b/backend/internal/oauth/client.go index 1660e1b..991fa3a 100644 --- a/backend/internal/oauth/client.go +++ b/backend/internal/oauth/client.go @@ -86,15 +86,46 @@ func GenerateKey() (*ecdsa.PrivateKey, error) { } func (c *Client) ResolveHandle(ctx context.Context, handle string) (string, error) { - url := fmt.Sprintf("https://bsky.social/xrpc/com.atproto.identity.resolveHandle?handle=%s", url.QueryEscape(handle)) - resp, err := http.Get(url) + did, err := c.resolveHandleAt(ctx, handle, "https://public.api.bsky.app") + if err == nil { + return did, nil + } + + parts := strings.Split(handle, ".") + if len(parts) >= 2 { + if len(parts) > 2 { + domain := strings.Join(parts[1:], ".") + did, err := c.resolveHandleAt(ctx, handle, fmt.Sprintf("https://%s", domain)) + if err == nil { + return did, nil + } + } + + did, err := c.resolveHandleAt(ctx, handle, fmt.Sprintf("https://%s", handle)) + if err == nil { + return did, nil + } + } + + return "", fmt.Errorf("failed to resolve handle %s: %v", handle, err) +} + +func (c *Client) resolveHandleAt(ctx context.Context, handle, service string) (string, error) { + endpoint := fmt.Sprintf("%s/xrpc/com.atproto.identity.resolveHandle?handle=%s", strings.TrimSuffix(service, "/"), url.QueryEscape(handle)) + + req, err := http.NewRequestWithContext(ctx, "GET", endpoint, nil) + if err != nil { + return "", err + } + + resp, err := http.DefaultClient.Do(req) if err != nil { return "", err } defer resp.Body.Close() if resp.StatusCode != 200 { - return "", fmt.Errorf("failed to resolve handle: %d", resp.StatusCode) + return "", fmt.Errorf("status %d from %s", resp.StatusCode, service) } var result struct { diff --git a/backend/internal/oauth/handler.go b/backend/internal/oauth/handler.go index 4ca29e9..f2aa410 100644 --- a/backend/internal/oauth/handler.go +++ b/backend/internal/oauth/handler.go @@ -140,14 +140,7 @@ func (h *Handler) HandleLogin(w http.ResponseWriter, r *http.Request) { pkceVerifier, pkceChallenge := client.GeneratePKCE() - scope := "atproto " + - "at.margin.annotation " + - "at.margin.highlight " + - "at.margin.bookmark " + - "at.margin.reply " + - "at.margin.like " + - "at.margin.collection " + - "at.margin.collectionItem" + scope := "atproto offline_access blob:* include:at.margin.authFull" parResp, state, dpopNonce, err := client.SendPAR(meta, handle, scope, dpopKey, pkceChallenge) if err != nil { @@ -218,7 +211,7 @@ func (h *Handler) HandleStart(w http.ResponseWriter, r *http.Request) { if err != nil { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusBadRequest) - json.NewEncoder(w).Encode(map[string]string{"error": "Could not find that Bluesky account"}) + json.NewEncoder(w).Encode(map[string]string{"error": "Could not find that account. Please check the handle."}) return } @@ -247,14 +240,7 @@ func (h *Handler) HandleStart(w http.ResponseWriter, r *http.Request) { } pkceVerifier, pkceChallenge := client.GeneratePKCE() - scope := "atproto " + - "at.margin.annotation " + - "at.margin.highlight " + - "at.margin.bookmark " + - "at.margin.reply " + - "at.margin.like " + - "at.margin.collection " + - "at.margin.collectionItem" + scope := "atproto offline_access blob:* include:at.margin.authFull" parResp, state, dpopNonce, err := client.SendPAR(meta, req.Handle, scope, dpopKey, pkceChallenge) if err != nil { @@ -495,23 +481,16 @@ func (h *Handler) HandleClientMetadata(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]interface{}{ - "client_id": client.ClientID, - "client_name": "Margin", - "client_uri": baseURL, - "logo_uri": baseURL + "/logo.svg", - "tos_uri": baseURL + "/terms", - "policy_uri": baseURL + "/privacy", - "redirect_uris": []string{client.RedirectURI}, - "grant_types": []string{"authorization_code", "refresh_token"}, - "response_types": []string{"code"}, - "scope": "atproto " + - "at.margin.annotation " + - "at.margin.highlight " + - "at.margin.bookmark " + - "at.margin.reply " + - "at.margin.like " + - "at.margin.collection " + - "at.margin.collectionItem", + "client_id": client.ClientID, + "client_name": "Margin", + "client_uri": baseURL, + "logo_uri": baseURL + "/logo.svg", + "tos_uri": baseURL + "/terms", + "policy_uri": baseURL + "/privacy", + "redirect_uris": []string{client.RedirectURI}, + "grant_types": []string{"authorization_code", "refresh_token"}, + "response_types": []string{"code"}, + "scope": "atproto offline_access blob:* include:at.margin.authFull", "token_endpoint_auth_method": "private_key_jwt", "token_endpoint_auth_signing_alg": "ES256", "dpop_bound_access_tokens": true, diff --git a/lexicons/at.margin.authFull.json b/lexicons/at.margin.authFull.json new file mode 100644 index 0000000..419d7dc --- /dev/null +++ b/lexicons/at.margin.authFull.json @@ -0,0 +1,29 @@ +{ + "lexicon": 1, + "id": "at.margin.authFull", + "defs": { + "main": { + "type": "permission-set", + "title": "Margin", + "title:langs": {}, + "detail": "Full access to Margin features including annotations, highlights, bookmarks, and collections.", + "detail:langs": {}, + "permissions": [ + { + "type": "permission", + "resource": "repo", + "action": ["create", "update", "delete"], + "collection": [ + "at.margin.annotation", + "at.margin.highlight", + "at.margin.bookmark", + "at.margin.reply", + "at.margin.like", + "at.margin.collection", + "at.margin.collectionItem" + ] + } + ] + } + } +} diff --git a/web/src/api/client.js b/web/src/api/client.js index a8db4d8..2de87d5 100644 --- a/web/src/api/client.js +++ b/web/src/api/client.js @@ -451,3 +451,33 @@ export async function createAPIKey(name) { export async function deleteAPIKey(id) { return request(`${API_BASE}/keys/${id}`, { method: "DELETE" }); } + +export async function describeServer(service) { + const res = await fetch(`${service}/xrpc/com.atproto.server.describeServer`); + if (!res.ok) throw new Error("Failed to describe server"); + return res.json(); +} + +export async function createAccount( + service, + { handle, email, password, inviteCode }, +) { + const res = await fetch(`${service}/xrpc/com.atproto.server.createAccount`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + handle, + email, + password, + inviteCode, + }), + }); + + const data = await res.json(); + if (!res.ok) { + throw new Error(data.message || data.error || "Failed to create account"); + } + return data; +} diff --git a/web/src/components/Icons.jsx b/web/src/components/Icons.jsx index f38c0a9..cc53e22 100644 --- a/web/src/components/Icons.jsx +++ b/web/src/components/Icons.jsx @@ -351,3 +351,114 @@ export function AturiIcon({ size = 18 }) { ); } + +export function BlackskyIcon({ size = 18 }) { + return ( + + ); +} + +export function NorthskyIcon({ size = 18 }) { + return ( + + ); +} + +export function TopphieIcon({ size = 18 }) { + return ( + + ); +} diff --git a/web/src/components/SignUpModal.jsx b/web/src/components/SignUpModal.jsx new file mode 100644 index 0000000..058bc4a --- /dev/null +++ b/web/src/components/SignUpModal.jsx @@ -0,0 +1,393 @@ +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"; + +const PROVIDERS = [ + { + id: "bluesky", + name: "Bluesky", + service: "https://bsky.social", + Icon: BlueskyIcon, + description: "The main network", + }, + { + id: "blacksky", + name: "Blacksky", + service: "https://blacksky.app", + Icon: BlackskyIcon, + description: "For the Culture. A safe space for Black users and allies", + }, + { + id: "northsky", + name: "Northsky", + service: "https://northsky.social", + Icon: NorthskyIcon, + description: "A Canadian-based worker-owned cooperative", + inviteUrl: "https://northskysocial.com/join", + }, + { + id: "topphie", + name: "Topphie", + service: "https://tophhie.social", + Icon: TopphieIcon, + description: "A welcoming and friendly community", + }, + { + id: "altq", + name: "AltQ", + service: "https://altq.net", + Icon: null, + description: "An independent, self-hosted PDS instance", + }, + { + id: "selfhosted", + name: "Self-Hosted", + service: "", + custom: true, + Icon: null, + description: "Connect to your own Personal Data Server", + }, +]; + +export default function SignUpModal({ onClose }) { + const [step, setStep] = useState(1); + const [selectedProvider, setSelectedProvider] = useState(null); + 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"; + return () => { + document.body.style.overflow = "unset"; + }; + }, []); + + const handleProviderSelect = (provider) => { + setSelectedProvider(provider); + if (!provider.custom) { + checkServer(provider.service); + } else { + setStep(1.5); + } + }; + + 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 }); + } + + setStep(2); + } catch (err) { + console.error(err); + setError("Could not connect to this PDS. Please check the URL."); + } finally { + setLoading(false); + } + }; + + const handleCreateAccount = async (e) => { + e.preventDefault(); + if (!serverInfo) return; + + setLoading(true); + setError(null); + + let domain = + serverInfo.selectedDomain || serverInfo.availableUserDomains[0]; + if (!domain.startsWith(".")) { + domain = "." + domain; + } + + const fullHandle = formData.handle.endsWith(domain) + ? formData.handle + : `${formData.handle}${domain}`; + + try { + await createAccount(serverInfo.service, { + handle: fullHandle, + email: formData.email, + password: formData.password, + inviteCode: formData.inviteCode, + }); + + const result = await startLogin(fullHandle); + 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"); + setLoading(false); + } + }; + + return ( +
+ Where would you like to host your account? +
+