From fa2569c7e495b7891fad6bbf467d70c410248e15 Mon Sep 17 00:00:00 2001 From: pyr0ball Date: Sun, 22 Mar 2026 16:01:29 -0700 Subject: [PATCH] =?UTF-8?q?feat(settings):=20License,=20Data,=20Privacy,?= =?UTF-8?q?=20Developer=20tabs=20=E2=80=94=20stores,=20views,=20endpoints?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - useLicenseStore: load/activate/deactivate with tier badge and key input - useDataStore: createBackup with file count and size display - usePrivacyStore: BYOK panel logic (dismissal snapshot tracks new backends), telemetry toggle (self-hosted) and master-off/usage/content controls (cloud) - Views: LicenseView (cloud/self-hosted split), LicenseSelfHosted, LicenseCloud, DataView, PrivacyView, DeveloperView - dev-api.py: /api/settings/license, /activate, /deactivate; /api/settings/data/backup/create; /api/settings/privacy GET+PUT; /api/settings/developer GET, /tier PUT, /hf-token PUT+test, /wizard-reset, /export-classifier; _load_user_config/_save_user_config helpers; CONFIG_DIR - TDD: 10/10 store tests passing (license×3, data×2, privacy×5) --- dev-api.py | 222 +++++++++++++++++++ web/src/stores/settings/data.test.ts | 22 ++ web/src/stores/settings/data.ts | 30 +++ web/src/stores/settings/license.test.ts | 30 +++ web/src/stores/settings/license.ts | 51 +++++ web/src/stores/settings/privacy.test.ts | 43 ++++ web/src/stores/settings/privacy.ts | 64 ++++++ web/src/views/settings/DataView.vue | 81 +++++++ web/src/views/settings/DeveloperView.vue | 130 +++++++++++ web/src/views/settings/LicenseCloud.vue | 18 ++ web/src/views/settings/LicenseSelfHosted.vue | 57 +++++ web/src/views/settings/LicenseView.vue | 16 ++ web/src/views/settings/PrivacyView.vue | 82 +++++++ 13 files changed, 846 insertions(+) create mode 100644 web/src/stores/settings/data.test.ts create mode 100644 web/src/stores/settings/data.ts create mode 100644 web/src/stores/settings/license.test.ts create mode 100644 web/src/stores/settings/license.ts create mode 100644 web/src/stores/settings/privacy.test.ts create mode 100644 web/src/stores/settings/privacy.ts create mode 100644 web/src/views/settings/DataView.vue create mode 100644 web/src/views/settings/DeveloperView.vue create mode 100644 web/src/views/settings/LicenseCloud.vue create mode 100644 web/src/views/settings/LicenseSelfHosted.vue create mode 100644 web/src/views/settings/LicenseView.vue create mode 100644 web/src/views/settings/PrivacyView.vue diff --git a/dev-api.py b/dev-api.py index ece6794..0edfc4f 100644 --- a/dev-api.py +++ b/dev-api.py @@ -1515,3 +1515,225 @@ def finetune_local_status(): return {"model_ready": model_ready} except Exception: return {"model_ready": False} + + +# ── Settings: License ───────────────────────────────────────────────────────── + +# CONFIG_DIR resolves relative to staging.db location (same convention as _user_yaml_path) +CONFIG_DIR = Path(os.path.dirname(DB_PATH)) / "config" +if not CONFIG_DIR.exists(): + CONFIG_DIR = Path("/devl/job-seeker/config") + +LICENSE_PATH = CONFIG_DIR / "license.yaml" + + +def _load_user_config() -> dict: + """Load user.yaml using the same path logic as _user_yaml_path().""" + return load_user_profile(_user_yaml_path()) + + +def _save_user_config(cfg: dict) -> None: + """Save user.yaml using the same path logic as _user_yaml_path().""" + save_user_profile(_user_yaml_path(), cfg) + + +@app.get("/api/settings/license") +def get_license(): + try: + if LICENSE_PATH.exists(): + with open(LICENSE_PATH) as f: + data = yaml.safe_load(f) or {} + else: + data = {} + return { + "tier": data.get("tier", "free"), + "key": data.get("key"), + "active": bool(data.get("active", False)), + "grace_period_ends": data.get("grace_period_ends"), + } + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +class LicenseActivatePayload(BaseModel): + key: str + +@app.post("/api/settings/license/activate") +def activate_license(payload: LicenseActivatePayload): + try: + # In dev: accept any key matching our format, grant paid tier + key = payload.key.strip() + if not re.match(r'^CFG-[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{4}$', key): + return {"ok": False, "error": "Invalid key format"} + data = {"tier": "paid", "key": key, "active": True} + CONFIG_DIR.mkdir(parents=True, exist_ok=True) + fd = os.open(str(LICENSE_PATH), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) + with os.fdopen(fd, "w") as f: + yaml.dump(data, f, allow_unicode=True, default_flow_style=False) + return {"ok": True, "tier": "paid"} + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@app.post("/api/settings/license/deactivate") +def deactivate_license(): + try: + if LICENSE_PATH.exists(): + with open(LICENSE_PATH) as f: + data = yaml.safe_load(f) or {} + data["active"] = False + fd = os.open(str(LICENSE_PATH), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) + with os.fdopen(fd, "w") as f: + yaml.dump(data, f, allow_unicode=True, default_flow_style=False) + return {"ok": True} + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +# ── Settings: Data ──────────────────────────────────────────────────────────── + +class BackupCreatePayload(BaseModel): + include_db: bool = False + +@app.post("/api/settings/data/backup/create") +def create_backup(payload: BackupCreatePayload): + try: + import zipfile + import datetime + backup_dir = Path("data/backups") + backup_dir.mkdir(parents=True, exist_ok=True) + ts = datetime.datetime.now().strftime("%Y%m%d_%H%M%S") + dest = backup_dir / f"peregrine_backup_{ts}.zip" + file_count = 0 + with zipfile.ZipFile(dest, "w", zipfile.ZIP_DEFLATED) as zf: + for cfg_file in CONFIG_DIR.glob("*.yaml"): + if cfg_file.name not in ("tokens.yaml",): + zf.write(cfg_file, f"config/{cfg_file.name}") + file_count += 1 + if payload.include_db: + db_path = Path(DB_PATH) + if db_path.exists(): + zf.write(db_path, "data/staging.db") + file_count += 1 + size_bytes = dest.stat().st_size + return {"path": str(dest), "file_count": file_count, "size_bytes": size_bytes} + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +# ── Settings: Privacy ───────────────────────────────────────────────────────── + +PRIVACY_YAML_FIELDS = {"telemetry_opt_in", "byok_info_dismissed", "master_off", "usage_events", "content_sharing"} + +@app.get("/api/settings/privacy") +def get_privacy(): + try: + cfg = _load_user_config() + return { + "telemetry_opt_in": bool(cfg.get("telemetry_opt_in", False)), + "byok_info_dismissed": bool(cfg.get("byok_info_dismissed", False)), + "master_off": bool(cfg.get("master_off", False)), + "usage_events": cfg.get("usage_events", True), + "content_sharing": bool(cfg.get("content_sharing", False)), + } + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@app.put("/api/settings/privacy") +def save_privacy(payload: dict): + try: + cfg = _load_user_config() + for k, v in payload.items(): + if k in PRIVACY_YAML_FIELDS: + cfg[k] = v + _save_user_config(cfg) + return {"ok": True} + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +# ── Settings: Developer ─────────────────────────────────────────────────────── + +TOKENS_PATH = CONFIG_DIR / "tokens.yaml" + +@app.get("/api/settings/developer") +def get_developer(): + try: + cfg = _load_user_config() + tokens = {} + if TOKENS_PATH.exists(): + with open(TOKENS_PATH) as f: + tokens = yaml.safe_load(f) or {} + return { + "dev_tier_override": cfg.get("dev_tier_override"), + "hf_token_set": bool(tokens.get("huggingface_token")), + } + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +class DevTierPayload(BaseModel): + tier: Optional[str] + +@app.put("/api/settings/developer/tier") +def set_dev_tier(payload: DevTierPayload): + try: + cfg = _load_user_config() + cfg["dev_tier_override"] = payload.tier + _save_user_config(cfg) + return {"ok": True} + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +class HfTokenPayload(BaseModel): + token: str + +@app.put("/api/settings/developer/hf-token") +def save_hf_token(payload: HfTokenPayload): + try: + set_credential("peregrine_tokens", "huggingface_token", payload.token) + return {"ok": True} + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@app.post("/api/settings/developer/hf-token/test") +def test_hf_token(): + try: + token = get_credential("peregrine_tokens", "huggingface_token") + if not token: + return {"ok": False, "error": "No token stored"} + from huggingface_hub import whoami + info = whoami(token=token) + return {"ok": True, "username": info.get("name")} + except Exception as e: + return {"ok": False, "error": str(e)} + + +@app.post("/api/settings/developer/wizard-reset") +def wizard_reset(): + try: + cfg = _load_user_config() + cfg["wizard_complete"] = False + _save_user_config(cfg) + return {"ok": True} + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@app.post("/api/settings/developer/export-classifier") +def export_classifier(): + try: + import json as _json + from scripts.db import get_labeled_emails + emails = get_labeled_emails(DB_PATH) + export_path = Path("data/email_score.jsonl") + export_path.parent.mkdir(parents=True, exist_ok=True) + with open(export_path, "w") as f: + for e in emails: + f.write(_json.dumps(e) + "\n") + return {"ok": True, "count": len(emails), "path": str(export_path)} + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) diff --git a/web/src/stores/settings/data.test.ts b/web/src/stores/settings/data.test.ts new file mode 100644 index 0000000..32769c2 --- /dev/null +++ b/web/src/stores/settings/data.test.ts @@ -0,0 +1,22 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { setActivePinia, createPinia } from 'pinia' +import { useDataStore } from './data' + +vi.mock('../../composables/useApi', () => ({ useApiFetch: vi.fn() })) +import { useApiFetch } from '../../composables/useApi' +const mockFetch = vi.mocked(useApiFetch) + +describe('useDataStore', () => { + beforeEach(() => { setActivePinia(createPinia()); vi.clearAllMocks() }) + + it('initial backupPath is null', () => { + expect(useDataStore().backupPath).toBeNull() + }) + + it('createBackup() sets backupPath after success', async () => { + mockFetch.mockResolvedValue({ data: { path: 'data/backup.zip', file_count: 12, size_bytes: 1024 }, error: null }) + const store = useDataStore() + await store.createBackup(false) + expect(store.backupPath).toBe('data/backup.zip') + }) +}) diff --git a/web/src/stores/settings/data.ts b/web/src/stores/settings/data.ts new file mode 100644 index 0000000..aab5dda --- /dev/null +++ b/web/src/stores/settings/data.ts @@ -0,0 +1,30 @@ +import { ref } from 'vue' +import { defineStore } from 'pinia' +import { useApiFetch } from '../../composables/useApi' + +export const useDataStore = defineStore('settings/data', () => { + const backupPath = ref(null) + const backupFileCount = ref(0) + const backupSizeBytes = ref(0) + const creatingBackup = ref(false) + const restoring = ref(false) + const restoreResult = ref<{restored: string[]; skipped: string[]} | null>(null) + const backupError = ref(null) + const restoreError = ref(null) + + async function createBackup(includeDb: boolean) { + creatingBackup.value = true + backupError.value = null + const { data, error } = await useApiFetch<{path: string; file_count: number; size_bytes: number}>( + '/api/settings/data/backup/create', + { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ include_db: includeDb }) } + ) + creatingBackup.value = false + if (error || !data) { backupError.value = 'Backup failed'; return } + backupPath.value = data.path + backupFileCount.value = data.file_count + backupSizeBytes.value = data.size_bytes + } + + return { backupPath, backupFileCount, backupSizeBytes, creatingBackup, restoring, restoreResult, backupError, restoreError, createBackup } +}) diff --git a/web/src/stores/settings/license.test.ts b/web/src/stores/settings/license.test.ts new file mode 100644 index 0000000..8eca5d8 --- /dev/null +++ b/web/src/stores/settings/license.test.ts @@ -0,0 +1,30 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { setActivePinia, createPinia } from 'pinia' +import { useLicenseStore } from './license' + +vi.mock('../../composables/useApi', () => ({ useApiFetch: vi.fn() })) +import { useApiFetch } from '../../composables/useApi' +const mockFetch = vi.mocked(useApiFetch) + +describe('useLicenseStore', () => { + beforeEach(() => { setActivePinia(createPinia()); vi.clearAllMocks() }) + + it('initial active is false', () => { + expect(useLicenseStore().active).toBe(false) + }) + + it('activate() on success sets tier and active=true', async () => { + mockFetch.mockResolvedValue({ data: { ok: true, tier: 'paid' }, error: null }) + const store = useLicenseStore() + await store.activate('CFG-PRNG-TEST-1234-5678') + expect(store.tier).toBe('paid') + expect(store.active).toBe(true) + }) + + it('activate() on failure sets activateError', async () => { + mockFetch.mockResolvedValue({ data: { ok: false, error: 'Invalid key' }, error: null }) + const store = useLicenseStore() + await store.activate('bad-key') + expect(store.activateError).toBe('Invalid key') + }) +}) diff --git a/web/src/stores/settings/license.ts b/web/src/stores/settings/license.ts new file mode 100644 index 0000000..7e462a9 --- /dev/null +++ b/web/src/stores/settings/license.ts @@ -0,0 +1,51 @@ +import { ref } from 'vue' +import { defineStore } from 'pinia' +import { useApiFetch } from '../../composables/useApi' + +export const useLicenseStore = defineStore('settings/license', () => { + const tier = ref('free') + const licenseKey = ref(null) + const active = ref(false) + const gracePeriodEnds = ref(null) + const loading = ref(false) + const activating = ref(false) + const activateError = ref(null) + + async function loadLicense() { + loading.value = true + const { data } = await useApiFetch<{tier: string; key: string | null; active: boolean; grace_period_ends?: string}>('/api/settings/license') + loading.value = false + if (!data) return + tier.value = data.tier + licenseKey.value = data.key + active.value = data.active + gracePeriodEnds.value = data.grace_period_ends ?? null + } + + async function activate(key: string) { + activating.value = true + activateError.value = null + const { data } = await useApiFetch<{ok: boolean; tier?: string; error?: string}>( + '/api/settings/license/activate', + { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ key }) } + ) + activating.value = false + if (!data) { activateError.value = 'Request failed'; return } + if (data.ok) { + active.value = true + tier.value = data.tier ?? tier.value + licenseKey.value = key + } else { + activateError.value = data.error ?? 'Activation failed' + } + } + + async function deactivate() { + await useApiFetch('/api/settings/license/deactivate', { method: 'POST' }) + active.value = false + licenseKey.value = null + tier.value = 'free' + } + + return { tier, licenseKey, active, gracePeriodEnds, loading, activating, activateError, loadLicense, activate, deactivate } +}) diff --git a/web/src/stores/settings/privacy.test.ts b/web/src/stores/settings/privacy.test.ts new file mode 100644 index 0000000..b908081 --- /dev/null +++ b/web/src/stores/settings/privacy.test.ts @@ -0,0 +1,43 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { setActivePinia, createPinia } from 'pinia' +import { usePrivacyStore } from './privacy' + +vi.mock('../../composables/useApi', () => ({ useApiFetch: vi.fn() })) +import { useApiFetch } from '../../composables/useApi' +const mockFetch = vi.mocked(useApiFetch) + +describe('usePrivacyStore', () => { + beforeEach(() => { setActivePinia(createPinia()); vi.clearAllMocks() }) + + it('byokInfoDismissed is false by default', () => { + expect(usePrivacyStore().byokInfoDismissed).toBe(false) + }) + + it('dismissByokInfo() sets dismissed to true', () => { + const store = usePrivacyStore() + store.dismissByokInfo() + expect(store.byokInfoDismissed).toBe(true) + }) + + it('showByokPanel is true when cloud backends configured and not dismissed', () => { + const store = usePrivacyStore() + store.activeCloudBackends = ['anthropic'] + store.byokInfoDismissed = false + expect(store.showByokPanel).toBe(true) + }) + + it('showByokPanel is false when dismissed', () => { + const store = usePrivacyStore() + store.activeCloudBackends = ['anthropic'] + store.byokInfoDismissed = true + expect(store.showByokPanel).toBe(false) + }) + + it('showByokPanel re-appears when new backend added after dismissal', () => { + const store = usePrivacyStore() + store.activeCloudBackends = ['anthropic'] + store.dismissByokInfo() + store.activeCloudBackends = ['anthropic', 'openai'] + expect(store.showByokPanel).toBe(true) + }) +}) diff --git a/web/src/stores/settings/privacy.ts b/web/src/stores/settings/privacy.ts new file mode 100644 index 0000000..e96506e --- /dev/null +++ b/web/src/stores/settings/privacy.ts @@ -0,0 +1,64 @@ +import { ref, computed } from 'vue' +import { defineStore } from 'pinia' +import { useApiFetch } from '../../composables/useApi' + +export const usePrivacyStore = defineStore('settings/privacy', () => { + // Session-scoped BYOK panel state + const activeCloudBackends = ref([]) + const byokInfoDismissed = ref(false) + const dismissedForBackends = ref([]) + + // Self-hosted privacy prefs + const telemetryOptIn = ref(false) + + // Cloud privacy prefs + const masterOff = ref(false) + const usageEvents = ref(true) + const contentSharing = ref(false) + + const loading = ref(false) + const saving = ref(false) + + // Panel shows if there are active cloud backends not yet covered by dismissal snapshot, + // or if byokInfoDismissed was set directly (e.g. loaded from server) and new backends haven't appeared + const showByokPanel = computed(() => { + if (activeCloudBackends.value.length === 0) return false + if (byokInfoDismissed.value && activeCloudBackends.value.every(b => dismissedForBackends.value.includes(b))) return false + if (byokInfoDismissed.value && dismissedForBackends.value.length === 0) return false + return !activeCloudBackends.value.every(b => dismissedForBackends.value.includes(b)) + }) + + function dismissByokInfo() { + dismissedForBackends.value = [...activeCloudBackends.value] + byokInfoDismissed.value = true + } + + async function loadPrivacy() { + loading.value = true + const { data } = await useApiFetch>('/api/settings/privacy') + loading.value = false + if (!data) return + telemetryOptIn.value = Boolean(data.telemetry_opt_in) + byokInfoDismissed.value = Boolean(data.byok_info_dismissed) + masterOff.value = Boolean(data.master_off) + usageEvents.value = data.usage_events !== false + contentSharing.value = Boolean(data.content_sharing) + } + + async function savePrivacy(prefs: Record) { + saving.value = true + await useApiFetch('/api/settings/privacy', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(prefs), + }) + saving.value = false + } + + return { + activeCloudBackends, byokInfoDismissed, dismissedForBackends, + telemetryOptIn, masterOff, usageEvents, contentSharing, + loading, saving, showByokPanel, + dismissByokInfo, loadPrivacy, savePrivacy, + } +}) diff --git a/web/src/views/settings/DataView.vue b/web/src/views/settings/DataView.vue new file mode 100644 index 0000000..b8b3059 --- /dev/null +++ b/web/src/views/settings/DataView.vue @@ -0,0 +1,81 @@ + + + diff --git a/web/src/views/settings/DeveloperView.vue b/web/src/views/settings/DeveloperView.vue new file mode 100644 index 0000000..2f02aa5 --- /dev/null +++ b/web/src/views/settings/DeveloperView.vue @@ -0,0 +1,130 @@ + + + diff --git a/web/src/views/settings/LicenseCloud.vue b/web/src/views/settings/LicenseCloud.vue new file mode 100644 index 0000000..f4ab48a --- /dev/null +++ b/web/src/views/settings/LicenseCloud.vue @@ -0,0 +1,18 @@ + + + diff --git a/web/src/views/settings/LicenseSelfHosted.vue b/web/src/views/settings/LicenseSelfHosted.vue new file mode 100644 index 0000000..76ac55d --- /dev/null +++ b/web/src/views/settings/LicenseSelfHosted.vue @@ -0,0 +1,57 @@ + + + diff --git a/web/src/views/settings/LicenseView.vue b/web/src/views/settings/LicenseView.vue new file mode 100644 index 0000000..156456f --- /dev/null +++ b/web/src/views/settings/LicenseView.vue @@ -0,0 +1,16 @@ + + + diff --git a/web/src/views/settings/PrivacyView.vue b/web/src/views/settings/PrivacyView.vue new file mode 100644 index 0000000..5ed8841 --- /dev/null +++ b/web/src/views/settings/PrivacyView.vue @@ -0,0 +1,82 @@ + + +