feat: wire up account settings endpoints (email, device, guardians)
Server (src/encryptid/server.ts): - POST /api/account/email/start — send 6-digit verification code via SMTP - POST /api/account/email/verify — verify code and set email on account - POST /api/account/device/start — WebAuthn creation options for same-device passkey registration (authenticated, reuses existing userId) - POST /api/account/device/complete — store additional credential under existing account DB (src/encryptid/db.ts): - Add 'device_registration' to StoredChallenge.type union - Add 'email_verification' to StoredRecoveryToken.type union Client (shared/components/rstack-identity.ts): - Rewrite social recovery modal to use existing guardian API: GET /api/guardians, POST /api/guardians, DELETE /api/guardians/:id - Loads existing guardians on open, adds/removes in real-time - Shows guardian status (accepted/pending), invite emails sent on add - Two name+email inputs (max 3 guardians, server-enforced) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
0647f19bd9
commit
914d0e61c4
|
|
@ -586,29 +586,26 @@ export class RStackIdentity extends HTMLElement {
|
||||||
if (document.querySelector(".rstack-auth-overlay")) return;
|
if (document.querySelector(".rstack-auth-overlay")) return;
|
||||||
const overlay = document.createElement("div");
|
const overlay = document.createElement("div");
|
||||||
overlay.className = "rstack-auth-overlay";
|
overlay.className = "rstack-auth-overlay";
|
||||||
const contacts: string[] = [];
|
let guardians: { id: string; name: string; email?: string; status: string }[] = [];
|
||||||
|
let threshold = 2;
|
||||||
|
let loading = true;
|
||||||
|
|
||||||
const render = () => {
|
const render = () => {
|
||||||
const contactsHTML = contacts.length > 0
|
const guardiansHTML = guardians.length > 0
|
||||||
? `<div class="contact-list">${contacts.map((c, i) => `
|
? `<div class="contact-list">${guardians.map(g => `
|
||||||
<div class="contact-item">
|
<div class="contact-item">
|
||||||
<span>${c.replace(/</g, "<")}</span>
|
<div style="display:flex;flex-direction:column;gap:2px;min-width:0;flex:1">
|
||||||
<button class="contact-remove" data-remove="${i}">×</button>
|
<span>${g.name.replace(/</g, "<")}${g.email ? ` <span style="opacity:0.5;font-size:0.8rem">${g.email.replace(/</g, "<")}</span>` : ""}</span>
|
||||||
|
<span style="font-size:0.7rem;color:${g.status === "accepted" ? "#34d399" : "#fbbf24"}">${g.status === "accepted" ? "Accepted" : "Pending invite"}</span>
|
||||||
|
</div>
|
||||||
|
<button class="contact-remove" data-remove-id="${g.id}">×</button>
|
||||||
</div>
|
</div>
|
||||||
`).join("")}</div>`
|
`).join("")}</div>`
|
||||||
: "";
|
: "";
|
||||||
|
|
||||||
const thresholdHTML = contacts.length >= 2
|
const infoHTML = guardians.length < 2
|
||||||
? `<div class="threshold-row">
|
? `<div class="info-text">Add at least 2 trusted guardians to enable social recovery. Threshold: ${threshold} of ${Math.max(guardians.length, 2)} needed to recover.</div>`
|
||||||
<label>Recovery threshold:</label>
|
: `<div class="info-text" style="color:#34d399">Social recovery is active. ${threshold} of ${guardians.length} guardians needed to recover your account.</div>`;
|
||||||
<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 = `
|
overlay.innerHTML = `
|
||||||
<style>${MODAL_STYLES}${SETTINGS_STYLES}</style>
|
<style>${MODAL_STYLES}${SETTINGS_STYLES}</style>
|
||||||
|
|
@ -616,17 +613,16 @@ export class RStackIdentity extends HTMLElement {
|
||||||
<button class="close-btn" data-action="close">×</button>
|
<button class="close-btn" data-action="close">×</button>
|
||||||
<h2>Social Recovery</h2>
|
<h2>Social Recovery</h2>
|
||||||
<p>Choose trusted contacts who can help recover your account.</p>
|
<p>Choose trusted contacts who can help recover your account.</p>
|
||||||
<div class="input-row">
|
${loading ? '<div style="text-align:center;padding:1rem;color:#94a3b8"><span class="spinner"></span> Loading guardians...</div>' : `
|
||||||
<input class="input input--inline" id="s-contact" type="text" placeholder="Enter username" />
|
${guardians.length < 3 ? `<div class="input-row">
|
||||||
<button class="btn btn--small btn--primary" data-action="add-contact">Add</button>
|
<input class="input input--inline" id="s-name" type="text" placeholder="Guardian name" />
|
||||||
</div>
|
<input class="input input--inline" id="s-email" type="email" placeholder="Email (optional)" />
|
||||||
${contactsHTML}
|
<button class="btn btn--small btn--primary" data-action="add-guardian">Add</button>
|
||||||
${thresholdHTML}
|
</div>` : ""}
|
||||||
<div class="actions" style="margin-top:1rem">
|
${guardiansHTML}
|
||||||
${contacts.length >= 2 ? '<button class="btn btn--primary" data-action="save-recovery">Save Recovery Settings</button>' : ""}
|
${infoHTML}
|
||||||
</div>
|
`}
|
||||||
<div class="error" id="s-error"></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>
|
</div>
|
||||||
`;
|
`;
|
||||||
attach();
|
attach();
|
||||||
|
|
@ -634,57 +630,81 @@ export class RStackIdentity extends HTMLElement {
|
||||||
|
|
||||||
const close = () => overlay.remove();
|
const close = () => overlay.remove();
|
||||||
|
|
||||||
|
const loadGuardians = async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${ENCRYPTID_URL}/api/guardians`, {
|
||||||
|
headers: { Authorization: `Bearer ${getAccessToken()}` },
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
guardians = data.guardians || [];
|
||||||
|
threshold = data.threshold || 2;
|
||||||
|
}
|
||||||
|
} catch { /* offline */ }
|
||||||
|
loading = false;
|
||||||
|
render();
|
||||||
|
};
|
||||||
|
|
||||||
const attach = () => {
|
const attach = () => {
|
||||||
overlay.querySelector('[data-action="close"]')?.addEventListener("click", close);
|
overlay.querySelector('[data-action="close"]')?.addEventListener("click", close);
|
||||||
overlay.addEventListener("click", (e) => { if (e.target === overlay) close(); });
|
overlay.addEventListener("click", (e) => { if (e.target === overlay) close(); });
|
||||||
|
|
||||||
overlay.querySelector('[data-action="add-contact"]')?.addEventListener("click", () => {
|
overlay.querySelector('[data-action="add-guardian"]')?.addEventListener("click", async () => {
|
||||||
const input = overlay.querySelector("#s-contact") as HTMLInputElement;
|
const nameInput = overlay.querySelector("#s-name") as HTMLInputElement;
|
||||||
|
const emailInput = overlay.querySelector("#s-email") as HTMLInputElement;
|
||||||
const err = overlay.querySelector("#s-error") as HTMLElement;
|
const err = overlay.querySelector("#s-error") as HTMLElement;
|
||||||
const name = input.value.trim();
|
const btn = overlay.querySelector('[data-action="add-guardian"]') as HTMLButtonElement;
|
||||||
if (!name) { err.textContent = "Enter a username."; input.focus(); return; }
|
const name = nameInput.value.trim();
|
||||||
if (contacts.includes(name)) { err.textContent = "Already added."; return; }
|
const email = emailInput.value.trim();
|
||||||
contacts.push(name);
|
if (!name) { err.textContent = "Enter a guardian name."; nameInput.focus(); return; }
|
||||||
render();
|
err.textContent = "";
|
||||||
setTimeout(() => (overlay.querySelector("#s-contact") as HTMLInputElement)?.focus(), 50);
|
btn.disabled = true; btn.innerHTML = '<span class="spinner"></span>';
|
||||||
});
|
|
||||||
|
|
||||||
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 {
|
try {
|
||||||
const res = await fetch(`${ENCRYPTID_URL}/api/account/recovery/setup`, {
|
const res = await fetch(`${ENCRYPTID_URL}/api/guardians`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json", Authorization: `Bearer ${getAccessToken()}` },
|
headers: { "Content-Type": "application/json", Authorization: `Bearer ${getAccessToken()}` },
|
||||||
body: JSON.stringify({ contacts, threshold }),
|
body: JSON.stringify({ name, email: email || undefined }),
|
||||||
});
|
});
|
||||||
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || "Failed to save recovery settings");
|
const data = await res.json();
|
||||||
btn.innerHTML = "Saved";
|
if (!res.ok) throw new Error(data.error || "Failed to add guardian");
|
||||||
btn.className = "btn btn--success";
|
guardians.push({ id: data.guardian.id, name: data.guardian.name, email: data.guardian.email, status: data.guardian.status });
|
||||||
this.dispatchEvent(new CustomEvent("identity-action", { bubbles: true, composed: true, detail: { action: "recovery-configured", contacts, threshold } }));
|
render();
|
||||||
setTimeout(close, 1500);
|
setTimeout(() => (overlay.querySelector("#s-name") as HTMLInputElement)?.focus(), 50);
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
btn.disabled = false; btn.innerHTML = "Save Recovery Settings";
|
btn.disabled = false; btn.innerHTML = "Add";
|
||||||
err.textContent = e.message;
|
err.textContent = e.message;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
overlay.querySelector("#s-name")?.addEventListener("keydown", (e) => {
|
||||||
|
if ((e as KeyboardEvent).key === "Enter") (overlay.querySelector('[data-action="add-guardian"]') as HTMLElement)?.click();
|
||||||
|
});
|
||||||
|
overlay.querySelector("#s-email")?.addEventListener("keydown", (e) => {
|
||||||
|
if ((e as KeyboardEvent).key === "Enter") (overlay.querySelector('[data-action="add-guardian"]') as HTMLElement)?.click();
|
||||||
|
});
|
||||||
|
|
||||||
|
overlay.querySelectorAll("[data-remove-id]").forEach(el => {
|
||||||
|
el.addEventListener("click", async () => {
|
||||||
|
const id = (el as HTMLElement).dataset.removeId!;
|
||||||
|
const err = overlay.querySelector("#s-error") as HTMLElement;
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${ENCRYPTID_URL}/api/guardians/${id}`, {
|
||||||
|
method: "DELETE",
|
||||||
|
headers: { Authorization: `Bearer ${getAccessToken()}` },
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error("Failed to remove guardian");
|
||||||
|
guardians = guardians.filter(g => g.id !== id);
|
||||||
|
render();
|
||||||
|
} catch (e: any) {
|
||||||
|
err.textContent = e.message;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
document.body.appendChild(overlay);
|
document.body.appendChild(overlay);
|
||||||
render();
|
render();
|
||||||
|
loadGuardians();
|
||||||
}
|
}
|
||||||
|
|
||||||
static define(tag = "rstack-identity") {
|
static define(tag = "rstack-identity") {
|
||||||
|
|
|
||||||
|
|
@ -43,7 +43,7 @@ export interface StoredCredential {
|
||||||
export interface StoredChallenge {
|
export interface StoredChallenge {
|
||||||
challenge: string;
|
challenge: string;
|
||||||
userId?: string;
|
userId?: string;
|
||||||
type: 'registration' | 'authentication';
|
type: 'registration' | 'authentication' | 'device_registration';
|
||||||
createdAt: number;
|
createdAt: number;
|
||||||
expiresAt: number;
|
expiresAt: number;
|
||||||
}
|
}
|
||||||
|
|
@ -219,7 +219,7 @@ export async function getUserById(userId: string) {
|
||||||
export interface StoredRecoveryToken {
|
export interface StoredRecoveryToken {
|
||||||
token: string;
|
token: string;
|
||||||
userId: string;
|
userId: string;
|
||||||
type: 'email_verify' | 'account_recovery';
|
type: 'email_verify' | 'account_recovery' | 'email_verification';
|
||||||
createdAt: number;
|
createdAt: number;
|
||||||
expiresAt: number;
|
expiresAt: number;
|
||||||
used: boolean;
|
used: boolean;
|
||||||
|
|
|
||||||
|
|
@ -637,6 +637,191 @@ app.get('/api/user/credentials', async (c) => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// ACCOUNT SETTINGS ENDPOINTS
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/account/email/start — send verification code to email
|
||||||
|
* Body: { email }
|
||||||
|
* Auth required
|
||||||
|
*/
|
||||||
|
app.post('/api/account/email/start', async (c) => {
|
||||||
|
const claims = await verifyTokenFromRequest(c.req.header('Authorization'));
|
||||||
|
if (!claims) return c.json({ error: 'Unauthorized' }, 401);
|
||||||
|
|
||||||
|
const { email } = await c.req.json();
|
||||||
|
if (!email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
|
||||||
|
return c.json({ error: 'Valid email required' }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const code = String(Math.floor(100000 + Math.random() * 900000));
|
||||||
|
const tokenKey = `emailverify_${claims.sub}_${code}`;
|
||||||
|
|
||||||
|
await storeRecoveryToken({
|
||||||
|
token: tokenKey,
|
||||||
|
userId: claims.sub as string,
|
||||||
|
type: 'email_verification',
|
||||||
|
createdAt: Date.now(),
|
||||||
|
expiresAt: Date.now() + 10 * 60 * 1000, // 10 minutes
|
||||||
|
used: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (smtpTransport) {
|
||||||
|
try {
|
||||||
|
await smtpTransport.sendMail({
|
||||||
|
from: CONFIG.smtp.from,
|
||||||
|
to: email,
|
||||||
|
subject: `${code} — rStack Email Verification`,
|
||||||
|
text: `Your verification code is: ${code}\n\nThis code expires in 10 minutes.\n\n— rStack Identity`,
|
||||||
|
html: `
|
||||||
|
<!DOCTYPE html><html><head><meta charset="utf-8"></head>
|
||||||
|
<body style="margin:0;padding:0;background:#1a1a2e;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;">
|
||||||
|
<table width="100%" cellpadding="0" cellspacing="0" style="background:#1a1a2e;padding:40px 20px;">
|
||||||
|
<tr><td align="center">
|
||||||
|
<table width="480" cellpadding="0" cellspacing="0" style="background:#16213e;border-radius:12px;border:1px solid rgba(255,255,255,0.1);">
|
||||||
|
<tr><td style="padding:32px 32px 24px;text-align:center;">
|
||||||
|
<div style="font-size:36px;margin-bottom:8px;">✉</div>
|
||||||
|
<h1 style="margin:0;font-size:24px;background:linear-gradient(90deg,#00d4ff,#7c3aed);-webkit-background-clip:text;-webkit-text-fill-color:transparent;">Email Verification</h1>
|
||||||
|
</td></tr>
|
||||||
|
<tr><td style="padding:0 32px 24px;color:#e2e8f0;font-size:15px;line-height:1.6;text-align:center;">
|
||||||
|
<p>Your verification code is:</p>
|
||||||
|
<div style="font-size:36px;font-weight:800;letter-spacing:8px;color:#06b6d4;padding:16px 0;">${code}</div>
|
||||||
|
<p style="color:#94a3b8;font-size:13px;">This code expires in 10 minutes.</p>
|
||||||
|
</td></tr>
|
||||||
|
</table>
|
||||||
|
</td></tr>
|
||||||
|
</table>
|
||||||
|
</body></html>`,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('EncryptID: Failed to send verification email:', err);
|
||||||
|
return c.json({ error: 'Failed to send verification email' }, 500);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log(`EncryptID: [NO SMTP] Email verification code for ${email}: ${code}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.json({ success: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/account/email/verify — verify code and set email
|
||||||
|
* Body: { email, code }
|
||||||
|
* Auth required
|
||||||
|
*/
|
||||||
|
app.post('/api/account/email/verify', async (c) => {
|
||||||
|
const claims = await verifyTokenFromRequest(c.req.header('Authorization'));
|
||||||
|
if (!claims) return c.json({ error: 'Unauthorized' }, 401);
|
||||||
|
|
||||||
|
const { email, code } = await c.req.json();
|
||||||
|
if (!email || !code) return c.json({ error: 'Email and code required' }, 400);
|
||||||
|
|
||||||
|
const tokenKey = `emailverify_${claims.sub}_${code}`;
|
||||||
|
const rt = await getRecoveryToken(tokenKey);
|
||||||
|
|
||||||
|
if (!rt || rt.used || Date.now() > rt.expiresAt || rt.userId !== (claims.sub as string)) {
|
||||||
|
return c.json({ error: 'Invalid or expired verification code' }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
await markRecoveryTokenUsed(tokenKey);
|
||||||
|
await setUserEmail(claims.sub as string, email);
|
||||||
|
|
||||||
|
return c.json({ success: true, email });
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/account/device/start — get WebAuthn options for registering another passkey
|
||||||
|
* Auth required
|
||||||
|
*/
|
||||||
|
app.post('/api/account/device/start', async (c) => {
|
||||||
|
const claims = await verifyTokenFromRequest(c.req.header('Authorization'));
|
||||||
|
if (!claims) return c.json({ error: 'Unauthorized' }, 401);
|
||||||
|
|
||||||
|
const user = await getUserById(claims.sub as string);
|
||||||
|
if (!user) return c.json({ error: 'User not found' }, 404);
|
||||||
|
|
||||||
|
const challenge = Buffer.from(crypto.getRandomValues(new Uint8Array(32))).toString('base64url');
|
||||||
|
|
||||||
|
await storeChallenge({
|
||||||
|
challenge,
|
||||||
|
userId: claims.sub as string,
|
||||||
|
type: 'device_registration',
|
||||||
|
createdAt: Date.now(),
|
||||||
|
expiresAt: Date.now() + 5 * 60 * 1000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const rpId = resolveRpId(c);
|
||||||
|
const options = {
|
||||||
|
challenge,
|
||||||
|
rp: { id: rpId, name: CONFIG.rpName },
|
||||||
|
user: {
|
||||||
|
id: claims.sub as string,
|
||||||
|
name: user.username,
|
||||||
|
displayName: user.username,
|
||||||
|
},
|
||||||
|
pubKeyCredParams: [
|
||||||
|
{ alg: -7, type: 'public-key' },
|
||||||
|
{ alg: -257, type: 'public-key' },
|
||||||
|
],
|
||||||
|
authenticatorSelection: {
|
||||||
|
residentKey: 'required',
|
||||||
|
requireResidentKey: true,
|
||||||
|
userVerification: 'required',
|
||||||
|
},
|
||||||
|
timeout: 60000,
|
||||||
|
attestation: 'none',
|
||||||
|
};
|
||||||
|
|
||||||
|
return c.json({ options, userId: claims.sub });
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/account/device/complete — register additional passkey for existing account
|
||||||
|
* Body: { challenge, credential: { credentialId, publicKey, transports } }
|
||||||
|
* Auth required
|
||||||
|
*/
|
||||||
|
app.post('/api/account/device/complete', async (c) => {
|
||||||
|
const claims = await verifyTokenFromRequest(c.req.header('Authorization'));
|
||||||
|
if (!claims) return c.json({ error: 'Unauthorized' }, 401);
|
||||||
|
|
||||||
|
const { challenge, credential } = await c.req.json();
|
||||||
|
if (!challenge || !credential?.credentialId) {
|
||||||
|
return c.json({ error: 'Challenge and credential required' }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const challengeRecord = await getChallenge(challenge);
|
||||||
|
if (!challengeRecord || challengeRecord.type !== 'device_registration') {
|
||||||
|
return c.json({ error: 'Invalid challenge' }, 400);
|
||||||
|
}
|
||||||
|
if (challengeRecord.userId !== (claims.sub as string)) {
|
||||||
|
return c.json({ error: 'Challenge mismatch' }, 400);
|
||||||
|
}
|
||||||
|
if (Date.now() > challengeRecord.expiresAt) {
|
||||||
|
await deleteChallenge(challenge);
|
||||||
|
return c.json({ error: 'Challenge expired' }, 400);
|
||||||
|
}
|
||||||
|
await deleteChallenge(challenge);
|
||||||
|
|
||||||
|
const user = await getUserById(claims.sub as string);
|
||||||
|
if (!user) return c.json({ error: 'User not found' }, 404);
|
||||||
|
|
||||||
|
const rpId = resolveRpId(c);
|
||||||
|
await storeCredential({
|
||||||
|
credentialId: credential.credentialId,
|
||||||
|
publicKey: credential.publicKey || '',
|
||||||
|
userId: claims.sub as string,
|
||||||
|
username: user.username,
|
||||||
|
counter: 0,
|
||||||
|
createdAt: Date.now(),
|
||||||
|
transports: credential.transports || [],
|
||||||
|
rpId,
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('EncryptID: Additional device registered for', user.username);
|
||||||
|
return c.json({ success: true });
|
||||||
|
});
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// RECOVERY ENDPOINTS
|
// RECOVERY ENDPOINTS
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue