Margin is an open annotation layer for the internet. Powered by the AT Protocol.
margin.at
extension
web
atproto
comments
1import { useState, useEffect, useRef } from "react";
2import { Link } from "react-router-dom";
3import { useAuth } from "../context/AuthContext";
4import { searchActors, startLogin } from "../api/client";
5import { HelpCircle } from "lucide-react";
6import logo from "../assets/logo.svg";
7
8export default function Login() {
9 const { isAuthenticated, user, logout } = useAuth();
10 const [handle, setHandle] = useState("");
11 const [inviteCode, setInviteCode] = useState("");
12 const [showInviteInput, setShowInviteInput] = useState(false);
13 const [suggestions, setSuggestions] = useState([]);
14 const [showSuggestions, setShowSuggestions] = useState(false);
15 const [showHelp, setShowHelp] = useState(false);
16 const [loading, setLoading] = useState(false);
17 const [error, setError] = useState(null);
18 const [selectedIndex, setSelectedIndex] = useState(-1);
19 const inputRef = useRef(null);
20 const inviteRef = useRef(null);
21 const suggestionsRef = useRef(null);
22
23 const isSelectionRef = useRef(false);
24
25 useEffect(() => {
26 if (handle.length < 3) {
27 setSuggestions([]);
28 setShowSuggestions(false);
29 return;
30 }
31
32 if (isSelectionRef.current) {
33 isSelectionRef.current = false;
34 return;
35 }
36
37 const timer = setTimeout(async () => {
38 try {
39 const data = await searchActors(handle);
40 setSuggestions(data.actors || []);
41 setShowSuggestions(true);
42 setSelectedIndex(-1);
43 } catch (e) {
44 console.error("Search failed:", e);
45 }
46 }, 300);
47
48 return () => clearTimeout(timer);
49 }, [handle]);
50
51 useEffect(() => {
52 const handleClickOutside = (e) => {
53 if (
54 suggestionsRef.current &&
55 !suggestionsRef.current.contains(e.target) &&
56 inputRef.current &&
57 !inputRef.current.contains(e.target)
58 ) {
59 setShowSuggestions(false);
60 }
61 };
62 document.addEventListener("mousedown", handleClickOutside);
63 return () => document.removeEventListener("mousedown", handleClickOutside);
64 }, []);
65
66 const handleKeyDown = (e) => {
67 if (!showSuggestions || suggestions.length === 0) return;
68
69 if (e.key === "ArrowDown") {
70 e.preventDefault();
71 setSelectedIndex((prev) => Math.min(prev + 1, suggestions.length - 1));
72 } else if (e.key === "ArrowUp") {
73 e.preventDefault();
74 setSelectedIndex((prev) => Math.max(prev - 1, -1));
75 } else if (e.key === "Enter" && selectedIndex >= 0) {
76 e.preventDefault();
77 selectSuggestion(suggestions[selectedIndex]);
78 } else if (e.key === "Escape") {
79 setShowSuggestions(false);
80 }
81 };
82
83 const selectSuggestion = (actor) => {
84 isSelectionRef.current = true;
85 setHandle(actor.handle);
86 setSuggestions([]);
87 setShowSuggestions(false);
88 inputRef.current?.blur();
89 };
90
91 const handleSubmit = async (e) => {
92 e.preventDefault();
93 if (!handle.trim()) return;
94 if (showInviteInput && !inviteCode.trim()) return;
95
96 setLoading(true);
97 setError(null);
98
99 try {
100 const result = await startLogin(handle.trim(), inviteCode.trim());
101 if (result.authorizationUrl) {
102 window.location.href = result.authorizationUrl;
103 }
104 } catch (err) {
105 console.error("Login error:", err);
106 if (
107 err.message &&
108 (err.message.includes("invite_required") ||
109 err.message.includes("Invite code required"))
110 ) {
111 setShowInviteInput(true);
112 setError("Please enter an invite code to continue.");
113 setTimeout(() => inviteRef.current?.focus(), 100);
114 } else {
115 setError(err.message || "Failed to start login");
116 }
117 setLoading(false);
118 }
119 };
120
121 if (isAuthenticated) {
122 return (
123 <div className="login-page">
124 <div className="login-avatar-large">
125 {user?.avatar ? (
126 <img src={user.avatar} alt={user.displayName || user.handle} />
127 ) : (
128 <span>
129 {(user?.displayName || user?.handle || "??")
130 .substring(0, 2)
131 .toUpperCase()}
132 </span>
133 )}
134 </div>
135 <h1 className="login-welcome">
136 Welcome back, {user?.displayName || user?.handle}
137 </h1>
138 <div className="login-actions">
139 <Link to={`/profile/${user?.did}`} className="btn btn-primary">
140 View Profile
141 </Link>
142 <button onClick={logout} className="btn btn-ghost">
143 Sign out
144 </button>
145 </div>
146 </div>
147 );
148 }
149
150 return (
151 <div className="login-page">
152 <img src={logo} alt="Margin Logo" className="login-logo-img" />
153
154 <h1 className="login-heading">
155 Use the AT Protocol to login to Margin
156 <button
157 className="login-help-btn"
158 onClick={() => setShowHelp(!showHelp)}
159 type="button"
160 >
161 <HelpCircle size={20} />
162 </button>
163 </h1>
164
165 {showHelp && (
166 <p className="login-help-text">
167 The AT Protocol is an open, decentralized network for social apps.
168 Your handle looks like <code>name.bsky.social</code> or your own
169 domain.
170 </p>
171 )}
172
173 <form onSubmit={handleSubmit} className="login-form">
174 <div className="login-input-wrapper">
175 <input
176 ref={inputRef}
177 type="text"
178 className="login-input"
179 placeholder="yourname.bsky.social"
180 value={handle}
181 onChange={(e) => setHandle(e.target.value)}
182 onKeyDown={handleKeyDown}
183 onFocus={() =>
184 handle.length >= 3 &&
185 suggestions.length > 0 &&
186 !handle.includes(".") &&
187 setShowSuggestions(true)
188 }
189 autoComplete="off"
190 autoCapitalize="off"
191 autoCorrect="off"
192 spellCheck="false"
193 disabled={loading}
194 />
195
196 {showSuggestions && suggestions.length > 0 && (
197 <div className="login-suggestions" ref={suggestionsRef}>
198 {suggestions.map((actor, index) => (
199 <button
200 key={actor.did}
201 type="button"
202 className={`login-suggestion ${index === selectedIndex ? "selected" : ""}`}
203 onClick={() => selectSuggestion(actor)}
204 >
205 <div className="login-suggestion-avatar">
206 {actor.avatar ? (
207 <img src={actor.avatar} alt="" />
208 ) : (
209 <span>
210 {(actor.displayName || actor.handle)
211 .substring(0, 2)
212 .toUpperCase()}
213 </span>
214 )}
215 </div>
216 <div className="login-suggestion-info">
217 <span className="login-suggestion-name">
218 {actor.displayName || actor.handle}
219 </span>
220 <span className="login-suggestion-handle">
221 @{actor.handle}
222 </span>
223 </div>
224 </button>
225 ))}
226 </div>
227 )}
228 </div>
229
230 {showInviteInput && (
231 <div
232 className="login-input-wrapper"
233 style={{ marginTop: "12px", animation: "fadeIn 0.3s ease" }}
234 >
235 <input
236 ref={inviteRef}
237 type="text"
238 className="login-input"
239 placeholder="Enter invite code"
240 value={inviteCode}
241 onChange={(e) => setInviteCode(e.target.value)}
242 autoComplete="off"
243 disabled={loading}
244 style={{ borderColor: "var(--accent)" }}
245 />
246 </div>
247 )}
248
249 {error && <p className="login-error">{error}</p>}
250
251 <button
252 type="submit"
253 className="btn btn-primary login-submit"
254 disabled={
255 loading || !handle.trim() || (showInviteInput && !inviteCode.trim())
256 }
257 >
258 {loading
259 ? "Connecting..."
260 : showInviteInput
261 ? "Submit Code"
262 : "Continue"}
263 </button>
264 </form>
265 </div>
266 );
267}