Changeset 0.35.1 (#211)
This commit is contained in:
137
CHANGELOG.md
137
CHANGELOG.md
@@ -1,5 +1,43 @@
|
||||
# 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
|
||||
|
||||
### Summary
|
||||
@@ -64,6 +102,105 @@ Observability: structured logging, Prometheus metrics, OpenAPI docs,
|
||||
Grafana dashboards, alerting rules, and a built-in admin monitoring
|
||||
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
|
||||
|
||||
- **Structured logging (`slog`)** — `LOG_FORMAT=json|text`,
|
||||
|
||||
@@ -32,7 +32,7 @@ v0.9.x–v0.28.7 Foundation through Platform Polish ✅
|
||||
│ │
|
||||
v0.29.0 Starlark Sandbox ✅ v0.32.0 Multi-Replica HA ✅
|
||||
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.30.0 Package Lifecycle ✅ │
|
||||
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)
|
||||
- Bearer token / mTLS auth documentation in spec
|
||||
|
||||
### v0.34.0 — Data Portability
|
||||
### v0.34.0 — Data Portability ✅
|
||||
|
||||
Export, import, backup, compliance.
|
||||
|
||||
Depends on: v0.29.2 (DB extensions — extension data in exports).
|
||||
|
||||
- [ ] Bulk export/import: account data, conversations, settings, files
|
||||
- [ ] GDPR "download my data" + "delete my data" (cascade + audit trail)
|
||||
- [ ] ChatGPT/other tool import (conversation format mapping)
|
||||
- [ ] Backup/restore CronJob manifests for K8s
|
||||
- [ ] Admin export: team/user config (excludes vault-encrypted keys)
|
||||
- [x] Bulk export/import: account data, conversations, settings, files
|
||||
- [x] GDPR "download my data" + "delete my data" (cascade + audit trail)
|
||||
- [x] ChatGPT/other tool import (conversation format mapping)
|
||||
- [x] Backup/restore CronJob manifests for K8s
|
||||
- [x] Admin export: team/user config (excludes vault-encrypted keys)
|
||||
|
||||
### 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
|
||||
workflow builder, SDK-based surfaces, team workflow self-service)
|
||||
- Operations track through v0.34.0 (multi-replica HA, observability,
|
||||
data portability)
|
||||
data portability) ✅
|
||||
- Workflow product v0.35.0 (form rendering, data pipeline, conditional
|
||||
routing, structured review, monitoring dashboard)
|
||||
- Full OpenAPI spec v0.36.0 (complete API documentation for all domains)
|
||||
|
||||
@@ -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/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/data" class="settings-nav-link{{if eq $section "data"}} active{{end}}">Data & Privacy</a>
|
||||
|
||||
{{/* BYOK-gated nav items */}}
|
||||
<div id="settingsByokNav" style="display:none;">
|
||||
@@ -58,7 +59,7 @@
|
||||
{{/* Content */}}
|
||||
<div class="settings-content" id="settingsContentMount">
|
||||
<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 & Privacy{{else}}Settings{{end}}</h2>
|
||||
|
||||
{{if eq .Section "general"}}
|
||||
<div class="settings-section">
|
||||
@@ -158,6 +159,8 @@
|
||||
<div id="settingsDynamic"><div class="settings-placeholder">Loading workflows…</div></div>
|
||||
{{else if eq .Section "tasks"}}
|
||||
<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>
|
||||
{{end}}
|
||||
</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/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/data-portability.js?v={{.Version}}"></script>
|
||||
<script nonce="{{.CSPNonce}}">
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
const section = document.getElementById('settingsSection')?.dataset.section;
|
||||
@@ -259,6 +263,7 @@
|
||||
workflows: () => typeof loadTeamWorkflows === 'function' && loadTeamWorkflows(),
|
||||
tasks: () => typeof _loadSettingsTasks === 'function' && _loadSettingsTasks(),
|
||||
gitkeys: () => typeof _loadSettingsGitKeys === 'function' && _loadSettingsGitKeys(),
|
||||
data: () => typeof loadDataPrivacy === 'function' && loadDataPrivacy(),
|
||||
};
|
||||
|
||||
// Populate App.policies and App.models from API.
|
||||
|
||||
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