/* Settings.jsx — Налаштування карти, live API. */ const { useState: useStateSt, useEffect: useEffectSt } = React; const PROVIDERS = [ { id: "maptiler", name: "MapTiler", desc: "Рекомендовано · GDPR-compliant · Україна є" }, { id: "mapbox", name: "Mapbox", desc: "Преміум якість · платний понад 50k req/місяць" }, { id: "osm", name: "OpenStreetMap (tile.openstreetmap.org)", desc: "Безкоштовно · обмеження rate-limit" }, ]; const FALLBACKS = [ { id: "auto", label: "Авто", desc: "При недоступності провайдера — перемкнутись на OSM автоматично" }, { id: "manual", label: "Ручне перемикання", desc: "Адмін отримує алерт і вирішує вручну" }, { id: "coords", label: "Координатний режим", desc: "Карта прихована, всі дії — через lat/lon поля" }, ]; function Settings() { const [loaded, setLoaded] = useStateSt(false); const [error, setError] = useStateSt(null); const [provider, setProvider] = useStateSt("maptiler"); const [apiKey, setApiKey] = useStateSt(""); // raw input — pristine empty if not changed const [hasKey, setHasKey] = useStateSt(false); // backend reports {set: true} const [keyPreview, setKeyPreview] = useStateSt(""); const [showKey, setShowKey] = useStateSt(false); const [fallback, setFallback] = useStateSt("auto"); const [saving, setSaving] = useStateSt(false); const [saved, setSaved] = useStateSt(false); const reload = async () => { try { const res = await window.passengerApi.settings.get(); const it = res.items || {}; setProvider(it["map.provider"] || "maptiler"); setFallback(it["map.fallback_policy"] || "auto"); const keyMeta = it["map.api_key"] || { set: false, preview: "" }; setHasKey(!!keyMeta.set); setKeyPreview(keyMeta.preview || ""); setApiKey(""); // raw never returned setError(null); setLoaded(true); } catch (e) { setError(e); window.showToast && window.showToast("Не вдалося завантажити: " + (e.message || ""), "bad"); } }; useEffectSt(() => { reload(); }, []); const handleSave = async () => { setSaving(true); setSaved(false); try { const items = { "map.provider": provider, "map.fallback_policy": fallback, }; const rotatesSecret = !!apiKey.trim(); if (rotatesSecret) items["map.api_key"] = apiKey.trim(); let confirm_password = null; if (rotatesSecret && window.confirmPassword) { confirm_password = await window.confirmPassword( "Ротація API-ключа карти потребує підтвердження. Введіть свій пароль." ); if (confirm_password === null) { setSaving(false); return; } } await window.passengerApi.settings.save(items, confirm_password); setSaved(true); setApiKey(""); window.showToast && window.showToast("Налаштування збережено", "ok"); await reload(); setTimeout(() => setSaved(false), 3000); } catch (e) { const msg = (e && e.errors && e.errors.confirm_password) || (e && e.message) || ""; window.showToast && window.showToast("Помилка збереження: " + msg, "bad"); } finally { setSaving(false); } }; const handleReset = async () => { setProvider("maptiler"); setFallback("auto"); setApiKey(""); window.showToast && window.showToast("Поля скинуто до дефолтів (натисніть «Зберегти»)", "info"); }; if (!loaded) { return ( <>
Завантаження…
); } return ( <>
{error && (
Помилка завантаження.
)} {/* Provider */}
Tile-провайдер карти
{PROVIDERS.map(p => ( ))}
{/* API Key */}
API-ключ Конфіденційно
{hasKey && !apiKey && (
Поточний ключ: {keyPreview || "(встановлено)"} · введіть нове значення для ротації
)}
setApiKey(e.target.value)} placeholder={hasKey ? "Введіть нове значення для зміни" : "Введіть API-ключ"} style={{ paddingRight: 80 }} />
Зберігається у БД на сервері. Сирое значення ніколи не повертається через API.
{/* Fallback */}
Fallback-режим
{FALLBACKS.map(f => ( ))}
💡 Зміни набувають чинності після збереження. Карта поки не рендериться у Stops/Routes — провайдер фіксується для майбутніх паків.
); } const ROLE_OPTIONS = [ { id: "dispatcher", label: "Диспетчер" }, { id: "engineer", label: "Інженер" }, { id: "admin", label: "Адміністратор" }, ]; function UsersBlock() { const [items, setItems] = useStateSt([]); const [loading, setLoading] = useStateSt(true); const [adding, setAdding] = useStateSt(false); const [form, setForm] = useStateSt({ email: "", role: "engineer", name: "", password: "" }); const [errors, setErrors] = useStateSt({}); const [editing, setEditing] = useStateSt(null); // email being edited const [editForm, setEditForm] = useStateSt({}); const reload = async () => { setLoading(true); try { const res = await window.passengerApi.users.list(); setItems(res.items || []); } catch (e) { window.showToast && window.showToast("Не вдалося завантажити: " + (e.message || ""), "bad"); } finally { setLoading(false); } }; useEffectSt(() => { reload(); }, []); const handleCreate = async () => { setErrors({}); try { await window.passengerApi.users.create({ email: form.email.trim(), role: form.role, name: form.name.trim() || null, password: form.password, }); window.showToast && window.showToast(`Користувача ${form.email} створено`, "ok"); setForm({ email: "", role: "engineer", name: "", password: "" }); setAdding(false); await reload(); } catch (e) { if (e.errors) setErrors(e.errors); else window.showToast && window.showToast("Помилка: " + (e.message || ""), "bad"); } }; const handleSave = async (email) => { try { const orig = items.find(u => u.email === email) || {}; const roleChanging = editForm.role && editForm.role !== orig.role; const pwdRotating = !!editForm.password; let confirm_password = null; if ((roleChanging || pwdRotating) && window.confirmPassword) { const msg = pwdRotating ? `Ротація пароля для ${email} потребує підтвердження. Введіть свій пароль.` : `Зміна ролі для ${email} потребує підтвердження. Введіть свій пароль.`; confirm_password = await window.confirmPassword(msg); if (confirm_password === null) return; } await window.passengerApi.users.update(email, { role: editForm.role, name: editForm.name, password: editForm.password || undefined, confirm_password, }); window.showToast && window.showToast( `${email} оновлено` + (pwdRotating ? " (пароль ротовано)" : ""), "ok" ); setEditing(null); setEditForm({}); await reload(); } catch (e) { if (e.errors) { const msg = Object.values(e.errors).join(", "); window.showToast && window.showToast("Помилка: " + msg, "bad"); } else if (e.status === 409) { window.showToast && window.showToast(e.data?.detail?.errors?.role || "Conflict", "bad"); } else { window.showToast && window.showToast("Помилка: " + (e.message || ""), "bad"); } } }; const handleDelete = async (email) => { if (!window.confirmPassword) return; const confirm_password = await window.confirmPassword( `Видалення користувача ${email} потребує підтвердження. Введіть свій пароль.` ); if (confirm_password === null) return; try { await window.passengerApi.users.remove(email, confirm_password); window.showToast && window.showToast(`${email} видалено`, "warn"); await reload(); } catch (e) { const msg = (e && e.errors && e.errors.confirm_password) || (e && e.message) || (e && e.status === 409 ? "Cannot delete self" : ""); window.showToast && window.showToast("Помилка: " + msg, "bad"); } }; return (
Користувачі панелі {!adding && ( )}
{adding && (
{ setForm(f => ({ ...f, email: e.target.value })); setErrors(er => ({ ...er, email: "" })); }} placeholder="user@passenger.app" /> {errors.email &&
{errors.email}
}
setForm(f => ({ ...f, name: e.target.value }))} placeholder="Іван Петрович" />
{ setForm(f => ({ ...f, password: e.target.value })); setErrors(er => ({ ...er, password: "" })); }} placeholder="••••••••" /> {errors.password &&
{errors.password}
}
)} {loading ? ( ) : items.length === 0 ? ( ) : items.map(u => { const isEdit = editing === u.email; return ( ); })}
EmailРольІм'яОстанній логін
Завантаження…
Немає користувачів
{u.email} {isEdit ? ( ) : ( {ROLE_OPTIONS.find(r => r.id === u.role)?.label || u.role} )} {isEdit ? ( setEditForm(f => ({ ...f, name: e.target.value }))} placeholder="Ім'я" /> ) : (u.name || )} {u.last_login_at ? new Date(u.last_login_at).toLocaleString("uk-UA") : "—"} {isEdit ? ( <> setEditForm(f => ({ ...f, password: e.target.value }))} placeholder="новий пароль (опц.)" />
) : (
)}
); } Object.assign(window, { Settings });