Changeset 0.35.1 (#211)
This commit is contained in:
137
CHANGELOG.md
137
CHANGELOG.md
@@ -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`,
|
||||||
|
|||||||
@@ -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.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)
|
||||||
|
|||||||
@@ -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 & 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 & 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
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 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>
|
||||||
|
|||||||
Reference in New Issue
Block a user