feat(settings): credential store + fix Task 6 blocking review issues
- add scripts/credential_store.py (keyring/file/env-ref backends, Fernet encryption)
- email password stored via credential store, never returned in GET
- email GET returns password_set flag; PUT accepts new password or ${ENV_VAR} ref
- move integration actions to store (connectIntegration, testIntegration, disconnectIntegration)
- add tier-gating UI with locked state and upgrade prompt
- move subprocess/socket/imaplib/ssl imports to top level
This commit is contained in:
parent
bce997e596
commit
f6ddaca14f
4 changed files with 348 additions and 62 deletions
63
dev-api.py
63
dev-api.py
|
|
@ -4,28 +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 typing import Optional, List
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
import requests
|
||||||
import yaml
|
import yaml
|
||||||
|
from bs4 import BeautifulSoup
|
||||||
from fastapi import FastAPI, HTTPException, Response, UploadFile
|
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, List
|
|
||||||
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")
|
||||||
|
|
@ -1196,9 +1204,6 @@ def byok_ack(payload: ByokAckPayload):
|
||||||
|
|
||||||
# ── Settings: System — Services ───────────────────────────────────────────────
|
# ── Settings: System — Services ───────────────────────────────────────────────
|
||||||
|
|
||||||
import subprocess
|
|
||||||
import socket
|
|
||||||
|
|
||||||
SERVICES_REGISTRY = [
|
SERVICES_REGISTRY = [
|
||||||
{"name": "ollama", "port": 11434, "compose_service": "ollama", "note": "LLM inference", "profiles": ["cpu","single-gpu","dual-gpu"]},
|
{"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": "vllm", "port": 8000, "compose_service": "vllm", "note": "vLLM server", "profiles": ["single-gpu","dual-gpu"]},
|
||||||
|
|
@ -1261,15 +1266,25 @@ def stop_service(name: str):
|
||||||
# ── Settings: System — Email ──────────────────────────────────────────────────
|
# ── Settings: System — Email ──────────────────────────────────────────────────
|
||||||
|
|
||||||
EMAIL_PATH = Path("config/email.yaml")
|
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")
|
@app.get("/api/settings/system/email")
|
||||||
def get_email_config():
|
def get_email_config():
|
||||||
try:
|
try:
|
||||||
if not EMAIL_PATH.exists():
|
config = {}
|
||||||
return {}
|
if EMAIL_PATH.exists():
|
||||||
with open(EMAIL_PATH) as f:
|
with open(EMAIL_PATH) as f:
|
||||||
return yaml.safe_load(f) or {}
|
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:
|
except Exception as e:
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
@ -1278,10 +1293,16 @@ def get_email_config():
|
||||||
def save_email_config(payload: dict):
|
def save_email_config(payload: dict):
|
||||||
try:
|
try:
|
||||||
EMAIL_PATH.parent.mkdir(parents=True, exist_ok=True)
|
EMAIL_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||||
# Write with restricted permissions (contains password)
|
# Extract password before writing yaml
|
||||||
|
password = payload.pop("password", None) or payload.pop("password_set", None)
|
||||||
|
# Only store if it's a real new value (not the sentinel True/False)
|
||||||
|
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)
|
fd = os.open(str(EMAIL_PATH), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
||||||
with os.fdopen(fd, 'w') as f:
|
with os.fdopen(fd, "w") as f:
|
||||||
yaml.dump(dict(payload), f, allow_unicode=True, default_flow_style=False)
|
yaml.dump(safe_config, f, allow_unicode=True, default_flow_style=False)
|
||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
@ -1289,13 +1310,15 @@ def save_email_config(payload: dict):
|
||||||
|
|
||||||
@app.post("/api/settings/system/email/test")
|
@app.post("/api/settings/system/email/test")
|
||||||
def test_email(payload: dict):
|
def test_email(payload: dict):
|
||||||
import imaplib, ssl as ssl_mod
|
|
||||||
try:
|
try:
|
||||||
|
# Allow test to pass in a password override, or use stored credential
|
||||||
|
password = payload.get("password") or get_credential(EMAIL_CRED_SERVICE, EMAIL_CRED_KEY)
|
||||||
host = payload.get("host", "")
|
host = payload.get("host", "")
|
||||||
port = int(payload.get("port", 993))
|
port = int(payload.get("port", 993))
|
||||||
use_ssl = payload.get("ssl", True)
|
use_ssl = payload.get("ssl", True)
|
||||||
username = payload.get("username", "")
|
username = payload.get("username", "")
|
||||||
password = payload.get("password", "")
|
if not all([host, username, password]):
|
||||||
|
return {"ok": False, "error": "Missing host, username, or password"}
|
||||||
if use_ssl:
|
if use_ssl:
|
||||||
ctx = ssl_mod.create_default_context()
|
ctx = ssl_mod.create_default_context()
|
||||||
conn = imaplib.IMAP4_SSL(host, port, ssl_context=ctx)
|
conn = imaplib.IMAP4_SSL(host, port, ssl_context=ctx)
|
||||||
|
|
|
||||||
197
scripts/credential_store.py
Normal file
197
scripts/credential_store.py
Normal file
|
|
@ -0,0 +1,197 @@
|
||||||
|
"""
|
||||||
|
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_]*)\}$')
|
||||||
|
|
||||||
|
CRED_DIR = Path("config/credentials")
|
||||||
|
KEY_PATH = Path("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()
|
||||||
|
|
@ -153,6 +153,46 @@ export const useSystemStore = defineStore('settings/system', () => {
|
||||||
if (data) integrations.value = data
|
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) }
|
||||||
|
)
|
||||||
|
if (error || !data?.ok) {
|
||||||
|
return { ok: false, error: data?.error ?? 'Connection failed' }
|
||||||
|
}
|
||||||
|
await loadIntegrations()
|
||||||
|
return { ok: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
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) }
|
||||||
|
)
|
||||||
|
return { ok: data?.ok ?? false, error: data?.error ?? (error ? 'Test failed' : undefined) }
|
||||||
|
}
|
||||||
|
|
||||||
|
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() {
|
async function loadFilePaths() {
|
||||||
const { data } = await useApiFetch<Record<string, string>>('/api/settings/system/paths')
|
const { data } = await useApiFetch<Record<string, string>>('/api/settings/system/paths')
|
||||||
if (data) filePaths.value = data
|
if (data) filePaths.value = data
|
||||||
|
|
@ -191,8 +231,8 @@ export const useSystemStore = defineStore('settings/system', () => {
|
||||||
services, emailConfig, integrations, serviceErrors, emailSaving, emailError,
|
services, emailConfig, integrations, serviceErrors, emailSaving, emailError,
|
||||||
filePaths, deployConfig, filePathsSaving, deploySaving,
|
filePaths, deployConfig, filePathsSaving, deploySaving,
|
||||||
loadServices, startService, stopService,
|
loadServices, startService, stopService,
|
||||||
loadEmail, saveEmail, testEmail,
|
loadEmail, saveEmail, testEmail, saveEmailWithPassword,
|
||||||
loadIntegrations,
|
loadIntegrations, connectIntegration, testIntegration, disconnectIntegration,
|
||||||
loadFilePaths, saveFilePaths,
|
loadFilePaths, saveFilePaths,
|
||||||
loadDeployConfig, saveDeployConfig,
|
loadDeployConfig, saveDeployConfig,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -86,8 +86,12 @@
|
||||||
</div>
|
</div>
|
||||||
<div class="field-row">
|
<div class="field-row">
|
||||||
<label>Password / App Password</label>
|
<label>Password / App Password</label>
|
||||||
<input v-model="(store.emailConfig as any).password" type="password" />
|
<input
|
||||||
<span class="field-hint">Gmail: use an App Password, not your account password.</span>
|
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>
|
||||||
<div class="field-row">
|
<div class="field-row">
|
||||||
<label>Sent Folder</label>
|
<label>Sent Folder</label>
|
||||||
|
|
@ -98,7 +102,7 @@
|
||||||
<input v-model.number="(store.emailConfig as any).lookback_days" type="number" placeholder="30" />
|
<input v-model.number="(store.emailConfig as any).lookback_days" type="number" placeholder="30" />
|
||||||
</div>
|
</div>
|
||||||
<div class="form-actions">
|
<div class="form-actions">
|
||||||
<button @click="store.saveEmail()" :disabled="store.emailSaving" class="btn-primary">
|
<button @click="handleSaveEmail()" :disabled="store.emailSaving" class="btn-primary">
|
||||||
{{ store.emailSaving ? 'Saving…' : 'Save Email Config' }}
|
{{ store.emailSaving ? 'Saving…' : 'Save Email Config' }}
|
||||||
</button>
|
</button>
|
||||||
<button @click="handleTestEmail" class="btn-secondary">Test Connection</button>
|
<button @click="handleTestEmail" class="btn-secondary">Test Connection</button>
|
||||||
|
|
@ -116,24 +120,39 @@
|
||||||
<div v-for="integration in store.integrations" :key="integration.id" class="integration-card">
|
<div v-for="integration in store.integrations" :key="integration.id" class="integration-card">
|
||||||
<div class="integration-header">
|
<div class="integration-header">
|
||||||
<span class="integration-name">{{ integration.name }}</span>
|
<span class="integration-name">{{ integration.name }}</span>
|
||||||
<span :class="['status-badge', integration.connected ? 'badge-connected' : 'badge-disconnected']">
|
<div class="integration-badges">
|
||||||
{{ integration.connected ? 'Connected' : 'Disconnected' }}
|
<span v-if="!meetsRequiredTier(integration.tier_required)" class="tier-badge">
|
||||||
</span>
|
Requires {{ integration.tier_required }}
|
||||||
</div>
|
</span>
|
||||||
<div v-if="!integration.connected" class="integration-form">
|
<span :class="['status-badge', integration.connected ? 'badge-connected' : 'badge-disconnected']">
|
||||||
<div v-for="field in integration.fields" :key="field.key" class="field-row">
|
{{ integration.connected ? 'Connected' : 'Disconnected' }}
|
||||||
<label>{{ field.label }}</label>
|
</span>
|
||||||
<input v-model="integrationInputs[integration.id + ':' + field.key]"
|
|
||||||
:type="field.type === 'password' ? 'password' : 'text'" />
|
|
||||||
</div>
|
|
||||||
<div class="form-actions">
|
|
||||||
<button @click="connectIntegration(integration.id)" class="btn-primary">Connect</button>
|
|
||||||
<button @click="testIntegration(integration.id)" class="btn-secondary">Test</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-else>
|
<!-- Locked state for insufficient tier -->
|
||||||
<button @click="disconnectIntegration(integration.id)" class="btn-danger">Disconnect</button>
|
<div v-if="!meetsRequiredTier(integration.tier_required)" class="tier-locked">
|
||||||
|
<p>Upgrade to {{ integration.tier_required }} to use this integration.</p>
|
||||||
</div>
|
</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="integrationResults[integration.id]" :class="integrationResults[integration.id].ok ? 'test-ok' : 'test-fail'">
|
||||||
|
{{ integrationResults[integration.id].ok ? '✓ OK' : '✗ ' + integrationResults[integration.id].error }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-else>
|
||||||
|
<button @click="store.disconnectIntegration(integration.id)" class="btn-danger">Disconnect</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|
@ -215,12 +234,13 @@
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted } from 'vue'
|
import { ref, computed, onMounted } from 'vue'
|
||||||
|
import { storeToRefs } from 'pinia'
|
||||||
import { useSystemStore } from '../../stores/settings/system'
|
import { useSystemStore } from '../../stores/settings/system'
|
||||||
import { useAppConfigStore } from '../../stores/appConfig'
|
import { useAppConfigStore } from '../../stores/appConfig'
|
||||||
import { useApiFetch } from '../../composables/useApi'
|
|
||||||
|
|
||||||
const store = useSystemStore()
|
const store = useSystemStore()
|
||||||
const config = useAppConfigStore()
|
const config = useAppConfigStore()
|
||||||
|
const { tier } = storeToRefs(config)
|
||||||
|
|
||||||
const byokConfirmed = ref(false)
|
const byokConfirmed = ref(false)
|
||||||
const dragIdx = ref<number | null>(null)
|
const dragIdx = ref<number | null>(null)
|
||||||
|
|
@ -233,6 +253,11 @@ const visibleBackends = computed(() =>
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const tierOrder = ['free', 'paid', 'premium', 'ultra']
|
||||||
|
function meetsRequiredTier(required: string): boolean {
|
||||||
|
return tierOrder.indexOf(tier.value) >= tierOrder.indexOf(required || 'free')
|
||||||
|
}
|
||||||
|
|
||||||
function dragStart(idx: number) {
|
function dragStart(idx: number) {
|
||||||
dragIdx.value = idx
|
dragIdx.value = idx
|
||||||
}
|
}
|
||||||
|
|
@ -261,42 +286,40 @@ async function handleConfirmByok() {
|
||||||
}
|
}
|
||||||
|
|
||||||
const emailTestResult = ref<boolean | null>(null)
|
const emailTestResult = ref<boolean | null>(null)
|
||||||
|
const emailPasswordInput = ref('')
|
||||||
const integrationInputs = ref<Record<string, string>>({})
|
const integrationInputs = ref<Record<string, string>>({})
|
||||||
|
const integrationResults = ref<Record<string, {ok: boolean; error?: string}>>({})
|
||||||
|
|
||||||
async function handleTestEmail() {
|
async function handleTestEmail() {
|
||||||
const result = await store.testEmail()
|
const result = await store.testEmail()
|
||||||
emailTestResult.value = result?.ok ?? false
|
emailTestResult.value = result?.ok ?? false
|
||||||
}
|
}
|
||||||
|
|
||||||
async function connectIntegration(id: string) {
|
async function handleSaveEmail() {
|
||||||
const integration = store.integrations.find(i => i.id === id)
|
const payload = { ...store.emailConfig, password: emailPasswordInput.value || undefined }
|
||||||
if (!integration) return
|
await store.saveEmailWithPassword(payload)
|
||||||
const payload: Record<string, string> = {}
|
|
||||||
for (const field of integration.fields) {
|
|
||||||
payload[field.key] = integrationInputs.value[`${id}:${field.key}`] ?? ''
|
|
||||||
}
|
|
||||||
const { data } = await useApiFetch<{ok: boolean; error?: string}>(
|
|
||||||
`/api/settings/system/integrations/${id}/connect`,
|
|
||||||
{ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }
|
|
||||||
)
|
|
||||||
if (data?.ok) await store.loadIntegrations()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function testIntegration(id: string) {
|
async function handleConnect(id: string) {
|
||||||
const integration = store.integrations.find(i => i.id === id)
|
const integration = store.integrations.find(i => i.id === id)
|
||||||
if (!integration) return
|
if (!integration) return
|
||||||
const payload: Record<string, string> = {}
|
const credentials: Record<string, string> = {}
|
||||||
for (const field of integration.fields) {
|
for (const field of integration.fields) {
|
||||||
payload[field.key] = integrationInputs.value[`${id}:${field.key}`] ?? ''
|
credentials[field.key] = integrationInputs.value[`${id}:${field.key}`] ?? ''
|
||||||
}
|
}
|
||||||
await useApiFetch(`/api/settings/system/integrations/${id}/test`,
|
const result = await store.connectIntegration(id, credentials)
|
||||||
{ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }
|
integrationResults.value = { ...integrationResults.value, [id]: result }
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function disconnectIntegration(id: string) {
|
async function handleTest(id: string) {
|
||||||
await useApiFetch(`/api/settings/system/integrations/${id}/disconnect`, { method: 'POST' })
|
const integration = store.integrations.find(i => i.id === id)
|
||||||
await store.loadIntegrations()
|
if (!integration) return
|
||||||
|
const credentials: Record<string, string> = {}
|
||||||
|
for (const field of integration.fields) {
|
||||||
|
credentials[field.key] = integrationInputs.value[`${id}:${field.key}`] ?? ''
|
||||||
|
}
|
||||||
|
const result = await store.testIntegration(id, credentials)
|
||||||
|
integrationResults.value = { ...integrationResults.value, [id]: result }
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
|
|
@ -367,4 +390,7 @@ h3 { font-size: 1rem; font-weight: 600; margin-bottom: var(--space-3, 16px); col
|
||||||
.badge-connected { background: rgba(34,197,94,0.15); color: #22c55e; border: 1px solid rgba(34,197,94,0.3); }
|
.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); }
|
.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; }
|
.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>
|
</style>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue