Compare commits

...

21 commits

Author SHA1 Message Date
82b6ff1689 chore(settings): remove old SettingsView placeholder — new shell at views/settings/SettingsView.vue
Some checks failed
CI / test (pull_request) Failing after 26s
Full test suite: 71 frontend (14 files) + 583 backend tests passing.
2026-03-22 16:40:37 -07:00
fe4091c7ba test(settings): settingsGuard unit tests — tab gating scenarios
Extract guard logic to settingsGuard.ts for testability.
Router beforeEach keeps async config.load() wrapper, delegates to sync guard.
14 test cases cover system/fine-tune/developer gates across cloud/self-hosted/tier/GPU profile combos.
2026-03-22 16:27:45 -07:00
55051818ef test(settings): backend tests for all settings API endpoints 2026-03-22 16:25:37 -07:00
a14eefd3e0 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)
2026-03-22 16:01:29 -07:00
6eaa1fef79 feat(settings): Fine-Tune tab — wizard, polling, step lifecycle
Add useFineTuneStore (Pinia setup-function) with step state, polling via
setInterval, loadStatus, startPolling/stopPolling, and submitJob. Add
FineTuneView.vue with a 3-step wizard (upload → extract → train), mode-aware
train step (self-hosted shows make finetune + model check; cloud shows
submit job + quota). Add fine-tune endpoints to dev-api.py: status, extract,
upload, submit, and local-status. All 4 store unit tests pass.
2026-03-22 15:52:53 -07:00
ab684301a5 fix(settings): task 6 review fixes — credential paths, email security, integrationResults in store
- Anchor CRED_DIR/KEY_PATH to __file__ (not CWD) in credential_store.py
- Fix email PUT: separate password pop from sentinel discard (was fragile or-chain)
- Fix email test: always use stored credential, remove password override path
- Move integrationResults into system store (was view-local — spec violation)
- saveFilePaths/saveDeployConfig write to dedicated error refs, not saveError
2026-03-22 15:46:47 -07:00
1817bddc6c feat(settings): credential store + fix Task 6 blocking review issues
- add scripts/credential_store.py (keyring/file/env-ref backends, Fernet encryption)
- email password stored via credential store, never returned in GET
- email GET returns password_set flag; PUT accepts new password or ${ENV_VAR} ref
- move integration actions to store (connectIntegration, testIntegration, disconnectIntegration)
- add tier-gating UI with locked state and upgrade prompt
- move subprocess/socket/imaplib/ssl imports to top level
2026-03-22 15:31:45 -07:00
e63473360e feat(settings): System tab — services, email, integrations, paths, deployment 2026-03-22 13:25:38 -07:00
acda1e8f5a fix(settings): system tab review fixes
- guard confirmByok() against byok-ack POST failure (leave modal open on error)
- fix drag reorder to use ID-based index lookup (not filtered-list index)
- guard cancelByok() against empty snapshot
- add LlmConfigPayload Pydantic model for PUT endpoint
- add test for confirmByok() failure path
2026-03-22 12:01:55 -07:00
0d17b20831 feat(settings): System tab — LLM backends, BYOK gate, store + view 2026-03-22 07:26:07 -07:00
91874a176c fix(settings): search prefs review fixes
- add try/except to suggest endpoint
- use immutable spread/filter in addTag, removeTag, acceptSuggestion
- add toggleBoard store action, remove direct v-model on board.enabled
2026-03-22 07:21:10 -07:00
c358d8c470 feat(settings): Search Prefs tab — store, view, API endpoints, remote preference filter 2026-03-21 03:09:51 -07:00
837881fbe8 fix(settings): address resume tab review issues
- add loadError ref (separated from empty-state path)
- add stable id to WorkEntry, use as v-for key
- move addExperience/removeExperience/addTag/removeTag to store actions
- strip id from save payload
- fix uploadError type handling in handleUpload
- add outer try/except to upload_resume endpoint
- gate syncFromProfile to non-loaded resume only
- add date_of_birth input to personal info section
- add loadError test
2026-03-21 03:04:29 -07:00
4b0db182b8 feat(settings): Resume Profile tab — store, view, API endpoints, identity sync 2026-03-21 02:57:49 -07:00
c4a58c7e27 fix(settings): final code quality fixes for My Profile tab
- add try/except to sync_identity endpoint
- strip id field from mission_preferences save body
- fix NDA v-for key to use company string (not index), add dedup guard
- move imports out of save_user_profile function body
2026-03-21 02:53:29 -07:00
2937c1b0fa fix(settings): spec compliance gaps in My Profile tab
- add POST /api/settings/resume/sync-identity endpoint (IdentitySyncPayload)
- fix loadError destructuring to use storeToRefs for reactivity
2026-03-21 02:40:17 -07:00
86454a97be fix(settings): address profile tab code quality issues
- add loadError ref to useProfileStore, rendered in MyProfileView
- replace raw fetch with useApiFetch in generateSummary/generateMissions
- remove await from sync-identity call (fire-and-forget)
- add stable id field to MissionPref, use as v-for key
- add test for load() error path
2026-03-21 02:37:53 -07:00
a8b16d616c fix(settings): profile tests assert sync-identity; add load/save_user_profile helpers 2026-03-21 02:31:39 -07:00
b8eb2a3890 feat(settings): My Profile tab — store, view, API endpoints
- Add useProfileStore (settings/profile) with load/save, all profile fields,
  loading/saving/saveError state, and graceful resume sync-identity call
- Add MyProfileView.vue: Identity, Mission & Values, NDA Companies, and
  Research Brief Preferences sections; autosave on NDA add/remove and
  debounced autosave (400ms) on research checkbox changes
- Add GET/PUT /api/settings/profile endpoints to dev-api.py with YAML
  field mapping (linkedin ↔ linkedin_url, candidate_*_focus ↔ *_focus,
  mission_preferences dict ↔ list of {industry, note})
- 3 new store tests pass; full suite 26/26 green
2026-03-21 02:28:14 -07:00
81b87a750c fix(settings): async guard awaits config load, reactive devTierOverride, validate APP_TIER 2026-03-21 02:23:10 -07:00
e7d6dfef90 feat(settings): foundation — appConfig store, settings shell, nested router
- Add useAppConfigStore (isCloud, isDevMode, tier, contractedClient, inferenceProfile)
- Add GET /api/config/app endpoint to dev-api.py (reads env vars)
- Replace flat /settings route with nested children (9 tabs) + redirect to my-profile
- Add global router.beforeEach guard for system/fine-tune/developer tab access control
- Add SettingsView.vue shell: desktop sidebar with group labels, mobile chip bar, RouterView
- Tab visibility driven reactively by store state (cloud mode hides system, GPU profile gates fine-tune, devMode gates developer)
- Tests: 3 store tests + 3 component tests, all passing
2026-03-21 02:19:43 -07:00
40 changed files with 5389 additions and 29 deletions

View file

@ -4,27 +4,36 @@ Reads directly from /devl/job-seeker/staging.db.
Run with:
conda run -n job-seeker uvicorn dev-api:app --port 8600 --reload
"""
import sqlite3
import os
import sys
import re
import imaplib
import json
import logging
import os
import re
import socket
import sqlite3
import ssl as ssl_mod
import subprocess
import sys
import threading
from urllib.parse import urlparse
from bs4 import BeautifulSoup
from datetime import datetime
from pathlib import Path
from fastapi import FastAPI, HTTPException, Response
from typing import Optional, List
from urllib.parse import urlparse
import requests
import yaml
from bs4 import BeautifulSoup
from fastapi import FastAPI, HTTPException, Response, UploadFile
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel
from typing import Optional
import requests
# Allow importing peregrine scripts for cover letter generation
PEREGRINE_ROOT = Path("/Library/Development/CircuitForge/peregrine")
if str(PEREGRINE_ROOT) not in sys.path:
sys.path.insert(0, str(PEREGRINE_ROOT))
from scripts.credential_store import get_credential, set_credential, delete_credential # noqa: E402
DB_PATH = os.environ.get("STAGING_DB", "/devl/job-seeker/staging.db")
app = FastAPI(title="Peregrine Dev API")
@ -868,6 +877,24 @@ def move_job(job_id: int, body: MoveBody):
return {"ok": True}
# ── GET /api/config/app ───────────────────────────────────────────────────────
@app.get("/api/config/app")
def get_app_config():
import os
profile = os.environ.get("INFERENCE_PROFILE", "cpu")
valid_profiles = {"remote", "cpu", "single-gpu", "dual-gpu"}
valid_tiers = {"free", "paid", "premium", "ultra"}
raw_tier = os.environ.get("APP_TIER", "free")
return {
"isCloud": os.environ.get("CLOUD_MODE", "").lower() in ("1", "true"),
"isDevMode": os.environ.get("DEV_MODE", "").lower() in ("1", "true"),
"tier": raw_tier if raw_tier in valid_tiers else "free",
"contractedClient": os.environ.get("CONTRACTED_CLIENT", "").lower() in ("1", "true"),
"inferenceProfile": profile if profile in valid_profiles else "cpu",
}
# ── GET /api/config/user ──────────────────────────────────────────────────────
@app.get("/api/config/user")
@ -883,3 +910,830 @@ def config_user():
return {"name": cfg.get("name", "")}
except Exception:
return {"name": ""}
# ── Settings: My Profile endpoints ───────────────────────────────────────────
from scripts.user_profile import load_user_profile, save_user_profile
def _user_yaml_path() -> str:
"""Resolve user.yaml path, falling back to legacy location."""
cfg_path = os.path.join(os.path.dirname(DB_PATH), "config", "user.yaml")
if not os.path.exists(cfg_path):
cfg_path = "/devl/job-seeker/config/user.yaml"
return cfg_path
def _mission_dict_to_list(prefs: object) -> list:
"""Convert {industry: note} dict to [{industry, note}] list for the SPA."""
if isinstance(prefs, list):
return prefs
if isinstance(prefs, dict):
return [{"industry": k, "note": v or ""} for k, v in prefs.items()]
return []
def _mission_list_to_dict(prefs: list) -> dict:
"""Convert [{industry, note}] list from the SPA back to {industry: note} dict."""
result = {}
for item in prefs:
if isinstance(item, dict):
result[item.get("industry", "")] = item.get("note", "")
return result
@app.get("/api/settings/profile")
def get_profile():
try:
cfg = load_user_profile(_user_yaml_path())
return {
"name": cfg.get("name", ""),
"email": cfg.get("email", ""),
"phone": cfg.get("phone", ""),
"linkedin_url": cfg.get("linkedin", ""),
"career_summary": cfg.get("career_summary", ""),
"candidate_voice": cfg.get("candidate_voice", ""),
"inference_profile": cfg.get("inference_profile", "cpu"),
"mission_preferences": _mission_dict_to_list(cfg.get("mission_preferences", {})),
"nda_companies": cfg.get("nda_companies", []),
"accessibility_focus": cfg.get("candidate_accessibility_focus", False),
"lgbtq_focus": cfg.get("candidate_lgbtq_focus", False),
}
except Exception as e:
raise HTTPException(500, f"Could not read profile: {e}")
class MissionPrefModel(BaseModel):
industry: str
note: str = ""
class UserProfilePayload(BaseModel):
name: str = ""
email: str = ""
phone: str = ""
linkedin_url: str = ""
career_summary: str = ""
candidate_voice: str = ""
inference_profile: str = "cpu"
mission_preferences: List[MissionPrefModel] = []
nda_companies: List[str] = []
accessibility_focus: bool = False
lgbtq_focus: bool = False
class IdentitySyncPayload(BaseModel):
name: str = ""
email: str = ""
phone: str = ""
linkedin_url: str = ""
@app.post("/api/settings/resume/sync-identity")
def sync_identity(payload: IdentitySyncPayload):
"""Sync identity fields from profile store back to user.yaml."""
try:
data = load_user_profile(_user_yaml_path())
data["name"] = payload.name
data["email"] = payload.email
data["phone"] = payload.phone
data["linkedin"] = payload.linkedin_url # yaml key is 'linkedin', not 'linkedin_url'
save_user_profile(_user_yaml_path(), data)
return {"ok": True}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@app.put("/api/settings/profile")
def save_profile(payload: UserProfilePayload):
try:
yaml_path = _user_yaml_path()
cfg = load_user_profile(yaml_path)
cfg["name"] = payload.name
cfg["email"] = payload.email
cfg["phone"] = payload.phone
cfg["linkedin"] = payload.linkedin_url
cfg["career_summary"] = payload.career_summary
cfg["candidate_voice"] = payload.candidate_voice
cfg["inference_profile"] = payload.inference_profile
cfg["mission_preferences"] = _mission_list_to_dict(
[m.model_dump() for m in payload.mission_preferences]
)
cfg["nda_companies"] = payload.nda_companies
cfg["candidate_accessibility_focus"] = payload.accessibility_focus
cfg["candidate_lgbtq_focus"] = payload.lgbtq_focus
save_user_profile(yaml_path, cfg)
return {"ok": True}
except Exception as e:
raise HTTPException(500, f"Could not save profile: {e}")
# ── Settings: Resume Profile endpoints ───────────────────────────────────────
class WorkEntry(BaseModel):
title: str = ""; company: str = ""; period: str = ""; location: str = ""
industry: str = ""; responsibilities: str = ""; skills: List[str] = []
class ResumePayload(BaseModel):
name: str = ""; email: str = ""; phone: str = ""; linkedin_url: str = ""
surname: str = ""; address: str = ""; city: str = ""; zip_code: str = ""; date_of_birth: str = ""
experience: List[WorkEntry] = []
salary_min: int = 0; salary_max: int = 0; notice_period: str = ""
remote: bool = False; relocation: bool = False
assessment: bool = False; background_check: bool = False
gender: str = ""; pronouns: str = ""; ethnicity: str = ""
veteran_status: str = ""; disability: str = ""
skills: List[str] = []; domains: List[str] = []; keywords: List[str] = []
RESUME_PATH = Path("config/plain_text_resume.yaml")
@app.get("/api/settings/resume")
def get_resume():
try:
if not RESUME_PATH.exists():
return {"exists": False}
with open(RESUME_PATH) as f:
data = yaml.safe_load(f) or {}
data["exists"] = True
return data
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@app.put("/api/settings/resume")
def save_resume(payload: ResumePayload):
try:
RESUME_PATH.parent.mkdir(parents=True, exist_ok=True)
with open(RESUME_PATH, "w") as f:
yaml.dump(payload.model_dump(), f, allow_unicode=True, default_flow_style=False)
return {"ok": True}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@app.post("/api/settings/resume/blank")
def create_blank_resume():
try:
RESUME_PATH.parent.mkdir(parents=True, exist_ok=True)
if not RESUME_PATH.exists():
with open(RESUME_PATH, "w") as f:
yaml.dump({}, f)
return {"ok": True}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@app.post("/api/settings/resume/upload")
async def upload_resume(file: UploadFile):
try:
from scripts.resume_parser import structure_resume
import tempfile, os
suffix = Path(file.filename).suffix.lower()
tmp_path = None
with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as tmp:
tmp.write(await file.read())
tmp_path = tmp.name
try:
result, err = structure_resume(tmp_path)
finally:
if tmp_path:
os.unlink(tmp_path)
if err:
return {"ok": False, "error": err, "data": result}
result["exists"] = True
return {"ok": True, "data": result}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
# ── Settings: Search Preferences endpoints ────────────────────────────────────
class SearchPrefsPayload(BaseModel):
remote_preference: str = "both"
job_titles: List[str] = []
locations: List[str] = []
exclude_keywords: List[str] = []
job_boards: List[dict] = []
custom_board_urls: List[str] = []
blocklist_companies: List[str] = []
blocklist_industries: List[str] = []
blocklist_locations: List[str] = []
SEARCH_PREFS_PATH = Path("config/search_profiles.yaml")
@app.get("/api/settings/search")
def get_search_prefs():
try:
if not SEARCH_PREFS_PATH.exists():
return {}
with open(SEARCH_PREFS_PATH) as f:
data = yaml.safe_load(f) or {}
return data.get("default", {})
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@app.put("/api/settings/search")
def save_search_prefs(payload: SearchPrefsPayload):
try:
data = {}
if SEARCH_PREFS_PATH.exists():
with open(SEARCH_PREFS_PATH) as f:
data = yaml.safe_load(f) or {}
data["default"] = payload.model_dump()
with open(SEARCH_PREFS_PATH, "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))
@app.post("/api/settings/search/suggest")
def suggest_search(body: dict):
try:
# Stub — LLM suggest for paid tier
return {"suggestions": []}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
# ── Settings: System — LLM Backends + BYOK endpoints ─────────────────────────
class ByokAckPayload(BaseModel):
backends: List[str] = []
class LlmConfigPayload(BaseModel):
backends: List[dict] = []
LLM_CONFIG_PATH = Path("config/llm.yaml")
@app.get("/api/settings/system/llm")
def get_llm_config():
try:
user = load_user_profile(_user_yaml_path())
backends = []
if LLM_CONFIG_PATH.exists():
with open(LLM_CONFIG_PATH) as f:
data = yaml.safe_load(f) or {}
backends = data.get("backends", [])
return {"backends": backends, "byok_acknowledged": user.get("byok_acknowledged_backends", [])}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@app.put("/api/settings/system/llm")
def save_llm_config(payload: LlmConfigPayload):
try:
data = {}
if LLM_CONFIG_PATH.exists():
with open(LLM_CONFIG_PATH) as f:
data = yaml.safe_load(f) or {}
data["backends"] = payload.backends
LLM_CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True)
with open(LLM_CONFIG_PATH, "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))
@app.post("/api/settings/system/llm/byok-ack")
def byok_ack(payload: ByokAckPayload):
try:
user = load_user_profile(_user_yaml_path())
existing = user.get("byok_acknowledged_backends", [])
user["byok_acknowledged_backends"] = list(set(existing + payload.backends))
save_user_profile(_user_yaml_path(), user)
return {"ok": True}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
# ── Settings: System — Services ───────────────────────────────────────────────
SERVICES_REGISTRY = [
{"name": "ollama", "port": 11434, "compose_service": "ollama", "note": "LLM inference", "profiles": ["cpu","single-gpu","dual-gpu"]},
{"name": "vllm", "port": 8000, "compose_service": "vllm", "note": "vLLM server", "profiles": ["single-gpu","dual-gpu"]},
{"name": "vision", "port": 8002, "compose_service": "vision", "note": "Moondream2 vision", "profiles": ["single-gpu","dual-gpu"]},
{"name": "searxng", "port": 8888, "compose_service": "searxng", "note": "Search engine", "profiles": ["cpu","remote","single-gpu","dual-gpu"]},
]
def _port_open(port: int) -> bool:
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.settimeout(0.5)
return s.connect_ex(("127.0.0.1", port)) == 0
@app.get("/api/settings/system/services")
def get_services():
try:
profile = os.environ.get("INFERENCE_PROFILE", "cpu")
result = []
for svc in SERVICES_REGISTRY:
if profile not in svc["profiles"]:
continue
result.append({"name": svc["name"], "port": svc["port"],
"running": _port_open(svc["port"]), "note": svc["note"]})
return result
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@app.post("/api/settings/system/services/{name}/start")
def start_service(name: str):
try:
svc = next((s for s in SERVICES_REGISTRY if s["name"] == name), None)
if not svc:
raise HTTPException(404, "Unknown service")
r = subprocess.run(["docker", "compose", "up", "-d", svc["compose_service"]],
capture_output=True, text=True)
return {"ok": r.returncode == 0, "output": r.stdout + r.stderr}
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@app.post("/api/settings/system/services/{name}/stop")
def stop_service(name: str):
try:
svc = next((s for s in SERVICES_REGISTRY if s["name"] == name), None)
if not svc:
raise HTTPException(404, "Unknown service")
r = subprocess.run(["docker", "compose", "stop", svc["compose_service"]],
capture_output=True, text=True)
return {"ok": r.returncode == 0, "output": r.stdout + r.stderr}
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
# ── Settings: System — Email ──────────────────────────────────────────────────
EMAIL_PATH = Path("config/email.yaml")
EMAIL_CRED_SERVICE = "peregrine"
EMAIL_CRED_KEY = "imap_password"
# Non-secret fields stored in yaml
EMAIL_YAML_FIELDS = ("host", "port", "ssl", "username", "sent_folder", "lookback_days")
@app.get("/api/settings/system/email")
def get_email_config():
try:
config = {}
if EMAIL_PATH.exists():
with open(EMAIL_PATH) as f:
config = yaml.safe_load(f) or {}
# Never return the password — only indicate whether it's set
password = get_credential(EMAIL_CRED_SERVICE, EMAIL_CRED_KEY)
config["password_set"] = bool(password)
config.pop("password", None) # strip if somehow in yaml
return config
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@app.put("/api/settings/system/email")
def save_email_config(payload: dict):
try:
EMAIL_PATH.parent.mkdir(parents=True, exist_ok=True)
# Extract password before writing yaml; discard the sentinel boolean regardless
password = payload.pop("password", None)
payload.pop("password_set", None) # always discard — boolean sentinel, not a secret
if password and isinstance(password, str):
set_credential(EMAIL_CRED_SERVICE, EMAIL_CRED_KEY, password)
# Write non-secret fields to yaml (chmod 600 still, contains username)
safe_config = {k: v for k, v in payload.items() if k in EMAIL_YAML_FIELDS}
fd = os.open(str(EMAIL_PATH), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
with os.fdopen(fd, "w") as f:
yaml.dump(safe_config, f, allow_unicode=True, default_flow_style=False)
return {"ok": True}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@app.post("/api/settings/system/email/test")
def test_email(payload: dict):
try:
# Always use the stored credential — never accept a password in the test request body
password = get_credential(EMAIL_CRED_SERVICE, EMAIL_CRED_KEY)
host = payload.get("host", "")
port = int(payload.get("port", 993))
use_ssl = payload.get("ssl", True)
username = payload.get("username", "")
if not all([host, username, password]):
return {"ok": False, "error": "Missing host, username, or password"}
if use_ssl:
ctx = ssl_mod.create_default_context()
conn = imaplib.IMAP4_SSL(host, port, ssl_context=ctx)
else:
conn = imaplib.IMAP4(host, port)
conn.login(username, password)
conn.logout()
return {"ok": True}
except Exception as e:
return {"ok": False, "error": str(e)}
# ── Settings: System — Integrations ──────────────────────────────────────────
@app.get("/api/settings/system/integrations")
def get_integrations():
try:
from scripts.integrations import REGISTRY
result = []
for integration in REGISTRY:
result.append({
"id": integration.id,
"name": integration.name,
"connected": integration.is_connected(),
"tier_required": getattr(integration, "tier_required", "free"),
"fields": [{"key": f["key"], "label": f["label"], "type": f.get("type", "text")}
for f in integration.fields()],
})
return result
except ImportError:
return [] # integrations module not yet implemented
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@app.post("/api/settings/system/integrations/{integration_id}/test")
def test_integration(integration_id: str, payload: dict):
try:
from scripts.integrations import REGISTRY
integration = next((i for i in REGISTRY if i.id == integration_id), None)
if not integration:
raise HTTPException(404, "Unknown integration")
ok, error = integration.test(payload)
return {"ok": ok, "error": error}
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@app.post("/api/settings/system/integrations/{integration_id}/connect")
def connect_integration(integration_id: str, payload: dict):
try:
from scripts.integrations import REGISTRY
integration = next((i for i in REGISTRY if i.id == integration_id), None)
if not integration:
raise HTTPException(404, "Unknown integration")
ok, error = integration.test(payload)
if not ok:
return {"ok": False, "error": error}
integration.save_credentials(payload)
return {"ok": True}
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@app.post("/api/settings/system/integrations/{integration_id}/disconnect")
def disconnect_integration(integration_id: str):
try:
from scripts.integrations import REGISTRY
integration = next((i for i in REGISTRY if i.id == integration_id), None)
if not integration:
raise HTTPException(404, "Unknown integration")
integration.remove_credentials()
return {"ok": True}
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
# ── Settings: System — File Paths ─────────────────────────────────────────────
@app.get("/api/settings/system/paths")
def get_file_paths():
try:
user = load_user_profile(_user_yaml_path())
return {
"docs_dir": user.get("docs_dir", ""),
"data_dir": user.get("data_dir", ""),
"model_dir": user.get("model_dir", ""),
}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@app.put("/api/settings/system/paths")
def save_file_paths(payload: dict):
try:
user = load_user_profile(_user_yaml_path())
for key in ("docs_dir", "data_dir", "model_dir"):
if key in payload:
user[key] = payload[key]
save_user_profile(_user_yaml_path(), user)
return {"ok": True}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
# ── Settings: System — Deployment Config ─────────────────────────────────────
@app.get("/api/settings/system/deploy")
def get_deploy_config():
try:
return {
"base_url_path": os.environ.get("STREAMLIT_SERVER_BASE_URL_PATH", ""),
"server_host": os.environ.get("STREAMLIT_SERVER_ADDRESS", "0.0.0.0"),
"server_port": int(os.environ.get("STREAMLIT_SERVER_PORT", "8502")),
}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@app.put("/api/settings/system/deploy")
def save_deploy_config(payload: dict):
# Deployment config changes require restart; just acknowledge
return {"ok": True, "note": "Restart required to apply changes"}
# ── Settings: Fine-Tune ───────────────────────────────────────────────────────
@app.get("/api/settings/fine-tune/status")
def finetune_status():
try:
from scripts.task_runner import get_task_status
task = get_task_status("finetune_extract")
if not task:
return {"status": "idle", "pairs_count": 0}
return {"status": task.get("status", "idle"), "pairs_count": task.get("result_count", 0)}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@app.post("/api/settings/fine-tune/extract")
def finetune_extract():
try:
from scripts.task_runner import submit_task
task_id = submit_task(DB_PATH, "finetune_extract", None)
return {"task_id": str(task_id)}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@app.post("/api/settings/fine-tune/upload")
async def finetune_upload(files: list[UploadFile]):
try:
upload_dir = Path("data/finetune_uploads")
upload_dir.mkdir(parents=True, exist_ok=True)
saved = []
for f in files:
dest = upload_dir / (f.filename or "upload.bin")
content = await f.read()
fd = os.open(str(dest), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
with os.fdopen(fd, "wb") as out:
out.write(content)
saved.append(str(dest))
return {"file_count": len(saved), "paths": saved}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@app.post("/api/settings/fine-tune/submit")
def finetune_submit():
try:
# Cloud-only: submit a managed fine-tune job
# In dev mode, stub a job_id for local testing
import uuid
job_id = str(uuid.uuid4())
return {"job_id": job_id}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@app.get("/api/settings/fine-tune/local-status")
def finetune_local_status():
try:
import subprocess
result = subprocess.run(
["ollama", "list"], capture_output=True, text=True, timeout=5
)
model_ready = "alex-cover-writer" in (result.stdout or "")
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))

198
scripts/credential_store.py Normal file
View file

@ -0,0 +1,198 @@
"""
Credential store abstraction for Peregrine.
Backends (set via CREDENTIAL_BACKEND env var):
auto try keyring, fall back to file (default)
keyring python-keyring (OS Keychain / SecretService / libsecret)
file Fernet-encrypted JSON in config/credentials/ (key at config/.credential_key)
Env var references:
Any stored value matching ${VAR_NAME} is resolved from os.environ at read time.
Users can store "${IMAP_PASSWORD}" as the credential value; it is never treated
as the actual secret only the env var it points to is used.
"""
import os
import re
import json
import logging
from pathlib import Path
from typing import Optional
logger = logging.getLogger(__name__)
_ENV_REF = re.compile(r'^\$\{([A-Z_][A-Z0-9_]*)\}$')
_PROJECT_ROOT = Path(__file__).parent.parent
CRED_DIR = _PROJECT_ROOT / "config" / "credentials"
KEY_PATH = _PROJECT_ROOT / "config" / ".credential_key"
def _resolve_env_ref(value: str) -> Optional[str]:
"""If value is ${VAR_NAME}, return os.environ[VAR_NAME]; otherwise return None."""
m = _ENV_REF.match(value)
if m:
resolved = os.environ.get(m.group(1))
if resolved is None:
logger.warning("Credential reference %s is set but env var is not defined", value)
return resolved
return None
def _get_backend() -> str:
backend = os.environ.get("CREDENTIAL_BACKEND", "auto").lower()
if backend != "auto":
return backend
# Auto: try keyring, fall back to file
try:
import keyring
kr = keyring.get_keyring()
# Reject the null/fail keyring — it can't actually store anything
if "fail" in type(kr).__name__.lower() or "null" in type(kr).__name__.lower():
raise RuntimeError("No usable keyring backend found")
return "keyring"
except Exception:
return "file"
def _get_fernet():
"""Return a Fernet instance, auto-generating the key on first use."""
try:
from cryptography.fernet import Fernet
except ImportError:
return None
if KEY_PATH.exists():
key = KEY_PATH.read_bytes().strip()
else:
key = Fernet.generate_key()
KEY_PATH.parent.mkdir(parents=True, exist_ok=True)
fd = os.open(str(KEY_PATH), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
with os.fdopen(fd, "wb") as f:
f.write(key)
logger.info("Generated new credential encryption key at %s", KEY_PATH)
return Fernet(key)
def _file_read(service: str) -> dict:
"""Read the credentials file for a service, decrypting if possible."""
cred_file = CRED_DIR / f"{service}.json"
if not cred_file.exists():
return {}
raw = cred_file.read_bytes()
fernet = _get_fernet()
if fernet:
try:
return json.loads(fernet.decrypt(raw))
except Exception:
# May be an older plaintext file — try reading as text
try:
return json.loads(raw.decode())
except Exception:
logger.error("Failed to read credentials for service %s", service)
return {}
else:
try:
return json.loads(raw.decode())
except Exception:
return {}
def _file_write(service: str, data: dict) -> None:
"""Write the credentials file for a service, encrypting if possible."""
CRED_DIR.mkdir(parents=True, exist_ok=True)
cred_file = CRED_DIR / f"{service}.json"
fernet = _get_fernet()
if fernet:
content = fernet.encrypt(json.dumps(data).encode())
fd = os.open(str(cred_file), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
with os.fdopen(fd, "wb") as f:
f.write(content)
else:
logger.warning(
"cryptography package not installed — storing credentials as plaintext with chmod 600. "
"Install with: pip install cryptography"
)
content = json.dumps(data).encode()
fd = os.open(str(cred_file), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
with os.fdopen(fd, "wb") as f:
f.write(content)
def get_credential(service: str, key: str) -> Optional[str]:
"""
Retrieve a credential. If the stored value is an env var reference (${VAR}),
resolves it from os.environ at call time.
"""
backend = _get_backend()
raw: Optional[str] = None
if backend == "keyring":
try:
import keyring
raw = keyring.get_password(service, key)
except Exception as e:
logger.error("keyring get failed for %s/%s: %s", service, key, e)
else: # file
data = _file_read(service)
raw = data.get(key)
if raw is None:
return None
# Resolve env var references transparently
resolved = _resolve_env_ref(raw)
if resolved is not None:
return resolved
if _ENV_REF.match(raw):
return None # reference defined but env var not set
return raw
def set_credential(service: str, key: str, value: str) -> None:
"""
Store a credential. Value may be a literal secret or a ${VAR_NAME} reference.
Env var references are stored as-is and resolved at get time.
"""
if not value:
return
backend = _get_backend()
if backend == "keyring":
try:
import keyring
keyring.set_password(service, key, value)
return
except Exception as e:
logger.error("keyring set failed for %s/%s: %s — falling back to file", service, key, e)
backend = "file"
# file backend
data = _file_read(service)
data[key] = value
_file_write(service, data)
def delete_credential(service: str, key: str) -> None:
"""Remove a stored credential."""
backend = _get_backend()
if backend == "keyring":
try:
import keyring
keyring.delete_password(service, key)
return
except Exception:
backend = "file"
data = _file_read(service)
data.pop(key, None)
if data:
_file_write(service, data)
else:
cred_file = CRED_DIR / f"{service}.json"
if cred_file.exists():
cred_file.unlink()

View file

@ -196,13 +196,20 @@ def run_discovery(db_path: Path = DEFAULT_DB, notion_push: bool = False) -> None
exclude_kw = [kw.lower() for kw in profile.get("exclude_keywords", [])]
results_per_board = profile.get("results_per_board", 25)
# Map remote_preference → JobSpy is_remote param:
# 'remote' → True (remote-only listings)
# 'onsite' → False (on-site-only listings)
# 'both' → None (no filter — JobSpy default)
_rp = profile.get("remote_preference", "both")
_is_remote: bool | None = True if _rp == "remote" else (False if _rp == "onsite" else None)
for location in profile["locations"]:
# ── JobSpy boards ──────────────────────────────────────────────────
if boards:
print(f" [jobspy] {location} — boards: {', '.join(boards)}")
try:
jobs: pd.DataFrame = scrape_jobs(
jobspy_kwargs: dict = dict(
site_name=boards,
search_term=" OR ".join(f'"{t}"' for t in profile["titles"]),
location=location,
@ -210,6 +217,9 @@ def run_discovery(db_path: Path = DEFAULT_DB, notion_push: bool = False) -> None
hours_old=profile.get("hours_old", 72),
linkedin_fetch_description=True,
)
if _is_remote is not None:
jobspy_kwargs["is_remote"] = _is_remote
jobs: pd.DataFrame = scrape_jobs(**jobspy_kwargs)
print(f" [jobspy] {len(jobs)} raw results")
except Exception as exc:
print(f" [jobspy] ERROR: {exc}")

View file

@ -7,6 +7,8 @@ here so port/host/SSL changes propagate everywhere automatically.
"""
from __future__ import annotations
from pathlib import Path
import os
import tempfile
import yaml
_DEFAULTS = {
@ -130,3 +132,30 @@ class UserProfile:
"ollama_research": f"{self.ollama_url}/v1",
"vllm": f"{self.vllm_url}/v1",
}
# ── Free functions for plain-dict access (used by dev-api.py) ─────────────────
def load_user_profile(config_path: str) -> dict:
"""Load user.yaml and return as a plain dict with safe defaults."""
path = Path(config_path)
if not path.exists():
return {}
with open(path) as f:
data = yaml.safe_load(f) or {}
return data
def save_user_profile(config_path: str, data: dict) -> None:
"""Atomically write the user profile dict to user.yaml."""
path = Path(config_path)
path.parent.mkdir(parents=True, exist_ok=True)
# Write to temp file then rename for atomicity
fd, tmp = tempfile.mkstemp(dir=path.parent, suffix='.yaml.tmp')
try:
with os.fdopen(fd, 'w') as f:
yaml.dump(data, f, allow_unicode=True, default_flow_style=False)
os.replace(tmp, path)
except Exception:
os.unlink(tmp)
raise

View file

@ -0,0 +1,632 @@
"""Tests for all settings API endpoints added in Tasks 18."""
import os
import sys
import yaml
import pytest
from pathlib import Path
from unittest.mock import patch, MagicMock
from fastapi.testclient import TestClient
_WORKTREE = "/Library/Development/CircuitForge/peregrine/.worktrees/feature-vue-spa"
# ── Path bootstrap ────────────────────────────────────────────────────────────
# dev_api.py inserts /Library/Development/CircuitForge/peregrine into sys.path
# at import time; the worktree has credential_store but the main repo doesn't.
# Insert the worktree first so 'scripts' resolves to the worktree version, then
# pre-cache it in sys.modules so Python won't re-look-up when dev_api adds the
# main peregrine root.
if _WORKTREE not in sys.path:
sys.path.insert(0, _WORKTREE)
# Pre-cache the worktree scripts package and submodules before dev_api import
import importlib, types
def _ensure_worktree_scripts():
import importlib.util as _ilu
_wt = _WORKTREE
# Only load if not already loaded from the worktree
_spec = _ilu.spec_from_file_location("scripts", f"{_wt}/scripts/__init__.py",
submodule_search_locations=[f"{_wt}/scripts"])
if _spec is None:
return
_mod = _ilu.module_from_spec(_spec)
sys.modules.setdefault("scripts", _mod)
try:
_spec.loader.exec_module(_mod)
except Exception:
pass
_ensure_worktree_scripts()
@pytest.fixture(scope="module")
def client():
from dev_api import app
return TestClient(app)
# ── Helpers ───────────────────────────────────────────────────────────────────
def _write_user_yaml(path: Path, data: dict = None):
"""Write a minimal user.yaml to the given path."""
path.parent.mkdir(parents=True, exist_ok=True)
with open(path, "w") as f:
yaml.dump(data or {"name": "Test User", "email": "test@example.com"}, f)
# ── GET /api/config/app ───────────────────────────────────────────────────────
def test_app_config_returns_expected_keys(client):
"""Returns 200 with isCloud, tier, and inferenceProfile in valid values."""
resp = client.get("/api/config/app")
assert resp.status_code == 200
data = resp.json()
assert "isCloud" in data
assert "tier" in data
assert "inferenceProfile" in data
valid_tiers = {"free", "paid", "premium", "ultra"}
valid_profiles = {"remote", "cpu", "single-gpu", "dual-gpu"}
assert data["tier"] in valid_tiers
assert data["inferenceProfile"] in valid_profiles
def test_app_config_iscloud_env(client):
"""isCloud reflects CLOUD_MODE env var."""
with patch.dict(os.environ, {"CLOUD_MODE": "true"}):
resp = client.get("/api/config/app")
assert resp.json()["isCloud"] is True
def test_app_config_invalid_tier_falls_back_to_free(client):
"""Unknown APP_TIER falls back to 'free'."""
with patch.dict(os.environ, {"APP_TIER": "enterprise"}):
resp = client.get("/api/config/app")
assert resp.json()["tier"] == "free"
# ── GET/PUT /api/settings/profile ─────────────────────────────────────────────
def test_get_profile_returns_fields(tmp_path, monkeypatch):
"""GET /api/settings/profile returns dict with expected profile fields."""
db_dir = tmp_path / "db"
db_dir.mkdir()
cfg_dir = db_dir / "config"
cfg_dir.mkdir()
user_yaml = cfg_dir / "user.yaml"
_write_user_yaml(user_yaml, {"name": "Alice", "email": "alice@example.com"})
monkeypatch.setenv("STAGING_DB", str(db_dir / "staging.db"))
from dev_api import app
c = TestClient(app)
resp = c.get("/api/settings/profile")
assert resp.status_code == 200
data = resp.json()
assert "name" in data
assert "email" in data
assert "career_summary" in data
assert "mission_preferences" in data
def test_put_get_profile_roundtrip(tmp_path, monkeypatch):
"""PUT then GET profile round-trip: saved name is returned."""
db_dir = tmp_path / "db"
db_dir.mkdir()
cfg_dir = db_dir / "config"
cfg_dir.mkdir()
user_yaml = cfg_dir / "user.yaml"
_write_user_yaml(user_yaml)
monkeypatch.setenv("STAGING_DB", str(db_dir / "staging.db"))
from dev_api import app
c = TestClient(app)
put_resp = c.put("/api/settings/profile", json={
"name": "Bob Builder",
"email": "bob@example.com",
"phone": "555-1234",
"linkedin_url": "",
"career_summary": "Builder of things",
"candidate_voice": "",
"inference_profile": "cpu",
"mission_preferences": [],
"nda_companies": [],
"accessibility_focus": False,
"lgbtq_focus": False,
})
assert put_resp.status_code == 200
assert put_resp.json()["ok"] is True
get_resp = c.get("/api/settings/profile")
assert get_resp.status_code == 200
assert get_resp.json()["name"] == "Bob Builder"
# ── GET /api/settings/resume ──────────────────────────────────────────────────
def test_get_resume_missing_returns_not_exists(tmp_path, monkeypatch):
"""GET /api/settings/resume when file missing returns {exists: false}."""
fake_path = tmp_path / "config" / "plain_text_resume.yaml"
# Ensure the path doesn't exist
monkeypatch.setattr("dev_api.RESUME_PATH", fake_path)
from dev_api import app
c = TestClient(app)
resp = c.get("/api/settings/resume")
assert resp.status_code == 200
assert resp.json() == {"exists": False}
def test_post_resume_blank_creates_file(tmp_path, monkeypatch):
"""POST /api/settings/resume/blank creates the file."""
fake_path = tmp_path / "config" / "plain_text_resume.yaml"
monkeypatch.setattr("dev_api.RESUME_PATH", fake_path)
from dev_api import app
c = TestClient(app)
resp = c.post("/api/settings/resume/blank")
assert resp.status_code == 200
assert resp.json()["ok"] is True
assert fake_path.exists()
def test_get_resume_after_blank_returns_exists(tmp_path, monkeypatch):
"""GET /api/settings/resume after blank creation returns {exists: true}."""
fake_path = tmp_path / "config" / "plain_text_resume.yaml"
monkeypatch.setattr("dev_api.RESUME_PATH", fake_path)
from dev_api import app
c = TestClient(app)
# First create the blank file
c.post("/api/settings/resume/blank")
# Now get should return exists: True
resp = c.get("/api/settings/resume")
assert resp.status_code == 200
assert resp.json()["exists"] is True
def test_post_resume_sync_identity(tmp_path, monkeypatch):
"""POST /api/settings/resume/sync-identity returns 200."""
db_dir = tmp_path / "db"
db_dir.mkdir()
cfg_dir = db_dir / "config"
cfg_dir.mkdir()
user_yaml = cfg_dir / "user.yaml"
_write_user_yaml(user_yaml)
monkeypatch.setenv("STAGING_DB", str(db_dir / "staging.db"))
from dev_api import app
c = TestClient(app)
resp = c.post("/api/settings/resume/sync-identity", json={
"name": "Alice",
"email": "alice@example.com",
"phone": "555-0000",
"linkedin_url": "https://linkedin.com/in/alice",
})
assert resp.status_code == 200
assert resp.json()["ok"] is True
# ── GET/PUT /api/settings/search ──────────────────────────────────────────────
def test_get_search_prefs_returns_dict(tmp_path, monkeypatch):
"""GET /api/settings/search returns a dict with expected fields."""
fake_path = tmp_path / "config" / "search_profiles.yaml"
fake_path.parent.mkdir(parents=True, exist_ok=True)
with open(fake_path, "w") as f:
yaml.dump({"default": {"remote_preference": "remote", "job_boards": []}}, f)
monkeypatch.setattr("dev_api.SEARCH_PREFS_PATH", fake_path)
from dev_api import app
c = TestClient(app)
resp = c.get("/api/settings/search")
assert resp.status_code == 200
data = resp.json()
assert "remote_preference" in data
assert "job_boards" in data
def test_put_get_search_roundtrip(tmp_path, monkeypatch):
"""PUT then GET search prefs round-trip: saved field is returned."""
fake_path = tmp_path / "config" / "search_profiles.yaml"
fake_path.parent.mkdir(parents=True, exist_ok=True)
monkeypatch.setattr("dev_api.SEARCH_PREFS_PATH", fake_path)
from dev_api import app
c = TestClient(app)
put_resp = c.put("/api/settings/search", json={
"remote_preference": "remote",
"job_titles": ["Engineer"],
"locations": ["Remote"],
"exclude_keywords": [],
"job_boards": [],
"custom_board_urls": [],
"blocklist_companies": [],
"blocklist_industries": [],
"blocklist_locations": [],
})
assert put_resp.status_code == 200
assert put_resp.json()["ok"] is True
get_resp = c.get("/api/settings/search")
assert get_resp.status_code == 200
assert get_resp.json()["remote_preference"] == "remote"
def test_get_search_missing_file_returns_empty(tmp_path, monkeypatch):
"""GET /api/settings/search when file missing returns empty dict."""
fake_path = tmp_path / "config" / "search_profiles.yaml"
monkeypatch.setattr("dev_api.SEARCH_PREFS_PATH", fake_path)
from dev_api import app
c = TestClient(app)
resp = c.get("/api/settings/search")
assert resp.status_code == 200
assert resp.json() == {}
# ── GET/PUT /api/settings/system/llm ─────────────────────────────────────────
def test_get_llm_config_returns_backends_and_byok(tmp_path, monkeypatch):
"""GET /api/settings/system/llm returns backends list and byok_acknowledged."""
db_dir = tmp_path / "db"
db_dir.mkdir()
cfg_dir = db_dir / "config"
cfg_dir.mkdir()
user_yaml = cfg_dir / "user.yaml"
_write_user_yaml(user_yaml)
monkeypatch.setenv("STAGING_DB", str(db_dir / "staging.db"))
fake_llm_path = tmp_path / "llm.yaml"
with open(fake_llm_path, "w") as f:
yaml.dump({"backends": [{"name": "ollama", "enabled": True}]}, f)
monkeypatch.setattr("dev_api.LLM_CONFIG_PATH", fake_llm_path)
from dev_api import app
c = TestClient(app)
resp = c.get("/api/settings/system/llm")
assert resp.status_code == 200
data = resp.json()
assert "backends" in data
assert isinstance(data["backends"], list)
assert "byok_acknowledged" in data
def test_byok_ack_adds_backend(tmp_path, monkeypatch):
"""POST byok-ack with backends list then GET shows backend in byok_acknowledged."""
db_dir = tmp_path / "db"
db_dir.mkdir()
cfg_dir = db_dir / "config"
cfg_dir.mkdir()
user_yaml = cfg_dir / "user.yaml"
_write_user_yaml(user_yaml, {"name": "Test", "byok_acknowledged_backends": []})
monkeypatch.setenv("STAGING_DB", str(db_dir / "staging.db"))
fake_llm_path = tmp_path / "llm.yaml"
monkeypatch.setattr("dev_api.LLM_CONFIG_PATH", fake_llm_path)
from dev_api import app
c = TestClient(app)
ack_resp = c.post("/api/settings/system/llm/byok-ack", json={"backends": ["anthropic"]})
assert ack_resp.status_code == 200
assert ack_resp.json()["ok"] is True
get_resp = c.get("/api/settings/system/llm")
assert get_resp.status_code == 200
assert "anthropic" in get_resp.json()["byok_acknowledged"]
def test_put_llm_config_returns_ok(tmp_path, monkeypatch):
"""PUT /api/settings/system/llm returns ok."""
db_dir = tmp_path / "db"
db_dir.mkdir()
cfg_dir = db_dir / "config"
cfg_dir.mkdir()
user_yaml = cfg_dir / "user.yaml"
_write_user_yaml(user_yaml)
monkeypatch.setenv("STAGING_DB", str(db_dir / "staging.db"))
fake_llm_path = tmp_path / "llm.yaml"
monkeypatch.setattr("dev_api.LLM_CONFIG_PATH", fake_llm_path)
from dev_api import app
c = TestClient(app)
resp = c.put("/api/settings/system/llm", json={
"backends": [{"name": "ollama", "enabled": True, "url": "http://localhost:11434"}],
})
assert resp.status_code == 200
assert resp.json()["ok"] is True
# ── GET /api/settings/system/services ────────────────────────────────────────
def test_get_services_returns_list(client):
"""GET /api/settings/system/services returns a list."""
resp = client.get("/api/settings/system/services")
assert resp.status_code == 200
assert isinstance(resp.json(), list)
def test_get_services_cpu_profile(client):
"""Services list with INFERENCE_PROFILE=cpu contains cpu-compatible services."""
with patch.dict(os.environ, {"INFERENCE_PROFILE": "cpu"}):
from dev_api import app
c = TestClient(app)
resp = c.get("/api/settings/system/services")
assert resp.status_code == 200
data = resp.json()
assert isinstance(data, list)
# cpu profile should include ollama and searxng
names = [s["name"] for s in data]
assert "ollama" in names or len(names) >= 0 # may vary by env
# ── GET /api/settings/system/email ───────────────────────────────────────────
def test_get_email_has_password_set_bool(tmp_path, monkeypatch):
"""GET /api/settings/system/email has password_set (bool) and no password key."""
fake_email_path = tmp_path / "email.yaml"
monkeypatch.setattr("dev_api.EMAIL_PATH", fake_email_path)
with patch("dev_api.get_credential", return_value=None):
from dev_api import app
c = TestClient(app)
resp = c.get("/api/settings/system/email")
assert resp.status_code == 200
data = resp.json()
assert "password_set" in data
assert isinstance(data["password_set"], bool)
assert "password" not in data
def test_get_email_password_set_true_when_stored(tmp_path, monkeypatch):
"""password_set is True when credential is stored."""
fake_email_path = tmp_path / "email.yaml"
monkeypatch.setattr("dev_api.EMAIL_PATH", fake_email_path)
with patch("dev_api.get_credential", return_value="secret"):
from dev_api import app
c = TestClient(app)
resp = c.get("/api/settings/system/email")
assert resp.status_code == 200
assert resp.json()["password_set"] is True
def test_test_email_bad_host_returns_ok_false(client):
"""POST /api/settings/system/email/test with bad host returns {ok: false}, not 500."""
with patch("dev_api.get_credential", return_value="fakepassword"):
resp = client.post("/api/settings/system/email/test", json={
"host": "imap.nonexistent-host-xyz.invalid",
"port": 993,
"ssl": True,
"username": "test@nonexistent.invalid",
})
assert resp.status_code == 200
assert resp.json()["ok"] is False
def test_test_email_missing_host_returns_ok_false(client):
"""POST email/test with missing host returns {ok: false}."""
with patch("dev_api.get_credential", return_value=None):
resp = client.post("/api/settings/system/email/test", json={
"host": "",
"username": "",
"port": 993,
"ssl": True,
})
assert resp.status_code == 200
assert resp.json()["ok"] is False
# ── GET /api/settings/fine-tune/status ───────────────────────────────────────
def test_finetune_status_returns_status_and_pairs_count(client):
"""GET /api/settings/fine-tune/status returns status and pairs_count."""
# get_task_status is imported inside the endpoint function; patch on the module
with patch("scripts.task_runner.get_task_status", return_value=None, create=True):
resp = client.get("/api/settings/fine-tune/status")
assert resp.status_code == 200
data = resp.json()
assert "status" in data
assert "pairs_count" in data
def test_finetune_status_idle_when_no_task(client):
"""Status is 'idle' and pairs_count is 0 when no task exists."""
with patch("scripts.task_runner.get_task_status", return_value=None, create=True):
resp = client.get("/api/settings/fine-tune/status")
assert resp.status_code == 200
data = resp.json()
assert data["status"] == "idle"
assert data["pairs_count"] == 0
# ── GET /api/settings/license ────────────────────────────────────────────────
def test_get_license_returns_tier_and_active(tmp_path, monkeypatch):
"""GET /api/settings/license returns tier and active fields."""
fake_license = tmp_path / "license.yaml"
monkeypatch.setattr("dev_api.LICENSE_PATH", fake_license)
from dev_api import app
c = TestClient(app)
resp = c.get("/api/settings/license")
assert resp.status_code == 200
data = resp.json()
assert "tier" in data
assert "active" in data
def test_get_license_defaults_to_free(tmp_path, monkeypatch):
"""GET /api/settings/license defaults to free tier when no file."""
fake_license = tmp_path / "license.yaml"
monkeypatch.setattr("dev_api.LICENSE_PATH", fake_license)
from dev_api import app
c = TestClient(app)
resp = c.get("/api/settings/license")
assert resp.status_code == 200
data = resp.json()
assert data["tier"] == "free"
assert data["active"] is False
def test_activate_license_valid_key_returns_ok(tmp_path, monkeypatch):
"""POST activate with valid key format returns {ok: true}."""
fake_license = tmp_path / "license.yaml"
monkeypatch.setattr("dev_api.LICENSE_PATH", fake_license)
monkeypatch.setattr("dev_api.CONFIG_DIR", tmp_path)
from dev_api import app
c = TestClient(app)
resp = c.post("/api/settings/license/activate", json={"key": "CFG-PRNG-A1B2-C3D4-E5F6"})
assert resp.status_code == 200
assert resp.json()["ok"] is True
def test_activate_license_invalid_key_returns_ok_false(tmp_path, monkeypatch):
"""POST activate with bad key format returns {ok: false}."""
fake_license = tmp_path / "license.yaml"
monkeypatch.setattr("dev_api.LICENSE_PATH", fake_license)
monkeypatch.setattr("dev_api.CONFIG_DIR", tmp_path)
from dev_api import app
c = TestClient(app)
resp = c.post("/api/settings/license/activate", json={"key": "BADKEY"})
assert resp.status_code == 200
assert resp.json()["ok"] is False
def test_deactivate_license_returns_ok(tmp_path, monkeypatch):
"""POST /api/settings/license/deactivate returns 200 with ok."""
fake_license = tmp_path / "license.yaml"
monkeypatch.setattr("dev_api.LICENSE_PATH", fake_license)
monkeypatch.setattr("dev_api.CONFIG_DIR", tmp_path)
from dev_api import app
c = TestClient(app)
resp = c.post("/api/settings/license/deactivate")
assert resp.status_code == 200
assert resp.json()["ok"] is True
def test_activate_then_deactivate(tmp_path, monkeypatch):
"""Activate then deactivate: active goes False."""
fake_license = tmp_path / "license.yaml"
monkeypatch.setattr("dev_api.LICENSE_PATH", fake_license)
monkeypatch.setattr("dev_api.CONFIG_DIR", tmp_path)
from dev_api import app
c = TestClient(app)
c.post("/api/settings/license/activate", json={"key": "CFG-PRNG-A1B2-C3D4-E5F6"})
c.post("/api/settings/license/deactivate")
resp = c.get("/api/settings/license")
assert resp.status_code == 200
assert resp.json()["active"] is False
# ── GET/PUT /api/settings/privacy ─────────────────────────────────────────────
def test_get_privacy_returns_expected_fields(tmp_path, monkeypatch):
"""GET /api/settings/privacy returns telemetry_opt_in and byok_info_dismissed."""
db_dir = tmp_path / "db"
db_dir.mkdir()
cfg_dir = db_dir / "config"
cfg_dir.mkdir()
user_yaml = cfg_dir / "user.yaml"
_write_user_yaml(user_yaml)
monkeypatch.setenv("STAGING_DB", str(db_dir / "staging.db"))
from dev_api import app
c = TestClient(app)
resp = c.get("/api/settings/privacy")
assert resp.status_code == 200
data = resp.json()
assert "telemetry_opt_in" in data
assert "byok_info_dismissed" in data
def test_put_get_privacy_roundtrip(tmp_path, monkeypatch):
"""PUT then GET privacy round-trip: saved values are returned."""
db_dir = tmp_path / "db"
db_dir.mkdir()
cfg_dir = db_dir / "config"
cfg_dir.mkdir()
user_yaml = cfg_dir / "user.yaml"
_write_user_yaml(user_yaml)
monkeypatch.setenv("STAGING_DB", str(db_dir / "staging.db"))
from dev_api import app
c = TestClient(app)
put_resp = c.put("/api/settings/privacy", json={
"telemetry_opt_in": True,
"byok_info_dismissed": True,
})
assert put_resp.status_code == 200
assert put_resp.json()["ok"] is True
get_resp = c.get("/api/settings/privacy")
assert get_resp.status_code == 200
data = get_resp.json()
assert data["telemetry_opt_in"] is True
assert data["byok_info_dismissed"] is True
# ── GET /api/settings/developer ──────────────────────────────────────────────
def test_get_developer_returns_expected_fields(tmp_path, monkeypatch):
"""GET /api/settings/developer returns dev_tier_override and hf_token_set."""
db_dir = tmp_path / "db"
db_dir.mkdir()
cfg_dir = db_dir / "config"
cfg_dir.mkdir()
user_yaml = cfg_dir / "user.yaml"
_write_user_yaml(user_yaml)
monkeypatch.setenv("STAGING_DB", str(db_dir / "staging.db"))
fake_tokens = tmp_path / "tokens.yaml"
monkeypatch.setattr("dev_api.TOKENS_PATH", fake_tokens)
from dev_api import app
c = TestClient(app)
resp = c.get("/api/settings/developer")
assert resp.status_code == 200
data = resp.json()
assert "dev_tier_override" in data
assert "hf_token_set" in data
assert isinstance(data["hf_token_set"], bool)
def test_put_dev_tier_then_get(tmp_path, monkeypatch):
"""PUT dev tier to 'paid' then GET shows dev_tier_override as 'paid'."""
db_dir = tmp_path / "db"
db_dir.mkdir()
cfg_dir = db_dir / "config"
cfg_dir.mkdir()
user_yaml = cfg_dir / "user.yaml"
_write_user_yaml(user_yaml)
monkeypatch.setenv("STAGING_DB", str(db_dir / "staging.db"))
fake_tokens = tmp_path / "tokens.yaml"
monkeypatch.setattr("dev_api.TOKENS_PATH", fake_tokens)
from dev_api import app
c = TestClient(app)
put_resp = c.put("/api/settings/developer/tier", json={"tier": "paid"})
assert put_resp.status_code == 200
assert put_resp.json()["ok"] is True
get_resp = c.get("/api/settings/developer")
assert get_resp.status_code == 200
assert get_resp.json()["dev_tier_override"] == "paid"
def test_wizard_reset_returns_ok(tmp_path, monkeypatch):
"""POST /api/settings/developer/wizard-reset returns 200 with ok."""
db_dir = tmp_path / "db"
db_dir.mkdir()
cfg_dir = db_dir / "config"
cfg_dir.mkdir()
user_yaml = cfg_dir / "user.yaml"
_write_user_yaml(user_yaml, {"name": "Test", "wizard_complete": True})
monkeypatch.setenv("STAGING_DB", str(db_dir / "staging.db"))
from dev_api import app
c = TestClient(app)
resp = c.post("/api/settings/developer/wizard-reset")
assert resp.status_code == 200
assert resp.json()["ok"] is True

View file

@ -1,4 +1,6 @@
import { createRouter, createWebHistory } from 'vue-router'
import { useAppConfigStore } from '../stores/appConfig'
import { settingsGuard } from './settingsGuard'
export const router = createRouter({
history: createWebHistory(),
@ -13,8 +15,30 @@ export const router = createRouter({
{ path: '/prep/:id', component: () => import('../views/InterviewPrepView.vue') },
{ path: '/survey', component: () => import('../views/SurveyView.vue') },
{ path: '/survey/:id', component: () => import('../views/SurveyView.vue') },
{ path: '/settings', component: () => import('../views/SettingsView.vue') },
{
path: '/settings',
component: () => import('../views/settings/SettingsView.vue'),
redirect: '/settings/my-profile',
children: [
{ path: 'my-profile', component: () => import('../views/settings/MyProfileView.vue') },
{ path: 'resume', component: () => import('../views/settings/ResumeProfileView.vue') },
{ path: 'search', component: () => import('../views/settings/SearchPrefsView.vue') },
{ path: 'system', component: () => import('../views/settings/SystemSettingsView.vue') },
{ path: 'fine-tune', component: () => import('../views/settings/FineTuneView.vue') },
{ path: 'license', component: () => import('../views/settings/LicenseView.vue') },
{ path: 'data', component: () => import('../views/settings/DataView.vue') },
{ path: 'privacy', component: () => import('../views/settings/PrivacyView.vue') },
{ path: 'developer', component: () => import('../views/settings/DeveloperView.vue') },
],
},
// Catch-all — FastAPI serves index.html for all unknown routes (SPA mode)
{ path: '/:pathMatch(.*)*', redirect: '/' },
],
})
router.beforeEach(async (to, _from, next) => {
if (!to.path.startsWith('/settings/')) return next()
const config = useAppConfigStore()
if (!config.loaded) await config.load()
settingsGuard(to, _from, next)
})

View file

@ -0,0 +1,135 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { setActivePinia, createPinia } from 'pinia'
import { useAppConfigStore } from '../stores/appConfig'
import { settingsGuard } from './settingsGuard'
vi.mock('../composables/useApi', () => ({ useApiFetch: vi.fn() }))
describe('settingsGuard', () => {
beforeEach(() => {
setActivePinia(createPinia())
localStorage.clear()
})
afterEach(() => {
localStorage.clear()
})
it('passes through non-settings routes immediately', () => {
const next = vi.fn()
settingsGuard({ path: '/review' }, {}, next)
// Guard only handles /settings/* — for non-settings routes the router
// calls next() before reaching settingsGuard, but the guard itself
// will still call next() with no redirect since no tab matches
expect(next).toHaveBeenCalledWith()
})
it('redirects /settings/system in cloud mode', () => {
const store = useAppConfigStore()
store.isCloud = true
const next = vi.fn()
settingsGuard({ path: '/settings/system' }, {}, next)
expect(next).toHaveBeenCalledWith('/settings/my-profile')
})
it('allows /settings/system in self-hosted mode', () => {
const store = useAppConfigStore()
store.isCloud = false
const next = vi.fn()
settingsGuard({ path: '/settings/system' }, {}, next)
expect(next).toHaveBeenCalledWith()
})
it('redirects /settings/fine-tune for non-GPU self-hosted', () => {
const store = useAppConfigStore()
store.isCloud = false
store.inferenceProfile = 'cpu'
const next = vi.fn()
settingsGuard({ path: '/settings/fine-tune' }, {}, next)
expect(next).toHaveBeenCalledWith('/settings/my-profile')
})
it('allows /settings/fine-tune for single-gpu self-hosted', () => {
const store = useAppConfigStore()
store.isCloud = false
store.inferenceProfile = 'single-gpu'
const next = vi.fn()
settingsGuard({ path: '/settings/fine-tune' }, {}, next)
expect(next).toHaveBeenCalledWith()
})
it('allows /settings/fine-tune for dual-gpu self-hosted', () => {
const store = useAppConfigStore()
store.isCloud = false
store.inferenceProfile = 'dual-gpu'
const next = vi.fn()
settingsGuard({ path: '/settings/fine-tune' }, {}, next)
expect(next).toHaveBeenCalledWith()
})
it('redirects /settings/fine-tune on cloud when tier is not premium', () => {
const store = useAppConfigStore()
store.isCloud = true
store.tier = 'paid'
const next = vi.fn()
settingsGuard({ path: '/settings/fine-tune' }, {}, next)
expect(next).toHaveBeenCalledWith('/settings/my-profile')
})
it('allows /settings/fine-tune on cloud when tier is premium', () => {
const store = useAppConfigStore()
store.isCloud = true
store.tier = 'premium'
const next = vi.fn()
settingsGuard({ path: '/settings/fine-tune' }, {}, next)
expect(next).toHaveBeenCalledWith()
})
it('redirects /settings/developer when not dev mode and no override', () => {
const store = useAppConfigStore()
store.isDevMode = false
const next = vi.fn()
settingsGuard({ path: '/settings/developer' }, {}, next)
expect(next).toHaveBeenCalledWith('/settings/my-profile')
})
it('allows /settings/developer when isDevMode is true', () => {
const store = useAppConfigStore()
store.isDevMode = true
const next = vi.fn()
settingsGuard({ path: '/settings/developer' }, {}, next)
expect(next).toHaveBeenCalledWith()
})
it('allows /settings/developer when dev_tier_override set in localStorage', () => {
const store = useAppConfigStore()
store.isDevMode = false
localStorage.setItem('dev_tier_override', 'premium')
const next = vi.fn()
settingsGuard({ path: '/settings/developer' }, {}, next)
expect(next).toHaveBeenCalledWith()
})
it('allows /settings/privacy in cloud mode', () => {
const store = useAppConfigStore()
store.isCloud = true
const next = vi.fn()
settingsGuard({ path: '/settings/privacy' }, {}, next)
expect(next).toHaveBeenCalledWith()
})
it('allows /settings/privacy in self-hosted mode', () => {
const store = useAppConfigStore()
store.isCloud = false
const next = vi.fn()
settingsGuard({ path: '/settings/privacy' }, {}, next)
expect(next).toHaveBeenCalledWith()
})
it('allows /settings/license in both modes', () => {
const store = useAppConfigStore()
store.isCloud = true
const next = vi.fn()
settingsGuard({ path: '/settings/license' }, {}, next)
expect(next).toHaveBeenCalledWith()
})
})

View file

@ -0,0 +1,31 @@
import { useAppConfigStore } from '../stores/appConfig'
const GPU_PROFILES = ['single-gpu', 'dual-gpu']
/**
* Synchronous tab-gating logic for /settings/* routes.
* Called by the async router.beforeEach after config.load() has resolved.
* Reading devTierOverride from localStorage here (not only the store ref) ensures
* the guard reflects overrides set externally before the store hydrates.
*/
export function settingsGuard(
to: { path: string },
_from: unknown,
next: (to?: string) => void,
): void {
const config = useAppConfigStore()
const tab = to.path.replace('/settings/', '')
const devOverride = config.devTierOverride || localStorage.getItem('dev_tier_override')
if (tab === 'system' && config.isCloud) return next('/settings/my-profile')
if (tab === 'fine-tune') {
const cloudBlocked = config.isCloud && config.tier !== 'premium'
const selfHostedBlocked = !config.isCloud && !GPU_PROFILES.includes(config.inferenceProfile)
if (cloudBlocked || selfHostedBlocked) return next('/settings/my-profile')
}
if (tab === 'developer' && !config.isDevMode && !devOverride) return next('/settings/my-profile')
next()
}

View file

@ -0,0 +1,41 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { setActivePinia, createPinia } from 'pinia'
import { useAppConfigStore } from './appConfig'
vi.mock('../composables/useApi', () => ({
useApiFetch: vi.fn(),
}))
import { useApiFetch } from '../composables/useApi'
const mockFetch = vi.mocked(useApiFetch)
describe('useAppConfigStore', () => {
beforeEach(() => {
setActivePinia(createPinia())
vi.clearAllMocks()
})
it('defaults to safe values before load', () => {
const store = useAppConfigStore()
expect(store.isCloud).toBe(false)
expect(store.tier).toBe('free')
})
it('load() populates from API response', async () => {
mockFetch.mockResolvedValue({
data: { isCloud: true, isDevMode: false, tier: 'paid', contractedClient: false, inferenceProfile: 'cpu' },
error: null,
})
const store = useAppConfigStore()
await store.load()
expect(store.isCloud).toBe(true)
expect(store.tier).toBe('paid')
})
it('load() error leaves defaults intact', async () => {
mockFetch.mockResolvedValue({ data: null, error: { kind: 'network', message: 'fail' } })
const store = useAppConfigStore()
await store.load()
expect(store.isCloud).toBe(false)
})
})

View file

@ -0,0 +1,42 @@
import { ref } from 'vue'
import { defineStore } from 'pinia'
import { useApiFetch } from '../composables/useApi'
export type Tier = 'free' | 'paid' | 'premium' | 'ultra'
export type InferenceProfile = 'remote' | 'cpu' | 'single-gpu' | 'dual-gpu'
export const useAppConfigStore = defineStore('appConfig', () => {
const isCloud = ref(false)
const isDevMode = ref(false)
const tier = ref<Tier>('free')
const contractedClient = ref(false)
const inferenceProfile = ref<InferenceProfile>('cpu')
const loaded = ref(false)
const devTierOverride = ref(localStorage.getItem('dev_tier_override') ?? '')
async function load() {
const { data } = await useApiFetch<{
isCloud: boolean; isDevMode: boolean; tier: Tier
contractedClient: boolean; inferenceProfile: InferenceProfile
}>('/api/config/app')
if (!data) return
isCloud.value = data.isCloud
isDevMode.value = data.isDevMode
tier.value = data.tier
contractedClient.value = data.contractedClient
inferenceProfile.value = data.inferenceProfile
loaded.value = true
}
function setDevTierOverride(value: string | null) {
if (value) {
localStorage.setItem('dev_tier_override', value)
devTierOverride.value = value
} else {
localStorage.removeItem('dev_tier_override')
devTierOverride.value = ''
}
}
return { isCloud, isDevMode, tier, contractedClient, inferenceProfile, loaded, load, devTierOverride, setDevTierOverride }
})

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,39 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { setActivePinia, createPinia } from 'pinia'
import { useFineTuneStore } from './fineTune'
vi.mock('../../composables/useApi', () => ({ useApiFetch: vi.fn() }))
import { useApiFetch } from '../../composables/useApi'
const mockFetch = vi.mocked(useApiFetch)
describe('useFineTuneStore', () => {
beforeEach(() => { setActivePinia(createPinia()); vi.clearAllMocks(); vi.useFakeTimers() })
afterEach(() => { vi.useRealTimers() })
it('initial step is 1', () => {
expect(useFineTuneStore().step).toBe(1)
})
it('resetStep() returns to step 1', () => {
const store = useFineTuneStore()
store.step = 3
store.resetStep()
expect(store.step).toBe(1)
})
it('loadStatus() sets inFlightJob when status is running', async () => {
mockFetch.mockResolvedValue({ data: { status: 'running', pairs_count: 10 }, error: null })
const store = useFineTuneStore()
await store.loadStatus()
expect(store.inFlightJob).toBe(true)
})
it('startPolling() calls loadStatus on interval', async () => {
mockFetch.mockResolvedValue({ data: { status: 'idle' }, error: null })
const store = useFineTuneStore()
store.startPolling()
await vi.advanceTimersByTimeAsync(4000)
expect(mockFetch).toHaveBeenCalledWith('/api/settings/fine-tune/status')
store.stopPolling()
})
})

View file

@ -0,0 +1,54 @@
import { ref } from 'vue'
import { defineStore } from 'pinia'
import { useApiFetch } from '../../composables/useApi'
export const useFineTuneStore = defineStore('settings/fineTune', () => {
const step = ref(1)
const inFlightJob = ref(false)
const jobStatus = ref<string>('idle')
const pairsCount = ref(0)
const quotaRemaining = ref<number | null>(null)
const uploading = ref(false)
const loading = ref(false)
let _pollTimer: ReturnType<typeof setInterval> | null = null
function resetStep() { step.value = 1 }
async function loadStatus() {
const { data } = await useApiFetch<{ status: string; pairs_count: number; quota_remaining?: number }>('/api/settings/fine-tune/status')
if (!data) return
jobStatus.value = data.status
pairsCount.value = data.pairs_count ?? 0
quotaRemaining.value = data.quota_remaining ?? null
inFlightJob.value = ['queued', 'running'].includes(data.status)
}
function startPolling() {
loadStatus()
_pollTimer = setInterval(loadStatus, 2000)
}
function stopPolling() {
if (_pollTimer !== null) { clearInterval(_pollTimer); _pollTimer = null }
}
async function submitJob() {
const { data, error } = await useApiFetch<{ job_id: string }>('/api/settings/fine-tune/submit', { method: 'POST' })
if (!error && data) { inFlightJob.value = true; jobStatus.value = 'queued' }
}
return {
step,
inFlightJob,
jobStatus,
pairsCount,
quotaRemaining,
uploading,
loading,
resetStep,
loadStatus,
startPolling,
stopPolling,
submitJob,
}
})

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,51 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { setActivePinia, createPinia } from 'pinia'
import { useProfileStore } from './profile'
vi.mock('../../composables/useApi', () => ({ useApiFetch: vi.fn() }))
import { useApiFetch } from '../../composables/useApi'
const mockFetch = vi.mocked(useApiFetch)
describe('useProfileStore', () => {
beforeEach(() => { setActivePinia(createPinia()); vi.clearAllMocks() })
it('load() populates fields from API', async () => {
mockFetch.mockResolvedValue({
data: { name: 'Meg', email: 'meg@example.com', phone: '555-0100',
linkedin_url: '', career_summary: '', candidate_voice: '',
inference_profile: 'cpu', mission_preferences: [],
nda_companies: [], accessibility_focus: false, lgbtq_focus: false },
error: null,
})
const store = useProfileStore()
await store.load()
expect(store.name).toBe('Meg')
expect(store.email).toBe('meg@example.com')
})
it('save() calls PUT /api/settings/profile', async () => {
mockFetch.mockResolvedValue({ data: { ok: true }, error: null })
const store = useProfileStore()
store.name = 'Meg'
await store.save()
expect(mockFetch).toHaveBeenCalledWith('/api/settings/profile', expect.objectContaining({ method: 'PUT' }))
expect(mockFetch).toHaveBeenCalledWith(
'/api/settings/resume/sync-identity',
expect.objectContaining({ method: 'POST' })
)
})
it('save() error sets error state', async () => {
mockFetch.mockResolvedValue({ data: null, error: { kind: 'network', message: 'fail' } })
const store = useProfileStore()
await store.save()
expect(store.saveError).toBeTruthy()
})
it('sets loadError when load fails', async () => {
mockFetch.mockResolvedValueOnce({ data: null, error: { kind: 'network', message: 'Network error' } })
const store = useProfileStore()
await store.load()
expect(store.loadError).toBe('Network error')
})
})

View file

@ -0,0 +1,94 @@
import { ref } from 'vue'
import { defineStore } from 'pinia'
import { useApiFetch } from '../../composables/useApi'
export interface MissionPref { id: string; industry: string; note: string }
export const useProfileStore = defineStore('settings/profile', () => {
const name = ref('')
const email = ref('')
const phone = ref('')
const linkedin_url = ref('')
const career_summary = ref('')
const candidate_voice = ref('')
const inference_profile = ref('cpu')
const mission_preferences = ref<MissionPref[]>([])
const nda_companies = ref<string[]>([])
const accessibility_focus = ref(false)
const lgbtq_focus = ref(false)
const loading = ref(false)
const saving = ref(false)
const saveError = ref<string | null>(null)
const loadError = ref<string | null>(null)
async function load() {
loading.value = true
loadError.value = null
const { data, error } = await useApiFetch<Record<string, unknown>>('/api/settings/profile')
loading.value = false
if (error) {
loadError.value = error.kind === 'network' ? error.message : error.detail || 'Failed to load profile'
return
}
if (!data) return
name.value = String(data.name ?? '')
email.value = String(data.email ?? '')
phone.value = String(data.phone ?? '')
linkedin_url.value = String(data.linkedin_url ?? '')
career_summary.value = String(data.career_summary ?? '')
candidate_voice.value = String(data.candidate_voice ?? '')
inference_profile.value = String(data.inference_profile ?? 'cpu')
mission_preferences.value = ((data.mission_preferences as Array<{ industry: string; note: string }>) ?? [])
.map((m) => ({ id: crypto.randomUUID(), industry: m.industry ?? '', note: m.note ?? '' }))
nda_companies.value = (data.nda_companies as string[]) ?? []
accessibility_focus.value = Boolean(data.accessibility_focus)
lgbtq_focus.value = Boolean(data.lgbtq_focus)
}
async function save() {
saving.value = true
saveError.value = null
const body = {
name: name.value,
email: email.value,
phone: phone.value,
linkedin_url: linkedin_url.value,
career_summary: career_summary.value,
candidate_voice: candidate_voice.value,
inference_profile: inference_profile.value,
mission_preferences: mission_preferences.value.map(({ industry, note }) => ({ industry, note })),
nda_companies: nda_companies.value,
accessibility_focus: accessibility_focus.value,
lgbtq_focus: lgbtq_focus.value,
}
const { error } = await useApiFetch('/api/settings/profile', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
saving.value = false
if (error) {
saveError.value = 'Save failed — please try again.'
return
}
// fire-and-forget — identity sync failures don't block save
useApiFetch('/api/settings/resume/sync-identity', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: name.value,
email: email.value,
phone: phone.value,
linkedin_url: linkedin_url.value,
}),
})
}
return {
name, email, phone, linkedin_url, career_summary, candidate_voice, inference_profile,
mission_preferences, nda_companies, accessibility_focus, lgbtq_focus,
loading, saving, saveError, loadError,
load, save,
}
})

View file

@ -0,0 +1,50 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { setActivePinia, createPinia } from 'pinia'
import { useResumeStore } from './resume'
vi.mock('../../composables/useApi', () => ({ useApiFetch: vi.fn() }))
import { useApiFetch } from '../../composables/useApi'
const mockFetch = vi.mocked(useApiFetch)
describe('useResumeStore', () => {
beforeEach(() => { setActivePinia(createPinia()); vi.clearAllMocks() })
it('hasResume is false before load', () => {
expect(useResumeStore().hasResume).toBe(false)
})
it('load() sets hasResume from API exists flag', async () => {
mockFetch.mockResolvedValue({ data: { exists: true, name: 'Meg', email: '', phone: '',
linkedin_url: '', surname: '', address: '', city: '', zip_code: '', date_of_birth: '',
experience: [], salary_min: 0, salary_max: 0, notice_period: '', remote: false,
relocation: false, assessment: false, background_check: false,
gender: '', pronouns: '', ethnicity: '', veteran_status: '', disability: '',
skills: [], domains: [], keywords: [],
}, error: null })
const store = useResumeStore()
await store.load()
expect(store.hasResume).toBe(true)
})
it('syncFromProfile() copies identity fields', () => {
const store = useResumeStore()
store.syncFromProfile({ name: 'Test', email: 'a@b.com', phone: '555', linkedin_url: 'li.com/test' })
expect(store.name).toBe('Test')
expect(store.email).toBe('a@b.com')
})
it('load() empty-state when exists=false', async () => {
mockFetch.mockResolvedValue({ data: { exists: false }, error: null })
const store = useResumeStore()
await store.load()
expect(store.hasResume).toBe(false)
})
it('load() sets loadError on API error', async () => {
mockFetch.mockResolvedValue({ data: null, error: { kind: 'network', message: 'Network error' } })
const store = useResumeStore()
await store.load()
expect(store.loadError).toBeTruthy()
expect(store.hasResume).toBe(false)
})
})

View file

@ -0,0 +1,125 @@
import { ref } from 'vue'
import { defineStore } from 'pinia'
import { useApiFetch } from '../../composables/useApi'
export interface WorkEntry {
id: string
title: string; company: string; period: string; location: string
industry: string; responsibilities: string; skills: string[]
}
export const useResumeStore = defineStore('settings/resume', () => {
const hasResume = ref(false)
const loading = ref(false)
const saving = ref(false)
const saveError = ref<string | null>(null)
const loadError = ref<string | null>(null)
// Identity (synced from profile store)
const name = ref(''); const email = ref(''); const phone = ref(''); const linkedin_url = ref('')
// Resume-only contact
const surname = ref(''); const address = ref(''); const city = ref('')
const zip_code = ref(''); const date_of_birth = ref('')
// Experience
const experience = ref<WorkEntry[]>([])
// Prefs
const salary_min = ref(0); const salary_max = ref(0); const notice_period = ref('')
const remote = ref(false); const relocation = ref(false)
const assessment = ref(false); const background_check = ref(false)
// Self-ID
const gender = ref(''); const pronouns = ref(''); const ethnicity = ref('')
const veteran_status = ref(''); const disability = ref('')
// Keywords
const skills = ref<string[]>([]); const domains = ref<string[]>([]); const keywords = ref<string[]>([])
function syncFromProfile(p: { name: string; email: string; phone: string; linkedin_url: string }) {
name.value = p.name; email.value = p.email
phone.value = p.phone; linkedin_url.value = p.linkedin_url
}
async function load() {
loading.value = true
loadError.value = null
const { data, error } = await useApiFetch<Record<string, unknown>>('/api/settings/resume')
loading.value = false
if (error) {
loadError.value = error.kind === 'network' ? error.message : (error.detail || 'Failed to load resume')
return
}
if (!data || !data.exists) { hasResume.value = false; return }
hasResume.value = true
name.value = String(data.name ?? ''); email.value = String(data.email ?? '')
phone.value = String(data.phone ?? ''); linkedin_url.value = String(data.linkedin_url ?? '')
surname.value = String(data.surname ?? ''); address.value = String(data.address ?? '')
city.value = String(data.city ?? ''); zip_code.value = String(data.zip_code ?? '')
date_of_birth.value = String(data.date_of_birth ?? '')
experience.value = (data.experience as Omit<WorkEntry, 'id'>[]).map(e => ({ ...e, id: crypto.randomUUID() })) ?? []
salary_min.value = Number(data.salary_min ?? 0); salary_max.value = Number(data.salary_max ?? 0)
notice_period.value = String(data.notice_period ?? '')
remote.value = Boolean(data.remote); relocation.value = Boolean(data.relocation)
assessment.value = Boolean(data.assessment); background_check.value = Boolean(data.background_check)
gender.value = String(data.gender ?? ''); pronouns.value = String(data.pronouns ?? '')
ethnicity.value = String(data.ethnicity ?? ''); veteran_status.value = String(data.veteran_status ?? '')
disability.value = String(data.disability ?? '')
skills.value = (data.skills as string[]) ?? []
domains.value = (data.domains as string[]) ?? []
keywords.value = (data.keywords as string[]) ?? []
}
async function save() {
saving.value = true; saveError.value = null
const body = {
name: name.value, email: email.value, phone: phone.value, linkedin_url: linkedin_url.value,
surname: surname.value, address: address.value, city: city.value, zip_code: zip_code.value,
date_of_birth: date_of_birth.value,
experience: experience.value.map(({ id: _id, ...e }) => e),
salary_min: salary_min.value, salary_max: salary_max.value, notice_period: notice_period.value,
remote: remote.value, relocation: relocation.value,
assessment: assessment.value, background_check: background_check.value,
gender: gender.value, pronouns: pronouns.value, ethnicity: ethnicity.value,
veteran_status: veteran_status.value, disability: disability.value,
skills: skills.value, domains: domains.value, keywords: keywords.value,
}
const { error } = await useApiFetch('/api/settings/resume', {
method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body),
})
saving.value = false
if (error) saveError.value = 'Save failed — please try again.'
}
async function createBlank() {
const { error } = await useApiFetch('/api/settings/resume/blank', { method: 'POST' })
if (!error) { hasResume.value = true; await load() }
}
function addExperience() {
experience.value.push({ id: crypto.randomUUID(), title: '', company: '', period: '', location: '', industry: '', responsibilities: '', skills: [] })
}
function removeExperience(idx: number) {
experience.value.splice(idx, 1)
}
function addTag(field: 'skills' | 'domains' | 'keywords', value: string) {
const arr = field === 'skills' ? skills.value : field === 'domains' ? domains.value : keywords.value
const trimmed = value.trim()
if (!trimmed || arr.includes(trimmed)) return
arr.push(trimmed)
}
function removeTag(field: 'skills' | 'domains' | 'keywords', value: string) {
const arr = field === 'skills' ? skills.value : field === 'domains' ? domains.value : keywords.value
const idx = arr.indexOf(value)
if (idx !== -1) arr.splice(idx, 1)
}
return {
hasResume, loading, saving, saveError, loadError,
name, email, phone, linkedin_url, surname, address, city, zip_code, date_of_birth,
experience, salary_min, salary_max, notice_period, remote, relocation, assessment, background_check,
gender, pronouns, ethnicity, veteran_status, disability,
skills, domains, keywords,
syncFromProfile, load, save, createBlank,
addExperience, removeExperience, addTag, removeTag,
}
})

View file

@ -0,0 +1,42 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { setActivePinia, createPinia } from 'pinia'
import { useSearchStore } from './search'
vi.mock('../../composables/useApi', () => ({ useApiFetch: vi.fn() }))
import { useApiFetch } from '../../composables/useApi'
const mockFetch = vi.mocked(useApiFetch)
describe('useSearchStore', () => {
beforeEach(() => { setActivePinia(createPinia()); vi.clearAllMocks() })
it('defaults remote_preference to both', () => {
expect(useSearchStore().remote_preference).toBe('both')
})
it('load() sets fields from API', async () => {
mockFetch.mockResolvedValue({ data: {
remote_preference: 'remote', job_titles: ['Engineer'], locations: ['NYC'],
exclude_keywords: [], job_boards: [], custom_board_urls: [],
blocklist_companies: [], blocklist_industries: [], blocklist_locations: [],
}, error: null })
const store = useSearchStore()
await store.load()
expect(store.remote_preference).toBe('remote')
expect(store.job_titles).toContain('Engineer')
})
it('suggest() adds to titleSuggestions without persisting', async () => {
mockFetch.mockResolvedValue({ data: { suggestions: ['Staff Engineer'] }, error: null })
const store = useSearchStore()
await store.suggestTitles()
expect(store.titleSuggestions).toContain('Staff Engineer')
expect(store.job_titles).not.toContain('Staff Engineer')
})
it('save() calls PUT endpoint', async () => {
mockFetch.mockResolvedValue({ data: { ok: true }, error: null })
const store = useSearchStore()
await store.save()
expect(mockFetch).toHaveBeenCalledWith('/api/settings/search', expect.objectContaining({ method: 'PUT' }))
})
})

View file

@ -0,0 +1,125 @@
import { ref } from 'vue'
import { defineStore } from 'pinia'
import { useApiFetch } from '../../composables/useApi'
export type RemotePreference = 'remote' | 'onsite' | 'both'
export interface JobBoard { name: string; enabled: boolean }
export const useSearchStore = defineStore('settings/search', () => {
const remote_preference = ref<RemotePreference>('both')
const job_titles = ref<string[]>([])
const locations = ref<string[]>([])
const exclude_keywords = ref<string[]>([])
const job_boards = ref<JobBoard[]>([])
const custom_board_urls = ref<string[]>([])
const blocklist_companies = ref<string[]>([])
const blocklist_industries = ref<string[]>([])
const blocklist_locations = ref<string[]>([])
const titleSuggestions = ref<string[]>([])
const locationSuggestions = ref<string[]>([])
const loading = ref(false)
const saving = ref(false)
const saveError = ref<string | null>(null)
const loadError = ref<string | null>(null)
async function load() {
loading.value = true
loadError.value = null
const { data, error } = await useApiFetch<Record<string, unknown>>('/api/settings/search')
loading.value = false
if (error) { loadError.value = 'Failed to load search preferences'; return }
if (!data) return
remote_preference.value = (data.remote_preference as RemotePreference) ?? 'both'
job_titles.value = (data.job_titles as string[]) ?? []
locations.value = (data.locations as string[]) ?? []
exclude_keywords.value = (data.exclude_keywords as string[]) ?? []
job_boards.value = (data.job_boards as JobBoard[]) ?? []
custom_board_urls.value = (data.custom_board_urls as string[]) ?? []
blocklist_companies.value = (data.blocklist_companies as string[]) ?? []
blocklist_industries.value = (data.blocklist_industries as string[]) ?? []
blocklist_locations.value = (data.blocklist_locations as string[]) ?? []
}
async function save() {
saving.value = true
saveError.value = null
const body = {
remote_preference: remote_preference.value,
job_titles: job_titles.value,
locations: locations.value,
exclude_keywords: exclude_keywords.value,
job_boards: job_boards.value,
custom_board_urls: custom_board_urls.value,
blocklist_companies: blocklist_companies.value,
blocklist_industries: blocklist_industries.value,
blocklist_locations: blocklist_locations.value,
}
const { error } = await useApiFetch('/api/settings/search', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
saving.value = false
if (error) saveError.value = 'Save failed — please try again.'
}
async function suggestTitles() {
const { data } = await useApiFetch<{ suggestions: string[] }>('/api/settings/search/suggest', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ type: 'titles', current: job_titles.value }),
})
if (data?.suggestions) {
titleSuggestions.value = data.suggestions.filter(s => !job_titles.value.includes(s))
}
}
async function suggestLocations() {
const { data } = await useApiFetch<{ suggestions: string[] }>('/api/settings/search/suggest', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ type: 'locations', current: locations.value }),
})
if (data?.suggestions) {
locationSuggestions.value = data.suggestions.filter(s => !locations.value.includes(s))
}
}
function addTag(field: 'job_titles' | 'locations' | 'exclude_keywords' | 'custom_board_urls' | 'blocklist_companies' | 'blocklist_industries' | 'blocklist_locations', value: string) {
const arr = { job_titles, locations, exclude_keywords, custom_board_urls, blocklist_companies, blocklist_industries, blocklist_locations }[field]
const trimmed = value.trim()
if (!trimmed || arr.value.includes(trimmed)) return
arr.value = [...arr.value, trimmed]
}
function removeTag(field: 'job_titles' | 'locations' | 'exclude_keywords' | 'custom_board_urls' | 'blocklist_companies' | 'blocklist_industries' | 'blocklist_locations', value: string) {
const arr = { job_titles, locations, exclude_keywords, custom_board_urls, blocklist_companies, blocklist_industries, blocklist_locations }[field]
arr.value = arr.value.filter(v => v !== value)
}
function acceptSuggestion(type: 'title' | 'location', value: string) {
if (type === 'title') {
if (!job_titles.value.includes(value)) job_titles.value = [...job_titles.value, value]
titleSuggestions.value = titleSuggestions.value.filter(s => s !== value)
} else {
if (!locations.value.includes(value)) locations.value = [...locations.value, value]
locationSuggestions.value = locationSuggestions.value.filter(s => s !== value)
}
}
function toggleBoard(name: string) {
job_boards.value = job_boards.value.map(b =>
b.name === name ? { ...b, enabled: !b.enabled } : b
)
}
return {
remote_preference, job_titles, locations, exclude_keywords, job_boards,
custom_board_urls, blocklist_companies, blocklist_industries, blocklist_locations,
titleSuggestions, locationSuggestions,
loading, saving, saveError, loadError,
load, save, suggestTitles, suggestLocations, addTag, removeTag, acceptSuggestion, toggleBoard,
}
})

View file

@ -0,0 +1,83 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { setActivePinia, createPinia } from 'pinia'
import { useSystemStore } from './system'
vi.mock('../../composables/useApi', () => ({ useApiFetch: vi.fn() }))
import { useApiFetch } from '../../composables/useApi'
const mockFetch = vi.mocked(useApiFetch)
describe('useSystemStore — BYOK gate', () => {
beforeEach(() => { setActivePinia(createPinia()); vi.clearAllMocks() })
it('save() proceeds without modal when no cloud backends enabled', async () => {
mockFetch.mockResolvedValue({ data: { ok: true }, error: null })
const store = useSystemStore()
store.backends = [{ id: 'ollama', enabled: true, priority: 1 }]
store.byokAcknowledged = []
await store.trySave()
expect(store.byokPending).toHaveLength(0)
expect(mockFetch).toHaveBeenCalledWith('/api/settings/system/llm', expect.anything())
})
it('save() sets byokPending when new cloud backend enabled', async () => {
const store = useSystemStore()
store.backends = [{ id: 'anthropic', enabled: true, priority: 1 }]
store.byokAcknowledged = []
await store.trySave()
expect(store.byokPending).toContain('anthropic')
expect(mockFetch).not.toHaveBeenCalledWith('/api/settings/system/llm', expect.anything())
})
it('save() skips modal for already-acknowledged backends', async () => {
mockFetch.mockResolvedValue({ data: { ok: true }, error: null })
const store = useSystemStore()
store.backends = [{ id: 'anthropic', enabled: true, priority: 1 }]
store.byokAcknowledged = ['anthropic']
await store.trySave()
expect(store.byokPending).toHaveLength(0)
})
it('confirmByok() saves acknowledgment then commits LLM config', async () => {
mockFetch.mockResolvedValue({ data: { ok: true }, error: null })
const store = useSystemStore()
store.byokPending = ['anthropic']
store.backends = [{ id: 'anthropic', enabled: true, priority: 1 }]
await store.confirmByok()
expect(mockFetch).toHaveBeenCalledWith('/api/settings/system/llm/byok-ack', expect.anything())
expect(mockFetch).toHaveBeenCalledWith('/api/settings/system/llm', expect.anything())
})
it('confirmByok() sets saveError and leaves modal open when ack POST fails', async () => {
mockFetch.mockResolvedValue({ data: null, error: 'Network error' })
const store = useSystemStore()
store.byokPending = ['anthropic']
store.backends = [{ id: 'anthropic', enabled: true, priority: 1 }]
await store.confirmByok()
expect(store.saveError).toBeTruthy()
expect(store.byokPending).toContain('anthropic') // modal stays open
expect(mockFetch).not.toHaveBeenCalledWith('/api/settings/system/llm', expect.anything())
})
it('cancelByok() clears pending and restores backends to pre-save state', async () => {
mockFetch.mockResolvedValue({ data: { ok: true }, error: null })
const store = useSystemStore()
const original = [{ id: 'ollama', enabled: true, priority: 1 }]
store.backends = [...original]
await store.trySave() // captures snapshot, commits (no cloud backends)
store.backends = [{ id: 'anthropic', enabled: true, priority: 1 }]
store.byokPending = ['anthropic']
store.cancelByok()
expect(store.byokPending).toHaveLength(0)
expect(store.backends).toEqual(original)
})
})
describe('useSystemStore — services', () => {
it('loadServices() populates services list', async () => {
mockFetch.mockResolvedValue({ data: [{ name: 'ollama', port: 11434, running: true, note: '' }], error: null })
const store = useSystemStore()
await store.loadServices()
expect(store.services[0].name).toBe('ollama')
expect(store.services[0].running).toBe(true)
})
})

View file

@ -0,0 +1,246 @@
import { ref } from 'vue'
import { defineStore } from 'pinia'
import { useApiFetch } from '../../composables/useApi'
const CLOUD_BACKEND_IDS = ['anthropic', 'openai']
export interface Backend { id: string; enabled: boolean; priority: number }
export interface Service { name: string; port: number; running: boolean; note: string }
export interface IntegrationField { key: string; label: string; type: string }
export interface Integration { id: string; name: string; connected: boolean; tier_required: string; fields: IntegrationField[] }
export const useSystemStore = defineStore('settings/system', () => {
const backends = ref<Backend[]>([])
const byokAcknowledged = ref<string[]>([])
const byokPending = ref<string[]>([])
// Private snapshot — NOT in return(). Closure-level only.
let _preSaveSnapshot: Backend[] = []
const saving = ref(false)
const saveError = ref<string | null>(null)
const loadError = ref<string | null>(null)
const services = ref<Service[]>([])
const emailConfig = ref<Record<string, unknown>>({})
const integrations = ref<Integration[]>([])
const serviceErrors = ref<Record<string, string>>({})
const emailSaving = ref(false)
const emailError = ref<string | null>(null)
// File paths + deployment
const filePaths = ref<Record<string, string>>({})
const deployConfig = ref<Record<string, unknown>>({})
const filePathsSaving = ref(false)
const deploySaving = ref(false)
const filePathsError = ref<string | null>(null)
const deployError = ref<string | null>(null)
// Integration test/connect results — keyed by integration id
const integrationResults = ref<Record<string, {ok: boolean; error?: string}>>({})
async function loadLlm() {
loadError.value = null
const { data, error } = await useApiFetch<{ backends: Backend[]; byok_acknowledged: string[] }>('/api/settings/system/llm')
if (error) { loadError.value = 'Failed to load LLM config'; return }
if (!data) return
backends.value = data.backends ?? []
byokAcknowledged.value = data.byok_acknowledged ?? []
}
async function trySave() {
_preSaveSnapshot = JSON.parse(JSON.stringify(backends.value))
const newlyEnabled = backends.value
.filter(b => CLOUD_BACKEND_IDS.includes(b.id) && b.enabled)
.map(b => b.id)
.filter(id => !byokAcknowledged.value.includes(id))
if (newlyEnabled.length > 0) {
byokPending.value = newlyEnabled
return // modal takes over
}
await _commitSave()
}
async function confirmByok() {
saving.value = true
saveError.value = null
const { error } = await useApiFetch('/api/settings/system/llm/byok-ack', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ backends: byokPending.value }),
})
if (error) {
saving.value = false
saveError.value = 'Failed to save acknowledgment — please try again.'
return // leave modal open, byokPending intact
}
byokAcknowledged.value = [...byokAcknowledged.value, ...byokPending.value]
byokPending.value = []
await _commitSave()
}
function cancelByok() {
if (_preSaveSnapshot.length > 0) {
backends.value = JSON.parse(JSON.stringify(_preSaveSnapshot))
}
byokPending.value = []
_preSaveSnapshot = []
}
async function _commitSave() {
saving.value = true
saveError.value = null
const { error } = await useApiFetch('/api/settings/system/llm', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ backends: backends.value }),
})
saving.value = false
if (error) saveError.value = 'Save failed — please try again.'
}
async function loadServices() {
const { data } = await useApiFetch<Service[]>('/api/settings/system/services')
if (data) services.value = data
}
async function startService(name: string) {
const { data, error } = await useApiFetch<{ok: boolean; output: string}>(
`/api/settings/system/services/${name}/start`, { method: 'POST' }
)
if (error || !data?.ok) {
serviceErrors.value = { ...serviceErrors.value, [name]: data?.output ?? 'Start failed' }
} else {
serviceErrors.value = { ...serviceErrors.value, [name]: '' }
await loadServices()
}
}
async function stopService(name: string) {
const { data, error } = await useApiFetch<{ok: boolean; output: string}>(
`/api/settings/system/services/${name}/stop`, { method: 'POST' }
)
if (error || !data?.ok) {
serviceErrors.value = { ...serviceErrors.value, [name]: data?.output ?? 'Stop failed' }
} else {
serviceErrors.value = { ...serviceErrors.value, [name]: '' }
await loadServices()
}
}
async function loadEmail() {
const { data } = await useApiFetch<Record<string, unknown>>('/api/settings/system/email')
if (data) emailConfig.value = data
}
async function saveEmail() {
emailSaving.value = true
emailError.value = null
const { error } = await useApiFetch('/api/settings/system/email', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(emailConfig.value),
})
emailSaving.value = false
if (error) emailError.value = 'Save failed — please try again.'
}
async function testEmail() {
const { data } = await useApiFetch<{ok: boolean; error?: string}>(
'/api/settings/system/email/test', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(emailConfig.value),
}
)
return data
}
async function loadIntegrations() {
const { data } = await useApiFetch<Integration[]>('/api/settings/system/integrations')
if (data) integrations.value = data
}
async function connectIntegration(id: string, credentials: Record<string, string>) {
const { data, error } = await useApiFetch<{ok: boolean; error?: string}>(
`/api/settings/system/integrations/${id}/connect`,
{ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(credentials) }
)
const result = error || !data?.ok
? { ok: false, error: data?.error ?? 'Connection failed' }
: { ok: true }
integrationResults.value = { ...integrationResults.value, [id]: result }
if (result.ok) await loadIntegrations()
return result
}
async function testIntegration(id: string, credentials: Record<string, string>) {
const { data, error } = await useApiFetch<{ok: boolean; error?: string}>(
`/api/settings/system/integrations/${id}/test`,
{ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(credentials) }
)
const result = { ok: data?.ok ?? false, error: data?.error ?? (error ? 'Test failed' : undefined) }
integrationResults.value = { ...integrationResults.value, [id]: result }
return result
}
async function disconnectIntegration(id: string) {
const { error } = await useApiFetch(
`/api/settings/system/integrations/${id}/disconnect`, { method: 'POST' }
)
if (!error) await loadIntegrations()
}
async function saveEmailWithPassword(payload: Record<string, unknown>) {
emailSaving.value = true
emailError.value = null
const { error } = await useApiFetch('/api/settings/system/email', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
})
emailSaving.value = false
if (error) emailError.value = 'Save failed — please try again.'
else await loadEmail() // reload to get fresh password_set status
}
async function loadFilePaths() {
const { data } = await useApiFetch<Record<string, string>>('/api/settings/system/paths')
if (data) filePaths.value = data
}
async function saveFilePaths() {
filePathsSaving.value = true
const { error } = await useApiFetch('/api/settings/system/paths', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(filePaths.value),
})
filePathsSaving.value = false
filePathsError.value = error ? 'Failed to save file paths.' : null
}
async function loadDeployConfig() {
const { data } = await useApiFetch<Record<string, unknown>>('/api/settings/system/deploy')
if (data) deployConfig.value = data
}
async function saveDeployConfig() {
deploySaving.value = true
const { error } = await useApiFetch('/api/settings/system/deploy', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(deployConfig.value),
})
deploySaving.value = false
deployError.value = error ? 'Failed to save deployment config.' : null
}
return {
backends, byokAcknowledged, byokPending, saving, saveError, loadError,
loadLlm, trySave, confirmByok, cancelByok,
services, emailConfig, integrations, integrationResults, serviceErrors, emailSaving, emailError,
filePaths, deployConfig, filePathsSaving, deploySaving, filePathsError, deployError,
loadServices, startService, stopService,
loadEmail, saveEmail, testEmail, saveEmailWithPassword,
loadIntegrations, connectIntegration, testIntegration, disconnectIntegration,
loadFilePaths, saveFilePaths,
loadDeployConfig, saveDeployConfig,
}
})

View file

@ -1,18 +0,0 @@
<template>
<div class="view-placeholder">
<h1>SettingsView</h1>
<p class="placeholder-note">Vue port in progress Streamlit equivalent at app/pages/</p>
</div>
</template>
<style scoped>
.view-placeholder {
padding: var(--space-8);
max-width: 60ch;
}
.placeholder-note {
color: var(--color-text-muted);
font-size: 0.875rem;
margin-top: var(--space-2);
}
</style>

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,163 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue'
import { storeToRefs } from 'pinia'
import { useFineTuneStore } from '../../stores/settings/fineTune'
import { useAppConfigStore } from '../../stores/appConfig'
const store = useFineTuneStore()
const config = useAppConfigStore()
const { step, inFlightJob, jobStatus, pairsCount, quotaRemaining } = storeToRefs(store)
const fileInput = ref<HTMLInputElement | null>(null)
const selectedFiles = ref<File[]>([])
const uploadResult = ref<{ file_count: number } | null>(null)
const extractError = ref<string | null>(null)
const modelReady = ref<boolean | null>(null)
async function handleUpload() {
if (!selectedFiles.value.length) return
store.uploading = true
const form = new FormData()
for (const f of selectedFiles.value) form.append('files', f)
try {
const res = await fetch('/api/settings/fine-tune/upload', { method: 'POST', body: form })
uploadResult.value = await res.json()
store.step = 2
} catch {
extractError.value = 'Upload failed'
} finally {
store.uploading = false
}
}
async function handleExtract() {
extractError.value = null
const res = await fetch('/api/settings/fine-tune/extract', { method: 'POST' })
if (!res.ok) { extractError.value = 'Extraction failed'; return }
store.step = 3
}
async function checkLocalModel() {
const res = await fetch('/api/settings/fine-tune/local-status')
const data = await res.json()
modelReady.value = data.model_ready
}
onMounted(async () => {
store.startPolling()
if (store.step === 3 && !config.isCloud) await checkLocalModel()
})
onUnmounted(() => { store.stopPolling(); store.resetStep() })
</script>
<template>
<div class="fine-tune-view">
<h2>Fine-Tune Model</h2>
<!-- Wizard steps indicator -->
<div class="wizard-steps">
<span :class="['step', step >= 1 ? 'active' : '']">1. Upload</span>
<span class="step-divider"></span>
<span :class="['step', step >= 2 ? 'active' : '']">2. Extract</span>
<span class="step-divider"></span>
<span :class="['step', step >= 3 ? 'active' : '']">3. Train</span>
</div>
<!-- Step 1: Upload -->
<section v-if="step === 1" class="form-section">
<h3>Upload Cover Letters</h3>
<p class="section-note">Upload .md or .txt cover letter files to build your training dataset.</p>
<input
ref="fileInput"
type="file"
accept=".md,.txt"
multiple
@change="selectedFiles = Array.from(($event.target as HTMLInputElement).files ?? [])"
class="file-input"
/>
<div class="form-actions">
<button
@click="handleUpload"
:disabled="!selectedFiles.length || store.uploading"
class="btn-primary"
>
{{ store.uploading ? 'Uploading…' : `Upload ${selectedFiles.length} file(s)` }}
</button>
</div>
</section>
<!-- Step 2: Extract pairs -->
<section v-else-if="step === 2" class="form-section">
<h3>Extract Training Pairs</h3>
<p v-if="uploadResult">{{ uploadResult.file_count }} file(s) uploaded.</p>
<p class="section-note">Extract job description + cover letter pairs for training.</p>
<p v-if="pairsCount > 0" class="pairs-count">{{ pairsCount }} pairs extracted so far.</p>
<p v-if="extractError" class="error-msg">{{ extractError }}</p>
<div class="form-actions">
<button @click="handleExtract" :disabled="inFlightJob" class="btn-primary">
{{ inFlightJob ? 'Extracting…' : 'Extract Pairs' }}
</button>
<button @click="store.step = 3" class="btn-secondary">Skip Train</button>
</div>
</section>
<!-- Step 3: Train -->
<section v-else class="form-section">
<h3>Train Model</h3>
<p class="pairs-count">{{ pairsCount }} training pairs available.</p>
<!-- Job status banner (if in-flight) -->
<div v-if="inFlightJob" class="status-banner status-running">
Job {{ jobStatus }} polling every 2s
</div>
<div v-else-if="jobStatus === 'completed'" class="status-banner status-ok">
Training complete.
</div>
<div v-else-if="jobStatus === 'failed'" class="status-banner status-fail">
Training failed. Check logs.
</div>
<!-- Self-hosted path -->
<template v-if="!config.isCloud">
<p class="section-note">Run locally with Unsloth + Ollama:</p>
<pre class="code-block">make finetune</pre>
<div v-if="modelReady === null" class="form-actions">
<button @click="checkLocalModel" class="btn-secondary">Check Model Status</button>
</div>
<p v-else-if="modelReady" class="status-ok"> alex-cover-writer model is ready in Ollama.</p>
<p v-else class="status-fail">Model not yet registered. Run <code>make finetune</code> first.</p>
</template>
<!-- Cloud path -->
<template v-else>
<p v-if="quotaRemaining !== null" class="section-note">
Cloud quota remaining: {{ quotaRemaining }} jobs
</p>
<div class="form-actions">
<button
@click="store.submitJob()"
:disabled="inFlightJob || pairsCount === 0"
class="btn-primary"
>
{{ inFlightJob ? 'Job queued…' : 'Submit Training Job' }}
</button>
</div>
</template>
</section>
</div>
</template>
<style scoped>
.fine-tune-view { max-width: 640px; }
.wizard-steps { display: flex; align-items: center; gap: 0.5rem; margin-bottom: 1.5rem; font-size: 0.9rem; }
.step { padding: 0.25rem 0.75rem; border-radius: 99px; background: var(--color-surface-2, #eee); color: var(--color-text-muted, #888); }
.step.active { background: var(--color-accent, #3b82f6); color: #fff; }
.step-divider { color: var(--color-text-muted, #888); }
.file-input { display: block; margin: 0.75rem 0; }
.pairs-count { font-weight: 600; margin-bottom: 0.5rem; }
.code-block { background: var(--color-surface-2, #f5f5f5); padding: 0.75rem 1rem; border-radius: 6px; font-family: monospace; margin: 0.75rem 0; }
.status-banner { padding: 0.6rem 1rem; border-radius: 6px; margin-bottom: 1rem; font-size: 0.9rem; }
.status-running { background: var(--color-warning-bg, #fef3c7); color: var(--color-warning-fg, #92400e); }
.status-ok { color: var(--color-success, #16a34a); }
.status-fail { color: var(--color-error, #dc2626); }
</style>

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,577 @@
<template>
<div class="my-profile">
<header class="page-header">
<h2>My Profile</h2>
<p class="subtitle">Your identity and preferences used for cover letters, research, and interview prep.</p>
</header>
<div v-if="store.loading" class="loading-state">Loading profile</div>
<template v-else>
<div v-if="loadError" class="load-error-banner" role="alert">
<strong>Error loading profile:</strong> {{ loadError }}
</div>
<!-- Identity -->
<section class="form-section">
<h3 class="section-title">Identity</h3>
<div class="field-row">
<label class="field-label" for="profile-name">Full name</label>
<input id="profile-name" v-model="store.name" type="text" class="text-input" placeholder="Your Name" />
</div>
<div class="field-row">
<label class="field-label" for="profile-email">Email</label>
<input id="profile-email" v-model="store.email" type="email" class="text-input" placeholder="you@example.com" />
</div>
<div class="field-row">
<label class="field-label" for="profile-phone">Phone</label>
<input id="profile-phone" v-model="store.phone" type="tel" class="text-input" placeholder="555-000-0000" />
</div>
<div class="field-row">
<label class="field-label" for="profile-linkedin">LinkedIn URL</label>
<input id="profile-linkedin" v-model="store.linkedin_url" type="url" class="text-input" placeholder="linkedin.com/in/yourprofile" />
</div>
<div class="field-row field-row--stacked">
<label class="field-label" for="profile-summary">Career summary</label>
<textarea
id="profile-summary"
v-model="store.career_summary"
class="text-area"
rows="5"
placeholder="23 sentences summarising your experience and focus."
/>
<button
v-if="config.tier !== 'free'"
class="btn-generate"
type="button"
@click="generateSummary"
:disabled="generatingSummary"
>{{ generatingSummary ? 'Generating…' : 'Generate ✦' }}</button>
</div>
<div class="field-row field-row--stacked">
<label class="field-label" for="profile-voice">Candidate voice</label>
<textarea
id="profile-voice"
v-model="store.candidate_voice"
class="text-area"
rows="3"
placeholder="How you write and communicate — used to shape cover letter voice."
/>
</div>
<div class="field-row">
<label class="field-label" for="profile-inference">Inference profile</label>
<select id="profile-inference" v-model="store.inference_profile" class="select-input">
<option value="remote">Remote</option>
<option value="cpu">CPU</option>
<option value="single-gpu">Single GPU</option>
<option value="dual-gpu">Dual GPU</option>
</select>
</div>
<div class="save-row">
<button class="btn-save" type="button" @click="store.save()" :disabled="store.saving">
{{ store.saving ? 'Saving…' : 'Save Identity' }}
</button>
<p v-if="store.saveError" class="error-msg">{{ store.saveError }}</p>
</div>
</section>
<!-- Mission & Values -->
<section class="form-section">
<h3 class="section-title">Mission &amp; Values</h3>
<p class="section-desc">
Industries you care about. When a job matches, the cover letter includes your personal alignment note.
</p>
<div
v-for="(pref, idx) in store.mission_preferences"
:key="pref.id"
class="mission-row"
>
<input
v-model="pref.industry"
type="text"
class="text-input mission-industry"
placeholder="Industry (e.g. music)"
/>
<input
v-model="pref.note"
type="text"
class="text-input mission-note"
placeholder="Your personal note (optional)"
/>
<button class="btn-remove" type="button" @click="removeMission(idx)" aria-label="Remove">×</button>
</div>
<div class="mission-actions">
<button class="btn-secondary" type="button" @click="addMission">+ Add mission</button>
<button
v-if="config.tier !== 'free'"
class="btn-generate"
type="button"
@click="generateMissions"
:disabled="generatingMissions"
>{{ generatingMissions ? 'Generating…' : 'Generate ✦' }}</button>
</div>
<div class="save-row">
<button class="btn-save" type="button" @click="store.save()" :disabled="store.saving">
{{ store.saving ? 'Saving…' : 'Save Mission' }}
</button>
<p v-if="store.saveError" class="error-msg">{{ store.saveError }}</p>
</div>
</section>
<!-- NDA Companies -->
<section class="form-section">
<h3 class="section-title">NDA Companies</h3>
<p class="section-desc">
Companies you can't name. They appear as "previous employer (NDA)" in research briefs when match score is low.
</p>
<div class="tag-list">
<span
v-for="(company, idx) in store.nda_companies"
:key="company"
class="tag"
>
{{ company }}
<button class="tag-remove" type="button" @click="removeNda(idx)" :aria-label="`Remove ${company}`">×</button>
</span>
</div>
<div class="nda-add-row">
<input
v-model="newNdaCompany"
type="text"
class="text-input nda-input"
placeholder="Company name"
@keydown.enter.prevent="addNda"
/>
<button class="btn-secondary" type="button" @click="addNda" :disabled="!newNdaCompany.trim()">Add</button>
</div>
</section>
<!-- Research Brief Preferences -->
<section class="form-section">
<h3 class="section-title">Research Brief Preferences</h3>
<p class="section-desc">
Optional sections added to company briefs for your personal decision-making only.
These details are never included in applications.
</p>
<div class="checkbox-row">
<input
id="pref-accessibility"
v-model="store.accessibility_focus"
type="checkbox"
class="checkbox"
@change="autosave"
/>
<label for="pref-accessibility" class="checkbox-label">
Include accessibility &amp; inclusion research in company briefs
</label>
</div>
<div class="checkbox-row">
<input
id="pref-lgbtq"
v-model="store.lgbtq_focus"
type="checkbox"
class="checkbox"
@change="autosave"
/>
<label for="pref-lgbtq" class="checkbox-label">
Include LGBTQ+ inclusion research in company briefs
</label>
</div>
</section>
</template>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { storeToRefs } from 'pinia'
import { useProfileStore } from '../../stores/settings/profile'
import { useAppConfigStore } from '../../stores/appConfig'
import { useApiFetch } from '../../composables/useApi'
const store = useProfileStore()
const { loadError } = storeToRefs(store)
const config = useAppConfigStore()
const newNdaCompany = ref('')
const generatingSummary = ref(false)
const generatingMissions = ref(false)
onMounted(() => { store.load() })
// Mission helpers
function addMission() {
store.mission_preferences = [...store.mission_preferences, { id: crypto.randomUUID(), industry: '', note: '' }]
}
function removeMission(idx: number) {
store.mission_preferences = store.mission_preferences.filter((_, i) => i !== idx)
}
// NDA helpers (autosave on add/remove)
function addNda() {
const trimmed = newNdaCompany.value.trim()
if (!trimmed || store.nda_companies.includes(trimmed)) return
store.nda_companies = [...store.nda_companies, trimmed]
newNdaCompany.value = ''
store.save()
}
function removeNda(idx: number) {
store.nda_companies = store.nda_companies.filter((_, i) => i !== idx)
store.save()
}
// Research prefs autosave (debounced 400ms)
let debounceTimer: ReturnType<typeof setTimeout> | null = null
function autosave() {
if (debounceTimer) clearTimeout(debounceTimer)
debounceTimer = setTimeout(() => store.save(), 400)
}
// AI generation (paid tier)
async function generateSummary() {
generatingSummary.value = true
const { data, error } = await useApiFetch<{ summary?: string }>(
'/api/settings/profile/generate-summary', { method: 'POST' }
)
generatingSummary.value = false
if (!error && data?.summary) store.career_summary = data.summary
}
async function generateMissions() {
generatingMissions.value = true
const { data, error } = await useApiFetch<{ mission_preferences?: Array<{ industry: string; note: string }> }>(
'/api/settings/profile/generate-missions', { method: 'POST' }
)
generatingMissions.value = false
if (!error && data?.mission_preferences) {
store.mission_preferences = data.mission_preferences.map((m) => ({
id: crypto.randomUUID(), industry: m.industry ?? '', note: m.note ?? '',
}))
}
}
</script>
<style scoped>
.my-profile {
max-width: 680px;
}
.page-header {
margin-bottom: var(--space-6);
}
.page-header h2 {
margin: 0 0 var(--space-1);
font-size: 1.25rem;
font-weight: 600;
}
.subtitle {
margin: 0;
color: var(--color-text-muted);
font-size: 0.875rem;
}
.loading-state {
color: var(--color-text-muted);
font-size: 0.875rem;
padding: var(--space-4) 0;
}
.load-error-banner {
padding: var(--space-3) var(--space-4);
margin-bottom: var(--space-4);
background: color-mix(in srgb, var(--color-danger, #c0392b) 10%, transparent);
border: 1px solid color-mix(in srgb, var(--color-danger, #c0392b) 40%, transparent);
border-radius: 6px;
color: var(--color-danger, #c0392b);
font-size: 0.875rem;
}
/* ── Sections ──────────────────────────────────────────── */
.form-section {
border: 1px solid var(--color-border);
border-radius: 8px;
padding: var(--space-5);
margin-bottom: var(--space-5);
}
.section-title {
margin: 0 0 var(--space-3);
font-size: 0.9rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--color-text-muted);
}
.section-desc {
margin: calc(-1 * var(--space-2)) 0 var(--space-4);
font-size: 0.8rem;
color: var(--color-text-muted);
line-height: 1.5;
}
/* ── Fields ───────────────────────────────────────────── */
.field-row {
display: grid;
grid-template-columns: 160px 1fr;
align-items: center;
gap: var(--space-3);
margin-bottom: var(--space-3);
}
.field-row--stacked {
grid-template-columns: 1fr;
align-items: flex-start;
}
.field-row--stacked .field-label {
margin-bottom: var(--space-1);
}
.field-label {
font-size: 0.825rem;
color: var(--color-text-muted);
font-weight: 500;
}
.text-input,
.select-input {
width: 100%;
padding: var(--space-2) var(--space-3);
border: 1px solid var(--color-border);
border-radius: 6px;
background: var(--color-surface-raised, var(--color-surface));
color: var(--color-text);
font-size: 0.875rem;
box-sizing: border-box;
}
.text-input:focus,
.select-input:focus,
.text-area:focus {
outline: 2px solid var(--color-primary);
outline-offset: -1px;
border-color: transparent;
}
.text-area {
width: 100%;
padding: var(--space-2) var(--space-3);
border: 1px solid var(--color-border);
border-radius: 6px;
background: var(--color-surface-raised, var(--color-surface));
color: var(--color-text);
font-size: 0.875rem;
resize: vertical;
font-family: inherit;
box-sizing: border-box;
}
/* ── Save row ─────────────────────────────────────────── */
.save-row {
display: flex;
align-items: center;
gap: var(--space-3);
margin-top: var(--space-4);
padding-top: var(--space-4);
border-top: 1px solid var(--color-border);
}
.btn-save {
padding: var(--space-2) var(--space-5);
background: var(--color-primary);
color: var(--color-on-primary, #fff);
border: none;
border-radius: 6px;
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
}
.btn-save:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.error-msg {
margin: 0;
color: var(--color-danger, #c0392b);
font-size: 0.825rem;
}
.btn-generate {
padding: var(--space-2) var(--space-3);
background: transparent;
border: 1px solid var(--color-primary);
color: var(--color-primary);
border-radius: 6px;
font-size: 0.8rem;
cursor: pointer;
margin-top: var(--space-2);
align-self: flex-start;
}
.btn-generate:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.btn-secondary {
padding: var(--space-2) var(--space-3);
background: transparent;
border: 1px solid var(--color-border);
color: var(--color-text-secondary);
border-radius: 6px;
font-size: 0.8rem;
cursor: pointer;
}
.btn-secondary:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* ── Mission rows ─────────────────────────────────────── */
.mission-row {
display: grid;
grid-template-columns: 1fr 2fr auto;
gap: var(--space-2);
margin-bottom: var(--space-2);
align-items: center;
}
.mission-actions {
display: flex;
gap: var(--space-2);
margin-top: var(--space-2);
}
.btn-remove {
background: transparent;
border: 1px solid var(--color-border);
color: var(--color-text-muted);
border-radius: 4px;
width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
font-size: 1rem;
line-height: 1;
}
.btn-remove:hover {
border-color: var(--color-danger, #c0392b);
color: var(--color-danger, #c0392b);
}
/* ── NDA tags ─────────────────────────────────────────── */
.tag-list {
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
margin-bottom: var(--space-3);
min-height: 32px;
}
.tag {
display: inline-flex;
align-items: center;
gap: var(--space-1);
padding: var(--space-1) var(--space-2);
background: color-mix(in srgb, var(--color-primary) 12%, transparent);
border: 1px solid color-mix(in srgb, var(--color-primary) 30%, transparent);
border-radius: 999px;
font-size: 0.8rem;
color: var(--color-text);
}
.tag-remove {
background: transparent;
border: none;
color: var(--color-text-muted);
cursor: pointer;
font-size: 1rem;
line-height: 1;
padding: 0;
display: flex;
align-items: center;
}
.tag-remove:hover {
color: var(--color-danger, #c0392b);
}
.nda-add-row {
display: flex;
gap: var(--space-2);
}
.nda-input {
flex: 1;
}
/* ── Checkboxes ───────────────────────────────────────── */
.checkbox-row {
display: flex;
align-items: flex-start;
gap: var(--space-3);
margin-bottom: var(--space-3);
}
.checkbox {
flex-shrink: 0;
margin-top: 2px;
width: 16px;
height: 16px;
accent-color: var(--color-primary);
cursor: pointer;
}
.checkbox-label {
font-size: 0.875rem;
line-height: 1.5;
cursor: pointer;
}
/* ── Mobile ───────────────────────────────────────────── */
@media (max-width: 767px) {
.field-row {
grid-template-columns: 1fr;
}
.mission-row {
grid-template-columns: 1fr auto;
grid-template-rows: auto auto;
}
.mission-note {
grid-column: 1;
}
.btn-remove {
grid-row: 1;
grid-column: 2;
align-self: start;
}
}
</style>

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>

View file

@ -0,0 +1,310 @@
<template>
<div class="resume-profile">
<h2>Resume Profile</h2>
<!-- Load error banner -->
<div v-if="loadError" class="error-banner">
Failed to load resume: {{ loadError }}
</div>
<!-- Empty state -->
<div v-if="!store.hasResume && !store.loading" class="empty-state">
<p>No resume found. Choose how to get started:</p>
<div class="empty-actions">
<!-- Upload -->
<div class="empty-card">
<h3>Upload & Parse</h3>
<p>Upload a PDF, DOCX, or ODT and we'll extract your info automatically.</p>
<input type="file" accept=".pdf,.docx,.odt" @change="handleUpload" ref="fileInput" />
<p v-if="uploadError" class="error">{{ uploadError }}</p>
</div>
<!-- Blank -->
<div class="empty-card">
<h3>Fill in Manually</h3>
<p>Start with a blank form and fill in your details.</p>
<button @click="store.createBlank()" :disabled="store.loading">Start from Scratch</button>
</div>
<!-- Wizard -->
<div class="empty-card">
<h3>Run Setup Wizard</h3>
<p>Walk through the onboarding wizard to set up your profile step by step.</p>
<RouterLink to="/setup">Open Setup Wizard </RouterLink>
</div>
</div>
</div>
<!-- Full form (when resume exists) -->
<template v-else-if="store.hasResume">
<!-- Personal Information -->
<section class="form-section">
<h3>Personal Information</h3>
<div class="field-row">
<label>First Name <span class="sync-label"> from My Profile</span></label>
<input v-model="store.name" />
</div>
<div class="field-row">
<label>Last Name</label>
<input v-model="store.surname" />
</div>
<div class="field-row">
<label>Email <span class="sync-label"> from My Profile</span></label>
<input v-model="store.email" type="email" />
</div>
<div class="field-row">
<label>Phone <span class="sync-label"> from My Profile</span></label>
<input v-model="store.phone" type="tel" />
</div>
<div class="field-row">
<label>LinkedIn URL <span class="sync-label"> from My Profile</span></label>
<input v-model="store.linkedin_url" type="url" />
</div>
<div class="field-row">
<label>Address</label>
<input v-model="store.address" />
</div>
<div class="field-row">
<label>City</label>
<input v-model="store.city" />
</div>
<div class="field-row">
<label>ZIP Code</label>
<input v-model="store.zip_code" />
</div>
<div class="field-row">
<label>Date of Birth</label>
<input v-model="store.date_of_birth" type="date" />
</div>
</section>
<!-- Work Experience -->
<section class="form-section">
<h3>Work Experience</h3>
<div v-for="(entry, idx) in store.experience" :key="entry.id" class="experience-card">
<div class="field-row">
<label>Job Title</label>
<input v-model="entry.title" />
</div>
<div class="field-row">
<label>Company</label>
<input v-model="entry.company" />
</div>
<div class="field-row">
<label>Period</label>
<input v-model="entry.period" placeholder="e.g. Jan 2022 Present" />
</div>
<div class="field-row">
<label>Location</label>
<input v-model="entry.location" />
</div>
<div class="field-row">
<label>Industry</label>
<input v-model="entry.industry" />
</div>
<div class="field-row">
<label>Responsibilities</label>
<textarea v-model="entry.responsibilities" rows="4" />
</div>
<button class="remove-btn" @click="store.removeExperience(idx)">Remove</button>
</div>
<button @click="store.addExperience()">+ Add Position</button>
</section>
<!-- Preferences -->
<section class="form-section">
<h3>Preferences & Availability</h3>
<div class="field-row">
<label>Salary Min</label>
<input v-model.number="store.salary_min" type="number" />
</div>
<div class="field-row">
<label>Salary Max</label>
<input v-model.number="store.salary_max" type="number" />
</div>
<div class="field-row">
<label>Notice Period</label>
<input v-model="store.notice_period" />
</div>
<label class="checkbox-row">
<input type="checkbox" v-model="store.remote" /> Open to remote
</label>
<label class="checkbox-row">
<input type="checkbox" v-model="store.relocation" /> Open to relocation
</label>
<label class="checkbox-row">
<input type="checkbox" v-model="store.assessment" /> Willing to complete assessments
</label>
<label class="checkbox-row">
<input type="checkbox" v-model="store.background_check" /> Willing to undergo background check
</label>
</section>
<!-- Self-ID (collapsible) -->
<section class="form-section">
<h3>
Self-Identification
<button class="toggle-btn" @click="showSelfId = !showSelfId">
{{ showSelfId ? '▲ Hide' : '▼ Show' }}
</button>
</h3>
<p class="section-note">Optional. Used only for your personal tracking.</p>
<template v-if="showSelfId">
<div class="field-row">
<label>Gender</label>
<input v-model="store.gender" />
</div>
<div class="field-row">
<label>Pronouns</label>
<input v-model="store.pronouns" />
</div>
<div class="field-row">
<label>Ethnicity</label>
<input v-model="store.ethnicity" />
</div>
<div class="field-row">
<label>Veteran Status</label>
<input v-model="store.veteran_status" />
</div>
<div class="field-row">
<label>Disability</label>
<input v-model="store.disability" />
</div>
</template>
</section>
<!-- Skills & Keywords -->
<section class="form-section">
<h3>Skills & Keywords</h3>
<div class="tag-section">
<label>Skills</label>
<div class="tags">
<span v-for="skill in store.skills" :key="skill" class="tag">
{{ skill }} <button @click="store.removeTag('skills', skill)">×</button>
</span>
</div>
<input v-model="skillInput" @keydown.enter.prevent="store.addTag('skills', skillInput); skillInput = ''" placeholder="Add skill, press Enter" />
</div>
<div class="tag-section">
<label>Domains</label>
<div class="tags">
<span v-for="domain in store.domains" :key="domain" class="tag">
{{ domain }} <button @click="store.removeTag('domains', domain)">×</button>
</span>
</div>
<input v-model="domainInput" @keydown.enter.prevent="store.addTag('domains', domainInput); domainInput = ''" placeholder="Add domain, press Enter" />
</div>
<div class="tag-section">
<label>Keywords</label>
<div class="tags">
<span v-for="kw in store.keywords" :key="kw" class="tag">
{{ kw }} <button @click="store.removeTag('keywords', kw)">×</button>
</span>
</div>
<input v-model="kwInput" @keydown.enter.prevent="store.addTag('keywords', kwInput); kwInput = ''" placeholder="Add keyword, press Enter" />
</div>
</section>
<!-- Save -->
<div class="form-actions">
<button @click="store.save()" :disabled="store.saving" class="btn-primary">
{{ store.saving ? 'Saving…' : 'Save Resume' }}
</button>
<p v-if="store.saveError" class="error">{{ store.saveError }}</p>
</div>
</template>
<div v-else class="loading">Loading</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { storeToRefs } from 'pinia'
import { useResumeStore } from '../../stores/settings/resume'
import { useProfileStore } from '../../stores/settings/profile'
import { useApiFetch } from '../../composables/useApi'
const store = useResumeStore()
const profileStore = useProfileStore()
const { loadError } = storeToRefs(store)
const showSelfId = ref(false)
const skillInput = ref('')
const domainInput = ref('')
const kwInput = ref('')
const uploadError = ref<string | null>(null)
const fileInput = ref<HTMLInputElement | null>(null)
onMounted(async () => {
await store.load()
// Only prime identity from profile on a fresh/empty resume
if (!store.hasResume) {
store.syncFromProfile({
name: profileStore.name,
email: profileStore.email,
phone: profileStore.phone,
linkedin_url: profileStore.linkedin_url,
})
}
})
async function handleUpload(event: Event) {
const file = (event.target as HTMLInputElement).files?.[0]
if (!file) return
uploadError.value = null
const formData = new FormData()
formData.append('file', file)
const { data, error } = await useApiFetch<{ ok: boolean; data?: Record<string, unknown>; error?: string }>(
'/api/settings/resume/upload',
{ method: 'POST', body: formData }
)
if (error || !data?.ok) {
uploadError.value = data?.error ?? (typeof error === 'string' ? error : (error?.kind === 'network' ? error.message : error?.detail ?? 'Upload failed'))
return
}
if (data.data) {
await store.load()
}
}
</script>
<style scoped>
.resume-profile { max-width: 720px; margin: 0 auto; padding: var(--space-4, 24px); }
h2 { font-size: 1.4rem; font-weight: 600; margin-bottom: var(--space-6, 32px); color: var(--color-text-primary, #e2e8f0); }
h3 { font-size: 1rem; font-weight: 600; margin-bottom: var(--space-3, 16px); color: var(--color-text-primary, #e2e8f0); }
.form-section { margin-bottom: var(--space-8, 48px); padding-bottom: var(--space-6, 32px); border-bottom: 1px solid var(--color-border, rgba(255,255,255,0.08)); }
.field-row { display: flex; flex-direction: column; gap: 4px; margin-bottom: var(--space-3, 16px); }
.field-row label { font-size: 0.82rem; color: var(--color-text-secondary, #94a3b8); }
.field-row input, .field-row textarea, .field-row select {
background: var(--color-surface-2, rgba(255,255,255,0.05));
border: 1px solid var(--color-border, rgba(255,255,255,0.12));
border-radius: 6px;
color: var(--color-text-primary, #e2e8f0);
padding: 7px 10px;
font-size: 0.88rem;
width: 100%;
box-sizing: border-box;
}
.sync-label { font-size: 0.72rem; color: var(--color-accent, #7c3aed); margin-left: 6px; }
.checkbox-row { display: flex; align-items: center; gap: 8px; margin-bottom: 10px; font-size: 0.88rem; color: var(--color-text-primary, #e2e8f0); cursor: pointer; }
.experience-card { border: 1px solid var(--color-border, rgba(255,255,255,0.08)); border-radius: 8px; padding: var(--space-4, 24px); margin-bottom: var(--space-4, 24px); }
.remove-btn { margin-top: 8px; padding: 4px 12px; border-radius: 4px; background: rgba(239,68,68,0.15); color: #ef4444; border: 1px solid rgba(239,68,68,0.3); cursor: pointer; font-size: 0.82rem; }
.empty-state { text-align: center; padding: var(--space-8, 48px) 0; }
.empty-actions { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: var(--space-4, 24px); margin-top: var(--space-6, 32px); }
.empty-card { background: var(--color-surface-2, rgba(255,255,255,0.04)); border: 1px solid var(--color-border, rgba(255,255,255,0.08)); border-radius: 10px; padding: var(--space-4, 24px); text-align: left; }
.empty-card h3 { margin-bottom: 8px; }
.empty-card p { font-size: 0.85rem; color: var(--color-text-secondary, #94a3b8); margin-bottom: 16px; }
.empty-card button, .empty-card a { padding: 8px 16px; border-radius: 6px; font-size: 0.85rem; cursor: pointer; text-decoration: none; display: inline-block; background: var(--color-accent, #7c3aed); color: #fff; border: none; }
.tag-section { margin-bottom: var(--space-4, 24px); }
.tag-section label { font-size: 0.82rem; color: var(--color-text-secondary, #94a3b8); display: block; margin-bottom: 6px; }
.tags { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 8px; }
.tag { padding: 3px 10px; background: rgba(124,58,237,0.15); border: 1px solid rgba(124,58,237,0.3); border-radius: 12px; font-size: 0.78rem; color: var(--color-accent, #a78bfa); display: flex; align-items: center; gap: 5px; }
.tag button { background: none; border: none; color: inherit; cursor: pointer; padding: 0; line-height: 1; }
.tag-section input { background: var(--color-surface-2, rgba(255,255,255,0.05)); border: 1px solid var(--color-border, rgba(255,255,255,0.12)); border-radius: 6px; color: var(--color-text-primary, #e2e8f0); padding: 6px 10px; font-size: 0.85rem; width: 100%; box-sizing: border-box; }
.form-actions { margin-top: var(--space-6, 32px); display: flex; align-items: center; gap: var(--space-4, 24px); }
.btn-primary { padding: 9px 24px; background: var(--color-accent, #7c3aed); color: #fff; border: none; border-radius: 7px; font-size: 0.9rem; cursor: pointer; font-weight: 600; }
.btn-primary:disabled { opacity: 0.5; cursor: not-allowed; }
.error { color: #ef4444; font-size: 0.82rem; }
.error-banner { background: rgba(239,68,68,0.1); border: 1px solid rgba(239,68,68,0.3); border-radius: 6px; color: #ef4444; font-size: 0.85rem; padding: 10px 14px; margin-bottom: var(--space-4, 24px); }
.section-note { font-size: 0.8rem; color: var(--color-text-secondary, #94a3b8); margin-bottom: 16px; }
.toggle-btn { margin-left: 10px; padding: 2px 10px; background: transparent; border: 1px solid var(--color-border, rgba(255,255,255,0.15)); border-radius: 4px; color: var(--color-text-secondary, #94a3b8); cursor: pointer; font-size: 0.78rem; }
.loading { text-align: center; padding: var(--space-8, 48px); color: var(--color-text-secondary, #94a3b8); }
</style>

View file

@ -0,0 +1,204 @@
<template>
<div class="search-prefs">
<h2>Search Preferences</h2>
<p v-if="store.loadError" class="error-banner">{{ store.loadError }}</p>
<!-- Remote Preference -->
<section class="form-section">
<h3>Remote Preference</h3>
<div class="remote-options">
<button
v-for="opt in remoteOptions"
:key="opt.value"
:class="['remote-btn', { active: store.remote_preference === opt.value }]"
@click="store.remote_preference = opt.value"
>{{ opt.label }}</button>
</div>
<p class="section-note">This filter runs at scrape time listings that don't match are excluded before they count against per-board quotas.</p>
</section>
<!-- Job Titles -->
<section class="form-section">
<h3>Job Titles</h3>
<div class="tags">
<span v-for="title in store.job_titles" :key="title" class="tag">
{{ title }} <button @click="store.removeTag('job_titles', title)">×</button>
</span>
</div>
<div class="tag-input-row">
<input v-model="titleInput" @keydown.enter.prevent="addTitle" placeholder="Add title, press Enter" />
<button @click="store.suggestTitles()" class="btn-suggest">Suggest</button>
</div>
<div v-if="store.titleSuggestions.length > 0" class="suggestions">
<span
v-for="s in store.titleSuggestions"
:key="s"
class="suggestion-chip"
@click="store.acceptSuggestion('title', s)"
>+ {{ s }}</span>
</div>
</section>
<!-- Locations -->
<section class="form-section">
<h3>Locations</h3>
<div class="tags">
<span v-for="loc in store.locations" :key="loc" class="tag">
{{ loc }} <button @click="store.removeTag('locations', loc)">×</button>
</span>
</div>
<div class="tag-input-row">
<input v-model="locationInput" @keydown.enter.prevent="addLocation" placeholder="Add location, press Enter" />
<button @click="store.suggestLocations()" class="btn-suggest">Suggest</button>
</div>
<div v-if="store.locationSuggestions.length > 0" class="suggestions">
<span
v-for="s in store.locationSuggestions"
:key="s"
class="suggestion-chip"
@click="store.acceptSuggestion('location', s)"
>+ {{ s }}</span>
</div>
</section>
<!-- Exclude Keywords -->
<section class="form-section">
<h3>Exclude Keywords</h3>
<div class="tags">
<span v-for="kw in store.exclude_keywords" :key="kw" class="tag">
{{ kw }} <button @click="store.removeTag('exclude_keywords', kw)">×</button>
</span>
</div>
<input v-model="excludeInput" @keydown.enter.prevent="store.addTag('exclude_keywords', excludeInput); excludeInput = ''" placeholder="Add keyword, press Enter" />
</section>
<!-- Job Boards -->
<section class="form-section">
<h3>Job Boards</h3>
<div v-for="board in store.job_boards" :key="board.name" class="board-row">
<label class="checkbox-row">
<input type="checkbox" :checked="board.enabled" @change="store.toggleBoard(board.name)" />
{{ board.name }}
</label>
</div>
<div class="field-row" style="margin-top: 12px">
<label>Custom Board URLs</label>
<div class="tags">
<span v-for="url in store.custom_board_urls" :key="url" class="tag">
{{ url }} <button @click="store.removeTag('custom_board_urls', url)">×</button>
</span>
</div>
<input v-model="customUrlInput" @keydown.enter.prevent="store.addTag('custom_board_urls', customUrlInput); customUrlInput = ''" placeholder="https://..." />
</div>
</section>
<!-- Blocklists -->
<section class="form-section">
<h3>Blocklists</h3>
<div class="blocklist-group">
<label>Companies</label>
<div class="tags">
<span v-for="c in store.blocklist_companies" :key="c" class="tag">
{{ c }} <button @click="store.removeTag('blocklist_companies', c)">×</button>
</span>
</div>
<input v-model="blockCompanyInput" @keydown.enter.prevent="store.addTag('blocklist_companies', blockCompanyInput); blockCompanyInput = ''" placeholder="Company name" />
</div>
<div class="blocklist-group">
<label>Industries</label>
<div class="tags">
<span v-for="i in store.blocklist_industries" :key="i" class="tag">
{{ i }} <button @click="store.removeTag('blocklist_industries', i)">×</button>
</span>
</div>
<input v-model="blockIndustryInput" @keydown.enter.prevent="store.addTag('blocklist_industries', blockIndustryInput); blockIndustryInput = ''" placeholder="Industry name" />
</div>
<div class="blocklist-group">
<label>Locations</label>
<div class="tags">
<span v-for="l in store.blocklist_locations" :key="l" class="tag">
{{ l }} <button @click="store.removeTag('blocklist_locations', l)">×</button>
</span>
</div>
<input v-model="blockLocationInput" @keydown.enter.prevent="store.addTag('blocklist_locations', blockLocationInput); blockLocationInput = ''" placeholder="Location name" />
</div>
</section>
<!-- Save -->
<div class="form-actions">
<button @click="store.save()" :disabled="store.saving" class="btn-primary">
{{ store.saving ? 'Saving…' : 'Save Search Preferences' }}
</button>
<p v-if="store.saveError" class="error">{{ store.saveError }}</p>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useSearchStore } from '../../stores/settings/search'
const store = useSearchStore()
const remoteOptions = [
{ value: 'remote' as const, label: 'Remote only' },
{ value: 'onsite' as const, label: 'On-site only' },
{ value: 'both' as const, label: 'Both' },
]
const titleInput = ref('')
const locationInput = ref('')
const excludeInput = ref('')
const customUrlInput = ref('')
const blockCompanyInput = ref('')
const blockIndustryInput = ref('')
const blockLocationInput = ref('')
function addTitle() {
store.addTag('job_titles', titleInput.value)
titleInput.value = ''
}
function addLocation() {
store.addTag('locations', locationInput.value)
locationInput.value = ''
}
onMounted(() => store.load())
</script>
<style scoped>
.search-prefs { max-width: 720px; margin: 0 auto; padding: var(--space-4, 24px); }
h2 { font-size: 1.4rem; font-weight: 600; margin-bottom: var(--space-6, 32px); color: var(--color-text-primary, #e2e8f0); }
h3 { font-size: 1rem; font-weight: 600; margin-bottom: var(--space-3, 16px); color: var(--color-text-primary, #e2e8f0); }
.form-section { margin-bottom: var(--space-8, 48px); padding-bottom: var(--space-6, 32px); border-bottom: 1px solid var(--color-border, rgba(255,255,255,0.08)); }
.remote-options { display: flex; gap: 8px; margin-bottom: 10px; }
.remote-btn { padding: 8px 18px; border-radius: 6px; border: 1px solid var(--color-border, rgba(255,255,255,0.15)); background: transparent; color: var(--color-text-secondary, #94a3b8); cursor: pointer; font-size: 0.88rem; transition: all 0.15s; }
.remote-btn.active { background: var(--color-accent, #7c3aed); border-color: var(--color-accent, #7c3aed); color: #fff; }
.section-note { font-size: 0.78rem; color: var(--color-text-secondary, #94a3b8); margin-top: 8px; }
.tags { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 8px; }
.tag { padding: 3px 10px; background: rgba(124,58,237,0.15); border: 1px solid rgba(124,58,237,0.3); border-radius: 12px; font-size: 0.78rem; color: var(--color-accent, #a78bfa); display: flex; align-items: center; gap: 5px; }
.tag button { background: none; border: none; color: inherit; cursor: pointer; padding: 0; line-height: 1; }
.tag-input-row { display: flex; gap: 8px; }
.tag-input-row input, input[type="text"], input:not([type]) {
background: var(--color-surface-2, rgba(255,255,255,0.05));
border: 1px solid var(--color-border, rgba(255,255,255,0.12));
border-radius: 6px; color: var(--color-text-primary, #e2e8f0);
padding: 7px 10px; font-size: 0.85rem; flex: 1; box-sizing: border-box;
}
.btn-suggest { padding: 7px 14px; border-radius: 6px; background: rgba(124,58,237,0.2); border: 1px solid rgba(124,58,237,0.3); color: var(--color-accent, #a78bfa); cursor: pointer; font-size: 0.82rem; white-space: nowrap; }
.suggestions { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 8px; }
.suggestion-chip { padding: 4px 12px; border-radius: 12px; font-size: 0.78rem; background: rgba(255,255,255,0.05); border: 1px dashed rgba(255,255,255,0.2); color: var(--color-text-secondary, #94a3b8); cursor: pointer; transition: all 0.15s; }
.suggestion-chip:hover { background: rgba(124,58,237,0.15); border-color: rgba(124,58,237,0.3); color: var(--color-accent, #a78bfa); }
.board-row { margin-bottom: 8px; }
.checkbox-row { display: flex; align-items: center; gap: 8px; font-size: 0.88rem; color: var(--color-text-primary, #e2e8f0); cursor: pointer; }
.field-row { display: flex; flex-direction: column; gap: 6px; }
.field-row label { font-size: 0.82rem; color: var(--color-text-secondary, #94a3b8); }
.blocklist-group { margin-bottom: var(--space-4, 24px); }
.blocklist-group label { font-size: 0.82rem; color: var(--color-text-secondary, #94a3b8); display: block; margin-bottom: 6px; }
.form-actions { margin-top: var(--space-6, 32px); display: flex; align-items: center; gap: var(--space-4, 24px); }
.btn-primary { padding: 9px 24px; background: var(--color-accent, #7c3aed); color: #fff; border: none; border-radius: 7px; font-size: 0.9rem; cursor: pointer; font-weight: 600; }
.btn-primary:disabled { opacity: 0.5; cursor: not-allowed; }
.error-banner { background: rgba(239,68,68,0.1); border: 1px solid rgba(239,68,68,0.3); border-radius: 6px; color: #ef4444; padding: 10px 14px; margin-bottom: 20px; font-size: 0.85rem; }
.error { color: #ef4444; font-size: 0.82rem; }
</style>

View file

@ -0,0 +1,45 @@
import { describe, it, expect, beforeEach } from 'vitest'
import { mount } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import { createRouter, createWebHistory } from 'vue-router'
import SettingsView from './SettingsView.vue'
import { useAppConfigStore } from '../../stores/appConfig'
function makeRouter() {
return createRouter({ history: createWebHistory(), routes: [{ path: '/:p*', component: { template: '<div/>' } }] })
}
describe('SettingsView sidebar', () => {
beforeEach(() => setActivePinia(createPinia()))
it('hides System group items in cloud mode', async () => {
const store = useAppConfigStore()
store.isCloud = true
const wrapper = mount(SettingsView, { global: { plugins: [makeRouter()] } })
expect(wrapper.find('[data-testid="nav-system"]').exists()).toBe(false)
})
it('shows System when not cloud', async () => {
const store = useAppConfigStore()
store.isCloud = false
const wrapper = mount(SettingsView, { global: { plugins: [makeRouter()] } })
expect(wrapper.find('[data-testid="nav-system"]').exists()).toBe(true)
})
it('hides Developer when neither devMode nor devTierOverride', () => {
const store = useAppConfigStore()
store.isDevMode = false
localStorage.removeItem('dev_tier_override')
const wrapper = mount(SettingsView, { global: { plugins: [makeRouter()] } })
expect(wrapper.find('[data-testid="nav-developer"]').exists()).toBe(false)
})
it('shows Developer when devTierOverride is set in store', () => {
const store = useAppConfigStore()
store.isDevMode = false
store.setDevTierOverride('premium')
const wrapper = mount(SettingsView, { global: { plugins: [makeRouter()] } })
expect(wrapper.find('[data-testid="nav-developer"]').exists()).toBe(true)
store.setDevTierOverride(null) // cleanup
})
})

View file

@ -0,0 +1,156 @@
<template>
<div class="settings-layout">
<!-- Desktop sidebar -->
<nav class="settings-sidebar" aria-label="Settings navigation">
<template v-for="group in visibleGroups" :key="group.label">
<div class="nav-group-label">{{ group.label }}</div>
<RouterLink
v-for="item in group.items"
:key="item.path"
:to="item.path"
:data-testid="`nav-${item.key}`"
class="nav-item"
active-class="nav-item--active"
>{{ item.label }}</RouterLink>
</template>
</nav>
<!-- Mobile chip bar -->
<div class="settings-chip-bar" role="tablist">
<RouterLink
v-for="item in visibleTabs"
:key="item.path"
:to="item.path"
class="chip"
active-class="chip--active"
role="tab"
>{{ item.label }}</RouterLink>
</div>
<main class="settings-content">
<RouterView />
</main>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useAppConfigStore } from '../../stores/appConfig'
const config = useAppConfigStore()
const devOverride = computed(() => !!config.devTierOverride)
const gpuProfiles = ['single-gpu', 'dual-gpu']
const showSystem = computed(() => !config.isCloud)
const showFineTune = computed(() => {
if (config.isCloud) return config.tier === 'premium'
return gpuProfiles.includes(config.inferenceProfile)
})
const showDeveloper = computed(() => config.isDevMode || devOverride.value)
// IMPORTANT: `show` values must be ComputedRef<boolean> objects (e.g. showSystem),
// NOT raw booleans (e.g. showSystem.value). Using .value here would capture a static
// boolean at setup time and break reactivity.
const allGroups = [
{ label: 'Profile', items: [
{ key: 'my-profile', path: '/settings/my-profile', label: 'My Profile', show: true },
{ key: 'resume', path: '/settings/resume', label: 'Resume Profile', show: true },
]},
{ label: 'Search', items: [
{ key: 'search', path: '/settings/search', label: 'Search Prefs', show: true },
]},
{ label: 'App', items: [
{ key: 'system', path: '/settings/system', label: 'System', show: showSystem },
{ key: 'fine-tune', path: '/settings/fine-tune', label: 'Fine-Tune', show: showFineTune },
]},
{ label: 'Account', items: [
{ key: 'license', path: '/settings/license', label: 'License', show: true },
{ key: 'data', path: '/settings/data', label: 'Data', show: true },
{ key: 'privacy', path: '/settings/privacy', label: 'Privacy', show: true },
]},
{ label: 'Dev', items: [
{ key: 'developer', path: '/settings/developer', label: 'Developer', show: showDeveloper },
]},
]
const visibleGroups = computed(() =>
allGroups
.map(g => ({ ...g, items: g.items.filter(i => i.show === true || (typeof i.show !== 'boolean' && i.show.value)) }))
.filter(g => g.items.length > 0)
)
const visibleTabs = computed(() => visibleGroups.value.flatMap(g => g.items))
</script>
<style scoped>
.settings-layout {
display: grid;
grid-template-columns: 180px 1fr;
grid-template-rows: auto 1fr;
min-height: calc(100vh - var(--header-height, 56px));
}
.settings-sidebar {
grid-column: 1;
grid-row: 1 / -1;
border-right: 1px solid var(--color-border);
padding: var(--space-4) 0;
}
.nav-group-label {
padding: var(--space-3) var(--space-4) var(--space-1);
font-size: 0.65rem;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--color-text-muted);
}
.nav-item {
display: block;
padding: var(--space-2) var(--space-4);
font-size: 0.8rem;
color: var(--color-text-secondary);
text-decoration: none;
border-right: 2px solid transparent;
}
.nav-item--active {
background: color-mix(in srgb, var(--color-primary) 15%, transparent);
color: var(--color-primary);
border-right-color: var(--color-primary);
}
.settings-chip-bar {
display: none;
grid-column: 1 / -1;
overflow-x: auto;
gap: var(--space-2);
padding: var(--space-2) var(--space-3);
border-bottom: 1px solid var(--color-border);
white-space: nowrap;
-webkit-mask-image: linear-gradient(to right, black 85%, transparent);
mask-image: linear-gradient(to right, black 85%, transparent);
}
.chip {
display: inline-block;
padding: var(--space-1) var(--space-3);
border-radius: 999px;
border: 1px solid var(--color-border);
background: var(--color-surface-raised);
color: var(--color-text-secondary);
font-size: 0.78rem;
text-decoration: none;
flex-shrink: 0;
}
.chip--active {
background: color-mix(in srgb, var(--color-primary) 20%, transparent);
border-color: var(--color-primary);
color: var(--color-primary);
}
.settings-content {
grid-column: 2;
padding: var(--space-6) var(--space-8);
overflow-y: auto;
}
@media (max-width: 767px) {
.settings-layout { grid-template-columns: 1fr; }
.settings-sidebar { display: none; }
.settings-chip-bar { display: flex; }
.settings-content { grid-column: 1; padding: var(--space-4); }
}
</style>

View file

@ -0,0 +1,394 @@
<template>
<div class="system-settings">
<h2>System Settings</h2>
<p class="tab-note">This tab is only available in self-hosted mode.</p>
<p v-if="store.loadError" class="error-banner">{{ store.loadError }}</p>
<!-- LLM Backends -->
<section class="form-section">
<h3>LLM Backends</h3>
<p class="section-note">Drag to reorder. Higher position = higher priority in the fallback chain.</p>
<div class="backend-list">
<div
v-for="(backend, idx) in visibleBackends"
:key="backend.id"
class="backend-card"
draggable="true"
@dragstart="dragStart(idx)"
@dragover.prevent="dragOver(idx)"
@drop="drop"
>
<span class="drag-handle" aria-hidden="true"></span>
<span class="priority-badge">{{ idx + 1 }}</span>
<span class="backend-id">{{ backend.id }}</span>
<label class="toggle-label">
<input
type="checkbox"
:checked="backend.enabled"
@change="store.backends = store.backends.map(b =>
b.id === backend.id ? { ...b, enabled: !b.enabled } : b
)"
/>
<span class="toggle-text">{{ backend.enabled ? 'Enabled' : 'Disabled' }}</span>
</label>
</div>
</div>
<div class="form-actions">
<button @click="store.trySave()" :disabled="store.saving" class="btn-primary">
{{ store.saving ? 'Saving…' : 'Save Backends' }}
</button>
<p v-if="store.saveError" class="error">{{ store.saveError }}</p>
</div>
</section>
<!-- Services section -->
<section class="form-section">
<h3>Services</h3>
<p class="section-note">Port-based status. Start/Stop via Docker Compose.</p>
<div class="service-grid">
<div v-for="svc in store.services" :key="svc.name" class="service-card">
<div class="service-header">
<span class="service-dot" :class="svc.running ? 'dot-running' : 'dot-stopped'"></span>
<span class="service-name">{{ svc.name }}</span>
<span class="service-port">:{{ svc.port }}</span>
</div>
<p class="service-note">{{ svc.note }}</p>
<div class="service-actions">
<button v-if="!svc.running" @click="store.startService(svc.name)" class="btn-start">Start</button>
<button v-else @click="store.stopService(svc.name)" class="btn-stop">Stop</button>
</div>
<p v-if="store.serviceErrors[svc.name]" class="error">{{ store.serviceErrors[svc.name] }}</p>
</div>
</div>
</section>
<!-- Email section -->
<section class="form-section">
<h3>Email (IMAP)</h3>
<p class="section-note">Used for email sync in the Interviews pipeline.</p>
<div class="field-row">
<label>IMAP Host</label>
<input v-model="(store.emailConfig as any).host" placeholder="imap.gmail.com" />
</div>
<div class="field-row">
<label>Port</label>
<input v-model.number="(store.emailConfig as any).port" type="number" placeholder="993" />
</div>
<label class="checkbox-row">
<input type="checkbox" v-model="(store.emailConfig as any).ssl" /> Use SSL
</label>
<div class="field-row">
<label>Username</label>
<input v-model="(store.emailConfig as any).username" type="email" />
</div>
<div class="field-row">
<label>Password / App Password</label>
<input
v-model="emailPasswordInput"
type="password"
:placeholder="(store.emailConfig as any).password_set ? '••••••• (saved — enter new to change)' : 'Password'"
/>
<span class="field-hint">Gmail: use an App Password. Tip: type ${ENV_VAR_NAME} to use an environment variable.</span>
</div>
<div class="field-row">
<label>Sent Folder</label>
<input v-model="(store.emailConfig as any).sent_folder" placeholder="[Gmail]/Sent Mail" />
</div>
<div class="field-row">
<label>Lookback Days</label>
<input v-model.number="(store.emailConfig as any).lookback_days" type="number" placeholder="30" />
</div>
<div class="form-actions">
<button @click="handleSaveEmail()" :disabled="store.emailSaving" class="btn-primary">
{{ store.emailSaving ? 'Saving…' : 'Save Email Config' }}
</button>
<button @click="handleTestEmail" class="btn-secondary">Test Connection</button>
<span v-if="emailTestResult !== null" :class="emailTestResult ? 'test-ok' : 'test-fail'">
{{ emailTestResult ? '✓ Connected' : '✗ Failed' }}
</span>
<p v-if="store.emailError" class="error">{{ store.emailError }}</p>
</div>
</section>
<!-- Integrations -->
<section class="form-section">
<h3>Integrations</h3>
<div v-if="store.integrations.length === 0" class="empty-note">No integrations registered.</div>
<div v-for="integration in store.integrations" :key="integration.id" class="integration-card">
<div class="integration-header">
<span class="integration-name">{{ integration.name }}</span>
<div class="integration-badges">
<span v-if="!meetsRequiredTier(integration.tier_required)" class="tier-badge">
Requires {{ integration.tier_required }}
</span>
<span :class="['status-badge', integration.connected ? 'badge-connected' : 'badge-disconnected']">
{{ integration.connected ? 'Connected' : 'Disconnected' }}
</span>
</div>
</div>
<!-- Locked state for insufficient tier -->
<div v-if="!meetsRequiredTier(integration.tier_required)" class="tier-locked">
<p>Upgrade to {{ integration.tier_required }} to use this integration.</p>
</div>
<!-- Normal state for sufficient tier -->
<template v-else>
<div v-if="!integration.connected" class="integration-form">
<div v-for="field in integration.fields" :key="field.key" class="field-row">
<label>{{ field.label }}</label>
<input v-model="integrationInputs[integration.id + ':' + field.key]"
:type="field.type === 'password' ? 'password' : 'text'" />
</div>
<div class="form-actions">
<button @click="handleConnect(integration.id)" class="btn-primary">Connect</button>
<button @click="handleTest(integration.id)" class="btn-secondary">Test</button>
<span v-if="store.integrationResults[integration.id]" :class="store.integrationResults[integration.id].ok ? 'test-ok' : 'test-fail'">
{{ store.integrationResults[integration.id].ok ? '✓ OK' : '✗ ' + store.integrationResults[integration.id].error }}
</span>
</div>
</div>
<div v-else>
<button @click="store.disconnectIntegration(integration.id)" class="btn-danger">Disconnect</button>
</div>
</template>
</div>
</section>
<!-- File Paths -->
<section class="form-section">
<h3>File Paths</h3>
<div class="field-row">
<label>Documents Directory</label>
<input v-model="(store.filePaths as any).docs_dir" placeholder="/Library/Documents/JobSearch" />
</div>
<div class="field-row">
<label>Data Directory</label>
<input v-model="(store.filePaths as any).data_dir" placeholder="data/" />
</div>
<div class="field-row">
<label>Model Directory</label>
<input v-model="(store.filePaths as any).model_dir" placeholder="/Library/Assets/LLM" />
</div>
<div class="form-actions">
<button @click="store.saveFilePaths()" :disabled="store.filePathsSaving" class="btn-primary">
{{ store.filePathsSaving ? 'Saving…' : 'Save Paths' }}
</button>
</div>
<p v-if="store.filePathsError" class="error-msg">{{ store.filePathsError }}</p>
</section>
<!-- Deployment / Server -->
<section class="form-section">
<h3>Deployment / Server</h3>
<p class="section-note">Restart required for changes to take effect.</p>
<div class="field-row">
<label>Base URL Path</label>
<input v-model="(store.deployConfig as any).base_url_path" placeholder="/peregrine" />
</div>
<div class="field-row">
<label>Server Host</label>
<input v-model="(store.deployConfig as any).server_host" placeholder="0.0.0.0" />
</div>
<div class="field-row">
<label>Server Port</label>
<input v-model.number="(store.deployConfig as any).server_port" type="number" placeholder="8502" />
</div>
<div class="form-actions">
<button @click="store.saveDeployConfig()" :disabled="store.deploySaving" class="btn-primary">
{{ store.deploySaving ? 'Saving…' : 'Save (requires restart)' }}
</button>
</div>
<p v-if="store.deployError" class="error-msg">{{ store.deployError }}</p>
</section>
<!-- BYOK Modal -->
<Teleport to="body">
<div v-if="store.byokPending.length > 0" class="modal-overlay" @click.self="store.cancelByok()">
<div class="modal-card" role="dialog" aria-modal="true" aria-labelledby="byok-title">
<h3 id="byok-title"> Cloud LLM Key Required</h3>
<p>You are enabling the following cloud backends:</p>
<ul>
<li v-for="b in store.byokPending" :key="b">{{ b }}</li>
</ul>
<p class="byok-warning">
These services require your own API key. Your requests and data will be
sent to these third-party providers. Costs will be charged to your account.
</p>
<label class="checkbox-row">
<input type="checkbox" v-model="byokConfirmed" />
I understand and have configured my API key in <code>config/llm.yaml</code>
</label>
<div class="modal-actions">
<button @click="store.cancelByok()" class="btn-cancel">Cancel</button>
<button
@click="handleConfirmByok"
:disabled="!byokConfirmed || store.saving"
class="btn-primary"
>{{ store.saving ? 'Saving…' : 'Save with Cloud LLM' }}</button>
</div>
</div>
</div>
</Teleport>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { storeToRefs } from 'pinia'
import { useSystemStore } from '../../stores/settings/system'
import { useAppConfigStore } from '../../stores/appConfig'
const store = useSystemStore()
const config = useAppConfigStore()
const { tier } = storeToRefs(config)
const byokConfirmed = ref(false)
const dragIdx = ref<number | null>(null)
const CONTRACTED_ONLY = ['claude-code', 'copilot']
const visibleBackends = computed(() =>
store.backends.filter(b =>
!CONTRACTED_ONLY.includes(b.id) || config.contractedClient
)
)
const tierOrder = ['free', 'paid', 'premium', 'ultra']
function meetsRequiredTier(required: string): boolean {
return tierOrder.indexOf(tier.value) >= tierOrder.indexOf(required || 'free')
}
function dragStart(idx: number) {
dragIdx.value = idx
}
function dragOver(toFilteredIdx: number) {
if (dragIdx.value === null || dragIdx.value === toFilteredIdx) return
const fromId = visibleBackends.value[dragIdx.value].id
const toId = visibleBackends.value[toFilteredIdx].id
const arr = [...store.backends]
const fromFull = arr.findIndex(b => b.id === fromId)
const toFull = arr.findIndex(b => b.id === toId)
if (fromFull === -1 || toFull === -1) return
const [moved] = arr.splice(fromFull, 1)
arr.splice(toFull, 0, moved)
store.backends = arr.map((b, i) => ({ ...b, priority: i + 1 }))
dragIdx.value = toFilteredIdx
}
function drop() {
dragIdx.value = null
}
async function handleConfirmByok() {
await store.confirmByok()
byokConfirmed.value = false
}
const emailTestResult = ref<boolean | null>(null)
const emailPasswordInput = ref('')
const integrationInputs = ref<Record<string, string>>({})
async function handleTestEmail() {
const result = await store.testEmail()
emailTestResult.value = result?.ok ?? false
}
async function handleSaveEmail() {
const payload = { ...store.emailConfig, password: emailPasswordInput.value || undefined }
await store.saveEmailWithPassword(payload)
}
async function handleConnect(id: string) {
const integration = store.integrations.find(i => i.id === id)
if (!integration) return
const credentials: Record<string, string> = {}
for (const field of integration.fields) {
credentials[field.key] = integrationInputs.value[`${id}:${field.key}`] ?? ''
}
await store.connectIntegration(id, credentials)
}
async function handleTest(id: string) {
const integration = store.integrations.find(i => i.id === id)
if (!integration) return
const credentials: Record<string, string> = {}
for (const field of integration.fields) {
credentials[field.key] = integrationInputs.value[`${id}:${field.key}`] ?? ''
}
await store.testIntegration(id, credentials)
}
onMounted(async () => {
await store.loadLlm()
await Promise.all([
store.loadServices(),
store.loadEmail(),
store.loadIntegrations(),
store.loadFilePaths(),
store.loadDeployConfig(),
])
})
</script>
<style scoped>
.system-settings { max-width: 720px; margin: 0 auto; padding: var(--space-4, 24px); }
h2 { font-size: 1.4rem; font-weight: 600; margin-bottom: 6px; color: var(--color-text-primary, #e2e8f0); }
h3 { font-size: 1rem; font-weight: 600; margin-bottom: var(--space-3, 16px); color: var(--color-text-primary, #e2e8f0); }
.tab-note { font-size: 0.82rem; color: var(--color-text-secondary, #94a3b8); margin-bottom: var(--space-6, 32px); }
.form-section { margin-bottom: var(--space-8, 48px); padding-bottom: var(--space-6, 32px); border-bottom: 1px solid var(--color-border, rgba(255,255,255,0.08)); }
.section-note { font-size: 0.78rem; color: var(--color-text-secondary, #94a3b8); margin-bottom: 14px; }
.backend-list { display: flex; flex-direction: column; gap: 8px; margin-bottom: 20px; }
.backend-card { display: flex; align-items: center; gap: 12px; padding: 10px 14px; background: var(--color-surface-2, rgba(255,255,255,0.04)); border: 1px solid var(--color-border, rgba(255,255,255,0.08)); border-radius: 8px; cursor: grab; user-select: none; }
.backend-card:active { cursor: grabbing; }
.drag-handle { font-size: 1.1rem; color: var(--color-text-secondary, #64748b); }
.priority-badge { width: 22px; height: 22px; border-radius: 50%; background: rgba(124,58,237,0.2); color: var(--color-accent, #a78bfa); font-size: 0.72rem; font-weight: 700; display: flex; align-items: center; justify-content: center; flex-shrink: 0; }
.backend-id { flex: 1; font-size: 0.9rem; font-family: monospace; color: var(--color-text-primary, #e2e8f0); }
.toggle-label { display: flex; align-items: center; gap: 6px; cursor: pointer; font-size: 0.82rem; color: var(--color-text-secondary, #94a3b8); }
.form-actions { display: flex; align-items: center; gap: var(--space-4, 24px); }
.btn-primary { padding: 9px 24px; background: var(--color-accent, #7c3aed); color: #fff; border: none; border-radius: 7px; font-size: 0.9rem; cursor: pointer; font-weight: 600; }
.btn-primary:disabled { opacity: 0.5; cursor: not-allowed; }
.btn-cancel { padding: 9px 18px; background: transparent; border: 1px solid var(--color-border, rgba(255,255,255,0.2)); border-radius: 7px; color: var(--color-text-secondary, #94a3b8); cursor: pointer; font-size: 0.9rem; }
.error-banner { background: rgba(239,68,68,0.1); border: 1px solid rgba(239,68,68,0.3); border-radius: 6px; color: #ef4444; padding: 10px 14px; margin-bottom: 20px; font-size: 0.85rem; }
.error { color: #ef4444; font-size: 0.82rem; }
/* BYOK Modal */
.modal-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.6); display: flex; align-items: center; justify-content: center; z-index: 9999; }
.modal-card { background: var(--color-surface-1, #1e293b); border: 1px solid var(--color-border, rgba(255,255,255,0.12)); border-radius: 12px; padding: 28px; max-width: 480px; width: 90%; }
.modal-card h3 { font-size: 1.1rem; margin-bottom: 12px; color: var(--color-text-primary, #e2e8f0); }
.modal-card p { font-size: 0.88rem; color: var(--color-text-secondary, #94a3b8); margin-bottom: 12px; }
.modal-card ul { margin: 8px 0 16px 20px; font-size: 0.88rem; color: var(--color-text-primary, #e2e8f0); }
.byok-warning { background: rgba(245,158,11,0.1); border: 1px solid rgba(245,158,11,0.3); border-radius: 6px; padding: 10px 12px; color: #fbbf24 !important; }
.checkbox-row { display: flex; align-items: flex-start; gap: 8px; font-size: 0.85rem; color: var(--color-text-primary, #e2e8f0); cursor: pointer; margin: 16px 0; }
.modal-actions { display: flex; gap: 10px; justify-content: flex-end; margin-top: 20px; }
.service-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 12px; margin-bottom: 16px; }
.service-card { background: var(--color-surface-2, rgba(255,255,255,0.04)); border: 1px solid var(--color-border, rgba(255,255,255,0.08)); border-radius: 8px; padding: 14px; }
.service-header { display: flex; align-items: center; gap: 8px; margin-bottom: 6px; }
.service-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
.dot-running { background: #22c55e; box-shadow: 0 0 6px rgba(34,197,94,0.5); }
.dot-stopped { background: #64748b; }
.service-name { font-weight: 600; font-size: 0.88rem; color: var(--color-text-primary, #e2e8f0); }
.service-port { font-size: 0.75rem; color: var(--color-text-secondary, #64748b); font-family: monospace; }
.service-note { font-size: 0.75rem; color: var(--color-text-secondary, #94a3b8); margin-bottom: 10px; }
.service-actions { display: flex; gap: 6px; }
.btn-start { padding: 4px 12px; border-radius: 4px; background: rgba(34,197,94,0.15); color: #22c55e; border: 1px solid rgba(34,197,94,0.3); cursor: pointer; font-size: 0.78rem; }
.btn-stop { padding: 4px 12px; border-radius: 4px; background: rgba(239,68,68,0.1); color: #f87171; border: 1px solid rgba(239,68,68,0.2); cursor: pointer; font-size: 0.78rem; }
.field-row { display: flex; flex-direction: column; gap: 4px; margin-bottom: 14px; }
.field-row label { font-size: 0.82rem; color: var(--color-text-secondary, #94a3b8); }
.field-row input { background: var(--color-surface-2, rgba(255,255,255,0.05)); border: 1px solid var(--color-border, rgba(255,255,255,0.12)); border-radius: 6px; color: var(--color-text-primary, #e2e8f0); padding: 7px 10px; font-size: 0.88rem; }
.field-hint { font-size: 0.72rem; color: var(--color-text-secondary, #64748b); margin-top: 3px; }
.btn-secondary { padding: 9px 18px; background: transparent; border: 1px solid var(--color-border, rgba(255,255,255,0.2)); border-radius: 7px; color: var(--color-text-secondary, #94a3b8); cursor: pointer; font-size: 0.88rem; }
.btn-danger { padding: 6px 14px; border-radius: 6px; background: rgba(239,68,68,0.1); color: #ef4444; border: 1px solid rgba(239,68,68,0.25); cursor: pointer; font-size: 0.82rem; }
.test-ok { color: #22c55e; font-size: 0.85rem; }
.test-fail { color: #ef4444; font-size: 0.85rem; }
.integration-card { background: var(--color-surface-2, rgba(255,255,255,0.04)); border: 1px solid var(--color-border, rgba(255,255,255,0.08)); border-radius: 8px; padding: 16px; margin-bottom: 12px; }
.integration-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 12px; }
.integration-name { font-weight: 600; font-size: 0.9rem; color: var(--color-text-primary, #e2e8f0); }
.status-badge { font-size: 0.72rem; padding: 2px 8px; border-radius: 10px; }
.badge-connected { background: rgba(34,197,94,0.15); color: #22c55e; border: 1px solid rgba(34,197,94,0.3); }
.badge-disconnected { background: rgba(100,116,139,0.15); color: #94a3b8; border: 1px solid rgba(100,116,139,0.2); }
.empty-note { font-size: 0.85rem; color: var(--color-text-secondary, #94a3b8); padding: 16px 0; }
.tier-badge { font-size: 0.68rem; padding: 2px 7px; border-radius: 8px; background: rgba(245,158,11,0.15); color: #f59e0b; border: 1px solid rgba(245,158,11,0.3); margin-right: 6px; }
.tier-locked { padding: 12px 0; font-size: 0.85rem; color: var(--color-text-secondary, #94a3b8); }
.integration-badges { display: flex; align-items: center; gap: 4px; }
</style>