From 419a4460b935d0d7f3341d71c69f90af0ae466b0 Mon Sep 17 00:00:00 2001
From: scanash00
Date: Sat, 24 Jan 2026 19:24:05 -0900
Subject: [PATCH] Sign up form with multiple PDS choices and fix granular oauth
---
backend/internal/oauth/client.go | 37 ++-
backend/internal/oauth/handler.go | 47 +---
lexicons/at.margin.authFull.json | 29 +++
web/src/api/client.js | 30 +++
web/src/components/Icons.jsx | 111 ++++++++
web/src/components/SignUpModal.jsx | 393 +++++++++++++++++++++++++++++
web/src/css/login.css | 41 +++
web/src/css/modals.css | 178 +++++++++++++
web/src/pages/Login.jsx | 17 +-
9 files changed, 845 insertions(+), 38 deletions(-)
create mode 100644 lexicons/at.margin.authFull.json
create mode 100644 web/src/components/SignUpModal.jsx
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 && (
+
+ )}
+
+ {step === 2 && serverInfo && (
+
+
+
+
+ Create Account on {selectedProvider?.name || "Custom PDS"}
+
+
+
+
+
+ )}
+
+
+ );
+}
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)} />}
);
}
--
2.51.2