From cd33f7c050cb79a864880ae8e22192e766ec6176 Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Wed, 25 Feb 2026 14:50:35 -0800 Subject: [PATCH] feat: redesign identity modal and space switcher UX MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- server/index.ts | 1 + shared/components/rstack-identity.ts | 385 ++++++++++++++++++++- shared/components/rstack-space-switcher.ts | 18 +- 3 files changed, 380 insertions(+), 24 deletions(-) diff --git a/server/index.ts b/server/index.ts index f89420c..1374215 100644 --- a/server/index.ts +++ b/server/index.ts @@ -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", diff --git a/shared/components/rstack-identity.ts b/shared/components/rstack-identity.ts index 5a6b343..5d06979 100644 --- a/shared/components/rstack-identity.ts +++ b/shared/components/rstack-identity.ts @@ -138,8 +138,11 @@ export class RStackIdentity extends HTMLElement {
${initial}
${displayName} @@ -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 = () => `
-

Sign in with EncryptID

-

Use your passkey to sign in. No passwords needed.

-
- + +

Sign up / Sign in

+

Secure, passwordless authentication powered by passkeys.

+
+
-
Don't have an account? Create one
+
Powered by EncryptID
`; const registerHTML = () => `
+

Create your EncryptID

Set up a secure, passwordless identity.

- +
-
Already have an account? Sign in
+
Powered by EncryptID
`; @@ -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" ? ` + +
+ +

Add Email

+

Link an email for notifications and account recovery.

+ +
+ +
+
+
+ ` : ` + +
+ +

Verify Email

+

Enter the 6-digit code sent to ${emailAddr.replace(/

+ +
+ + +
+
+
+ `; + 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 = ' 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 = ' 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 = ` + +
+ +

Add Second Device

+

Register an additional passkey for backup access. Use this on a different device or browser.

+
+ +
+
+
Each device you register can independently sign in to your account.
+
+ `; + + 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 = ' 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 + ? `
${contacts.map((c, i) => ` +
+ ${c.replace(/ + +
+ `).join("")}
` + : ""; + + const thresholdHTML = contacts.length >= 2 + ? `
+ + + contacts needed to recover +
` + : ""; + + overlay.innerHTML = ` + +
+ +

Social Recovery

+

Choose trusted contacts who can help recover your account.

+
+ + +
+ ${contactsHTML} + ${thresholdHTML} +
+ ${contacts.length >= 2 ? '' : ""} +
+
+ ${contacts.length < 2 ? '
Add at least 2 trusted contacts to enable social recovery.
' : ""} +
+ `; + 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 = ' 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; } +`; diff --git a/shared/components/rstack-space-switcher.ts b/shared/components/rstack-space-switcher.ts index 29fa080..c7ff94d 100644 --- a/shared/components/rstack-space-switcher.ts +++ b/shared/components/rstack-space-switcher.ts @@ -81,7 +81,6 @@ export class RStackSpaceSwitcher extends HTMLElement {
@@ -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; }