| 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 |