Changeset 0.35.1 (#211)
This commit is contained in:
307
src/js/data-portability.js
Normal file
307
src/js/data-portability.js
Normal file
@@ -0,0 +1,307 @@
|
||||
// data-portability.js — v0.35.1
|
||||
//
|
||||
// Settings → Data & Privacy section. Wires the v0.34.0 backend
|
||||
// endpoints: export/import user data, ChatGPT import, and GDPR
|
||||
// account deletion. Also used by admin-handlers.js for team export.
|
||||
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
// ── API helpers ──────────────────────────────
|
||||
|
||||
const _base = (window.__BASE__ || '') + '/api/v1';
|
||||
|
||||
async function _exportMyData() {
|
||||
const btn = document.getElementById('dpExportBtn');
|
||||
if (!btn) return;
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'Exporting…';
|
||||
try {
|
||||
const resp = await fetch(_base + '/export/me', {
|
||||
headers: { Authorization: 'Bearer ' + API.accessToken },
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const err = await resp.json().catch(() => ({}));
|
||||
throw new Error(err.error || 'Export failed (' + resp.status + ')');
|
||||
}
|
||||
const blob = await resp.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
// Extract filename from Content-Disposition or use default
|
||||
const cd = resp.headers.get('Content-Disposition') || '';
|
||||
const match = cd.match(/filename="?([^"]+)"?/);
|
||||
a.download = match ? match[1] : 'switchboard-export.switchboard';
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
UI.toast('Export downloaded', 'success');
|
||||
} catch (e) {
|
||||
UI.toast(e.message, 'error');
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
btn.textContent = 'Download My Data';
|
||||
}
|
||||
}
|
||||
|
||||
async function _importMyData() {
|
||||
const fileInput = document.getElementById('dpImportFile');
|
||||
if (!fileInput || !fileInput.files[0]) return;
|
||||
const file = fileInput.files[0];
|
||||
|
||||
const btn = document.getElementById('dpImportBtn');
|
||||
if (btn) { btn.disabled = true; btn.textContent = 'Importing…'; }
|
||||
|
||||
const form = new FormData();
|
||||
form.append('file', file);
|
||||
|
||||
try {
|
||||
const resp = await fetch(_base + '/import/me', {
|
||||
method: 'POST',
|
||||
headers: { Authorization: 'Bearer ' + API.accessToken },
|
||||
body: form,
|
||||
});
|
||||
const data = await resp.json();
|
||||
if (!resp.ok) throw new Error(data.error || 'Import failed');
|
||||
|
||||
let msg = 'Import complete.';
|
||||
if (data.imported) {
|
||||
const counts = Object.entries(data.imported)
|
||||
.filter(([, v]) => v > 0)
|
||||
.map(([k, v]) => v + ' ' + k)
|
||||
.join(', ');
|
||||
if (counts) msg += ' Imported: ' + counts + '.';
|
||||
}
|
||||
if (data.skipped) {
|
||||
const skips = Object.entries(data.skipped)
|
||||
.filter(([, v]) => v > 0)
|
||||
.map(([k, v]) => v + ' ' + k)
|
||||
.join(', ');
|
||||
if (skips) msg += ' Skipped (already exist): ' + skips + '.';
|
||||
}
|
||||
if (data.errors && data.errors.length) {
|
||||
msg += ' Errors: ' + data.errors.join('; ');
|
||||
UI.toast(msg, 'warning');
|
||||
} else {
|
||||
UI.toast(msg, 'success');
|
||||
}
|
||||
} catch (e) {
|
||||
UI.toast(e.message, 'error');
|
||||
} finally {
|
||||
fileInput.value = '';
|
||||
if (btn) { btn.disabled = false; btn.textContent = 'Import'; }
|
||||
}
|
||||
}
|
||||
|
||||
async function _importChatGPT() {
|
||||
const fileInput = document.getElementById('dpChatGPTFile');
|
||||
if (!fileInput || !fileInput.files[0]) return;
|
||||
const file = fileInput.files[0];
|
||||
|
||||
const btn = document.getElementById('dpChatGPTBtn');
|
||||
if (btn) { btn.disabled = true; btn.textContent = 'Importing…'; }
|
||||
|
||||
const form = new FormData();
|
||||
form.append('file', file);
|
||||
|
||||
try {
|
||||
const resp = await fetch(_base + '/import/chatgpt', {
|
||||
method: 'POST',
|
||||
headers: { Authorization: 'Bearer ' + API.accessToken },
|
||||
body: form,
|
||||
});
|
||||
const data = await resp.json();
|
||||
if (!resp.ok) throw new Error(data.error || 'Import failed');
|
||||
|
||||
let msg = 'ChatGPT import complete.';
|
||||
if (data.imported) {
|
||||
const counts = Object.entries(data.imported)
|
||||
.filter(([, v]) => v > 0)
|
||||
.map(([k, v]) => v + ' ' + k)
|
||||
.join(', ');
|
||||
if (counts) msg += ' Imported: ' + counts + '.';
|
||||
}
|
||||
if (data.skipped) {
|
||||
const skips = Object.entries(data.skipped)
|
||||
.filter(([, v]) => v > 0)
|
||||
.map(([k, v]) => v + ' ' + k)
|
||||
.join(', ');
|
||||
if (skips) msg += ' Skipped: ' + skips + '.';
|
||||
}
|
||||
UI.toast(msg, 'success');
|
||||
} catch (e) {
|
||||
UI.toast(e.message, 'error');
|
||||
} finally {
|
||||
fileInput.value = '';
|
||||
if (btn) { btn.disabled = false; btn.textContent = 'Import'; }
|
||||
}
|
||||
}
|
||||
|
||||
function _showDeleteConfirm() {
|
||||
const el = document.getElementById('dpDeleteSection');
|
||||
if (!el) return;
|
||||
el.innerHTML = `
|
||||
<div style="background:var(--danger-dim);border:1px solid var(--danger);border-radius:var(--radius);padding:14px 16px;margin-top:12px;">
|
||||
<p style="color:var(--danger);font-weight:600;font-size:13px;margin:0 0 8px;">
|
||||
This will permanently delete your account and all associated data.
|
||||
This action cannot be undone.
|
||||
</p>
|
||||
<div class="form-group" style="margin-bottom:8px;">
|
||||
<label style="font-size:12px;color:var(--text-2);">Enter your password to confirm</label>
|
||||
<input type="password" id="dpDeletePassword" autocomplete="current-password"
|
||||
style="max-width:280px;" placeholder="Your password">
|
||||
</div>
|
||||
<div style="display:flex;gap:8px;align-items:center;">
|
||||
<button class="btn-md btn-danger" id="dpDeleteConfirmBtn">Delete My Account</button>
|
||||
<button class="btn-md btn-ghost" id="dpDeleteCancelBtn">Cancel</button>
|
||||
</div>
|
||||
</div>`;
|
||||
document.getElementById('dpDeleteConfirmBtn')
|
||||
.addEventListener('click', _executeDelete);
|
||||
document.getElementById('dpDeleteCancelBtn')
|
||||
.addEventListener('click', () => { _renderDeleteButton(el); });
|
||||
}
|
||||
|
||||
function _renderDeleteButton(el) {
|
||||
el.innerHTML = `
|
||||
<button class="btn-md btn-ghost" id="dpDeleteStartBtn"
|
||||
style="color:var(--danger);border-color:var(--danger);">
|
||||
Delete My Account
|
||||
</button>`;
|
||||
document.getElementById('dpDeleteStartBtn')
|
||||
.addEventListener('click', _showDeleteConfirm);
|
||||
}
|
||||
|
||||
async function _executeDelete() {
|
||||
const pw = document.getElementById('dpDeletePassword');
|
||||
if (!pw || !pw.value) {
|
||||
UI.toast('Password is required', 'error');
|
||||
return;
|
||||
}
|
||||
const btn = document.getElementById('dpDeleteConfirmBtn');
|
||||
if (btn) { btn.disabled = true; btn.textContent = 'Deleting…'; }
|
||||
try {
|
||||
const resp = await fetch(_base + '/me', {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
Authorization: 'Bearer ' + API.accessToken,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ confirm: 'DELETE', password: pw.value }),
|
||||
});
|
||||
const data = await resp.json();
|
||||
if (!resp.ok) throw new Error(data.error || 'Delete failed');
|
||||
UI.toast('Account deleted. Redirecting…', 'success');
|
||||
setTimeout(() => {
|
||||
API.clearTokens();
|
||||
window.location.href = '/login';
|
||||
}, 1500);
|
||||
} catch (e) {
|
||||
UI.toast(e.message, 'error');
|
||||
if (btn) { btn.disabled = false; btn.textContent = 'Delete My Account'; }
|
||||
}
|
||||
}
|
||||
|
||||
// ── Section renderer ─────────────────────────
|
||||
|
||||
function loadDataPrivacy() {
|
||||
const mount = document.getElementById('dpMount');
|
||||
if (!mount) return;
|
||||
|
||||
mount.innerHTML = `
|
||||
<div class="settings-section">
|
||||
<h3>Export My Data</h3>
|
||||
<p style="font-size:13px;color:var(--text-2);margin:0 0 10px;">
|
||||
Download all your data as a <code>.switchboard</code> archive: conversations,
|
||||
notes, memories, projects, files, settings, and usage history.
|
||||
</p>
|
||||
<button class="btn-md btn-primary" id="dpExportBtn">Download My Data</button>
|
||||
</div>
|
||||
|
||||
<div class="settings-section" style="margin-top:20px;">
|
||||
<h3>Import Data</h3>
|
||||
<p style="font-size:13px;color:var(--text-2);margin:0 0 10px;">
|
||||
Restore from a <code>.switchboard</code> archive exported from this or another
|
||||
instance. Existing items with the same ID are skipped (no duplicates).
|
||||
</p>
|
||||
<div style="display:flex;gap:8px;align-items:center;">
|
||||
<input type="file" id="dpImportFile" accept=".switchboard,.zip" style="font-size:13px;">
|
||||
<button class="btn-md btn-primary" id="dpImportBtn" disabled>Import</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-section" style="margin-top:20px;">
|
||||
<h3>Import from ChatGPT</h3>
|
||||
<p style="font-size:13px;color:var(--text-2);margin:0 0 10px;">
|
||||
Import conversations from a ChatGPT export. Go to
|
||||
<a href="https://chatgpt.com/#settings/DataControls" target="_blank"
|
||||
style="color:var(--accent);">ChatGPT Settings → Data Controls → Export</a>,
|
||||
download the zip, and upload it here. Conversations become direct chats.
|
||||
</p>
|
||||
<div style="display:flex;gap:8px;align-items:center;">
|
||||
<input type="file" id="dpChatGPTFile" accept=".zip" style="font-size:13px;">
|
||||
<button class="btn-md btn-primary" id="dpChatGPTBtn" disabled>Import</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-section" style="margin-top:24px;">
|
||||
<h3 style="color:var(--danger);">Danger Zone</h3>
|
||||
<p style="font-size:13px;color:var(--text-2);margin:0 0 10px;">
|
||||
Permanently delete your account and all associated data. Your messages in
|
||||
shared channels will be anonymized. This cannot be undone.
|
||||
</p>
|
||||
<div id="dpDeleteSection"></div>
|
||||
</div>`;
|
||||
|
||||
// Wire listeners
|
||||
document.getElementById('dpExportBtn').addEventListener('click', _exportMyData);
|
||||
|
||||
const impFile = document.getElementById('dpImportFile');
|
||||
const impBtn = document.getElementById('dpImportBtn');
|
||||
impFile.addEventListener('change', () => { impBtn.disabled = !impFile.files.length; });
|
||||
impBtn.addEventListener('click', _importMyData);
|
||||
|
||||
const cgFile = document.getElementById('dpChatGPTFile');
|
||||
const cgBtn = document.getElementById('dpChatGPTBtn');
|
||||
cgFile.addEventListener('change', () => { cgBtn.disabled = !cgFile.files.length; });
|
||||
cgBtn.addEventListener('click', _importChatGPT);
|
||||
|
||||
_renderDeleteButton(document.getElementById('dpDeleteSection'));
|
||||
}
|
||||
|
||||
// ── Admin: team export ───────────────────────
|
||||
|
||||
async function exportTeamData(teamId, teamName) {
|
||||
UI.toast('Exporting team data…', 'info');
|
||||
try {
|
||||
const resp = await fetch(_base + '/admin/teams/' + teamId + '/export', {
|
||||
headers: { Authorization: 'Bearer ' + API.accessToken },
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const err = await resp.json().catch(() => ({}));
|
||||
throw new Error(err.error || 'Export failed (' + resp.status + ')');
|
||||
}
|
||||
const blob = await resp.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
const cd = resp.headers.get('Content-Disposition') || '';
|
||||
const match = cd.match(/filename="?([^"]+)"?/);
|
||||
a.download = match ? match[1] : ('switchboard-team-' + (teamName || teamId).replace(/\s+/g, '-') + '.switchboard');
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
UI.toast('Team export downloaded', 'success');
|
||||
} catch (e) {
|
||||
UI.toast(e.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// ── Register ─────────────────────────────────
|
||||
|
||||
sb.register('loadDataPrivacy', loadDataPrivacy);
|
||||
sb.register('exportTeamData', exportTeamData);
|
||||
|
||||
})();
|
||||
@@ -890,6 +890,7 @@ Object.assign(UI, {
|
||||
<td style="font-size:12px;color:var(--text-2)">${t.member_count} member${t.member_count !== 1 ? 's' : ''}</td>
|
||||
<td class="admin-actions-cell">
|
||||
<button class="icon-btn" data-action="UI.openTeamDetail" data-args='${JSON.stringify([t.id, esc(t.name)])}' title="Members">👥</button>
|
||||
<button class="icon-btn" data-action="exportTeamData" data-args='${JSON.stringify([t.id, esc(t.name)])}' title="Export">📦</button>
|
||||
<button class="icon-btn" data-action="toggleTeamActive" data-args='${JSON.stringify([t.id, !t.is_active])}' title="${t.is_active ? 'Disable' : 'Enable'}">${t.is_active ? '⏸' : '▶'}</button>
|
||||
<button class="icon-btn icon-btn-danger" data-action="deleteTeam" data-args='${JSON.stringify([t.id, esc(t.name)])}' title="Delete">🗑</button>
|
||||
</td>
|
||||
|
||||
Reference in New Issue
Block a user