feat(settings): License, Data, Privacy, Developer tabs — stores, views, endpoints
- 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)
This commit is contained in:
parent
eb72776e9f
commit
fa2569c7e4
13 changed files with 846 additions and 0 deletions
222
dev-api.py
222
dev-api.py
|
|
@ -1515,3 +1515,225 @@ def finetune_local_status():
|
||||||
return {"model_ready": model_ready}
|
return {"model_ready": model_ready}
|
||||||
except Exception:
|
except Exception:
|
||||||
return {"model_ready": False}
|
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))
|
||||||
|
|
|
||||||
22
web/src/stores/settings/data.test.ts
Normal file
22
web/src/stores/settings/data.test.ts
Normal file
|
|
@ -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')
|
||||||
|
})
|
||||||
|
})
|
||||||
30
web/src/stores/settings/data.ts
Normal file
30
web/src/stores/settings/data.ts
Normal file
|
|
@ -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<string | null>(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<string | null>(null)
|
||||||
|
const restoreError = ref<string | null>(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 }
|
||||||
|
})
|
||||||
30
web/src/stores/settings/license.test.ts
Normal file
30
web/src/stores/settings/license.test.ts
Normal file
|
|
@ -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')
|
||||||
|
})
|
||||||
|
})
|
||||||
51
web/src/stores/settings/license.ts
Normal file
51
web/src/stores/settings/license.ts
Normal file
|
|
@ -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<string>('free')
|
||||||
|
const licenseKey = ref<string | null>(null)
|
||||||
|
const active = ref(false)
|
||||||
|
const gracePeriodEnds = ref<string | null>(null)
|
||||||
|
const loading = ref(false)
|
||||||
|
const activating = ref(false)
|
||||||
|
const activateError = ref<string | null>(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 }
|
||||||
|
})
|
||||||
43
web/src/stores/settings/privacy.test.ts
Normal file
43
web/src/stores/settings/privacy.test.ts
Normal file
|
|
@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
64
web/src/stores/settings/privacy.ts
Normal file
64
web/src/stores/settings/privacy.ts
Normal file
|
|
@ -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<string[]>([])
|
||||||
|
const byokInfoDismissed = ref(false)
|
||||||
|
const dismissedForBackends = ref<string[]>([])
|
||||||
|
|
||||||
|
// 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<Record<string, unknown>>('/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<string, unknown>) {
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
})
|
||||||
81
web/src/views/settings/DataView.vue
Normal file
81
web/src/views/settings/DataView.vue
Normal file
|
|
@ -0,0 +1,81 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { storeToRefs } from 'pinia'
|
||||||
|
import { useDataStore } from '../../stores/settings/data'
|
||||||
|
|
||||||
|
const store = useDataStore()
|
||||||
|
const { backupPath, backupFileCount, backupSizeBytes, creatingBackup, backupError } = storeToRefs(store)
|
||||||
|
const includeDb = ref(false)
|
||||||
|
const showRestoreConfirm = ref(false)
|
||||||
|
const restoreFile = ref<File | null>(null)
|
||||||
|
|
||||||
|
function formatBytes(b: number) {
|
||||||
|
if (b < 1024) return `${b} B`
|
||||||
|
if (b < 1024 * 1024) return `${(b / 1024).toFixed(1)} KB`
|
||||||
|
return `${(b / 1024 / 1024).toFixed(1)} MB`
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="data-view">
|
||||||
|
<h2>Data & Backup</h2>
|
||||||
|
|
||||||
|
<!-- Backup -->
|
||||||
|
<section class="form-section">
|
||||||
|
<h3>Create Backup</h3>
|
||||||
|
<p class="section-note">Exports your config files (and optionally the job database) as a zip archive.</p>
|
||||||
|
<label class="checkbox-row">
|
||||||
|
<input type="checkbox" v-model="includeDb" /> Include job database (staging.db)
|
||||||
|
</label>
|
||||||
|
<div class="form-actions">
|
||||||
|
<button @click="store.createBackup(includeDb)" :disabled="creatingBackup" class="btn-primary">
|
||||||
|
{{ creatingBackup ? 'Creating…' : 'Create Backup' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p v-if="backupError" class="error-msg">{{ backupError }}</p>
|
||||||
|
<div v-if="backupPath" class="backup-result">
|
||||||
|
<span>{{ backupFileCount }} files · {{ formatBytes(backupSizeBytes) }}</span>
|
||||||
|
<span class="backup-path">{{ backupPath }}</span>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Restore -->
|
||||||
|
<section class="form-section">
|
||||||
|
<h3>Restore from Backup</h3>
|
||||||
|
<p class="section-note">Upload a backup zip to restore your configuration. Existing files will be overwritten.</p>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept=".zip"
|
||||||
|
@change="restoreFile = ($event.target as HTMLInputElement).files?.[0] ?? null"
|
||||||
|
class="file-input"
|
||||||
|
/>
|
||||||
|
<div class="form-actions">
|
||||||
|
<button
|
||||||
|
@click="showRestoreConfirm = true"
|
||||||
|
:disabled="!restoreFile || store.restoring"
|
||||||
|
class="btn-warning"
|
||||||
|
>
|
||||||
|
{{ store.restoring ? 'Restoring…' : 'Restore' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div v-if="store.restoreResult" class="restore-result">
|
||||||
|
<p>Restored {{ store.restoreResult.restored.length }} files.</p>
|
||||||
|
<p v-if="store.restoreResult.skipped.length">Skipped: {{ store.restoreResult.skipped.join(', ') }}</p>
|
||||||
|
</div>
|
||||||
|
<p v-if="store.restoreError" class="error-msg">{{ store.restoreError }}</p>
|
||||||
|
|
||||||
|
<Teleport to="body">
|
||||||
|
<div v-if="showRestoreConfirm" class="modal-overlay" @click.self="showRestoreConfirm = false">
|
||||||
|
<div class="modal-card" role="dialog">
|
||||||
|
<h3>Restore Backup?</h3>
|
||||||
|
<p>This will overwrite your current configuration. This cannot be undone.</p>
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button @click="showRestoreConfirm = false" class="btn-danger">Restore</button>
|
||||||
|
<button @click="showRestoreConfirm = false" class="btn-secondary">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Teleport>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
130
web/src/views/settings/DeveloperView.vue
Normal file
130
web/src/views/settings/DeveloperView.vue
Normal file
|
|
@ -0,0 +1,130 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
|
import { useApiFetch } from '../../composables/useApi'
|
||||||
|
|
||||||
|
const devTierOverride = ref<string | null>(null)
|
||||||
|
const hfTokenInput = ref('')
|
||||||
|
const hfTokenSet = ref(false)
|
||||||
|
const hfTestResult = ref<{ok: boolean; error?: string; username?: string} | null>(null)
|
||||||
|
const saving = ref(false)
|
||||||
|
const showWizardResetConfirm = ref(false)
|
||||||
|
const exportResult = ref<{count: number} | null>(null)
|
||||||
|
|
||||||
|
const TIERS = ['free', 'paid', 'premium', 'ultra']
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
const { data } = await useApiFetch<{dev_tier_override: string | null; hf_token_set: boolean}>('/api/settings/developer')
|
||||||
|
if (data) {
|
||||||
|
devTierOverride.value = data.dev_tier_override ?? null
|
||||||
|
hfTokenSet.value = data.hf_token_set
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
async function saveTierOverride() {
|
||||||
|
saving.value = true
|
||||||
|
await useApiFetch('/api/settings/developer/tier', {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ tier: devTierOverride.value }),
|
||||||
|
})
|
||||||
|
saving.value = false
|
||||||
|
// Reload page so tier gate updates
|
||||||
|
window.location.reload()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveHfToken() {
|
||||||
|
if (!hfTokenInput.value) return
|
||||||
|
await useApiFetch('/api/settings/developer/hf-token', {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ token: hfTokenInput.value }),
|
||||||
|
})
|
||||||
|
hfTokenSet.value = true
|
||||||
|
hfTokenInput.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
async function testHfToken() {
|
||||||
|
const { data } = await useApiFetch<{ok: boolean; error?: string; username?: string}>('/api/settings/developer/hf-token/test', { method: 'POST' })
|
||||||
|
hfTestResult.value = data
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resetWizard() {
|
||||||
|
await useApiFetch('/api/settings/developer/wizard-reset', { method: 'POST' })
|
||||||
|
showWizardResetConfirm.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
async function exportClassifier() {
|
||||||
|
const { data } = await useApiFetch<{count: number}>('/api/settings/developer/export-classifier', { method: 'POST' })
|
||||||
|
if (data) exportResult.value = { count: data.count }
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="developer-view">
|
||||||
|
<h2>Developer</h2>
|
||||||
|
|
||||||
|
<!-- Tier override -->
|
||||||
|
<section class="form-section">
|
||||||
|
<h3>Tier Override</h3>
|
||||||
|
<p class="section-note">Override the effective tier for UI testing. Does not affect licensing.</p>
|
||||||
|
<div class="field-row">
|
||||||
|
<label>Override Tier</label>
|
||||||
|
<select v-model="devTierOverride">
|
||||||
|
<option :value="null">— none (use real tier) —</option>
|
||||||
|
<option v-for="t in TIERS" :key="t" :value="t">{{ t }}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-actions">
|
||||||
|
<button @click="saveTierOverride" :disabled="saving" class="btn-primary">Apply Override</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- HF Token -->
|
||||||
|
<section class="form-section">
|
||||||
|
<h3>HuggingFace Token</h3>
|
||||||
|
<p class="section-note">Required for model downloads and fine-tune uploads.</p>
|
||||||
|
<p v-if="hfTokenSet" class="token-set">✓ Token stored securely</p>
|
||||||
|
<div class="field-row">
|
||||||
|
<label>Token</label>
|
||||||
|
<input v-model="hfTokenInput" type="password" placeholder="hf_…" autocomplete="new-password" />
|
||||||
|
</div>
|
||||||
|
<div class="form-actions">
|
||||||
|
<button @click="saveHfToken" :disabled="!hfTokenInput" class="btn-primary">Save Token</button>
|
||||||
|
<button @click="testHfToken" class="btn-secondary">Test</button>
|
||||||
|
</div>
|
||||||
|
<p v-if="hfTestResult" :class="hfTestResult.ok ? 'status-ok' : 'error-msg'">
|
||||||
|
{{ hfTestResult.ok ? `✓ Logged in as ${hfTestResult.username}` : '✗ ' + hfTestResult.error }}
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Wizard reset -->
|
||||||
|
<section class="form-section">
|
||||||
|
<h3>Wizard</h3>
|
||||||
|
<div class="form-actions">
|
||||||
|
<button @click="showWizardResetConfirm = true" class="btn-warning">Reset Setup Wizard</button>
|
||||||
|
</div>
|
||||||
|
<Teleport to="body">
|
||||||
|
<div v-if="showWizardResetConfirm" class="modal-overlay" @click.self="showWizardResetConfirm = false">
|
||||||
|
<div class="modal-card" role="dialog">
|
||||||
|
<h3>Reset Setup Wizard?</h3>
|
||||||
|
<p>The first-run setup wizard will be shown again on next launch.</p>
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button @click="resetWizard" class="btn-warning">Reset</button>
|
||||||
|
<button @click="showWizardResetConfirm = false" class="btn-secondary">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Teleport>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Export classifier data -->
|
||||||
|
<section class="form-section">
|
||||||
|
<h3>Export Training Data</h3>
|
||||||
|
<p class="section-note">Export labeled emails as JSONL for classifier training.</p>
|
||||||
|
<div class="form-actions">
|
||||||
|
<button @click="exportClassifier" class="btn-secondary">Export to data/email_score.jsonl</button>
|
||||||
|
</div>
|
||||||
|
<p v-if="exportResult" class="status-ok">Exported {{ exportResult.count }} labeled emails.</p>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
18
web/src/views/settings/LicenseCloud.vue
Normal file
18
web/src/views/settings/LicenseCloud.vue
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { storeToRefs } from 'pinia'
|
||||||
|
import { useAppConfigStore } from '../../stores/appConfig'
|
||||||
|
|
||||||
|
const { tier } = storeToRefs(useAppConfigStore())
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="form-section">
|
||||||
|
<h2>Plan</h2>
|
||||||
|
<div class="license-info">
|
||||||
|
<span class="tier-badge">{{ tier?.toUpperCase() ?? 'FREE' }}</span>
|
||||||
|
</div>
|
||||||
|
<p class="section-note">
|
||||||
|
Manage your subscription at <a href="https://circuitforge.tech/account" target="_blank">circuitforge.tech/account</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
57
web/src/views/settings/LicenseSelfHosted.vue
Normal file
57
web/src/views/settings/LicenseSelfHosted.vue
Normal file
|
|
@ -0,0 +1,57 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
|
import { storeToRefs } from 'pinia'
|
||||||
|
import { useLicenseStore } from '../../stores/settings/license'
|
||||||
|
|
||||||
|
const store = useLicenseStore()
|
||||||
|
const { tier, licenseKey, active, gracePeriodEnds, activating, activateError } = storeToRefs(store)
|
||||||
|
const keyInput = ref('')
|
||||||
|
const showDeactivateConfirm = ref(false)
|
||||||
|
|
||||||
|
onMounted(() => store.loadLicense())
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="form-section">
|
||||||
|
<h2>License</h2>
|
||||||
|
|
||||||
|
<!-- Active license -->
|
||||||
|
<template v-if="active">
|
||||||
|
<div class="license-info">
|
||||||
|
<span :class="`tier-badge tier-${tier}`">{{ tier.toUpperCase() }}</span>
|
||||||
|
<span v-if="licenseKey" class="license-key">{{ licenseKey }}</span>
|
||||||
|
<span v-if="gracePeriodEnds" class="grace-notice">Grace period ends: {{ gracePeriodEnds }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="form-actions">
|
||||||
|
<button @click="showDeactivateConfirm = true" class="btn-danger">Deactivate</button>
|
||||||
|
</div>
|
||||||
|
<Teleport to="body">
|
||||||
|
<div v-if="showDeactivateConfirm" class="modal-overlay" @click.self="showDeactivateConfirm = false">
|
||||||
|
<div class="modal-card" role="dialog">
|
||||||
|
<h3>Deactivate License?</h3>
|
||||||
|
<p>You will lose access to paid features. You can reactivate later with the same key.</p>
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button @click="store.deactivate(); showDeactivateConfirm = false" class="btn-danger">Deactivate</button>
|
||||||
|
<button @click="showDeactivateConfirm = false" class="btn-secondary">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Teleport>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- No active license -->
|
||||||
|
<template v-else>
|
||||||
|
<p class="section-note">Enter your license key to unlock paid features.</p>
|
||||||
|
<div class="field-row">
|
||||||
|
<label>License Key</label>
|
||||||
|
<input v-model="keyInput" placeholder="CFG-PRNG-XXXX-XXXX-XXXX" class="monospace" />
|
||||||
|
</div>
|
||||||
|
<p v-if="activateError" class="error-msg">{{ activateError }}</p>
|
||||||
|
<div class="form-actions">
|
||||||
|
<button @click="store.activate(keyInput)" :disabled="!keyInput || activating" class="btn-primary">
|
||||||
|
{{ activating ? 'Activating…' : 'Activate' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
16
web/src/views/settings/LicenseView.vue
Normal file
16
web/src/views/settings/LicenseView.vue
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { useAppConfigStore } from '../../stores/appConfig'
|
||||||
|
import LicenseSelfHosted from './LicenseSelfHosted.vue'
|
||||||
|
import LicenseCloud from './LicenseCloud.vue'
|
||||||
|
|
||||||
|
const config = useAppConfigStore()
|
||||||
|
const isCloud = computed(() => config.isCloud)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="license-view">
|
||||||
|
<LicenseCloud v-if="isCloud" />
|
||||||
|
<LicenseSelfHosted v-else />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
82
web/src/views/settings/PrivacyView.vue
Normal file
82
web/src/views/settings/PrivacyView.vue
Normal file
|
|
@ -0,0 +1,82 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted } from 'vue'
|
||||||
|
import { storeToRefs } from 'pinia'
|
||||||
|
import { usePrivacyStore } from '../../stores/settings/privacy'
|
||||||
|
import { useAppConfigStore } from '../../stores/appConfig'
|
||||||
|
import { useSystemStore } from '../../stores/settings/system'
|
||||||
|
|
||||||
|
const privacy = usePrivacyStore()
|
||||||
|
const config = useAppConfigStore()
|
||||||
|
const system = useSystemStore()
|
||||||
|
const { telemetryOptIn, masterOff, usageEvents, contentSharing, showByokPanel, saving } = storeToRefs(privacy)
|
||||||
|
|
||||||
|
// Sync active cloud backends from system store into privacy store
|
||||||
|
const activeCloudBackends = computed(() =>
|
||||||
|
system.backends.filter(b => b.enabled && ['anthropic', 'openai'].includes(b.id)).map(b => b.id)
|
||||||
|
)
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await privacy.loadPrivacy()
|
||||||
|
privacy.activeCloudBackends = activeCloudBackends.value
|
||||||
|
})
|
||||||
|
|
||||||
|
async function handleSave() {
|
||||||
|
if (config.isCloud) {
|
||||||
|
await privacy.savePrivacy({ master_off: masterOff.value, usage_events: usageEvents.value, content_sharing: contentSharing.value })
|
||||||
|
} else {
|
||||||
|
await privacy.savePrivacy({ telemetry_opt_in: telemetryOptIn.value })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="privacy-view">
|
||||||
|
<h2>Privacy</h2>
|
||||||
|
|
||||||
|
<!-- Self-hosted -->
|
||||||
|
<template v-if="!config.isCloud">
|
||||||
|
<section class="form-section">
|
||||||
|
<h3>Telemetry</h3>
|
||||||
|
<p class="section-note">Peregrine is fully local by default — no data leaves your machine unless you opt in.</p>
|
||||||
|
<label class="checkbox-row">
|
||||||
|
<input type="checkbox" v-model="telemetryOptIn" />
|
||||||
|
Share anonymous usage statistics to help improve Peregrine
|
||||||
|
</label>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- BYOK Info Panel -->
|
||||||
|
<section v-if="showByokPanel" class="form-section byok-panel">
|
||||||
|
<h3>Cloud LLM Privacy Notice</h3>
|
||||||
|
<p>You have cloud LLM backends enabled. Your job descriptions and cover letter content will be sent to those providers' APIs. Peregrine never logs this content, but the providers' own data policies apply.</p>
|
||||||
|
<div class="form-actions">
|
||||||
|
<button @click="privacy.dismissByokInfo()" class="btn-secondary">Got it, don't show again</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Cloud -->
|
||||||
|
<template v-else>
|
||||||
|
<section class="form-section">
|
||||||
|
<h3>Data Controls</h3>
|
||||||
|
<label class="checkbox-row danger">
|
||||||
|
<input type="checkbox" v-model="masterOff" />
|
||||||
|
Disable all data collection (master off)
|
||||||
|
</label>
|
||||||
|
<label class="checkbox-row">
|
||||||
|
<input type="checkbox" v-model="usageEvents" :disabled="masterOff" />
|
||||||
|
Usage events (feature analytics)
|
||||||
|
</label>
|
||||||
|
<label class="checkbox-row">
|
||||||
|
<input type="checkbox" v-model="contentSharing" :disabled="masterOff" />
|
||||||
|
Share content for model improvement
|
||||||
|
</label>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div class="form-actions">
|
||||||
|
<button @click="handleSave" :disabled="saving" class="btn-primary">
|
||||||
|
{{ saving ? 'Saving…' : 'Save' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
Loading…
Reference in a new issue