返回 DeepSeek-Reasonix
auth.js
根目录 / site / src / scripts / auth.js
1 import { safeNext } from "./safe-next.js";
2
3 // Client for the reasonix-accounts API (id.reasonix.io). Cookie-based session,
4 // so every call sends credentials; the API base is build-time configurable.
5 const API = (import.meta.env.PUBLIC_ACCOUNTS_API || "https://id.reasonix.io").replace(/\/$/, "");
6
7 async function api(path, { method = "GET", body } = {}) {
8 const res = await fetch(API + path, {
9 method,
10 credentials: "include",
11 headers: body ? { "content-type": "application/json" } : undefined,
12 body: body ? JSON.stringify(body) : undefined,
13 });
14 let data = null;
15 try { data = await res.json(); } catch {}
16 if (!res.ok) {
17 const err = new Error(data?.error?.message || "Something went wrong. Please try again.");
18 err.code = data?.error?.code;
19 err.status = res.status;
20 throw err;
21 }
22 return data;
23 }
24
25 // Reusable helpers for account-aware pages (nav state, gated actions). Importing
26 // this module also runs the form auto-wiring below, but each block is guarded by
27 // element presence, so pages without auth forms just get these two helpers.
28 export async function currentAccount() {
29 try { return (await api("/me")).user; } catch { return null; }
30 }
31 export async function accountLogout() {
32 try { await api("/auth/logout", { method: "POST" }); } catch {}
33 }
34
35 const $ = (id) => document.getElementById(id);
36 const qp = new URLSearchParams(location.search);
37 const withBase = (p) => (import.meta.env.BASE_URL.replace(/\/$/, "") + p) || p;
38
39 function msg(el, kind, text) {
40 if (!el) return;
41 el.className = "auth-msg " + kind;
42 el.textContent = text;
43 el.hidden = false;
44 }
45 function clearMsg(el) { if (el) el.hidden = true; }
46
47 function busy(btn, on) {
48 if (!btn) return;
49 btn.disabled = on;
50 btn.classList.toggle("loading", on);
51 }
52
53 // login — POST /auth/login, then continue to ?next or /account.
54 const loginForm = $("login-form");
55 if (loginForm) {
56 const box = $("login-msg");
57 const verified = qp.get("verified");
58 if (verified === "1") msg(box, "ok", "Email confirmed. Sign in to continue.");
59 else if (verified === "0") msg(box, "error", "That confirmation link was invalid or has expired.");
60 loginForm.addEventListener("submit", async (e) => {
61 e.preventDefault();
62 clearMsg(box);
63 const btn = loginForm.querySelector("button[type=submit]");
64 busy(btn, true);
65 try {
66 await api("/auth/login", { method: "POST", body: { email: $("email").value.trim(), password: $("password").value } });
67 location.href = safeNext(qp.get("next"), location.origin) || withBase("/account/");
68 } catch (err) {
69 msg(box, "error", err.message);
70 busy(btn, false);
71 }
72 });
73 }
74
75 // register — enumeration-safe: the API returns the same message either way.
76 const registerForm = $("register-form");
77 if (registerForm) {
78 const box = $("register-msg");
79 registerForm.addEventListener("submit", async (e) => {
80 e.preventDefault();
81 clearMsg(box);
82 const btn = registerForm.querySelector("button[type=submit]");
83 busy(btn, true);
84 try {
85 const out = await api("/auth/register", {
86 method: "POST",
87 body: {
88 email: $("email").value.trim(),
89 password: $("password").value,
90 displayName: $("displayName").value.trim() || undefined,
91 },
92 });
93 msg(box, "ok", out?.message || "Check your inbox to confirm your account.");
94 registerForm.reset();
95 } catch (err) {
96 msg(box, "error", err.message);
97 } finally {
98 busy(btn, false);
99 }
100 });
101 }
102
103 // forgot — always a generic success (never reveals whether the email exists).
104 const forgotForm = $("forgot-form");
105 if (forgotForm) {
106 const box = $("forgot-msg");
107 forgotForm.addEventListener("submit", async (e) => {
108 e.preventDefault();
109 clearMsg(box);
110 const btn = forgotForm.querySelector("button[type=submit]");
111 busy(btn, true);
112 try {
113 const out = await api("/auth/forgot", { method: "POST", body: { email: $("email").value.trim() } });
114 msg(box, "ok", out?.message || "If that account exists, a reset link is on its way.");
115 forgotForm.reset();
116 } catch (err) {
117 msg(box, "error", err.message);
118 } finally {
119 busy(btn, false);
120 }
121 });
122 }
123
124 // reset — token comes from the emailed link.
125 const resetForm = $("reset-form");
126 if (resetForm) {
127 const box = $("reset-msg");
128 const token = qp.get("token") || "";
129 if (!token) {
130 msg(box, "error", "This reset link is incomplete. Request a new one.");
131 resetForm.querySelectorAll("input, button").forEach((el) => (el.disabled = true));
132 }
133 resetForm.addEventListener("submit", async (e) => {
134 e.preventDefault();
135 clearMsg(box);
136 const password = $("password").value;
137 if (password !== $("confirm").value) {
138 msg(box, "error", "The two passwords don't match.");
139 return;
140 }
141 const btn = resetForm.querySelector("button[type=submit]");
142 busy(btn, true);
143 try {
144 await api("/auth/reset", { method: "POST", body: { token, password } });
145 msg(box, "ok", "Password updated. You can sign in now.");
146 resetForm.reset();
147 resetForm.querySelectorAll("input, button").forEach((el) => (el.disabled = true));
148 } catch (err) {
149 msg(box, "error", err.message);
150 busy(btn, false);
151 }
152 });
153 }
154
155 // account — profile view/edit, password change, sign out, delete.
156 const accountView = $("account-view");
157 if (accountView) {
158 const gate = $("account-gate");
159 let accountUser = null;
160 const fill = (user) => {
161 accountUser = user;
162 $("acct-handle").textContent = "@" + user.handle;
163 $("acct-email").textContent = user.email + (user.emailVerified ? "" : " · unconfirmed");
164 $("acct-role").textContent = user.role;
165 $("acct-profile-link").href = withBase("/u/?handle=" + encodeURIComponent(user.handle));
166 $("verification-wrap").hidden = user.emailVerified;
167 $("f-displayName").value = user.displayName || "";
168 $("f-handle").value = user.handle || "";
169 $("f-bio").value = user.bio || "";
170 $("f-avatarUrl").value = user.avatarUrl || "";
171 accountView.hidden = false;
172 if (gate) gate.hidden = true;
173 };
174
175 api("/me")
176 .then((d) => fill(d.user))
177 .catch((err) => {
178 if (err.status === 401) location.href = withBase("/login/?next=/account/");
179 else if (gate) msg(gate, "error", err.message);
180 });
181
182 const profileForm = $("profile-form");
183 const pBox = $("profile-msg");
184 profileForm?.addEventListener("submit", async (e) => {
185 e.preventDefault();
186 clearMsg(pBox);
187 const btn = profileForm.querySelector("button[type=submit]");
188 busy(btn, true);
189 try {
190 const d = await api("/me", {
191 method: "PATCH",
192 body: {
193 displayName: $("f-displayName").value.trim(),
194 handle: $("f-handle").value.trim().toLowerCase(),
195 bio: $("f-bio").value.trim(),
196 avatarUrl: $("f-avatarUrl").value.trim(),
197 },
198 });
199 fill(d.user);
200 msg(pBox, "ok", "Profile saved.");
201 } catch (err) {
202 msg(pBox, "error", err.message);
203 } finally {
204 busy(btn, false);
205 }
206 });
207
208 const passwordForm = $("password-form");
209 const pwBox = $("password-msg");
210 passwordForm?.addEventListener("submit", async (e) => {
211 e.preventDefault();
212 clearMsg(pwBox);
213 const btn = passwordForm.querySelector("button[type=submit]");
214 busy(btn, true);
215 try {
216 await api("/me/password", {
217 method: "POST",
218 body: { currentPassword: $("currentPassword").value, newPassword: $("newPassword").value },
219 });
220 passwordForm.reset();
221 msg(pwBox, "ok", "Password changed.");
222 } catch (err) {
223 msg(pwBox, "error", err.message);
224 } finally {
225 busy(btn, false);
226 }
227 });
228
229 $("logout-btn")?.addEventListener("click", async () => {
230 try { await api("/auth/logout", { method: "POST" }); } catch {}
231 location.href = withBase("/login/");
232 });
233
234 $("resend-verification-btn")?.addEventListener("click", async () => {
235 if (!accountUser || accountUser.emailVerified) return;
236 const btn = $("resend-verification-btn");
237 const box = $("verification-msg");
238 clearMsg(box);
239 busy(btn, true);
240 try {
241 await api("/auth/resend-verification", { method: "POST", body: { email: accountUser.email } });
242 msg(box, "ok", "A new verification email is on its way.");
243 } catch (err) {
244 msg(box, "error", err.message);
245 } finally {
246 busy(btn, false);
247 }
248 });
249
250 $("delete-btn")?.addEventListener("click", async () => {
251 if (!confirm("Delete your account? This cannot be undone.")) return;
252 try {
253 await api("/me", { method: "DELETE" });
254 location.href = withBase("/");
255 } catch (err) {
256 msg(pBox, "error", err.message);
257 }
258 });
259 }
260
261 // public profile — the static site uses a query parameter while the accounts
262 // API keeps its canonical /u/:handle endpoint.
263 const publicProfile = $("public-profile");
264 if (publicProfile) {
265 const gate = $("public-profile-gate");
266 const handle = (qp.get("handle") || "").trim().toLowerCase();
267 if (!/^[a-z0-9](?:[a-z0-9_]*[a-z0-9])?$/.test(handle) || handle.length < 3 || handle.length > 30) {
268 msg(gate, "error", "That public profile address is invalid.");
269 } else {
270 api("/u/" + encodeURIComponent(handle))
271 .then(({ user }) => {
272 $("public-profile-handle").textContent = "@" + user.handle;
273 $("public-profile-name").textContent = user.displayName || user.handle;
274 $("public-profile-bio").textContent = user.bio || "No bio yet.";
275 $("public-profile-joined").textContent = new Date(user.joinedAt).toLocaleDateString();
276 const avatar = $("public-profile-avatar");
277 if (user.avatarUrl) {
278 try {
279 const url = new URL(user.avatarUrl);
280 if (url.protocol === "https:" || url.protocol === "http:") {
281 avatar.src = url.href;
282 avatar.hidden = false;
283 }
284 } catch {}
285 }
286 gate.hidden = true;
287 publicProfile.hidden = false;
288 })
289 .catch((err) => msg(gate, "error", err.status === 404 ? "That profile doesn't exist." : err.message));
290 }
291 }
292
293 // device — the human-facing half of the CLI/desktop device-authorization flow.
294 const deviceView = $("device-view");
295 if (deviceView) {
296 const gate = $("device-gate");
297 const box = $("device-msg");
298 const codeInput = $("device-code");
299 const meta = $("device-meta");
300 const actions = $("device-actions");
301 const preset = qp.get("code");
302 if (preset && codeInput) codeInput.value = preset;
303
304 const showGrant = async () => {
305 clearMsg(box);
306 if (meta) meta.hidden = true;
307 if (actions) actions.hidden = true;
308 const code = codeInput.value.trim();
309 if (!code) { msg(box, "error", "Enter the code shown in your terminal."); return; }
310 try {
311 const d = await api("/device/info?userCode=" + encodeURIComponent(code));
312 if (meta) {
313 meta.textContent = `Requested by ${d.grant.userAgent || "a device"}`;
314 meta.hidden = false;
315 }
316 if (actions) actions.hidden = false;
317 } catch (err) {
318 msg(box, "error", err.message);
319 }
320 };
321
322 const decide = async (path, okText) => {
323 clearMsg(box);
324 const code = codeInput.value.trim();
325 actions?.querySelectorAll("button").forEach((b) => (b.disabled = true));
326 try {
327 await api(path, { method: "POST", body: { userCode: code } });
328 msg(box, "ok", okText);
329 if (meta) meta.hidden = true;
330 if (actions) actions.hidden = true;
331 codeInput.disabled = true;
332 } catch (err) {
333 msg(box, "error", err.message);
334 actions?.querySelectorAll("button").forEach((b) => (b.disabled = false));
335 }
336 };
337
338 api("/me")
339 .then(() => {
340 deviceView.hidden = false;
341 if (gate) gate.hidden = true;
342 $("device-check")?.addEventListener("click", showGrant);
343 $("device-approve")?.addEventListener("click", () => decide("/device/approve", "Approved. Return to your terminal — you're signed in."));
344 $("device-deny")?.addEventListener("click", () => decide("/device/deny", "The sign-in request was rejected."));
345 if (preset) showGrant();
346 })
347 .catch((err) => {
348 if (err.status === 401) {
349 const next = "/device/" + (preset ? "?code=" + encodeURIComponent(preset) : "");
350 location.href = withBase("/login/?next=" + encodeURIComponent(next));
351 } else if (gate) msg(gate, "error", err.message);
352 });
353 }
354
354 lines JAVASCRIPT