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 ( +
+
+ + + {step === 1 && ( +
+

Choose a Provider

+

+ Where would you like to host your account? +

+
+ {PROVIDERS.map((p) => ( + + ))} +
+
+ )} + + {step === 1.5 && ( +
+

Custom Provider

+
{ + e.preventDefault(); + checkServer(customService); + }} + > +
+ + setCustomService(e.target.value)} + placeholder="example.com" + autoFocus + /> +
+ {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 + /> +
+ +
+ + + setFormData({ ...formData, password: e.target.value }) + } + required + /> +
+ +
+ +
+ + setFormData({ ...formData, handle: e.target.value }) + } + placeholder="username" + required + style={{ flex: 1 }} + /> + {serverInfo.availableUserDomains && + serverInfo.availableUserDomains.length > 1 ? ( + + ) : ( + + {(() => { + const d = + serverInfo.availableUserDomains?.[0] || "bsky.social"; + return d.startsWith(".") ? d : `.${d}`; + })()} + + )} +
+
+ + {error && ( +
+ {error} +
+ )} + + + +

+ By creating an account, you agree to {selectedProvider?.name} + 's{" "} + {serverInfo.links?.termsOfService ? ( + + Terms of Service + + ) : ( + "Terms of Service" + )} + . +

+
+
+ )} +
+
+ ); +} diff --git a/web/src/css/login.css b/web/src/css/login.css index aea3a1a..cef2298 100644 --- a/web/src/css/login.css +++ b/web/src/css/login.css @@ -335,3 +335,44 @@ height: 48px; } } + +.login-divider { + display: flex; + align-items: center; + text-align: center; + margin: 24px 0; + color: var(--text-tertiary); + font-size: 13px; + font-weight: 500; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.login-divider::before, +.login-divider::after { + content: ""; + flex: 1; + border-bottom: 1px solid var(--border); +} + +.login-divider::before { + margin-right: 16px; +} + +.login-divider::after { + margin-left: 16px; +} + +.login-signup-btn { + width: 100%; + border: 1px solid var(--border); + background: transparent; + color: var(--text-primary); + transition: all 0.2s; +} + +.login-signup-btn:hover { + border-color: var(--accent); + background: var(--bg-hover); + color: var(--accent); +} diff --git a/web/src/css/modals.css b/web/src/css/modals.css index 8f5f139..38d428c 100644 --- a/web/src/css/modals.css +++ b/web/src/css/modals.css @@ -260,3 +260,181 @@ cursor: pointer; opacity: 0; } + +.signup-modal { + background: var(--bg-card); + width: 100%; + max-width: 480px; + border-radius: 16px; + padding: 24px; + border: 1px solid var(--border); + position: relative; + max-height: 85vh; + overflow-y: auto; + overscroll-behavior: contain; + box-shadow: 0 10px 40px -10px rgba(0, 0, 0, 0.5); +} + +.modal-close { + position: absolute; + top: 16px; + right: 16px; + background: none; + border: none; + color: var(--text-secondary); + cursor: pointer; + padding: 4px; + border-radius: 50%; +} + +.modal-close:hover { + background: var(--bg-hover); + color: var(--text-primary); +} + +.signup-step h2 { + font-size: 24px; + margin-bottom: 8px; + font-weight: 700; +} + +.signup-subtitle { + color: var(--text-secondary); + margin-bottom: 24px; +} + +.provider-grid { + display: grid; + grid-template-columns: 1fr; + gap: 12px; +} + +.provider-card { + display: flex; + align-items: center; + gap: 16px; + padding: 16px; + border: 1px solid var(--border); + border-radius: 12px; + background: var(--bg-element); + cursor: pointer; + text-align: left; + transition: all 0.2s ease; +} + +.provider-card:hover { + border-color: var(--accent); + background: var(--bg-hover); + transform: translateY(-1px); +} + +.provider-icon { + width: 48px; + height: 48px; + border-radius: 10px; + background: var(--bg-card); + display: flex; + align-items: center; + justify-content: center; + border: 1px solid var(--border); + color: var(--text-primary); + flex-shrink: 0; +} + +.provider-icon.wide { + width: auto; + padding: 0 12px; + border: none; + background: transparent; +} + +.provider-icon.wide img { + max-height: 40px !important; + height: 40px !important; + width: auto !important; +} + +.provider-initial { + font-size: 20px; + font-weight: 700; +} + +.provider-info { + flex: 1; +} + +.provider-info h3 { + font-weight: 600; + font-size: 16px; + margin-bottom: 2px; +} + +.provider-info span { + color: var(--text-secondary); + font-size: 13px; +} + +.provider-arrow { + color: var(--text-tertiary); +} + +.signup-form { + display: flex; + flex-direction: column; + gap: 16px; +} + +.handle-input-group { + display: flex; + align-items: center; + gap: 8px; +} + +.handle-suffix { + color: var(--text-tertiary); + font-size: 14px; + white-space: nowrap; +} + +.error-message { + color: #ff4444; + background: rgba(255, 68, 68, 0.1); + padding: 12px; + border-radius: 8px; + font-size: 13px; + display: flex; + align-items: center; + gap: 8px; +} + +.step-header { + display: flex; + align-items: center; + gap: 12px; + margin-bottom: 24px; +} + +.step-header h2 { + margin: 0; + font-size: 20px; +} + +.btn-back { + background: none; + border: none; + color: var(--text-secondary); + cursor: pointer; + font-size: 14px; + padding: 0; +} + +.btn-back:hover { + color: var(--text-primary); +} + +.legal-text { + font-size: 12px; + color: var(--text-tertiary); + text-align: center; + margin-top: 8px; +} diff --git a/web/src/pages/Login.jsx b/web/src/pages/Login.jsx index e8d41e8..c666bd0 100644 --- a/web/src/pages/Login.jsx +++ b/web/src/pages/Login.jsx @@ -4,9 +4,11 @@ import { useAuth } from "../context/AuthContext"; import { searchActors, startLogin } from "../api/client"; import { AtSign } from "lucide-react"; import logo from "../assets/logo.svg"; +import SignUpModal from "../components/SignUpModal"; export default function Login() { const { isAuthenticated, user, logout } = useAuth(); + const [showSignUp, setShowSignUp] = useState(false); const [handle, setHandle] = useState(""); const [inviteCode, setInviteCode] = useState(""); const [showInviteInput, setShowInviteInput] = useState(false); @@ -26,7 +28,6 @@ export default function Login() { "Bluesky", "Blacksky", "Tangled", - "selfhosted.social", "Northsky", "witchcraft.systems", "topphie.social", @@ -291,7 +292,21 @@ export default function Login() { Terms of Service and{" "} Privacy Policy.

+ +
+ or +
+ + + + {showSignUp && setShowSignUp(false)} />} ); }