Compare commits
21 commits
5e22067ab5
...
82b6ff1689
| Author | SHA1 | Date | |
|---|---|---|---|
| 82b6ff1689 | |||
| fe4091c7ba | |||
| 55051818ef | |||
| a14eefd3e0 | |||
| 6eaa1fef79 | |||
| ab684301a5 | |||
| 1817bddc6c | |||
| e63473360e | |||
| acda1e8f5a | |||
| 0d17b20831 | |||
| 91874a176c | |||
| c358d8c470 | |||
| 837881fbe8 | |||
| 4b0db182b8 | |||
| c4a58c7e27 | |||
| 2937c1b0fa | |||
| 86454a97be | |||
| a8b16d616c | |||
| b8eb2a3890 | |||
| 81b87a750c | |||
| e7d6dfef90 |
40 changed files with 5389 additions and 29 deletions
872
dev-api.py
872
dev-api.py
|
|
@ -4,27 +4,36 @@ Reads directly from /devl/job-seeker/staging.db.
|
||||||
Run with:
|
Run with:
|
||||||
conda run -n job-seeker uvicorn dev-api:app --port 8600 --reload
|
conda run -n job-seeker uvicorn dev-api:app --port 8600 --reload
|
||||||
"""
|
"""
|
||||||
import sqlite3
|
import imaplib
|
||||||
import os
|
|
||||||
import sys
|
|
||||||
import re
|
|
||||||
import json
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import socket
|
||||||
|
import sqlite3
|
||||||
|
import ssl as ssl_mod
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
import threading
|
import threading
|
||||||
from urllib.parse import urlparse
|
|
||||||
from bs4 import BeautifulSoup
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from pathlib import Path
|
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 fastapi.middleware.cors import CORSMiddleware
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from typing import Optional
|
|
||||||
import requests
|
|
||||||
|
|
||||||
# Allow importing peregrine scripts for cover letter generation
|
# Allow importing peregrine scripts for cover letter generation
|
||||||
PEREGRINE_ROOT = Path("/Library/Development/CircuitForge/peregrine")
|
PEREGRINE_ROOT = Path("/Library/Development/CircuitForge/peregrine")
|
||||||
if str(PEREGRINE_ROOT) not in sys.path:
|
if str(PEREGRINE_ROOT) not in sys.path:
|
||||||
sys.path.insert(0, str(PEREGRINE_ROOT))
|
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")
|
DB_PATH = os.environ.get("STAGING_DB", "/devl/job-seeker/staging.db")
|
||||||
|
|
||||||
app = FastAPI(title="Peregrine Dev API")
|
app = FastAPI(title="Peregrine Dev API")
|
||||||
|
|
@ -868,6 +877,24 @@ def move_job(job_id: int, body: MoveBody):
|
||||||
return {"ok": True}
|
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 ──────────────────────────────────────────────────────
|
# ── GET /api/config/user ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
@app.get("/api/config/user")
|
@app.get("/api/config/user")
|
||||||
|
|
@ -883,3 +910,830 @@ def config_user():
|
||||||
return {"name": cfg.get("name", "")}
|
return {"name": cfg.get("name", "")}
|
||||||
except Exception:
|
except Exception:
|
||||||
return {"name": ""}
|
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
198
scripts/credential_store.py
Normal 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()
|
||||||
|
|
@ -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", [])]
|
exclude_kw = [kw.lower() for kw in profile.get("exclude_keywords", [])]
|
||||||
results_per_board = profile.get("results_per_board", 25)
|
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"]:
|
for location in profile["locations"]:
|
||||||
|
|
||||||
# ── JobSpy boards ──────────────────────────────────────────────────
|
# ── JobSpy boards ──────────────────────────────────────────────────
|
||||||
if boards:
|
if boards:
|
||||||
print(f" [jobspy] {location} — boards: {', '.join(boards)}")
|
print(f" [jobspy] {location} — boards: {', '.join(boards)}")
|
||||||
try:
|
try:
|
||||||
jobs: pd.DataFrame = scrape_jobs(
|
jobspy_kwargs: dict = dict(
|
||||||
site_name=boards,
|
site_name=boards,
|
||||||
search_term=" OR ".join(f'"{t}"' for t in profile["titles"]),
|
search_term=" OR ".join(f'"{t}"' for t in profile["titles"]),
|
||||||
location=location,
|
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),
|
hours_old=profile.get("hours_old", 72),
|
||||||
linkedin_fetch_description=True,
|
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")
|
print(f" [jobspy] {len(jobs)} raw results")
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
print(f" [jobspy] ERROR: {exc}")
|
print(f" [jobspy] ERROR: {exc}")
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,8 @@ here so port/host/SSL changes propagate everywhere automatically.
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
import yaml
|
import yaml
|
||||||
|
|
||||||
_DEFAULTS = {
|
_DEFAULTS = {
|
||||||
|
|
@ -130,3 +132,30 @@ class UserProfile:
|
||||||
"ollama_research": f"{self.ollama_url}/v1",
|
"ollama_research": f"{self.ollama_url}/v1",
|
||||||
"vllm": f"{self.vllm_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
|
||||||
|
|
|
||||||
632
tests/test_dev_api_settings.py
Normal file
632
tests/test_dev_api_settings.py
Normal file
|
|
@ -0,0 +1,632 @@
|
||||||
|
"""Tests for all settings API endpoints added in Tasks 1–8."""
|
||||||
|
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
|
||||||
|
|
@ -1,4 +1,6 @@
|
||||||
import { createRouter, createWebHistory } from 'vue-router'
|
import { createRouter, createWebHistory } from 'vue-router'
|
||||||
|
import { useAppConfigStore } from '../stores/appConfig'
|
||||||
|
import { settingsGuard } from './settingsGuard'
|
||||||
|
|
||||||
export const router = createRouter({
|
export const router = createRouter({
|
||||||
history: createWebHistory(),
|
history: createWebHistory(),
|
||||||
|
|
@ -13,8 +15,30 @@ export const router = createRouter({
|
||||||
{ path: '/prep/:id', component: () => import('../views/InterviewPrepView.vue') },
|
{ path: '/prep/:id', component: () => import('../views/InterviewPrepView.vue') },
|
||||||
{ path: '/survey', component: () => import('../views/SurveyView.vue') },
|
{ path: '/survey', component: () => import('../views/SurveyView.vue') },
|
||||||
{ path: '/survey/:id', 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)
|
// Catch-all — FastAPI serves index.html for all unknown routes (SPA mode)
|
||||||
{ path: '/:pathMatch(.*)*', redirect: '/' },
|
{ 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)
|
||||||
|
})
|
||||||
|
|
|
||||||
135
web/src/router/settings.guard.test.ts
Normal file
135
web/src/router/settings.guard.test.ts
Normal 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()
|
||||||
|
})
|
||||||
|
})
|
||||||
31
web/src/router/settingsGuard.ts
Normal file
31
web/src/router/settingsGuard.ts
Normal 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()
|
||||||
|
}
|
||||||
41
web/src/stores/appConfig.test.ts
Normal file
41
web/src/stores/appConfig.test.ts
Normal 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)
|
||||||
|
})
|
||||||
|
})
|
||||||
42
web/src/stores/appConfig.ts
Normal file
42
web/src/stores/appConfig.ts
Normal 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 }
|
||||||
|
})
|
||||||
22
web/src/stores/settings/data.test.ts
Normal file
22
web/src/stores/settings/data.test.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
import { setActivePinia, createPinia } from 'pinia'
|
||||||
|
import { useDataStore } from './data'
|
||||||
|
|
||||||
|
vi.mock('../../composables/useApi', () => ({ useApiFetch: vi.fn() }))
|
||||||
|
import { useApiFetch } from '../../composables/useApi'
|
||||||
|
const mockFetch = vi.mocked(useApiFetch)
|
||||||
|
|
||||||
|
describe('useDataStore', () => {
|
||||||
|
beforeEach(() => { setActivePinia(createPinia()); vi.clearAllMocks() })
|
||||||
|
|
||||||
|
it('initial backupPath is null', () => {
|
||||||
|
expect(useDataStore().backupPath).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('createBackup() sets backupPath after success', async () => {
|
||||||
|
mockFetch.mockResolvedValue({ data: { path: 'data/backup.zip', file_count: 12, size_bytes: 1024 }, error: null })
|
||||||
|
const store = useDataStore()
|
||||||
|
await store.createBackup(false)
|
||||||
|
expect(store.backupPath).toBe('data/backup.zip')
|
||||||
|
})
|
||||||
|
})
|
||||||
30
web/src/stores/settings/data.ts
Normal file
30
web/src/stores/settings/data.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { useApiFetch } from '../../composables/useApi'
|
||||||
|
|
||||||
|
export const useDataStore = defineStore('settings/data', () => {
|
||||||
|
const backupPath = ref<string | null>(null)
|
||||||
|
const backupFileCount = ref(0)
|
||||||
|
const backupSizeBytes = ref(0)
|
||||||
|
const creatingBackup = ref(false)
|
||||||
|
const restoring = ref(false)
|
||||||
|
const restoreResult = ref<{restored: string[]; skipped: string[]} | null>(null)
|
||||||
|
const backupError = ref<string | null>(null)
|
||||||
|
const restoreError = ref<string | null>(null)
|
||||||
|
|
||||||
|
async function createBackup(includeDb: boolean) {
|
||||||
|
creatingBackup.value = true
|
||||||
|
backupError.value = null
|
||||||
|
const { data, error } = await useApiFetch<{path: string; file_count: number; size_bytes: number}>(
|
||||||
|
'/api/settings/data/backup/create',
|
||||||
|
{ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ include_db: includeDb }) }
|
||||||
|
)
|
||||||
|
creatingBackup.value = false
|
||||||
|
if (error || !data) { backupError.value = 'Backup failed'; return }
|
||||||
|
backupPath.value = data.path
|
||||||
|
backupFileCount.value = data.file_count
|
||||||
|
backupSizeBytes.value = data.size_bytes
|
||||||
|
}
|
||||||
|
|
||||||
|
return { backupPath, backupFileCount, backupSizeBytes, creatingBackup, restoring, restoreResult, backupError, restoreError, createBackup }
|
||||||
|
})
|
||||||
39
web/src/stores/settings/fineTune.test.ts
Normal file
39
web/src/stores/settings/fineTune.test.ts
Normal 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()
|
||||||
|
})
|
||||||
|
})
|
||||||
54
web/src/stores/settings/fineTune.ts
Normal file
54
web/src/stores/settings/fineTune.ts
Normal 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,
|
||||||
|
}
|
||||||
|
})
|
||||||
30
web/src/stores/settings/license.test.ts
Normal file
30
web/src/stores/settings/license.test.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
import { setActivePinia, createPinia } from 'pinia'
|
||||||
|
import { useLicenseStore } from './license'
|
||||||
|
|
||||||
|
vi.mock('../../composables/useApi', () => ({ useApiFetch: vi.fn() }))
|
||||||
|
import { useApiFetch } from '../../composables/useApi'
|
||||||
|
const mockFetch = vi.mocked(useApiFetch)
|
||||||
|
|
||||||
|
describe('useLicenseStore', () => {
|
||||||
|
beforeEach(() => { setActivePinia(createPinia()); vi.clearAllMocks() })
|
||||||
|
|
||||||
|
it('initial active is false', () => {
|
||||||
|
expect(useLicenseStore().active).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('activate() on success sets tier and active=true', async () => {
|
||||||
|
mockFetch.mockResolvedValue({ data: { ok: true, tier: 'paid' }, error: null })
|
||||||
|
const store = useLicenseStore()
|
||||||
|
await store.activate('CFG-PRNG-TEST-1234-5678')
|
||||||
|
expect(store.tier).toBe('paid')
|
||||||
|
expect(store.active).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('activate() on failure sets activateError', async () => {
|
||||||
|
mockFetch.mockResolvedValue({ data: { ok: false, error: 'Invalid key' }, error: null })
|
||||||
|
const store = useLicenseStore()
|
||||||
|
await store.activate('bad-key')
|
||||||
|
expect(store.activateError).toBe('Invalid key')
|
||||||
|
})
|
||||||
|
})
|
||||||
51
web/src/stores/settings/license.ts
Normal file
51
web/src/stores/settings/license.ts
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { useApiFetch } from '../../composables/useApi'
|
||||||
|
|
||||||
|
export const useLicenseStore = defineStore('settings/license', () => {
|
||||||
|
const tier = ref<string>('free')
|
||||||
|
const licenseKey = ref<string | null>(null)
|
||||||
|
const active = ref(false)
|
||||||
|
const gracePeriodEnds = ref<string | null>(null)
|
||||||
|
const loading = ref(false)
|
||||||
|
const activating = ref(false)
|
||||||
|
const activateError = ref<string | null>(null)
|
||||||
|
|
||||||
|
async function loadLicense() {
|
||||||
|
loading.value = true
|
||||||
|
const { data } = await useApiFetch<{tier: string; key: string | null; active: boolean; grace_period_ends?: string}>('/api/settings/license')
|
||||||
|
loading.value = false
|
||||||
|
if (!data) return
|
||||||
|
tier.value = data.tier
|
||||||
|
licenseKey.value = data.key
|
||||||
|
active.value = data.active
|
||||||
|
gracePeriodEnds.value = data.grace_period_ends ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
async function activate(key: string) {
|
||||||
|
activating.value = true
|
||||||
|
activateError.value = null
|
||||||
|
const { data } = await useApiFetch<{ok: boolean; tier?: string; error?: string}>(
|
||||||
|
'/api/settings/license/activate',
|
||||||
|
{ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ key }) }
|
||||||
|
)
|
||||||
|
activating.value = false
|
||||||
|
if (!data) { activateError.value = 'Request failed'; return }
|
||||||
|
if (data.ok) {
|
||||||
|
active.value = true
|
||||||
|
tier.value = data.tier ?? tier.value
|
||||||
|
licenseKey.value = key
|
||||||
|
} else {
|
||||||
|
activateError.value = data.error ?? 'Activation failed'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deactivate() {
|
||||||
|
await useApiFetch('/api/settings/license/deactivate', { method: 'POST' })
|
||||||
|
active.value = false
|
||||||
|
licenseKey.value = null
|
||||||
|
tier.value = 'free'
|
||||||
|
}
|
||||||
|
|
||||||
|
return { tier, licenseKey, active, gracePeriodEnds, loading, activating, activateError, loadLicense, activate, deactivate }
|
||||||
|
})
|
||||||
43
web/src/stores/settings/privacy.test.ts
Normal file
43
web/src/stores/settings/privacy.test.ts
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
import { setActivePinia, createPinia } from 'pinia'
|
||||||
|
import { usePrivacyStore } from './privacy'
|
||||||
|
|
||||||
|
vi.mock('../../composables/useApi', () => ({ useApiFetch: vi.fn() }))
|
||||||
|
import { useApiFetch } from '../../composables/useApi'
|
||||||
|
const mockFetch = vi.mocked(useApiFetch)
|
||||||
|
|
||||||
|
describe('usePrivacyStore', () => {
|
||||||
|
beforeEach(() => { setActivePinia(createPinia()); vi.clearAllMocks() })
|
||||||
|
|
||||||
|
it('byokInfoDismissed is false by default', () => {
|
||||||
|
expect(usePrivacyStore().byokInfoDismissed).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('dismissByokInfo() sets dismissed to true', () => {
|
||||||
|
const store = usePrivacyStore()
|
||||||
|
store.dismissByokInfo()
|
||||||
|
expect(store.byokInfoDismissed).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('showByokPanel is true when cloud backends configured and not dismissed', () => {
|
||||||
|
const store = usePrivacyStore()
|
||||||
|
store.activeCloudBackends = ['anthropic']
|
||||||
|
store.byokInfoDismissed = false
|
||||||
|
expect(store.showByokPanel).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('showByokPanel is false when dismissed', () => {
|
||||||
|
const store = usePrivacyStore()
|
||||||
|
store.activeCloudBackends = ['anthropic']
|
||||||
|
store.byokInfoDismissed = true
|
||||||
|
expect(store.showByokPanel).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('showByokPanel re-appears when new backend added after dismissal', () => {
|
||||||
|
const store = usePrivacyStore()
|
||||||
|
store.activeCloudBackends = ['anthropic']
|
||||||
|
store.dismissByokInfo()
|
||||||
|
store.activeCloudBackends = ['anthropic', 'openai']
|
||||||
|
expect(store.showByokPanel).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
64
web/src/stores/settings/privacy.ts
Normal file
64
web/src/stores/settings/privacy.ts
Normal file
|
|
@ -0,0 +1,64 @@
|
||||||
|
import { ref, computed } from 'vue'
|
||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { useApiFetch } from '../../composables/useApi'
|
||||||
|
|
||||||
|
export const usePrivacyStore = defineStore('settings/privacy', () => {
|
||||||
|
// Session-scoped BYOK panel state
|
||||||
|
const activeCloudBackends = ref<string[]>([])
|
||||||
|
const byokInfoDismissed = ref(false)
|
||||||
|
const dismissedForBackends = ref<string[]>([])
|
||||||
|
|
||||||
|
// Self-hosted privacy prefs
|
||||||
|
const telemetryOptIn = ref(false)
|
||||||
|
|
||||||
|
// Cloud privacy prefs
|
||||||
|
const masterOff = ref(false)
|
||||||
|
const usageEvents = ref(true)
|
||||||
|
const contentSharing = ref(false)
|
||||||
|
|
||||||
|
const loading = ref(false)
|
||||||
|
const saving = ref(false)
|
||||||
|
|
||||||
|
// Panel shows if there are active cloud backends not yet covered by dismissal snapshot,
|
||||||
|
// or if byokInfoDismissed was set directly (e.g. loaded from server) and new backends haven't appeared
|
||||||
|
const showByokPanel = computed(() => {
|
||||||
|
if (activeCloudBackends.value.length === 0) return false
|
||||||
|
if (byokInfoDismissed.value && activeCloudBackends.value.every(b => dismissedForBackends.value.includes(b))) return false
|
||||||
|
if (byokInfoDismissed.value && dismissedForBackends.value.length === 0) return false
|
||||||
|
return !activeCloudBackends.value.every(b => dismissedForBackends.value.includes(b))
|
||||||
|
})
|
||||||
|
|
||||||
|
function dismissByokInfo() {
|
||||||
|
dismissedForBackends.value = [...activeCloudBackends.value]
|
||||||
|
byokInfoDismissed.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadPrivacy() {
|
||||||
|
loading.value = true
|
||||||
|
const { data } = await useApiFetch<Record<string, unknown>>('/api/settings/privacy')
|
||||||
|
loading.value = false
|
||||||
|
if (!data) return
|
||||||
|
telemetryOptIn.value = Boolean(data.telemetry_opt_in)
|
||||||
|
byokInfoDismissed.value = Boolean(data.byok_info_dismissed)
|
||||||
|
masterOff.value = Boolean(data.master_off)
|
||||||
|
usageEvents.value = data.usage_events !== false
|
||||||
|
contentSharing.value = Boolean(data.content_sharing)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function savePrivacy(prefs: Record<string, unknown>) {
|
||||||
|
saving.value = true
|
||||||
|
await useApiFetch('/api/settings/privacy', {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(prefs),
|
||||||
|
})
|
||||||
|
saving.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
activeCloudBackends, byokInfoDismissed, dismissedForBackends,
|
||||||
|
telemetryOptIn, masterOff, usageEvents, contentSharing,
|
||||||
|
loading, saving, showByokPanel,
|
||||||
|
dismissByokInfo, loadPrivacy, savePrivacy,
|
||||||
|
}
|
||||||
|
})
|
||||||
51
web/src/stores/settings/profile.test.ts
Normal file
51
web/src/stores/settings/profile.test.ts
Normal 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')
|
||||||
|
})
|
||||||
|
})
|
||||||
94
web/src/stores/settings/profile.ts
Normal file
94
web/src/stores/settings/profile.ts
Normal 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,
|
||||||
|
}
|
||||||
|
})
|
||||||
50
web/src/stores/settings/resume.test.ts
Normal file
50
web/src/stores/settings/resume.test.ts
Normal 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)
|
||||||
|
})
|
||||||
|
})
|
||||||
125
web/src/stores/settings/resume.ts
Normal file
125
web/src/stores/settings/resume.ts
Normal 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,
|
||||||
|
}
|
||||||
|
})
|
||||||
42
web/src/stores/settings/search.test.ts
Normal file
42
web/src/stores/settings/search.test.ts
Normal 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' }))
|
||||||
|
})
|
||||||
|
})
|
||||||
125
web/src/stores/settings/search.ts
Normal file
125
web/src/stores/settings/search.ts
Normal 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,
|
||||||
|
}
|
||||||
|
})
|
||||||
83
web/src/stores/settings/system.test.ts
Normal file
83
web/src/stores/settings/system.test.ts
Normal 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)
|
||||||
|
})
|
||||||
|
})
|
||||||
246
web/src/stores/settings/system.ts
Normal file
246
web/src/stores/settings/system.ts
Normal 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,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
@ -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>
|
|
||||||
81
web/src/views/settings/DataView.vue
Normal file
81
web/src/views/settings/DataView.vue
Normal file
|
|
@ -0,0 +1,81 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { storeToRefs } from 'pinia'
|
||||||
|
import { useDataStore } from '../../stores/settings/data'
|
||||||
|
|
||||||
|
const store = useDataStore()
|
||||||
|
const { backupPath, backupFileCount, backupSizeBytes, creatingBackup, backupError } = storeToRefs(store)
|
||||||
|
const includeDb = ref(false)
|
||||||
|
const showRestoreConfirm = ref(false)
|
||||||
|
const restoreFile = ref<File | null>(null)
|
||||||
|
|
||||||
|
function formatBytes(b: number) {
|
||||||
|
if (b < 1024) return `${b} B`
|
||||||
|
if (b < 1024 * 1024) return `${(b / 1024).toFixed(1)} KB`
|
||||||
|
return `${(b / 1024 / 1024).toFixed(1)} MB`
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="data-view">
|
||||||
|
<h2>Data & Backup</h2>
|
||||||
|
|
||||||
|
<!-- Backup -->
|
||||||
|
<section class="form-section">
|
||||||
|
<h3>Create Backup</h3>
|
||||||
|
<p class="section-note">Exports your config files (and optionally the job database) as a zip archive.</p>
|
||||||
|
<label class="checkbox-row">
|
||||||
|
<input type="checkbox" v-model="includeDb" /> Include job database (staging.db)
|
||||||
|
</label>
|
||||||
|
<div class="form-actions">
|
||||||
|
<button @click="store.createBackup(includeDb)" :disabled="creatingBackup" class="btn-primary">
|
||||||
|
{{ creatingBackup ? 'Creating…' : 'Create Backup' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p v-if="backupError" class="error-msg">{{ backupError }}</p>
|
||||||
|
<div v-if="backupPath" class="backup-result">
|
||||||
|
<span>{{ backupFileCount }} files · {{ formatBytes(backupSizeBytes) }}</span>
|
||||||
|
<span class="backup-path">{{ backupPath }}</span>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Restore -->
|
||||||
|
<section class="form-section">
|
||||||
|
<h3>Restore from Backup</h3>
|
||||||
|
<p class="section-note">Upload a backup zip to restore your configuration. Existing files will be overwritten.</p>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept=".zip"
|
||||||
|
@change="restoreFile = ($event.target as HTMLInputElement).files?.[0] ?? null"
|
||||||
|
class="file-input"
|
||||||
|
/>
|
||||||
|
<div class="form-actions">
|
||||||
|
<button
|
||||||
|
@click="showRestoreConfirm = true"
|
||||||
|
:disabled="!restoreFile || store.restoring"
|
||||||
|
class="btn-warning"
|
||||||
|
>
|
||||||
|
{{ store.restoring ? 'Restoring…' : 'Restore' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div v-if="store.restoreResult" class="restore-result">
|
||||||
|
<p>Restored {{ store.restoreResult.restored.length }} files.</p>
|
||||||
|
<p v-if="store.restoreResult.skipped.length">Skipped: {{ store.restoreResult.skipped.join(', ') }}</p>
|
||||||
|
</div>
|
||||||
|
<p v-if="store.restoreError" class="error-msg">{{ store.restoreError }}</p>
|
||||||
|
|
||||||
|
<Teleport to="body">
|
||||||
|
<div v-if="showRestoreConfirm" class="modal-overlay" @click.self="showRestoreConfirm = false">
|
||||||
|
<div class="modal-card" role="dialog">
|
||||||
|
<h3>Restore Backup?</h3>
|
||||||
|
<p>This will overwrite your current configuration. This cannot be undone.</p>
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button @click="showRestoreConfirm = false" class="btn-danger">Restore</button>
|
||||||
|
<button @click="showRestoreConfirm = false" class="btn-secondary">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Teleport>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
130
web/src/views/settings/DeveloperView.vue
Normal file
130
web/src/views/settings/DeveloperView.vue
Normal file
|
|
@ -0,0 +1,130 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
|
import { useApiFetch } from '../../composables/useApi'
|
||||||
|
|
||||||
|
const devTierOverride = ref<string | null>(null)
|
||||||
|
const hfTokenInput = ref('')
|
||||||
|
const hfTokenSet = ref(false)
|
||||||
|
const hfTestResult = ref<{ok: boolean; error?: string; username?: string} | null>(null)
|
||||||
|
const saving = ref(false)
|
||||||
|
const showWizardResetConfirm = ref(false)
|
||||||
|
const exportResult = ref<{count: number} | null>(null)
|
||||||
|
|
||||||
|
const TIERS = ['free', 'paid', 'premium', 'ultra']
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
const { data } = await useApiFetch<{dev_tier_override: string | null; hf_token_set: boolean}>('/api/settings/developer')
|
||||||
|
if (data) {
|
||||||
|
devTierOverride.value = data.dev_tier_override ?? null
|
||||||
|
hfTokenSet.value = data.hf_token_set
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
async function saveTierOverride() {
|
||||||
|
saving.value = true
|
||||||
|
await useApiFetch('/api/settings/developer/tier', {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ tier: devTierOverride.value }),
|
||||||
|
})
|
||||||
|
saving.value = false
|
||||||
|
// Reload page so tier gate updates
|
||||||
|
window.location.reload()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveHfToken() {
|
||||||
|
if (!hfTokenInput.value) return
|
||||||
|
await useApiFetch('/api/settings/developer/hf-token', {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ token: hfTokenInput.value }),
|
||||||
|
})
|
||||||
|
hfTokenSet.value = true
|
||||||
|
hfTokenInput.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
async function testHfToken() {
|
||||||
|
const { data } = await useApiFetch<{ok: boolean; error?: string; username?: string}>('/api/settings/developer/hf-token/test', { method: 'POST' })
|
||||||
|
hfTestResult.value = data
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resetWizard() {
|
||||||
|
await useApiFetch('/api/settings/developer/wizard-reset', { method: 'POST' })
|
||||||
|
showWizardResetConfirm.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
async function exportClassifier() {
|
||||||
|
const { data } = await useApiFetch<{count: number}>('/api/settings/developer/export-classifier', { method: 'POST' })
|
||||||
|
if (data) exportResult.value = { count: data.count }
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="developer-view">
|
||||||
|
<h2>Developer</h2>
|
||||||
|
|
||||||
|
<!-- Tier override -->
|
||||||
|
<section class="form-section">
|
||||||
|
<h3>Tier Override</h3>
|
||||||
|
<p class="section-note">Override the effective tier for UI testing. Does not affect licensing.</p>
|
||||||
|
<div class="field-row">
|
||||||
|
<label>Override Tier</label>
|
||||||
|
<select v-model="devTierOverride">
|
||||||
|
<option :value="null">— none (use real tier) —</option>
|
||||||
|
<option v-for="t in TIERS" :key="t" :value="t">{{ t }}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-actions">
|
||||||
|
<button @click="saveTierOverride" :disabled="saving" class="btn-primary">Apply Override</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- HF Token -->
|
||||||
|
<section class="form-section">
|
||||||
|
<h3>HuggingFace Token</h3>
|
||||||
|
<p class="section-note">Required for model downloads and fine-tune uploads.</p>
|
||||||
|
<p v-if="hfTokenSet" class="token-set">✓ Token stored securely</p>
|
||||||
|
<div class="field-row">
|
||||||
|
<label>Token</label>
|
||||||
|
<input v-model="hfTokenInput" type="password" placeholder="hf_…" autocomplete="new-password" />
|
||||||
|
</div>
|
||||||
|
<div class="form-actions">
|
||||||
|
<button @click="saveHfToken" :disabled="!hfTokenInput" class="btn-primary">Save Token</button>
|
||||||
|
<button @click="testHfToken" class="btn-secondary">Test</button>
|
||||||
|
</div>
|
||||||
|
<p v-if="hfTestResult" :class="hfTestResult.ok ? 'status-ok' : 'error-msg'">
|
||||||
|
{{ hfTestResult.ok ? `✓ Logged in as ${hfTestResult.username}` : '✗ ' + hfTestResult.error }}
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Wizard reset -->
|
||||||
|
<section class="form-section">
|
||||||
|
<h3>Wizard</h3>
|
||||||
|
<div class="form-actions">
|
||||||
|
<button @click="showWizardResetConfirm = true" class="btn-warning">Reset Setup Wizard</button>
|
||||||
|
</div>
|
||||||
|
<Teleport to="body">
|
||||||
|
<div v-if="showWizardResetConfirm" class="modal-overlay" @click.self="showWizardResetConfirm = false">
|
||||||
|
<div class="modal-card" role="dialog">
|
||||||
|
<h3>Reset Setup Wizard?</h3>
|
||||||
|
<p>The first-run setup wizard will be shown again on next launch.</p>
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button @click="resetWizard" class="btn-warning">Reset</button>
|
||||||
|
<button @click="showWizardResetConfirm = false" class="btn-secondary">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Teleport>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Export classifier data -->
|
||||||
|
<section class="form-section">
|
||||||
|
<h3>Export Training Data</h3>
|
||||||
|
<p class="section-note">Export labeled emails as JSONL for classifier training.</p>
|
||||||
|
<div class="form-actions">
|
||||||
|
<button @click="exportClassifier" class="btn-secondary">Export to data/email_score.jsonl</button>
|
||||||
|
</div>
|
||||||
|
<p v-if="exportResult" class="status-ok">Exported {{ exportResult.count }} labeled emails.</p>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
163
web/src/views/settings/FineTuneView.vue
Normal file
163
web/src/views/settings/FineTuneView.vue
Normal 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>
|
||||||
18
web/src/views/settings/LicenseCloud.vue
Normal file
18
web/src/views/settings/LicenseCloud.vue
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { storeToRefs } from 'pinia'
|
||||||
|
import { useAppConfigStore } from '../../stores/appConfig'
|
||||||
|
|
||||||
|
const { tier } = storeToRefs(useAppConfigStore())
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="form-section">
|
||||||
|
<h2>Plan</h2>
|
||||||
|
<div class="license-info">
|
||||||
|
<span class="tier-badge">{{ tier?.toUpperCase() ?? 'FREE' }}</span>
|
||||||
|
</div>
|
||||||
|
<p class="section-note">
|
||||||
|
Manage your subscription at <a href="https://circuitforge.tech/account" target="_blank">circuitforge.tech/account</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
57
web/src/views/settings/LicenseSelfHosted.vue
Normal file
57
web/src/views/settings/LicenseSelfHosted.vue
Normal file
|
|
@ -0,0 +1,57 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
|
import { storeToRefs } from 'pinia'
|
||||||
|
import { useLicenseStore } from '../../stores/settings/license'
|
||||||
|
|
||||||
|
const store = useLicenseStore()
|
||||||
|
const { tier, licenseKey, active, gracePeriodEnds, activating, activateError } = storeToRefs(store)
|
||||||
|
const keyInput = ref('')
|
||||||
|
const showDeactivateConfirm = ref(false)
|
||||||
|
|
||||||
|
onMounted(() => store.loadLicense())
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="form-section">
|
||||||
|
<h2>License</h2>
|
||||||
|
|
||||||
|
<!-- Active license -->
|
||||||
|
<template v-if="active">
|
||||||
|
<div class="license-info">
|
||||||
|
<span :class="`tier-badge tier-${tier}`">{{ tier.toUpperCase() }}</span>
|
||||||
|
<span v-if="licenseKey" class="license-key">{{ licenseKey }}</span>
|
||||||
|
<span v-if="gracePeriodEnds" class="grace-notice">Grace period ends: {{ gracePeriodEnds }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="form-actions">
|
||||||
|
<button @click="showDeactivateConfirm = true" class="btn-danger">Deactivate</button>
|
||||||
|
</div>
|
||||||
|
<Teleport to="body">
|
||||||
|
<div v-if="showDeactivateConfirm" class="modal-overlay" @click.self="showDeactivateConfirm = false">
|
||||||
|
<div class="modal-card" role="dialog">
|
||||||
|
<h3>Deactivate License?</h3>
|
||||||
|
<p>You will lose access to paid features. You can reactivate later with the same key.</p>
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button @click="store.deactivate(); showDeactivateConfirm = false" class="btn-danger">Deactivate</button>
|
||||||
|
<button @click="showDeactivateConfirm = false" class="btn-secondary">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Teleport>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- No active license -->
|
||||||
|
<template v-else>
|
||||||
|
<p class="section-note">Enter your license key to unlock paid features.</p>
|
||||||
|
<div class="field-row">
|
||||||
|
<label>License Key</label>
|
||||||
|
<input v-model="keyInput" placeholder="CFG-PRNG-XXXX-XXXX-XXXX" class="monospace" />
|
||||||
|
</div>
|
||||||
|
<p v-if="activateError" class="error-msg">{{ activateError }}</p>
|
||||||
|
<div class="form-actions">
|
||||||
|
<button @click="store.activate(keyInput)" :disabled="!keyInput || activating" class="btn-primary">
|
||||||
|
{{ activating ? 'Activating…' : 'Activate' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
16
web/src/views/settings/LicenseView.vue
Normal file
16
web/src/views/settings/LicenseView.vue
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { useAppConfigStore } from '../../stores/appConfig'
|
||||||
|
import LicenseSelfHosted from './LicenseSelfHosted.vue'
|
||||||
|
import LicenseCloud from './LicenseCloud.vue'
|
||||||
|
|
||||||
|
const config = useAppConfigStore()
|
||||||
|
const isCloud = computed(() => config.isCloud)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="license-view">
|
||||||
|
<LicenseCloud v-if="isCloud" />
|
||||||
|
<LicenseSelfHosted v-else />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
577
web/src/views/settings/MyProfileView.vue
Normal file
577
web/src/views/settings/MyProfileView.vue
Normal 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="2–3 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 & 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 & 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>
|
||||||
82
web/src/views/settings/PrivacyView.vue
Normal file
82
web/src/views/settings/PrivacyView.vue
Normal file
|
|
@ -0,0 +1,82 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted } from 'vue'
|
||||||
|
import { storeToRefs } from 'pinia'
|
||||||
|
import { usePrivacyStore } from '../../stores/settings/privacy'
|
||||||
|
import { useAppConfigStore } from '../../stores/appConfig'
|
||||||
|
import { useSystemStore } from '../../stores/settings/system'
|
||||||
|
|
||||||
|
const privacy = usePrivacyStore()
|
||||||
|
const config = useAppConfigStore()
|
||||||
|
const system = useSystemStore()
|
||||||
|
const { telemetryOptIn, masterOff, usageEvents, contentSharing, showByokPanel, saving } = storeToRefs(privacy)
|
||||||
|
|
||||||
|
// Sync active cloud backends from system store into privacy store
|
||||||
|
const activeCloudBackends = computed(() =>
|
||||||
|
system.backends.filter(b => b.enabled && ['anthropic', 'openai'].includes(b.id)).map(b => b.id)
|
||||||
|
)
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await privacy.loadPrivacy()
|
||||||
|
privacy.activeCloudBackends = activeCloudBackends.value
|
||||||
|
})
|
||||||
|
|
||||||
|
async function handleSave() {
|
||||||
|
if (config.isCloud) {
|
||||||
|
await privacy.savePrivacy({ master_off: masterOff.value, usage_events: usageEvents.value, content_sharing: contentSharing.value })
|
||||||
|
} else {
|
||||||
|
await privacy.savePrivacy({ telemetry_opt_in: telemetryOptIn.value })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="privacy-view">
|
||||||
|
<h2>Privacy</h2>
|
||||||
|
|
||||||
|
<!-- Self-hosted -->
|
||||||
|
<template v-if="!config.isCloud">
|
||||||
|
<section class="form-section">
|
||||||
|
<h3>Telemetry</h3>
|
||||||
|
<p class="section-note">Peregrine is fully local by default — no data leaves your machine unless you opt in.</p>
|
||||||
|
<label class="checkbox-row">
|
||||||
|
<input type="checkbox" v-model="telemetryOptIn" />
|
||||||
|
Share anonymous usage statistics to help improve Peregrine
|
||||||
|
</label>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- BYOK Info Panel -->
|
||||||
|
<section v-if="showByokPanel" class="form-section byok-panel">
|
||||||
|
<h3>Cloud LLM Privacy Notice</h3>
|
||||||
|
<p>You have cloud LLM backends enabled. Your job descriptions and cover letter content will be sent to those providers' APIs. Peregrine never logs this content, but the providers' own data policies apply.</p>
|
||||||
|
<div class="form-actions">
|
||||||
|
<button @click="privacy.dismissByokInfo()" class="btn-secondary">Got it, don't show again</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Cloud -->
|
||||||
|
<template v-else>
|
||||||
|
<section class="form-section">
|
||||||
|
<h3>Data Controls</h3>
|
||||||
|
<label class="checkbox-row danger">
|
||||||
|
<input type="checkbox" v-model="masterOff" />
|
||||||
|
Disable all data collection (master off)
|
||||||
|
</label>
|
||||||
|
<label class="checkbox-row">
|
||||||
|
<input type="checkbox" v-model="usageEvents" :disabled="masterOff" />
|
||||||
|
Usage events (feature analytics)
|
||||||
|
</label>
|
||||||
|
<label class="checkbox-row">
|
||||||
|
<input type="checkbox" v-model="contentSharing" :disabled="masterOff" />
|
||||||
|
Share content for model improvement
|
||||||
|
</label>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div class="form-actions">
|
||||||
|
<button @click="handleSave" :disabled="saving" class="btn-primary">
|
||||||
|
{{ saving ? 'Saving…' : 'Save' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
310
web/src/views/settings/ResumeProfileView.vue
Normal file
310
web/src/views/settings/ResumeProfileView.vue
Normal 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>
|
||||||
204
web/src/views/settings/SearchPrefsView.vue
Normal file
204
web/src/views/settings/SearchPrefsView.vue
Normal 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>
|
||||||
45
web/src/views/settings/SettingsView.test.ts
Normal file
45
web/src/views/settings/SettingsView.test.ts
Normal 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
|
||||||
|
})
|
||||||
|
})
|
||||||
156
web/src/views/settings/SettingsView.vue
Normal file
156
web/src/views/settings/SettingsView.vue
Normal 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>
|
||||||
394
web/src/views/settings/SystemSettingsView.vue
Normal file
394
web/src/views/settings/SystemSettingsView.vue
Normal 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>
|
||||||
Loading…
Reference in a new issue