feat(encryptid): add email login (magic link) and optional email on registration

- Sign-in modal: detect email input and send as { email } to auth/start
- Add "Send Magic Link" button alongside passkey sign-in
- Registration: optional email field sent with register/complete
- Enter on username field tabs to email; Enter on email submits

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-04-11 10:01:06 -04:00
parent e9b2a9314b
commit dda0492dbf
1 changed files with 48 additions and 2 deletions

View File

@ -875,9 +875,13 @@ export class RStackIdentity extends HTMLElement {
<input class="input" id="auth-signin-username" type="text" placeholder="Username or email" autocomplete="username webauthn" maxlength="64" /> <input class="input" id="auth-signin-username" type="text" placeholder="Username or email" autocomplete="username webauthn" maxlength="64" />
`} `}
<div class="actions actions--stack"> <div class="actions actions--stack">
${showPicker ? '' : `<button class="btn btn--primary" data-action="signin">🔑 Sign In with Passkey</button>`} ${showPicker ? '' : `
<button class="btn btn--primary" data-action="signin">🔑 Sign In with Passkey</button>
<button class="btn btn--outline" data-action="send-magic-link">📧 Send Magic Link</button>
`}
<button class="btn btn--outline" data-action="switch-register">🔐 Create New Account</button> <button class="btn btn--outline" data-action="switch-register">🔐 Create New Account</button>
</div> </div>
<div id="magic-link-msg" style="display:none;margin-top:0.5rem;font-size:0.85rem;text-align:center"></div>
<div class="error" id="auth-error"></div> <div class="error" id="auth-error"></div>
<div class="learn-more">Powered by <a href="https://ridentity.online" target="_blank" rel="noopener">EncryptID</a></div> <div class="learn-more">Powered by <a href="https://ridentity.online" target="_blank" rel="noopener">EncryptID</a></div>
</div> </div>
@ -890,6 +894,7 @@ export class RStackIdentity extends HTMLElement {
<h2>Create your EncryptID</h2> <h2>Create your EncryptID</h2>
<p>Set up a secure, passwordless identity.</p> <p>Set up a secure, passwordless identity.</p>
<input class="input" id="auth-username" type="text" placeholder="Choose a username" autocomplete="username webauthn" maxlength="32" /> <input class="input" id="auth-username" type="text" placeholder="Choose a username" autocomplete="username webauthn" maxlength="32" />
<input class="input" id="auth-email" type="email" placeholder="Email (optional — for account recovery)" autocomplete="email" maxlength="128" style="margin-top:-0.5rem" />
<div class="actions"> <div class="actions">
<button class="btn btn--secondary" data-action="switch-signin">Back</button> <button class="btn btn--secondary" data-action="switch-signin">Back</button>
<button class="btn btn--primary" data-action="register">🔐 Create Passkey</button> <button class="btn btn--primary" data-action="register">🔐 Create Passkey</button>
@ -921,10 +926,13 @@ export class RStackIdentity extends HTMLElement {
if (pickerBtn) { pickerBtn.style.opacity = "0.6"; pickerBtn.style.pointerEvents = "none"; const arrow = pickerBtn.querySelector(".account-pick-arrow"); if (arrow) arrow.innerHTML = '<span class="spinner" style="width:14px;height:14px;border-width:2px"></span>'; } if (pickerBtn) { pickerBtn.style.opacity = "0.6"; pickerBtn.style.pointerEvents = "none"; const arrow = pickerBtn.querySelector(".account-pick-arrow"); if (arrow) arrow.innerHTML = '<span class="spinner" style="width:14px;height:14px;border-width:2px"></span>'; }
try { try {
// Send as email or username depending on input format
const isEmail = loginUsername.includes("@");
const authBody = isEmail ? { email: loginUsername } : { username: loginUsername };
const startRes = await fetch(`${ENCRYPTID_URL}/api/auth/start`, { const startRes = await fetch(`${ENCRYPTID_URL}/api/auth/start`, {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username: loginUsername }), body: JSON.stringify(authBody),
}); });
if (!startRes.ok) throw new Error("Failed to start authentication"); if (!startRes.ok) throw new Error("Failed to start authentication");
const { options: serverOptions, userFound } = await startRes.json(); const { options: serverOptions, userFound } = await startRes.json();
@ -981,11 +989,40 @@ export class RStackIdentity extends HTMLElement {
} }
}; };
const handleMagicLink = async () => {
const input = overlay.querySelector("#auth-signin-username") as HTMLInputElement | null;
const msgEl = overlay.querySelector("#magic-link-msg") as HTMLElement | null;
const errEl = overlay.querySelector("#auth-error") as HTMLElement | null;
const value = input?.value.trim() || "";
if (!value || !value.includes("@")) {
if (errEl) errEl.textContent = "Enter an email address to receive a magic link.";
input?.focus();
return;
}
if (errEl) errEl.textContent = "";
const btn = overlay.querySelector('[data-action="send-magic-link"]') as HTMLButtonElement | null;
if (btn) { btn.disabled = true; btn.innerHTML = '<span class="spinner"></span> Sending...'; }
try {
await fetch(`${ENCRYPTID_URL}/api/auth/magic-link`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email: value }),
});
if (msgEl) { msgEl.style.display = ""; msgEl.style.color = "#22c55e"; msgEl.textContent = "Login link sent! Check your inbox."; }
} catch {
if (msgEl) { msgEl.style.display = ""; msgEl.style.color = "#f87171"; msgEl.textContent = "Failed to send. Try again."; }
} finally {
if (btn) { btn.disabled = false; btn.innerHTML = "📧 Send Magic Link"; }
}
};
const handleRegister = async () => { const handleRegister = async () => {
const usernameInput = overlay.querySelector("#auth-username") as HTMLInputElement; const usernameInput = overlay.querySelector("#auth-username") as HTMLInputElement;
const emailInput = overlay.querySelector("#auth-email") as HTMLInputElement | null;
const errEl = overlay.querySelector("#auth-error") as HTMLElement; const errEl = overlay.querySelector("#auth-error") as HTMLElement;
const btn = overlay.querySelector('[data-action="register"]') as HTMLButtonElement; const btn = overlay.querySelector('[data-action="register"]') as HTMLButtonElement;
const username = usernameInput.value.trim(); const username = usernameInput.value.trim();
const email = emailInput?.value.trim() || "";
if (!username) { if (!username) {
errEl.textContent = "Please enter a username."; errEl.textContent = "Please enter a username.";
@ -1037,6 +1074,7 @@ export class RStackIdentity extends HTMLElement {
}, },
userId, userId,
username, username,
...(email ? { email } : {}),
}), }),
}); });
const data = await completeRes.json().catch(() => null); const data = await completeRes.json().catch(() => null);
@ -1062,6 +1100,7 @@ export class RStackIdentity extends HTMLElement {
callbacks?.onCancel?.(); callbacks?.onCancel?.();
}); });
overlay.querySelector('[data-action="signin"]')?.addEventListener("click", handleSignIn); overlay.querySelector('[data-action="signin"]')?.addEventListener("click", handleSignIn);
overlay.querySelector('[data-action="send-magic-link"]')?.addEventListener("click", handleMagicLink);
overlay.querySelector('[data-action="register"]')?.addEventListener("click", handleRegister); overlay.querySelector('[data-action="register"]')?.addEventListener("click", handleRegister);
overlay.querySelector('[data-action="switch-register"]')?.addEventListener("click", () => { overlay.querySelector('[data-action="switch-register"]')?.addEventListener("click", () => {
mode = "register"; mode = "register";
@ -1099,6 +1138,13 @@ export class RStackIdentity extends HTMLElement {
}); });
}); });
overlay.querySelector("#auth-username")?.addEventListener("keydown", (e) => { overlay.querySelector("#auth-username")?.addEventListener("keydown", (e) => {
if ((e as KeyboardEvent).key === "Enter") {
// Tab to email field if empty, otherwise register
const emailInput = overlay.querySelector("#auth-email") as HTMLInputElement | null;
if (emailInput && !emailInput.value.trim()) { emailInput.focus(); } else { handleRegister(); }
}
});
overlay.querySelector("#auth-email")?.addEventListener("keydown", (e) => {
if ((e as KeyboardEvent).key === "Enter") handleRegister(); if ((e as KeyboardEvent).key === "Enter") handleRegister();
}); });
overlay.querySelector("#auth-signin-username")?.addEventListener("keydown", (e) => { overlay.querySelector("#auth-signin-username")?.addEventListener("keydown", (e) => {