feat(encryptid): show registered usernames in login modal instead of text input
Display known accounts as clickable buttons in the sign-in modal so users pick their username rather than typing it — prevents accidental new passkey creation from typos. Falls back to manual input via "Use a different account". Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
c2c0dadebe
commit
c3457cf98f
|
|
@ -243,6 +243,7 @@ function storeSession(token: string, username: string, did: string): void {
|
||||||
// ── Persona helpers (client-side multi-account) ──
|
// ── Persona helpers (client-side multi-account) ──
|
||||||
|
|
||||||
const PERSONAS_KEY = "rspace-known-personas";
|
const PERSONAS_KEY = "rspace-known-personas";
|
||||||
|
const KNOWN_ACCOUNTS_KEY = "encryptid-known-accounts";
|
||||||
|
|
||||||
interface KnownPersona {
|
interface KnownPersona {
|
||||||
username: string;
|
username: string;
|
||||||
|
|
@ -255,6 +256,30 @@ function getKnownPersonas(): KnownPersona[] {
|
||||||
} catch { return []; }
|
} catch { return []; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Merge personas from both localStorage keys into a deduplicated list by username. */
|
||||||
|
function getAllKnownUsernames(): string[] {
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const result: string[] = [];
|
||||||
|
// Primary: persona list (has DID, most reliable)
|
||||||
|
for (const p of getKnownPersonas()) {
|
||||||
|
if (p.username && !seen.has(p.username)) {
|
||||||
|
seen.add(p.username);
|
||||||
|
result.push(p.username);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Secondary: encryptid login-button known accounts
|
||||||
|
try {
|
||||||
|
const accounts: { username: string }[] = JSON.parse(localStorage.getItem(KNOWN_ACCOUNTS_KEY) || "[]");
|
||||||
|
for (const a of accounts) {
|
||||||
|
if (a.username && !seen.has(a.username)) {
|
||||||
|
seen.add(a.username);
|
||||||
|
result.push(a.username);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
function addKnownPersona(username: string, did: string): void {
|
function addKnownPersona(username: string, did: string): void {
|
||||||
const personas = getKnownPersonas();
|
const personas = getKnownPersonas();
|
||||||
const idx = personas.findIndex(p => p.did === did);
|
const idx = personas.findIndex(p => p.did === did);
|
||||||
|
|
@ -264,6 +289,14 @@ function addKnownPersona(username: string, did: string): void {
|
||||||
personas.push({ username, did });
|
personas.push({ username, did });
|
||||||
}
|
}
|
||||||
localStorage.setItem(PERSONAS_KEY, JSON.stringify(personas));
|
localStorage.setItem(PERSONAS_KEY, JSON.stringify(personas));
|
||||||
|
// Also sync to encryptid-known-accounts for login-button compat
|
||||||
|
try {
|
||||||
|
const accounts: { username: string; displayName?: string }[] = JSON.parse(localStorage.getItem(KNOWN_ACCOUNTS_KEY) || "[]");
|
||||||
|
if (!accounts.some(a => a.username === username)) {
|
||||||
|
accounts.unshift({ username });
|
||||||
|
localStorage.setItem(KNOWN_ACCOUNTS_KEY, JSON.stringify(accounts));
|
||||||
|
}
|
||||||
|
} catch { /* ignore */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
function removeKnownPersona(did: string): void {
|
function removeKnownPersona(did: string): void {
|
||||||
|
|
@ -790,21 +823,60 @@ export class RStackIdentity extends HTMLElement {
|
||||||
attachListeners();
|
attachListeners();
|
||||||
};
|
};
|
||||||
|
|
||||||
const signinHTML = () => `
|
let showManualInput = false;
|
||||||
<style>${MODAL_STYLES}</style>
|
|
||||||
|
const signinHTML = () => {
|
||||||
|
const knownUsers = usernameHint ? [] : getAllKnownUsernames();
|
||||||
|
const showPicker = knownUsers.length > 0 && !showManualInput;
|
||||||
|
|
||||||
|
const accountButtons = knownUsers.map(u => {
|
||||||
|
const initial = u[0]?.toUpperCase() || "?";
|
||||||
|
const escaped = u.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
||||||
|
return `<button class="account-pick-btn" data-pick-username="${escaped}">
|
||||||
|
<span class="account-pick-avatar">${initial}</span>
|
||||||
|
<span class="account-pick-name">${escaped}</span>
|
||||||
|
<span class="account-pick-arrow">→</span>
|
||||||
|
</button>`;
|
||||||
|
}).join("");
|
||||||
|
|
||||||
|
return `
|
||||||
|
<style>${MODAL_STYLES}
|
||||||
|
.account-picker { display: flex; flex-direction: column; gap: 8px; margin-bottom: 1rem; }
|
||||||
|
.account-pick-btn {
|
||||||
|
display: flex; align-items: center; gap: 12px; width: 100%; padding: 12px 16px;
|
||||||
|
background: var(--rs-bg-hover); border: 1px solid var(--rs-border); border-radius: 10px;
|
||||||
|
color: var(--rs-text-primary); cursor: pointer; transition: all 0.2s;
|
||||||
|
font-size: 0.95rem; font-family: inherit; text-align: left;
|
||||||
|
}
|
||||||
|
.account-pick-btn:hover { border-color: #06b6d4; background: rgba(6,182,212,0.08); transform: translateY(-1px); }
|
||||||
|
.account-pick-avatar {
|
||||||
|
width: 32px; height: 32px; border-radius: 50%; flex-shrink: 0;
|
||||||
|
background: linear-gradient(135deg, #06b6d4, #7c3aed); color: white;
|
||||||
|
display: flex; align-items: center; justify-content: center; font-weight: 600; font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
.account-pick-name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: 500; }
|
||||||
|
.account-pick-arrow { color: var(--rs-text-muted); font-size: 1.1rem; }
|
||||||
|
.alt-link { display: block; text-align: center; margin-top: 8px; font-size: 0.85rem; color: var(--rs-text-muted); cursor: pointer; background: none; border: none; font-family: inherit; width: 100%; padding: 4px; }
|
||||||
|
.alt-link:hover { color: #06b6d4; text-decoration: underline; }
|
||||||
|
</style>
|
||||||
<div class="auth-modal">
|
<div class="auth-modal">
|
||||||
<button class="close-btn" data-action="cancel">×</button>
|
<button class="close-btn" data-action="cancel">×</button>
|
||||||
<h2>Sign up / Sign in</h2>
|
<h2>Sign up / Sign in</h2>
|
||||||
<p>Secure, passwordless authentication powered by passkeys.</p>
|
<p>${showPicker ? "Choose an account on this device:" : "Secure, passwordless authentication powered by passkeys."}</p>
|
||||||
<input class="input" id="auth-signin-username" type="text" placeholder="Username or email" autocomplete="username webauthn" maxlength="64" />
|
${showPicker ? `
|
||||||
|
<div class="account-picker">${accountButtons}</div>
|
||||||
|
<button class="alt-link" data-action="show-manual">Use a different account</button>
|
||||||
|
` : `
|
||||||
|
<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">
|
||||||
<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="switch-register">🔐 Create New Account</button>
|
<button class="btn btn--outline" data-action="switch-register">🔐 Create New Account</button>
|
||||||
</div>
|
</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>
|
||||||
`;
|
`;};
|
||||||
|
|
||||||
const registerHTML = () => `
|
const registerHTML = () => `
|
||||||
<style>${MODAL_STYLES}</style>
|
<style>${MODAL_STYLES}</style>
|
||||||
|
|
@ -838,7 +910,10 @@ export class RStackIdentity extends HTMLElement {
|
||||||
usernameInput?.focus();
|
usernameInput?.focus();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// Show loading state on either the signin button or the clicked picker button
|
||||||
|
const pickerBtn = overlay.querySelector(`[data-pick-username="${CSS.escape(loginUsername)}"]`) as HTMLButtonElement | null;
|
||||||
if (btn) { btn.disabled = true; btn.innerHTML = '<span class="spinner"></span> Authenticating...'; }
|
if (btn) { btn.disabled = true; btn.innerHTML = '<span class="spinner"></span> Authenticating...'; }
|
||||||
|
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 {
|
||||||
const startRes = await fetch(`${ENCRYPTID_URL}/api/auth/start`, {
|
const startRes = await fetch(`${ENCRYPTID_URL}/api/auth/start`, {
|
||||||
|
|
@ -893,6 +968,7 @@ export class RStackIdentity extends HTMLElement {
|
||||||
autoResolveSpace(data.token, data.username || "");
|
autoResolveSpace(data.token, data.username || "");
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
if (btn) { btn.disabled = false; btn.innerHTML = "🔑 Sign In with Passkey"; }
|
if (btn) { btn.disabled = false; btn.innerHTML = "🔑 Sign In with Passkey"; }
|
||||||
|
if (pickerBtn) { pickerBtn.style.opacity = ""; pickerBtn.style.pointerEvents = ""; const arrow = pickerBtn.querySelector(".account-pick-arrow"); if (arrow) arrow.innerHTML = "→"; }
|
||||||
const msg = err.name === "NotAllowedError" ? "Authentication was cancelled." : err.message || "Authentication failed.";
|
const msg = err.name === "NotAllowedError" ? "Authentication was cancelled." : err.message || "Authentication failed.";
|
||||||
if (errEl) errEl.textContent = msg;
|
if (errEl) errEl.textContent = msg;
|
||||||
// If auto-triggered persona switch was cancelled, close modal and restore previous state
|
// If auto-triggered persona switch was cancelled, close modal and restore previous state
|
||||||
|
|
@ -989,8 +1065,34 @@ export class RStackIdentity extends HTMLElement {
|
||||||
});
|
});
|
||||||
overlay.querySelector('[data-action="switch-signin"]')?.addEventListener("click", () => {
|
overlay.querySelector('[data-action="switch-signin"]')?.addEventListener("click", () => {
|
||||||
mode = "signin";
|
mode = "signin";
|
||||||
|
showManualInput = false;
|
||||||
render();
|
render();
|
||||||
});
|
});
|
||||||
|
overlay.querySelector('[data-action="show-manual"]')?.addEventListener("click", () => {
|
||||||
|
showManualInput = true;
|
||||||
|
render();
|
||||||
|
setTimeout(() => (overlay.querySelector("#auth-signin-username") as HTMLInputElement)?.focus(), 50);
|
||||||
|
});
|
||||||
|
// Account picker buttons — click triggers sign-in with that username
|
||||||
|
overlay.querySelectorAll("[data-pick-username]").forEach(btn => {
|
||||||
|
btn.addEventListener("click", () => {
|
||||||
|
const picked = (btn as HTMLElement).dataset.pickUsername || "";
|
||||||
|
if (!picked) return;
|
||||||
|
// Populate a hidden username for handleSignIn, then trigger it
|
||||||
|
const input = overlay.querySelector("#auth-signin-username") as HTMLInputElement | null;
|
||||||
|
if (input) {
|
||||||
|
input.value = picked;
|
||||||
|
} else {
|
||||||
|
// No input in DOM (picker mode) — create a temp hidden one for handleSignIn
|
||||||
|
const tmp = document.createElement("input");
|
||||||
|
tmp.id = "auth-signin-username";
|
||||||
|
tmp.type = "hidden";
|
||||||
|
tmp.value = picked;
|
||||||
|
overlay.querySelector(".auth-modal")?.appendChild(tmp);
|
||||||
|
}
|
||||||
|
handleSignIn();
|
||||||
|
});
|
||||||
|
});
|
||||||
overlay.querySelector("#auth-username")?.addEventListener("keydown", (e) => {
|
overlay.querySelector("#auth-username")?.addEventListener("keydown", (e) => {
|
||||||
if ((e as KeyboardEvent).key === "Enter") handleRegister();
|
if ((e as KeyboardEvent).key === "Enter") handleRegister();
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue