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

View File

@@ -1,5 +1,43 @@
# Changelog # Changelog
## [0.35.1] — 2026-03-20
### Summary
Data Portability FE Wiring: connects the v0.34.0 backend (export,
import, ChatGPT import, GDPR delete, backup CronJob) to the frontend.
New Settings → Data & Privacy section. Admin team export button.
### New
- **Settings → Data & Privacy section** — new nav link and full-page
section with four features:
- **Download My Data** — streams `GET /export/me` as `.switchboard`
zip blob download (conversations, notes, memories, projects, files,
settings, usage).
- **Import Data** — file picker for `.switchboard` / `.zip` archives,
uploads to `POST /import/me` with import/skip/error counts in toast.
- **Import from ChatGPT** — file picker for ChatGPT export zips,
uploads to `POST /import/chatgpt`, maps conversations to direct
chats.
- **Delete My Account** — danger zone with password confirmation,
calls `DELETE /me`, shows anonymized username, clears tokens, and
redirects to `/login`.
- **Admin team export button** — 📦 icon button on each team row in
Admin → Teams. Calls `GET /admin/teams/:id/export` and downloads
`.switchboard` archive.
### New files
- `src/js/data-portability.js` — Settings section renderer + admin
team export helper.
### Changed
- `settings.html` — added nav link, section title, content area,
script tag, and dynamic section dispatch for `data`.
- `ui-admin.js` — added export button to team list action cells.
## [0.35.0] — 2026-03-19 ## [0.35.0] — 2026-03-19
### Summary ### Summary
@@ -64,6 +102,105 @@ Observability: structured logging, Prometheus metrics, OpenAPI docs,
Grafana dashboards, alerting rules, and a built-in admin monitoring Grafana dashboards, alerting rules, and a built-in admin monitoring
dashboard. Operate the platform without reading Go source code. dashboard. Operate the platform without reading Go source code.
## [0.34.0] — 2026-03-19
### Summary
Data Portability: bulk export/import for user and team data, GDPR
"download my data" and "delete my data" with cascade + audit trail,
ChatGPT conversation import, and Kubernetes backup/restore CronJob
manifests.
### New
- **User data export** (`GET /api/v1/export/me`) — streams a
`.switchboard` zip archive containing all user-scoped entities:
channels, messages, notes, note links, memories, projects, project
associations, workspaces, workspace files, file attachments (blobs),
folders, user model settings, notification preferences, usage entries,
persona groups, and persona group members. Manifest includes entity
counts and scope metadata. Sensitive fields (password hashes, vault
keys, provider config IDs) stripped via `export.Sanitize*` functions.
File blobs capped at 100 MB per file, 500 MB total archive, 10K files.
- **User data import** (`POST /api/v1/import/me`) — accepts
`.switchboard` zip upload, validates manifest and format version,
imports entities in FK-dependency order with UUID dedup (skip if ID
exists). User ID remapping for cross-instance portability (source
user → current user). Returns imported/skipped counts and error list.
- **ChatGPT import** (`POST /api/v1/import/chatgpt`) — accepts ChatGPT
export zip containing `conversations.json`. Maps ChatGPT conversation
format (alternating author roles, message parts) into Switchboard
channels + messages. Handles `system`, `user`, `assistant`, and
`tool` roles. Title becomes channel name.
- **GDPR account deletion** (`DELETE /api/v1/me`) — requires explicit
`"confirm": "DELETE"` + password verification. Prevents deleting the
last admin. Cascade: soft-deletes user data (messages anonymized,
channels/notes/memories/projects deleted), revokes tokens, deletes
personal provider configs, anonymizes user record to
`deleted-user-{hash}`. Full audit trail.
- **Team data export** (`GET /api/v1/admin/teams/:id/export`) — admin
endpoint streaming a `.switchboard` archive of all team-scoped
entities: channels, messages, members, personas, persona KBs,
knowledge bases, KB documents, workflows, workflow versions/stages,
groups, group members, resource grants, and projects. Sanitizes
personas (strips provider binding), workflows (strips webhook
secrets).
- **Team data import** (`POST /api/v1/admin/teams/:id/import`) — admin
endpoint accepting `.switchboard` archive for team data restoration.
- **Backup/restore CronJob** — Helm chart templates for scheduled
`pg_dump` backups. `cronjob-backup.yaml` runs on configurable
schedule (default: daily at 02:00), writes gzip-compressed dump to
PVC with configurable retention (default: 7 backups). Optional S3
offload. `pvc-backup.yaml` for backup storage. Gated by
`backup.enabled` in `values.yaml`.
### New files
- `server/export/format.go``.switchboard` archive format: zip
with `manifest.json`, `data/*.json` entity files, `files/` blob
directory. `ArchiveWriter` with size tracking, `ArchiveReader`
with manifest validation.
- `server/export/entities.go` — per-entity sanitization: `SanitizeUser`
(strips password hash, vault fields), `SanitizeChannels` (strips
provider config), `SanitizeMessages`, `SanitizeChannelModels`,
`SanitizeUserModelSettings`, `SanitizeUsageEntries`,
`SanitizeWorkspaces` (strips git creds), `SanitizePersonas`,
`SanitizeWorkflows` (strips webhook secrets).
- `server/export/chatgpt.go` — ChatGPT `conversations.json` parser.
`ParseChatGPTExport` reads zip, finds `conversations.json`,
maps to `ChatGPTConversation` structs with `ChatGPTMessage`
(author role, content parts, create_time).
- `server/handlers/export_data.go``DataExportHandler` with
`ExportMyData` and `ExportTeam` endpoints.
- `server/handlers/import_data.go``DataImportHandler` with
`ImportMyData`, `ImportTeam`, and `ImportChatGPT` endpoints.
FK-dependency-ordered import with dedup.
- `server/handlers/gdpr.go``GDPRHandler` with `DeleteMyAccount`.
- `server/store/postgres/export.go``ExportStore` Postgres
implementation: 25+ batch-read queries for user/team export,
import methods with `ON CONFLICT DO NOTHING`, `SoftDeleteUserData`,
`AnonymizeUser`, `DeleteUserTokens`, `CountActiveAdmins`.
- `server/store/sqlite/export.go``ExportStore` SQLite
implementation with identical interface.
- `chart/templates/cronjob-backup.yaml` — Kubernetes CronJob for
scheduled pg_dump with gzip compression and retention cleanup.
- `chart/templates/pvc-backup.yaml` — PersistentVolumeClaim for
backup storage (gated by `backup.persistence.enabled`).
### Schema
- No new migrations — uses existing tables only.
- `ExportStore` added to `store.Stores` struct.
### Changed
- `store/interfaces.go` — added `ExportStore` interface with 25+
methods for batch export reads, import writes, and GDPR operations.
- `chart/values.yaml` — added `backup` section with `enabled`,
`schedule`, `retention`, `persistence`, and `s3Offload` config.
- `main.go` — wired `DataExportHandler`, `DataImportHandler`,
`GDPRHandler` routes under authenticated and admin groups.
### New ### New
- **Structured logging (`slog`)** — `LOG_FORMAT=json|text`, - **Structured logging (`slog`)** — `LOG_FORMAT=json|text`,

View File

@@ -1 +1 @@
0.35.0 0.35.1

View File

@@ -32,7 +32,7 @@ v0.9.xv0.28.7 Foundation through Platform Polish ✅
│ │ │ │
v0.29.0 Starlark Sandbox ✅ v0.32.0 Multi-Replica HA ✅ v0.29.0 Starlark Sandbox ✅ v0.32.0 Multi-Replica HA ✅
v0.29.1 API Extensions ✅ v0.33.0 Observability ✅ v0.29.1 API Extensions ✅ v0.33.0 Observability ✅
v0.29.2 DB Extensions ✅ v0.34.0 Data Portability v0.29.2 DB Extensions ✅ v0.34.0 Data Portability
v0.29.3 Workflow Forms ✅ │ v0.29.3 Workflow Forms ✅ │
v0.30.0 Package Lifecycle ✅ │ v0.30.0 Package Lifecycle ✅ │
v0.30.1 SDK Adoption ✅ │ v0.30.1 SDK Adoption ✅ │
@@ -414,17 +414,17 @@ Depends on: v0.32.0 (multi-replica metrics aggregation).
- Full OpenAPI spec coverage (all 20 ICD domains) - Full OpenAPI spec coverage (all 20 ICD domains)
- Bearer token / mTLS auth documentation in spec - Bearer token / mTLS auth documentation in spec
### v0.34.0 — Data Portability ### v0.34.0 — Data Portability
Export, import, backup, compliance. Export, import, backup, compliance.
Depends on: v0.29.2 (DB extensions — extension data in exports). Depends on: v0.29.2 (DB extensions — extension data in exports).
- [ ] Bulk export/import: account data, conversations, settings, files - [x] Bulk export/import: account data, conversations, settings, files
- [ ] GDPR "download my data" + "delete my data" (cascade + audit trail) - [x] GDPR "download my data" + "delete my data" (cascade + audit trail)
- [ ] ChatGPT/other tool import (conversation format mapping) - [x] ChatGPT/other tool import (conversation format mapping)
- [ ] Backup/restore CronJob manifests for K8s - [x] Backup/restore CronJob manifests for K8s
- [ ] Admin export: team/user config (excludes vault-encrypted keys) - [x] Admin export: team/user config (excludes vault-encrypted keys)
### v0.35.0 — Workflow Product ### v0.35.0 — Workflow Product
@@ -538,7 +538,7 @@ reading Go source code. Team admins build workflows visually.
- Extension track through v0.31.2 (full package ecosystem, visual - Extension track through v0.31.2 (full package ecosystem, visual
workflow builder, SDK-based surfaces, team workflow self-service) workflow builder, SDK-based surfaces, team workflow self-service)
- Operations track through v0.34.0 (multi-replica HA, observability, - Operations track through v0.34.0 (multi-replica HA, observability,
data portability) data portability)
- Workflow product v0.35.0 (form rendering, data pipeline, conditional - Workflow product v0.35.0 (form rendering, data pipeline, conditional
routing, structured review, monitoring dashboard) routing, structured review, monitoring dashboard)
- Full OpenAPI spec v0.36.0 (complete API documentation for all domains) - Full OpenAPI spec v0.36.0 (complete API documentation for all domains)

View File

@@ -33,6 +33,7 @@
<a href="{{$base}}/settings/workflows" class="settings-nav-link{{if eq $section "workflows"}} active{{end}}">Workflows</a> <a href="{{$base}}/settings/workflows" class="settings-nav-link{{if eq $section "workflows"}} active{{end}}">Workflows</a>
<a href="{{$base}}/settings/tasks" class="settings-nav-link{{if eq $section "tasks"}} active{{end}}">Tasks</a> <a href="{{$base}}/settings/tasks" class="settings-nav-link{{if eq $section "tasks"}} active{{end}}">Tasks</a>
<a href="{{$base}}/settings/gitkeys" class="settings-nav-link{{if eq $section "gitkeys"}} active{{end}}">Git Keys</a> <a href="{{$base}}/settings/gitkeys" class="settings-nav-link{{if eq $section "gitkeys"}} active{{end}}">Git Keys</a>
<a href="{{$base}}/settings/data" class="settings-nav-link{{if eq $section "data"}} active{{end}}">Data &amp; Privacy</a>
{{/* BYOK-gated nav items */}} {{/* BYOK-gated nav items */}}
<div id="settingsByokNav" style="display:none;"> <div id="settingsByokNav" style="display:none;">
@@ -58,7 +59,7 @@
{{/* Content */}} {{/* Content */}}
<div class="settings-content" id="settingsContentMount"> <div class="settings-content" id="settingsContentMount">
<div id="settingsSection" data-section="{{.Section}}"> <div id="settingsSection" data-section="{{.Section}}">
<h2 id="settingsSectionTitle">{{if eq .Section "general"}}General{{else if eq .Section "appearance"}}Appearance{{else if eq .Section "models"}}Models{{else if eq .Section "personas"}}Personas{{else if eq .Section "profile"}}Profile{{else if eq .Section "teams"}}Teams{{else if eq .Section "workflows"}}Workflows{{else if eq .Section "tasks"}}Tasks{{else if eq .Section "providers"}}My Providers{{else if eq .Section "roles"}}Model Roles{{else if eq .Section "usage"}}My Usage{{else if eq .Section "knowledge"}}Knowledge Bases{{else if eq .Section "memory"}}Memory{{else if eq .Section "notifications"}}Notifications{{else}}Settings{{end}}</h2> <h2 id="settingsSectionTitle">{{if eq .Section "general"}}General{{else if eq .Section "appearance"}}Appearance{{else if eq .Section "models"}}Models{{else if eq .Section "personas"}}Personas{{else if eq .Section "profile"}}Profile{{else if eq .Section "teams"}}Teams{{else if eq .Section "workflows"}}Workflows{{else if eq .Section "tasks"}}Tasks{{else if eq .Section "providers"}}My Providers{{else if eq .Section "roles"}}Model Roles{{else if eq .Section "usage"}}My Usage{{else if eq .Section "knowledge"}}Knowledge Bases{{else if eq .Section "memory"}}Memory{{else if eq .Section "notifications"}}Notifications{{else if eq .Section "data"}}Data &amp; Privacy{{else}}Settings{{end}}</h2>
{{if eq .Section "general"}} {{if eq .Section "general"}}
<div class="settings-section"> <div class="settings-section">
@@ -158,6 +159,8 @@
<div id="settingsDynamic"><div class="settings-placeholder">Loading workflows…</div></div> <div id="settingsDynamic"><div class="settings-placeholder">Loading workflows…</div></div>
{{else if eq .Section "tasks"}} {{else if eq .Section "tasks"}}
<div id="settingsTasksMount"><div class="settings-placeholder">Loading tasks…</div></div> <div id="settingsTasksMount"><div class="settings-placeholder">Loading tasks…</div></div>
{{else if eq .Section "data"}}
<div id="dpMount"><div class="settings-placeholder">Loading…</div></div>
{{else}}<div class="settings-placeholder" id="settingsDynamic">Loading…</div> {{else}}<div class="settings-placeholder" id="settingsDynamic">Loading…</div>
{{end}} {{end}}
</div> </div>
@@ -181,6 +184,7 @@
<script type="module" nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/notifications.js?v={{.Version}}"></script> <script type="module" nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/notifications.js?v={{.Version}}"></script>
<script type="module" nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/notification-prefs.js?v={{.Version}}"></script> <script type="module" nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/notification-prefs.js?v={{.Version}}"></script>
<script type="module" nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/channel-models.js?v={{.Version}}"></script> <script type="module" nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/channel-models.js?v={{.Version}}"></script>
<script type="module" nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/data-portability.js?v={{.Version}}"></script>
<script nonce="{{.CSPNonce}}"> <script nonce="{{.CSPNonce}}">
document.addEventListener('DOMContentLoaded', async () => { document.addEventListener('DOMContentLoaded', async () => {
const section = document.getElementById('settingsSection')?.dataset.section; const section = document.getElementById('settingsSection')?.dataset.section;
@@ -259,6 +263,7 @@
workflows: () => typeof loadTeamWorkflows === 'function' && loadTeamWorkflows(), workflows: () => typeof loadTeamWorkflows === 'function' && loadTeamWorkflows(),
tasks: () => typeof _loadSettingsTasks === 'function' && _loadSettingsTasks(), tasks: () => typeof _loadSettingsTasks === 'function' && _loadSettingsTasks(),
gitkeys: () => typeof _loadSettingsGitKeys === 'function' && _loadSettingsGitKeys(), gitkeys: () => typeof _loadSettingsGitKeys === 'function' && _loadSettingsGitKeys(),
data: () => typeof loadDataPrivacy === 'function' && loadDataPrivacy(),
}; };
// Populate App.policies and App.models from API. // Populate App.policies and App.models from API.

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 style="font-size:12px;color:var(--text-2)">${t.member_count} member${t.member_count !== 1 ? 's' : ''}</td>
<td class="admin-actions-cell"> <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="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" 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> <button class="icon-btn icon-btn-danger" data-action="deleteTeam" data-args='${JSON.stringify([t.id, esc(t.name)])}' title="Delete">🗑</button>
</td> </td>