Skip to content

Commit 500b0d3

Browse files
committed
feat(settings): API settings PATCH+action + pagina /settings con tab Generale/Notifiche/Sicurezza/Danger Zone
2 parents 7ad558e + 8479943 commit 500b0d3

2 files changed

Lines changed: 193 additions & 160 deletions

File tree

web/app/api/settings/route.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,11 +36,58 @@ export async function GET() {
3636
}
3737
}
3838

39+
export async function PATCH(req: NextRequest) {
40+
let body: Record<string, unknown>
41+
try { body = await req.json() }
42+
catch { return NextResponse.json({ error: 'body non valido' }, { status: 400 }) }
43+
44+
let existing: Record<string, unknown> = {}
45+
if (fs.existsSync(CONFIG_PATH)) {
46+
try { existing = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8')) } catch { /* usa default */ }
47+
}
48+
const app = (body.app ?? {}) as Record<string, unknown>
49+
const notif = (body.notifications ?? {}) as Record<string, unknown>
50+
const updated = {
51+
...existing,
52+
app: { ...(existing.app as Record<string, unknown> ?? {}), ...app },
53+
notifications: { ...(existing.notifications as Record<string, unknown> ?? {}), ...notif },
54+
}
55+
try {
56+
fs.mkdirSync(CONFIG_DIR, { recursive: true })
57+
fs.writeFileSync(CONFIG_PATH, JSON.stringify(updated, null, 2) + '\n', 'utf-8')
58+
return NextResponse.json({ ok: true })
59+
} catch (err) {
60+
return NextResponse.json({ error: err instanceof Error ? err.message : 'errore' }, { status: 500 })
61+
}
62+
}
63+
3964
export async function POST(req: NextRequest) {
4065
let body: Record<string, unknown>
4166
try { body = await req.json() }
4267
catch { return NextResponse.json({ error: 'body non valido' }, { status: 400 }) }
4368

69+
// Danger zone actions
70+
if (body._action === 'reset_config') {
71+
try {
72+
const defaults = { version: 1, providers: {}, channels: {}, workspace: CONFIG_DIR, cron_enabled: false }
73+
fs.mkdirSync(CONFIG_DIR, { recursive: true })
74+
fs.writeFileSync(CONFIG_PATH, JSON.stringify(defaults, null, 2) + '\n', 'utf-8')
75+
return NextResponse.json({ ok: true })
76+
} catch (err) {
77+
return NextResponse.json({ error: err instanceof Error ? err.message : 'errore reset' }, { status: 500 })
78+
}
79+
}
80+
if (body._action === 'clear_cache') {
81+
const cacheDir = path.join(CONFIG_DIR, 'cache')
82+
try {
83+
if (fs.existsSync(cacheDir)) fs.rmSync(cacheDir, { recursive: true, force: true })
84+
fs.mkdirSync(cacheDir, { recursive: true })
85+
return NextResponse.json({ ok: true })
86+
} catch (err) {
87+
return NextResponse.json({ error: err instanceof Error ? err.message : 'errore cache' }, { status: 500 })
88+
}
89+
}
90+
4491
// Leggi config esistente come base
4592
let existing: Record<string, unknown> = { version: 1, providers: {}, channels: {}, workspace: CONFIG_DIR }
4693
if (fs.existsSync(CONFIG_PATH)) {

web/app/settings/page.tsx

Lines changed: 146 additions & 160 deletions
Original file line numberDiff line numberDiff line change
@@ -1,190 +1,176 @@
11
'use client'
22

3-
import { useEffect, useState } from 'react'
4-
import { Card, Field, inputCls, btnPrimary, btnSecondary } from '../setup/ui'
3+
import { useState, useEffect, useCallback } from 'react'
4+
import { Tabs, Tab } from '../components/Tabs'
5+
import { useToast } from '../components/Toast'
56

6-
type TgStatus = { configured: boolean; connected: boolean; running: boolean; botUsername: string | null; botName: string | null; mode: string | null }
7+
type NotifKey = 'telegram' | 'email' | 'desktop'
8+
type Settings = { app_name: string; language: string; notifications: Record<NotifKey, boolean> }
79

8-
function TelegramStatusWidget() {
9-
const [s, setS] = useState<TgStatus | null>(null)
10-
useEffect(() => {
11-
const load = () => fetch('/api/telegram/status').then(r => r.json()).then(setS).catch(() => null)
12-
load()
13-
const id = setInterval(load, 15_000)
14-
return () => clearInterval(id)
15-
}, [])
16-
if (!s) return <p className="text-[10px]" style={{ color: 'var(--color-dim)' }}>Verifica stato…</p>
17-
const dot = s.connected ? 'var(--color-green)' : s.configured ? 'var(--color-yellow)' : 'var(--color-dim)'
18-
const label = s.connected ? 'connesso' : s.configured ? 'token non valido' : 'non configurato'
10+
const DEFAULTS: Settings = { app_name: 'Job Hunter Team', language: 'it', notifications: { telegram: true, email: false, desktop: false } }
11+
12+
const inp: React.CSSProperties = {
13+
border: '1px solid var(--color-border)', background: 'var(--color-card)',
14+
color: 'var(--color-bright)', borderRadius: 6, fontSize: 11,
15+
padding: '6px 10px', outline: 'none', fontFamily: 'var(--font-mono)', width: '100%',
16+
}
17+
18+
function Row({ label, hint, children }: { label: string; hint?: string; children: React.ReactNode }) {
1919
return (
20-
<div className="flex flex-col gap-1.5 px-3 py-2.5 rounded border text-[11px]"
21-
style={{ borderColor: s.connected ? 'rgba(0,232,122,0.2)' : 'var(--color-border)', background: 'var(--color-card)' }}>
22-
<div className="flex items-center gap-2">
23-
<span style={{ color: dot, animation: s.connected ? 'pulse-dot 2.5s ease-in-out infinite' : undefined }}></span>
24-
<span style={{ color: dot }}>{label}</span>
25-
{s.running && <span className="ml-auto text-[9px] px-1.5 py-0.5 rounded border" style={{ color: 'var(--color-blue)', borderColor: 'rgba(77,159,255,0.25)' }}>tmux attivo</span>}
26-
</div>
27-
{s.botUsername && <p className="font-mono text-[10px]" style={{ color: 'var(--color-muted)' }}>@{s.botUsername} · {s.botName}</p>}
20+
<div className="flex flex-col gap-1.5">
21+
<label className="text-[11px] font-semibold" style={{ color: 'var(--color-muted)' }}>{label}</label>
22+
{hint && <p className="text-[9px]" style={{ color: 'var(--color-dim)' }}>{hint}</p>}
23+
{children}
2824
</div>
2925
)
3026
}
3127

32-
type Provider = 'claude' | 'openai' | 'minimax'
33-
34-
interface SettingsForm {
35-
activeProvider: Provider
36-
apiKeys: Record<Provider, string>
37-
telegramEnabled: boolean
38-
botToken: string
39-
chatId: string
40-
cronEnabled: boolean
28+
function Toggle({ checked, label, onChange }: { checked: boolean; label: string; onChange: (v: boolean) => void }) {
29+
return (
30+
<label className="flex items-center gap-3 cursor-pointer select-none" onClick={() => onChange(!checked)}>
31+
<div className="relative w-9 h-5 rounded-full transition-colors"
32+
style={{ background: checked ? 'var(--color-green)' : 'var(--color-border)' }}>
33+
<div className="absolute top-0.5 w-4 h-4 rounded-full transition-transform"
34+
style={{ background: 'white', transform: checked ? 'translateX(18px)' : 'translateX(2px)' }} />
35+
</div>
36+
<span className="text-[11px]" style={{ color: 'var(--color-muted)' }}>{label}</span>
37+
</label>
38+
)
4139
}
4240

43-
const EMPTY: SettingsForm = {
44-
activeProvider: 'claude',
45-
apiKeys: { claude: '', openai: '', minimax: '' },
46-
telegramEnabled: false,
47-
botToken: '',
48-
chatId: '',
49-
cronEnabled: false,
41+
function SaveBtn({ busy, onClick }: { busy: boolean; onClick: () => void }) {
42+
return (
43+
<button onClick={onClick} disabled={busy} className="self-start px-5 py-2 rounded text-[11px] font-bold cursor-pointer transition-all"
44+
style={{ background: busy ? 'var(--color-border)' : 'var(--color-green)', color: busy ? 'var(--color-dim)' : 'var(--color-void)', border: 'none' }}>
45+
{busy ? 'Salvataggio…' : 'Salva'}
46+
</button>
47+
)
5048
}
5149

52-
const PROVIDER_LABELS: Record<Provider, string> = {
53-
claude: 'Claude (Anthropic)',
54-
openai: 'OpenAI',
55-
minimax: 'MiniMax',
56-
}
50+
type TabId = 'general' | 'notifications' | 'security' | 'danger'
5751

5852
export default function SettingsPage() {
59-
const [form, setForm] = useState<SettingsForm>(EMPTY)
60-
const [status, setStatus] = useState<'idle' | 'loading' | 'saving' | 'ok' | 'error'>('loading')
61-
const [errMsg, setErrMsg] = useState('')
53+
const [s, setS] = useState<Settings>(DEFAULTS)
54+
const [loading, setL] = useState(true)
55+
const [busy, setBusy] = useState(false)
56+
const [tab, setTab] = useState<TabId>('general')
57+
const { addToast } = useToast()
6258

6359
useEffect(() => {
64-
fetch('/api/settings')
65-
.then(r => r.json())
66-
.then(({ config }) => {
67-
if (!config) { setStatus('idle'); return }
68-
setForm({
69-
activeProvider: config.active_provider ?? 'claude',
70-
apiKeys: {
71-
claude: config.providers?.claude?.api_key ?? '',
72-
openai: config.providers?.openai?.api_key ?? '',
73-
minimax: config.providers?.minimax?.api_key ?? '',
74-
},
75-
telegramEnabled: !!config.channels?.telegram?.bot_token,
76-
botToken: config.channels?.telegram?.bot_token ?? '',
77-
chatId: config.channels?.telegram?.chat_id ?? '',
78-
cronEnabled: config.cron_enabled ?? false,
79-
})
80-
setStatus('idle')
60+
fetch('/api/settings').then(r => r.json()).then(({ config }) => {
61+
if (config) setS({
62+
app_name: config.app?.name ?? DEFAULTS.app_name,
63+
language: config.app?.language ?? DEFAULTS.language,
64+
notifications: { ...DEFAULTS.notifications, ...config.notifications },
8165
})
82-
.catch(() => setStatus('idle'))
66+
setL(false)
67+
}).catch(() => setL(false))
8368
}, [])
8469

85-
const set = (p: Partial<SettingsForm>) => setForm(f => ({ ...f, ...p }))
86-
87-
const save = async () => {
88-
setStatus('saving')
89-
const body = {
90-
active_provider: form.activeProvider,
91-
providers: {
92-
[form.activeProvider]: {
93-
name: form.activeProvider,
94-
auth_method: 'api_key',
95-
api_key: form.apiKeys[form.activeProvider],
96-
},
97-
},
98-
channels: form.telegramEnabled ? {
99-
telegram: { bot_token: form.botToken, chat_id: form.chatId },
100-
} : {},
101-
cron_enabled: form.cronEnabled,
102-
}
70+
const save = useCallback(async () => {
71+
setBusy(true)
10372
try {
104-
const r = await fetch('/api/settings', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) })
105-
const data = await r.json()
106-
if (data.ok) { setStatus('ok'); setTimeout(() => setStatus('idle'), 2000) }
107-
else { setErrMsg(data.error ?? 'errore'); setStatus('error') }
108-
} catch { setErrMsg('network error'); setStatus('error') }
109-
}
110-
111-
if (status === 'loading') return (
112-
<main className="min-h-screen flex items-center justify-center">
113-
<p className="text-[11px]" style={{ color: 'var(--color-muted)' }}>Caricamento configurazione…</p>
114-
</main>
115-
)
73+
const r = await fetch('/api/settings', { method: 'PATCH', headers: { 'Content-Type': 'application/json' },
74+
body: JSON.stringify({ app: { name: s.app_name, language: s.language }, notifications: s.notifications }) })
75+
const d = await r.json()
76+
d.ok ? addToast({ type: 'success', message: 'Impostazioni salvate' })
77+
: addToast({ type: 'error', message: d.error ?? 'Errore' })
78+
} catch { addToast({ type: 'error', message: 'Errore di rete' }) }
79+
finally { setBusy(false) }
80+
}, [s, addToast])
81+
82+
const dangerAction = useCallback(async (act: string, label: string) => {
83+
setBusy(true)
84+
try {
85+
const r = await fetch('/api/settings', { method: 'POST', headers: { 'Content-Type': 'application/json' },
86+
body: JSON.stringify({ _action: act }) })
87+
const d = await r.json()
88+
d.ok ? addToast({ type: 'success', message: `${label} completato` })
89+
: addToast({ type: 'error', message: d.error ?? 'Errore' })
90+
} catch { addToast({ type: 'error', message: 'Errore di rete' }) }
91+
finally { setBusy(false) }
92+
}, [addToast])
93+
94+
if (loading) return <main className="p-10"><p className="text-[11px]" style={{ color: 'var(--color-muted)' }}>Caricamento…</p></main>
95+
96+
const TABS: Tab<TabId>[] = [
97+
{ id: 'general', label: 'Generale' },
98+
{ id: 'notifications', label: 'Notifiche' },
99+
{ id: 'security', label: 'Sicurezza' },
100+
{ id: 'danger', label: 'Danger Zone' },
101+
]
116102

117103
return (
118-
<main className="min-h-screen px-5 py-10 flex flex-col items-center">
119-
<div className="w-full max-w-lg flex flex-col gap-6">
120-
121-
<div className="mb-2">
122-
<p className="text-[9px] font-semibold tracking-[0.2em] uppercase mb-1" style={{ color: 'var(--color-green)' }}>impostazioni</p>
123-
<h1 className="text-xl font-bold tracking-tight" style={{ color: 'var(--color-white)' }}>Configurazione</h1>
104+
<main className="min-h-screen px-6 py-10">
105+
<div className="max-w-2xl flex flex-col gap-6">
106+
<div>
107+
<p className="text-[9px] font-semibold tracking-[0.2em] uppercase mb-1" style={{ color: 'var(--color-green)' }}>sistema</p>
108+
<h1 className="text-xl font-bold" style={{ color: 'var(--color-white)' }}>Impostazioni</h1>
124109
</div>
125110

126-
{/* Provider AI */}
127-
<Card title="Provider AI" sub="Provider attivo e API key">
128-
<Field label="Provider attivo">
129-
<select value={form.activeProvider} onChange={e => set({ activeProvider: e.target.value as Provider })}
130-
className={inputCls} style={{ color: 'var(--color-bright)' }}>
131-
{(Object.keys(PROVIDER_LABELS) as Provider[]).map(p => (
132-
<option key={p} value={p}>{PROVIDER_LABELS[p]}</option>
133-
))}
134-
</select>
135-
</Field>
136-
<Field label={`API Key — ${PROVIDER_LABELS[form.activeProvider]}`}>
137-
<input type="password" placeholder="sk-••••••••" className={inputCls}
138-
value={form.apiKeys[form.activeProvider]}
139-
onChange={e => set({ apiKeys: { ...form.apiKeys, [form.activeProvider]: e.target.value } })}
140-
style={{ color: 'var(--color-bright)' }} />
141-
</Field>
142-
</Card>
143-
144-
{/* Telegram */}
145-
<Card title="Telegram" sub="Notifiche e comandi via bot">
146-
<TelegramStatusWidget />
147-
<label className="flex items-center gap-3 cursor-pointer">
148-
<input type="checkbox" checked={form.telegramEnabled}
149-
onChange={e => set({ telegramEnabled: e.target.checked })} />
150-
<span className="text-[11px]" style={{ color: 'var(--color-muted)' }}>Abilita integrazione Telegram</span>
151-
</label>
152-
{form.telegramEnabled && <>
153-
<Field label="Bot Token">
154-
<input type="password" placeholder="123456:ABC-DEF…" className={inputCls}
155-
value={form.botToken} onChange={e => set({ botToken: e.target.value })}
156-
style={{ color: 'var(--color-bright)' }} />
157-
</Field>
158-
<Field label="Chat ID">
159-
<input type="text" placeholder="-100123456789" className={inputCls}
160-
value={form.chatId} onChange={e => set({ chatId: e.target.value })}
161-
style={{ color: 'var(--color-bright)' }} />
162-
</Field>
111+
<Tabs tabs={TABS} active={tab} onChange={setTab} />
112+
113+
<div className="flex flex-col gap-5 pt-1">
114+
{tab === 'general' && <>
115+
<Row label="Nome applicazione">
116+
<input style={inp} value={s.app_name} onChange={e => setS(p => ({ ...p, app_name: e.target.value }))} />
117+
</Row>
118+
<Row label="Lingua default">
119+
<select style={inp} value={s.language} onChange={e => setS(p => ({ ...p, language: e.target.value }))}>
120+
<option value="it">Italiano</option>
121+
<option value="en">English</option>
122+
</select>
123+
</Row>
124+
<SaveBtn busy={busy} onClick={save} />
163125
</>}
164-
</Card>
165-
166-
{/* Cron */}
167-
<Card title="Cron Jobs" sub="Esecuzione automatica schedulata">
168-
<label className="flex items-center gap-3 cursor-pointer">
169-
<input type="checkbox" checked={form.cronEnabled}
170-
onChange={e => set({ cronEnabled: e.target.checked })} />
171-
<span className="text-[11px]" style={{ color: 'var(--color-muted)' }}>Abilita cron jobs automatici</span>
172-
</label>
173-
</Card>
174-
175-
{/* Azioni */}
176-
<div className="flex gap-3">
177-
<a href="/" className="flex-1 py-2.5 rounded text-[12px] font-semibold text-center cursor-pointer" style={btnSecondary}>
178-
Annulla
179-
</a>
180-
<button onClick={save} disabled={status === 'saving'}
181-
className="flex-1 py-2.5 rounded text-[12px] font-bold cursor-pointer transition-all"
182-
style={status === 'saving' ? { background: 'var(--color-border)', color: 'var(--color-dim)' } : btnPrimary}>
183-
{status === 'saving' ? 'Salvataggio…' : status === 'ok' ? '✓ Salvato' : 'Salva configurazione'}
184-
</button>
185-
</div>
186-
{status === 'error' && <p className="text-[10px] text-center" style={{ color: 'var(--color-red)' }}>{errMsg}</p>}
187126

127+
{tab === 'notifications' && <>
128+
<p className="text-[10px] font-bold uppercase tracking-widest" style={{ color: 'var(--color-dim)' }}>Canali</p>
129+
{(['telegram', 'email', 'desktop'] as NotifKey[]).map(k => (
130+
<Toggle key={k} checked={s.notifications[k]}
131+
label={k === 'telegram' ? 'Telegram' : k === 'email' ? 'Email' : 'Desktop (browser)'}
132+
onChange={v => setS(p => ({ ...p, notifications: { ...p.notifications, [k]: v } }))} />
133+
))}
134+
<SaveBtn busy={busy} onClick={save} />
135+
</>}
136+
137+
{tab === 'security' && <>
138+
<Row label="Nuova password" hint="Disponibile in una prossima versione">
139+
<input type="password" style={{ ...inp, opacity: 0.4, cursor: 'not-allowed' }} disabled placeholder="••••••••" />
140+
</Row>
141+
<div className="flex items-center gap-3 px-3 py-2.5 rounded"
142+
style={{ border: '1px solid var(--color-border)', background: 'var(--color-card)' }}>
143+
<span className="text-[9px] px-2 py-0.5 rounded"
144+
style={{ background: 'rgba(255,196,0,0.1)', color: 'var(--color-yellow)', border: '1px solid rgba(255,196,0,0.2)' }}>
145+
Presto disponibile
146+
</span>
147+
<p className="text-[10px]" style={{ color: 'var(--color-dim)' }}>2FA via TOTP (Google Authenticator)</p>
148+
</div>
149+
</>}
150+
151+
{tab === 'danger' && <>
152+
<div className="px-4 py-3 rounded" style={{ background: 'rgba(255,69,96,0.06)', border: '1px solid rgba(255,69,96,0.2)' }}>
153+
<p className="text-[10px] font-bold" style={{ color: 'var(--color-red)' }}>Attenzione — azioni irreversibili</p>
154+
</div>
155+
{[
156+
{ act: 'reset_config', label: 'Reset configurazione', desc: 'Ripristina jht.config.json ai valori di default' },
157+
{ act: 'clear_cache', label: 'Svuota cache', desc: 'Elimina tutti i file in ~/.jht/cache/' },
158+
].map(({ act, label, desc }) => (
159+
<div key={act} className="flex items-center justify-between gap-4 px-4 py-3 rounded"
160+
style={{ border: '1px solid var(--color-border)', background: 'var(--color-card)' }}>
161+
<div>
162+
<p className="text-[11px] font-semibold" style={{ color: 'var(--color-muted)' }}>{label}</p>
163+
<p className="text-[10px]" style={{ color: 'var(--color-dim)' }}>{desc}</p>
164+
</div>
165+
<button onClick={() => dangerAction(act, label)} disabled={busy}
166+
className="px-3 py-1.5 rounded text-[10px] font-bold cursor-pointer flex-shrink-0 transition-all"
167+
style={{ border: '1px solid rgba(255,69,96,0.4)', color: 'var(--color-red)', background: 'transparent' }}>
168+
{label}
169+
</button>
170+
</div>
171+
))}
172+
</>}
173+
</div>
188174
</div>
189175
</main>
190176
)

0 commit comments

Comments
 (0)