feat: redesign identity modal and space switcher UX

- Auth modal: unified "Sign up / Sign in" landing with stacked passkey buttons,
  close X button, and "Powered by EncryptID" link to ridentity.online
- Logged-in dropdown: replace Profile/Recovery (auth.ridentity.online) with
  Add Email, Add Second Device, Add Social Recovery settings modals
- Add Email: two-step flow (enter email → verify code)
- Add Second Device: WebAuthn credential registration for backup access
- Add Social Recovery: trusted contacts with configurable threshold
- Space switcher: emoji visibility badges (🔓 green / 🔑 yellow / 🔒 red),
  remove slash prefix, match app-switcher button styling
- Add rdata.online to standalone domain list

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-02-25 14:50:35 -08:00
parent b872e8e053
commit cd33f7c050
3 changed files with 380 additions and 24 deletions

View File

@ -490,6 +490,7 @@ for (const mod of getAllModules()) {
// Domains we keep on their own containers (do NOT rewrite)
const keepStandalone = new Set([
"rcart.online",
"rdata.online",
"rfiles.online",
"swag.mycofi.earth",
"providers.mycofi.earth",

View File

@ -138,8 +138,11 @@ export class RStackIdentity extends HTMLElement {
<div class="avatar">${initial}</div>
<span class="name">${displayName}</span>
<div class="dropdown" id="dropdown">
<button class="dropdown-item" data-action="profile">👤 Profile</button>
<button class="dropdown-item" data-action="recovery">🛡 Recovery</button>
<div class="dropdown-header">${displayName}</div>
<div class="dropdown-divider"></div>
<button class="dropdown-item" data-action="add-email"> Add Email</button>
<button class="dropdown-item" data-action="add-device">📱 Add Second Device</button>
<button class="dropdown-item" data-action="add-recovery">🛡 Add Social Recovery</button>
<div class="dropdown-divider"></div>
<button class="dropdown-item dropdown-item--danger" data-action="signout">🚪 Sign Out</button>
</div>
@ -165,10 +168,12 @@ export class RStackIdentity extends HTMLElement {
clearSession();
this.#render();
this.dispatchEvent(new CustomEvent("auth-change", { bubbles: true, composed: true }));
} else if (action === "profile") {
window.open(ENCRYPTID_URL, "_blank");
} else if (action === "recovery") {
window.open(`${ENCRYPTID_URL}/recover`, "_blank");
} else if (action === "add-email") {
this.#showAddEmailModal();
} else if (action === "add-device") {
this.#showAddDeviceModal();
} else if (action === "add-recovery") {
this.#showAddRecoveryModal();
}
});
});
@ -200,29 +205,31 @@ export class RStackIdentity extends HTMLElement {
const signinHTML = () => `
<style>${MODAL_STYLES}</style>
<div class="auth-modal">
<h2>Sign in with EncryptID</h2>
<p>Use your passkey to sign in. No passwords needed.</p>
<div class="actions">
<button class="btn btn--secondary" data-action="cancel">Cancel</button>
<button class="close-btn" data-action="cancel">&times;</button>
<h2>Sign up / Sign in</h2>
<p>Secure, passwordless authentication powered by passkeys.</p>
<div class="actions actions--stack">
<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>
</div>
<div class="error" id="auth-error"></div>
<div class="toggle">Don't have an account? <a data-action="switch-register">Create one</a></div>
<div class="learn-more">Powered by <a href="https://ridentity.online" target="_blank" rel="noopener">EncryptID</a></div>
</div>
`;
const registerHTML = () => `
<style>${MODAL_STYLES}</style>
<div class="auth-modal">
<button class="close-btn" data-action="cancel">&times;</button>
<h2>Create your EncryptID</h2>
<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" />
<div class="actions">
<button class="btn btn--secondary" data-action="cancel">Cancel</button>
<button class="btn btn--secondary" data-action="switch-signin">Back</button>
<button class="btn btn--primary" data-action="register">🔐 Create Passkey</button>
</div>
<div class="error" id="auth-error"></div>
<div class="toggle">Already have an account? <a data-action="switch-signin">Sign in</a></div>
<div class="learn-more">Powered by <a href="https://ridentity.online" target="_blank" rel="noopener">EncryptID</a></div>
</div>
`;
@ -383,6 +390,303 @@ export class RStackIdentity extends HTMLElement {
render();
}
// ── Settings modals ──
#showAddEmailModal(): void {
if (document.querySelector(".rstack-auth-overlay")) return;
const overlay = document.createElement("div");
overlay.className = "rstack-auth-overlay";
let step: "input" | "verify" = "input";
let emailAddr = "";
const render = () => {
overlay.innerHTML = step === "input" ? `
<style>${MODAL_STYLES}${SETTINGS_STYLES}</style>
<div class="auth-modal">
<button class="close-btn" data-action="close">&times;</button>
<h2>Add Email</h2>
<p>Link an email for notifications and account recovery.</p>
<input class="input" id="s-email" type="email" placeholder="you@example.com" />
<div class="actions">
<button class="btn btn--primary" data-action="send-code">Send Verification Code</button>
</div>
<div class="error" id="s-error"></div>
</div>
` : `
<style>${MODAL_STYLES}${SETTINGS_STYLES}</style>
<div class="auth-modal">
<button class="close-btn" data-action="close">&times;</button>
<h2>Verify Email</h2>
<p>Enter the 6-digit code sent to <strong>${emailAddr.replace(/</g, "&lt;")}</strong></p>
<input class="input" id="s-code" type="text" placeholder="000000" maxlength="6" inputmode="numeric" />
<div class="actions">
<button class="btn btn--secondary" data-action="back">Back</button>
<button class="btn btn--primary" data-action="verify">Verify</button>
</div>
<div class="error" id="s-error"></div>
</div>
`;
attach();
};
const close = () => overlay.remove();
const attach = () => {
overlay.querySelector('[data-action="close"]')?.addEventListener("click", close);
overlay.addEventListener("click", (e) => { if (e.target === overlay) close(); });
overlay.querySelector('[data-action="send-code"]')?.addEventListener("click", async () => {
const input = overlay.querySelector("#s-email") as HTMLInputElement;
const err = overlay.querySelector("#s-error") as HTMLElement;
const btn = overlay.querySelector('[data-action="send-code"]') as HTMLButtonElement;
emailAddr = input.value.trim();
if (!emailAddr || !emailAddr.includes("@")) { err.textContent = "Enter a valid email address."; input.focus(); return; }
btn.disabled = true; btn.innerHTML = '<span class="spinner"></span> Sending...';
try {
const res = await fetch(`${ENCRYPTID_URL}/api/account/email/start`, {
method: "POST",
headers: { "Content-Type": "application/json", Authorization: `Bearer ${getAccessToken()}` },
body: JSON.stringify({ email: emailAddr }),
});
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || "Failed to send verification code");
step = "verify"; render();
setTimeout(() => (overlay.querySelector("#s-code") as HTMLInputElement)?.focus(), 50);
} catch (e: any) {
btn.disabled = false; btn.innerHTML = "Send Verification Code";
err.textContent = e.message;
}
});
overlay.querySelector('[data-action="back"]')?.addEventListener("click", () => { step = "input"; render(); });
overlay.querySelector('[data-action="verify"]')?.addEventListener("click", async () => {
const input = overlay.querySelector("#s-code") as HTMLInputElement;
const err = overlay.querySelector("#s-error") as HTMLElement;
const btn = overlay.querySelector('[data-action="verify"]') as HTMLButtonElement;
const code = input.value.trim();
if (!code) { err.textContent = "Enter the verification code."; input.focus(); return; }
btn.disabled = true; btn.innerHTML = '<span class="spinner"></span> Verifying...';
try {
const res = await fetch(`${ENCRYPTID_URL}/api/account/email/verify`, {
method: "POST",
headers: { "Content-Type": "application/json", Authorization: `Bearer ${getAccessToken()}` },
body: JSON.stringify({ email: emailAddr, code }),
});
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || "Verification failed");
close();
this.dispatchEvent(new CustomEvent("identity-action", { bubbles: true, composed: true, detail: { action: "email-added", email: emailAddr } }));
} catch (e: any) {
btn.disabled = false; btn.innerHTML = "Verify";
err.textContent = e.message;
}
});
overlay.querySelector("#s-email")?.addEventListener("keydown", (e) => {
if ((e as KeyboardEvent).key === "Enter") (overlay.querySelector('[data-action="send-code"]') as HTMLElement)?.click();
});
overlay.querySelector("#s-code")?.addEventListener("keydown", (e) => {
if ((e as KeyboardEvent).key === "Enter") (overlay.querySelector('[data-action="verify"]') as HTMLElement)?.click();
});
};
document.body.appendChild(overlay);
render();
}
#showAddDeviceModal(): void {
if (document.querySelector(".rstack-auth-overlay")) return;
const session = getSession();
if (!session) return;
const overlay = document.createElement("div");
overlay.className = "rstack-auth-overlay";
overlay.innerHTML = `
<style>${MODAL_STYLES}${SETTINGS_STYLES}</style>
<div class="auth-modal">
<button class="close-btn" data-action="close">&times;</button>
<h2>Add Second Device</h2>
<p>Register an additional passkey for backup access. Use this on a different device or browser.</p>
<div class="actions actions--stack">
<button class="btn btn--primary" data-action="register-device">🔑 Register Passkey on This Device</button>
</div>
<div class="error" id="s-error"></div>
<div class="info-text">Each device you register can independently sign in to your account.</div>
</div>
`;
const close = () => overlay.remove();
overlay.querySelector('[data-action="close"]')?.addEventListener("click", close);
overlay.addEventListener("click", (e) => { if (e.target === overlay) close(); });
overlay.querySelector('[data-action="register-device"]')?.addEventListener("click", async () => {
const err = overlay.querySelector("#s-error") as HTMLElement;
const btn = overlay.querySelector('[data-action="register-device"]') as HTMLButtonElement;
err.textContent = "";
btn.disabled = true; btn.innerHTML = '<span class="spinner"></span> Registering...';
try {
const startRes = await fetch(`${ENCRYPTID_URL}/api/account/device/start`, {
method: "POST",
headers: { "Content-Type": "application/json", Authorization: `Bearer ${getAccessToken()}` },
});
if (!startRes.ok) throw new Error((await startRes.json().catch(() => ({}))).error || "Failed to start device registration");
const { options: serverOptions, userId } = await startRes.json();
const username = session.claims.username || "";
const credential = (await navigator.credentials.create({
publicKey: {
challenge: new Uint8Array(base64urlToBuffer(serverOptions.challenge)),
rp: { id: serverOptions.rp?.id || "rspace.online", name: serverOptions.rp?.name || "EncryptID" },
user: {
id: new Uint8Array(base64urlToBuffer(serverOptions.user?.id || userId)),
name: username || session.claims.sub,
displayName: username || session.claims.sub,
},
pubKeyCredParams: serverOptions.pubKeyCredParams || [
{ alg: -7, type: "public-key" as const },
{ alg: -257, type: "public-key" as const },
],
authenticatorSelection: { residentKey: "required", requireResidentKey: true, userVerification: "required" },
attestation: "none",
timeout: 60000,
},
})) as PublicKeyCredential;
if (!credential) throw new Error("Passkey creation failed");
const response = credential.response as AuthenticatorAttestationResponse;
const publicKey = response.getPublicKey?.();
const completeRes = await fetch(`${ENCRYPTID_URL}/api/account/device/complete`, {
method: "POST",
headers: { "Content-Type": "application/json", Authorization: `Bearer ${getAccessToken()}` },
body: JSON.stringify({
challenge: serverOptions.challenge,
credential: {
credentialId: bufferToBase64url(credential.rawId),
publicKey: publicKey ? bufferToBase64url(publicKey) : "",
transports: response.getTransports?.() || [],
},
}),
});
if (!completeRes.ok) throw new Error((await completeRes.json().catch(() => ({}))).error || "Device registration failed");
btn.innerHTML = "Device Registered";
btn.className = "btn btn--success";
this.dispatchEvent(new CustomEvent("identity-action", { bubbles: true, composed: true, detail: { action: "device-added" } }));
setTimeout(close, 1500);
} catch (e: any) {
btn.disabled = false; btn.innerHTML = "🔑 Register Passkey on This Device";
err.textContent = e.name === "NotAllowedError" ? "Passkey creation was cancelled." : e.message;
}
});
document.body.appendChild(overlay);
}
#showAddRecoveryModal(): void {
if (document.querySelector(".rstack-auth-overlay")) return;
const overlay = document.createElement("div");
overlay.className = "rstack-auth-overlay";
const contacts: string[] = [];
const render = () => {
const contactsHTML = contacts.length > 0
? `<div class="contact-list">${contacts.map((c, i) => `
<div class="contact-item">
<span>${c.replace(/</g, "&lt;")}</span>
<button class="contact-remove" data-remove="${i}">&times;</button>
</div>
`).join("")}</div>`
: "";
const thresholdHTML = contacts.length >= 2
? `<div class="threshold-row">
<label>Recovery threshold:</label>
<select id="s-threshold">${
Array.from({ length: contacts.length - 1 }, (_, i) => i + 2)
.map(n => `<option value="${n}"${n === Math.ceil(contacts.length * 0.6) ? " selected" : ""}>${n} of ${contacts.length}</option>`)
.join("")
}</select>
<span class="threshold-hint">contacts needed to recover</span>
</div>`
: "";
overlay.innerHTML = `
<style>${MODAL_STYLES}${SETTINGS_STYLES}</style>
<div class="auth-modal">
<button class="close-btn" data-action="close">&times;</button>
<h2>Social Recovery</h2>
<p>Choose trusted contacts who can help recover your account.</p>
<div class="input-row">
<input class="input input--inline" id="s-contact" type="text" placeholder="Enter username" />
<button class="btn btn--small btn--primary" data-action="add-contact">Add</button>
</div>
${contactsHTML}
${thresholdHTML}
<div class="actions" style="margin-top:1rem">
${contacts.length >= 2 ? '<button class="btn btn--primary" data-action="save-recovery">Save Recovery Settings</button>' : ""}
</div>
<div class="error" id="s-error"></div>
${contacts.length < 2 ? '<div class="info-text">Add at least 2 trusted contacts to enable social recovery.</div>' : ""}
</div>
`;
attach();
};
const close = () => overlay.remove();
const attach = () => {
overlay.querySelector('[data-action="close"]')?.addEventListener("click", close);
overlay.addEventListener("click", (e) => { if (e.target === overlay) close(); });
overlay.querySelector('[data-action="add-contact"]')?.addEventListener("click", () => {
const input = overlay.querySelector("#s-contact") as HTMLInputElement;
const err = overlay.querySelector("#s-error") as HTMLElement;
const name = input.value.trim();
if (!name) { err.textContent = "Enter a username."; input.focus(); return; }
if (contacts.includes(name)) { err.textContent = "Already added."; return; }
contacts.push(name);
render();
setTimeout(() => (overlay.querySelector("#s-contact") as HTMLInputElement)?.focus(), 50);
});
overlay.querySelector("#s-contact")?.addEventListener("keydown", (e) => {
if ((e as KeyboardEvent).key === "Enter") (overlay.querySelector('[data-action="add-contact"]') as HTMLElement)?.click();
});
overlay.querySelectorAll("[data-remove]").forEach(el => {
el.addEventListener("click", () => {
contacts.splice(parseInt((el as HTMLElement).dataset.remove!, 10), 1);
render();
});
});
overlay.querySelector('[data-action="save-recovery"]')?.addEventListener("click", async () => {
const err = overlay.querySelector("#s-error") as HTMLElement;
const btn = overlay.querySelector('[data-action="save-recovery"]') as HTMLButtonElement;
const threshold = parseInt((overlay.querySelector("#s-threshold") as HTMLSelectElement)?.value || "2", 10);
btn.disabled = true; btn.innerHTML = '<span class="spinner"></span> Saving...';
try {
const res = await fetch(`${ENCRYPTID_URL}/api/account/recovery/setup`, {
method: "POST",
headers: { "Content-Type": "application/json", Authorization: `Bearer ${getAccessToken()}` },
body: JSON.stringify({ contacts, threshold }),
});
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || "Failed to save recovery settings");
btn.innerHTML = "Saved";
btn.className = "btn btn--success";
this.dispatchEvent(new CustomEvent("identity-action", { bubbles: true, composed: true, detail: { action: "recovery-configured", contacts, threshold } }));
setTimeout(close, 1500);
} catch (e: any) {
btn.disabled = false; btn.innerHTML = "Save Recovery Settings";
err.textContent = e.message;
}
});
};
document.body.appendChild(overlay);
render();
}
static define(tag = "rstack-identity") {
if (!customElements.get(tag)) customElements.define(tag, RStackIdentity);
}
@ -456,6 +760,13 @@ const STYLES = `
.user.dark .dropdown-item:hover { background: rgba(255,255,255,0.05); }
.dropdown-item--danger { color: #ef4444 !important; }
.dropdown-header {
padding: 10px 16px 6px; font-size: 0.8rem; font-weight: 700;
letter-spacing: 0.02em; white-space: nowrap; overflow: hidden;
text-overflow: ellipsis; max-width: 200px;
}
.user.light .dropdown-header { color: #1e293b; }
.user.dark .dropdown-header { color: #e2e8f0; }
.dropdown-divider { height: 1px; margin: 4px 0; }
.user.light .dropdown-divider { background: rgba(0,0,0,0.08); }
.user.dark .dropdown-divider { background: rgba(255,255,255,0.08); }
@ -507,7 +818,55 @@ const MODAL_STYLES = `
border-radius: 50%; animation: spin 0.7s linear infinite;
vertical-align: middle; margin-right: 6px;
}
.close-btn {
position: absolute; top: 12px; right: 16px;
background: none; border: none; color: #64748b; font-size: 1.5rem;
cursor: pointer; line-height: 1; padding: 4px 8px; border-radius: 6px;
transition: all 0.15s;
}
.close-btn:hover { color: white; background: rgba(255,255,255,0.1); }
.auth-modal { position: relative; }
.actions--stack { flex-direction: column; }
.btn--outline {
background: transparent; color: #94a3b8;
border: 1px solid rgba(255,255,255,0.15);
padding: 12px 20px; border-radius: 8px; font-size: 0.95rem;
font-weight: 600; cursor: pointer; transition: all 0.2s;
}
.btn--outline:hover { border-color: #06b6d4; color: white; background: rgba(6,182,212,0.08); }
.learn-more { margin-top: 1.5rem; font-size: 0.8rem; color: #475569; }
.learn-more a { color: #06b6d4; text-decoration: none; }
.learn-more a:hover { text-decoration: underline; }
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
@keyframes slideUp { from { opacity: 0; transform: translateY(20px); } to { opacity: 1; transform: translateY(0); } }
@keyframes spin { to { transform: rotate(360deg); } }
`;
const SETTINGS_STYLES = `
.info-text { margin-top: 1rem; font-size: 0.8rem; color: #475569; line-height: 1.5; }
.btn--success { background: #059669 !important; color: white; cursor: default; }
.btn--small { padding: 10px 16px; flex: none; }
.input-row { display: flex; gap: 8px; align-items: stretch; }
.input--inline { flex: 1; margin-bottom: 0; }
.contact-list { margin-top: 12px; display: flex; flex-direction: column; gap: 6px; }
.contact-item {
display: flex; align-items: center; justify-content: space-between;
padding: 8px 12px; border-radius: 8px; background: rgba(255,255,255,0.05);
border: 1px solid rgba(255,255,255,0.1); font-size: 0.9rem; color: #e2e8f0;
}
.contact-remove {
background: none; border: none; color: #64748b; font-size: 1.2rem;
cursor: pointer; padding: 2px 6px; border-radius: 4px; line-height: 1;
}
.contact-remove:hover { color: #ef4444; background: rgba(239,68,68,0.1); }
.threshold-row {
display: flex; align-items: center; gap: 8px; margin-top: 12px;
font-size: 0.85rem; color: #94a3b8;
}
.threshold-row label { white-space: nowrap; }
.threshold-row select {
padding: 6px 10px; border-radius: 6px; background: rgba(255,255,255,0.08);
border: 1px solid rgba(255,255,255,0.15); color: white; font-size: 0.85rem;
}
.threshold-hint { color: #64748b; font-size: 0.8rem; }
`;

View File

@ -81,7 +81,6 @@ export class RStackSpaceSwitcher extends HTMLElement {
<style>${STYLES}</style>
<div class="switcher">
<button class="trigger" id="trigger">
<span class="slash">/</span>
<span class="space-name">${name || "Select space"}</span>
<span class="caret"></span>
</button>
@ -108,9 +107,9 @@ export class RStackSpaceSwitcher extends HTMLElement {
#visibilityInfo(s: SpaceInfo): { cls: string; label: string } {
const v = s.visibility || "public_read";
if (v === "members_only") return { cls: "vis-private", label: "PRIVATE" };
if (v === "authenticated") return { cls: "vis-permissioned", label: "PERMISSIONED" };
return { cls: "vis-public", label: "PUBLIC" };
if (v === "members_only") return { cls: "vis-private", label: "\uD83D\uDD12" };
if (v === "authenticated") return { cls: "vis-permissioned", label: "\uD83D\uDD11" };
return { cls: "vis-public", label: "\uD83D\uDD13" };
}
#renderMenu(menu: HTMLElement, current: string) {
@ -191,15 +190,13 @@ const STYLES = `
.trigger {
display: flex; align-items: center; gap: 6px;
padding: 6px 14px; border-radius: 8px; border: none;
font-size: 0.875rem; font-weight: 600; cursor: pointer;
font-size: 0.9rem; font-weight: 600; cursor: pointer;
transition: background 0.15s; color: inherit;
}
:host-context([data-theme="light"]) .trigger { background: rgba(0,0,0,0.05); color: #374151; }
:host-context([data-theme="light"]) .trigger { background: rgba(0,0,0,0.05); color: #0f172a; }
:host-context([data-theme="light"]) .trigger:hover { background: rgba(0,0,0,0.08); }
:host-context([data-theme="dark"]) .trigger { background: rgba(255,255,255,0.08); color: #e2e8f0; }
:host-context([data-theme="dark"]) .trigger:hover { background: rgba(255,255,255,0.12); }
.slash { opacity: 0.4; font-weight: 300; margin-right: 2px; }
.space-name { max-width: 160px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.caret { font-size: 0.7em; opacity: 0.6; }
@ -240,9 +237,8 @@ const STYLES = `
/* Visibility badge */
.item-vis {
font-size: 0.55rem; font-weight: 700; text-transform: uppercase;
padding: 2px 6px; border-radius: 4px; flex-shrink: 0;
letter-spacing: 0.04em; line-height: 1.4;
font-size: 0.9rem; padding: 2px 4px; border-radius: 4px; flex-shrink: 0;
line-height: 1; display: flex; align-items: center; justify-content: center;
}
.item-vis.vis-public { background: rgba(52,211,153,0.15); color: #34d399; }
.item-vis.vis-private { background: rgba(248,113,113,0.15); color: #f87171; }