Changeset 0.35.1 (#211)

This commit is contained in:
2026-03-20 12:42:48 +00:00
parent 8a36c53a1c
commit 668c608b4f
6 changed files with 460 additions and 10 deletions

307
src/js/data-portability.js Normal file
View 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);
})();

View File

@@ -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>