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 {
-
Sign in with EncryptID
-
Use your passkey to sign in. No passwords needed.
-
-
Cancel
+
×
+
Sign up / Sign in
+
Secure, passwordless authentication powered by passkeys.
+
🔑 Sign In with Passkey
+ 🔐 Create New Account
-
+
`;
const registerHTML = () => `
+
×
Create your EncryptID
Set up a secure, passwordless identity.
- Cancel
+ Back
🔐 Create Passkey
-
+
`;
@@ -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.
+
+
+ Send Verification Code
+
+
+
+ ` : `
+
+
+
×
+
Verify Email
+
Enter the 6-digit code sent to ${emailAddr.replace(/
+
+
+ Back
+ Verify
+
+
+
+ `;
+ 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.
+
+ 🔑 Register Passkey on This Device
+
+
+
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
+ ? `
`
+ : "";
+
+ const thresholdHTML = contacts.length >= 2
+ ? `
+ Recovery threshold:
+ ${
+ Array.from({ length: contacts.length - 1 }, (_, i) => i + 2)
+ .map(n => `${n} of ${contacts.length} `)
+ .join("")
+ }
+ contacts needed to recover
+
`
+ : "";
+
+ overlay.innerHTML = `
+
+
+
×
+
Social Recovery
+
Choose trusted contacts who can help recover your account.
+
+
+ Add
+
+ ${contactsHTML}
+ ${thresholdHTML}
+
+ ${contacts.length >= 2 ? 'Save Recovery Settings ' : ""}
+
+
+ ${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 {
- /
${name || "Select space"}
▾
@@ -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; }