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:
|
||||
conda run -n job-seeker uvicorn dev-api:app --port 8600 --reload
|
||||
"""
|
||||
import sqlite3
|
||||
import os
|
||||
import sys
|
||||
import re
|
||||
import imaplib
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import socket
|
||||
import sqlite3
|
||||
import ssl as ssl_mod
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
from urllib.parse import urlparse
|
||||
from bs4 import BeautifulSoup
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Optional, List
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import requests
|
||||
import yaml
|
||||
from bs4 import BeautifulSoup
|
||||
from fastapi import FastAPI, HTTPException, Response, UploadFile
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional, List
|
||||
import requests
|
||||
|
||||
# Allow importing peregrine scripts for cover letter generation
|
||||
PEREGRINE_ROOT = Path("/Library/Development/CircuitForge/peregrine")
|
||||
if str(PEREGRINE_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(PEREGRINE_ROOT))
|
||||
|
||||
from scripts.credential_store import get_credential, set_credential, delete_credential # noqa: E402
|
||||
|
||||
DB_PATH = os.environ.get("STAGING_DB", "/devl/job-seeker/staging.db")
|
||||
|
||||
app = FastAPI(title="Peregrine Dev API")
|
||||
|
|
@ -1196,9 +1204,6 @@ def byok_ack(payload: ByokAckPayload):
|
|||
|
||||
# ── Settings: System — Services ───────────────────────────────────────────────
|
||||
|
||||
import subprocess
|
||||
import socket
|
||||
|
||||
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"]},
|
||||
|
|
@ -1261,15 +1266,25 @@ def stop_service(name: str):
|
|||
# ── 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:
|
||||
if not EMAIL_PATH.exists():
|
||||
return {}
|
||||
with open(EMAIL_PATH) as f:
|
||||
return yaml.safe_load(f) or {}
|
||||
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))
|
||||
|
||||
|
|
@ -1278,10 +1293,16 @@ def get_email_config():
|
|||
def save_email_config(payload: dict):
|
||||
try:
|
||||
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)
|
||||
with os.fdopen(fd, 'w') as f:
|
||||
yaml.dump(dict(payload), f, allow_unicode=True, default_flow_style=False)
|
||||
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))
|
||||
|
|
@ -1289,13 +1310,15 @@ def save_email_config(payload: dict):
|
|||
|
||||
@app.post("/api/settings/system/email/test")
|
||||
def test_email(payload: dict):
|
||||
import imaplib, ssl as ssl_mod
|
||||
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", "")
|
||||
port = int(payload.get("port", 993))
|
||||
use_ssl = payload.get("ssl", True)
|
||||
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:
|
||||
ctx = ssl_mod.create_default_context()
|
||||
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
|
||||
}
|
||||
|
||||
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() {
|
||||
const { data } = await useApiFetch<Record<string, string>>('/api/settings/system/paths')
|
||||
if (data) filePaths.value = data
|
||||
|
|
@ -191,8 +231,8 @@ export const useSystemStore = defineStore('settings/system', () => {
|
|||
services, emailConfig, integrations, serviceErrors, emailSaving, emailError,
|
||||
filePaths, deployConfig, filePathsSaving, deploySaving,
|
||||
loadServices, startService, stopService,
|
||||
loadEmail, saveEmail, testEmail,
|
||||
loadIntegrations,
|
||||
loadEmail, saveEmail, testEmail, saveEmailWithPassword,
|
||||
loadIntegrations, connectIntegration, testIntegration, disconnectIntegration,
|
||||
loadFilePaths, saveFilePaths,
|
||||
loadDeployConfig, saveDeployConfig,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -86,8 +86,12 @@
|
|||
</div>
|
||||
<div class="field-row">
|
||||
<label>Password / App Password</label>
|
||||
<input v-model="(store.emailConfig as any).password" type="password" />
|
||||
<span class="field-hint">Gmail: use an App Password, not your account password.</span>
|
||||
<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>
|
||||
|
|
@ -98,7 +102,7 @@
|
|||
<input v-model.number="(store.emailConfig as any).lookback_days" type="number" placeholder="30" />
|
||||
</div>
|
||||
<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' }}
|
||||
</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 class="integration-header">
|
||||
<span class="integration-name">{{ integration.name }}</span>
|
||||
<span :class="['status-badge', integration.connected ? 'badge-connected' : 'badge-disconnected']">
|
||||
{{ integration.connected ? 'Connected' : 'Disconnected' }}
|
||||
</span>
|
||||
</div>
|
||||
<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="connectIntegration(integration.id)" class="btn-primary">Connect</button>
|
||||
<button @click="testIntegration(integration.id)" class="btn-secondary">Test</button>
|
||||
<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>
|
||||
<div v-else>
|
||||
<button @click="disconnectIntegration(integration.id)" class="btn-danger">Disconnect</button>
|
||||
<!-- 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="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>
|
||||
</section>
|
||||
|
||||
|
|
@ -215,12 +234,13 @@
|
|||
|
||||
<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'
|
||||
import { useApiFetch } from '../../composables/useApi'
|
||||
|
||||
const store = useSystemStore()
|
||||
const config = useAppConfigStore()
|
||||
const { tier } = storeToRefs(config)
|
||||
|
||||
const byokConfirmed = ref(false)
|
||||
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) {
|
||||
dragIdx.value = idx
|
||||
}
|
||||
|
|
@ -261,42 +286,40 @@ async function handleConfirmByok() {
|
|||
}
|
||||
|
||||
const emailTestResult = ref<boolean | null>(null)
|
||||
const emailPasswordInput = ref('')
|
||||
const integrationInputs = ref<Record<string, string>>({})
|
||||
const integrationResults = ref<Record<string, {ok: boolean; error?: string}>>({})
|
||||
|
||||
async function handleTestEmail() {
|
||||
const result = await store.testEmail()
|
||||
emailTestResult.value = result?.ok ?? false
|
||||
}
|
||||
|
||||
async function connectIntegration(id: string) {
|
||||
const integration = store.integrations.find(i => i.id === id)
|
||||
if (!integration) return
|
||||
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 handleSaveEmail() {
|
||||
const payload = { ...store.emailConfig, password: emailPasswordInput.value || undefined }
|
||||
await store.saveEmailWithPassword(payload)
|
||||
}
|
||||
|
||||
async function testIntegration(id: string) {
|
||||
async function handleConnect(id: string) {
|
||||
const integration = store.integrations.find(i => i.id === id)
|
||||
if (!integration) return
|
||||
const payload: Record<string, string> = {}
|
||||
const credentials: Record<string, string> = {}
|
||||
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`,
|
||||
{ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }
|
||||
)
|
||||
const result = await store.connectIntegration(id, credentials)
|
||||
integrationResults.value = { ...integrationResults.value, [id]: result }
|
||||
}
|
||||
|
||||
async function disconnectIntegration(id: string) {
|
||||
await useApiFetch(`/api/settings/system/integrations/${id}/disconnect`, { method: 'POST' })
|
||||
await store.loadIntegrations()
|
||||
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}`] ?? ''
|
||||
}
|
||||
const result = await store.testIntegration(id, credentials)
|
||||
integrationResults.value = { ...integrationResults.value, [id]: result }
|
||||
}
|
||||
|
||||
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-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