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:
pyr0ball 2026-03-22 16:01:29 -07:00
parent 6eaa1fef79
commit a14eefd3e0
13 changed files with 846 additions and 0 deletions

View file

@ -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))

View 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')
})
})

View 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 }
})

View 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')
})
})

View 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 }
})

View 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)
})
})

View 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,
}
})

View 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 &amp; 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>

View 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">&#x2713; 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>

View 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>

View 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>

View 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>

View 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>