Files
DriverVault/Web App/web/src/views/Settings.vue
T
tajniak81andClaude Opus 5 86ea97b414 Online said out loud, not left as the absence of "Offline"
Settings listed a charger's reachability with one badge, drawn only when
the cloud said online:false. A reachable charger got nothing — the same
nothing a charger the cloud declined to report on gets — so the account's
two chargers read as one offline and one unremarkable. Online now has a
badge of its own, and the silent case stays silent: not saying is not the
same as saying no.

The Charging page said nothing at all, having only the record, which knows
what a charger is and not whether it is answering. So it asks: each
connected service's charger list is read into a live map keyed by the
provider's own id, and the information card carries the badge and, when
the service reports one, the operating state. Asking costs a round trip
per service, so it happens when the home tab is first opened — the moment
the question is being asked — and after that only when the user presses
Refresh or imports a charger. A service that will not answer leaves its
chargers unbadged rather than offline.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 11:22:02 +02:00

1867 lines
77 KiB
Vue

<script setup>
import { ref, computed, watch, onMounted, onBeforeUnmount } from "vue";
import { useRoute, useRouter } from "vue-router";
import { api } from "../api";
import { state, isAdmin, logout, refreshProfile } from "../auth";
import { prefs, applyProfilePrefs } from "../prefs";
import { formatDate, formatMoney } from "../lib/format.js";
import { t, tSplit, TRANSLATED_LANGUAGES } from "../i18n";
import OrgManager from "../components/OrgManager.vue";
import AdminUsers from "../components/AdminUsers.vue";
const router = useRouter();
const route = useRoute();
const loading = ref(true);
const loadError = ref("");
const profile = ref(null);
// Settings is split into tabs: personal account settings, — for admins — user
// management, the organization, and external integrations. The panels stay
// mounted (v-show) so their loaded state and in-flight edits survive a tab
// switch.
//
// `?tab=` picks the starting tab, which is what /admin redirects to.
const ALL_TABS = ["personal", "users", "organization", "integrations"];
const tabs = computed(() => ALL_TABS.filter((tab) => tab !== "users" || isAdmin.value));
const activeTab = ref(ALL_TABS.includes(route.query.tab) ? route.query.tab : "personal");
// The admin gate only settles once the profile is loaded, so a non-admin who
// asked for ?tab=users lands back on the personal tab rather than on nothing.
watch(tabs, (list) => {
if (!list.includes(activeTab.value)) activeTab.value = "personal";
});
// Each integration card folds open/closed, like the plugin rows in the API
// Server panel. Collapsed by default so the Integrations tab reads as a compact
// list of connectors you expand to configure.
const toyotaOpen = ref(false);
const ankerOpen = ref(false);
const greencellOpen = ref(false);
async function load() {
loading.value = true;
loadError.value = "";
try {
profile.value = await refreshProfile();
} catch (e) {
loadError.value = e.message;
} finally {
loading.value = false;
}
}
// --- Account: name ---
const nameDraft = ref("");
const nameSaving = ref(false);
const nameSaved = ref(false);
const nameError = ref("");
function initDrafts() {
nameDraft.value = profile.value.name || "";
bioDraft.value = profile.value.bio || "";
}
async function saveName() {
nameSaving.value = true;
nameError.value = "";
nameSaved.value = false;
try {
profile.value = await api.updateMe({ name: nameDraft.value.trim() });
// Keep the header's displayed name in sync.
state.user = { ...state.user, name: profile.value.name };
localStorage.setItem("cc_user", JSON.stringify(state.user));
nameSaved.value = true;
setTimeout(() => (nameSaved.value = false), 2000);
} catch (e) {
nameError.value = e.message;
} finally {
nameSaving.value = false;
}
}
// --- Account: email verification ---
const verifySending = ref(false);
const verifySent = ref(false);
const verifyError = ref("");
async function sendVerification() {
verifySending.value = true;
verifyError.value = "";
try {
await api.requestVerification();
verifySent.value = true;
} catch (e) {
verifyError.value = e.message;
} finally {
verifySending.value = false;
}
}
// --- Account: password change ---
const oldPassword = ref("");
const newPassword = ref("");
const confirmPassword = ref("");
const passwordSaving = ref(false);
const passwordSaved = ref(false);
const passwordError = ref("");
const passwordMismatch = computed(
() => confirmPassword.value.length > 0 && newPassword.value !== confirmPassword.value
);
async function savePassword() {
passwordError.value = "";
if (newPassword.value.length < 8) {
passwordError.value = t("settings.account.tooShort");
return;
}
if (passwordMismatch.value) {
passwordError.value = t("settings.account.mismatch");
return;
}
passwordSaving.value = true;
try {
await api.changePassword(oldPassword.value, newPassword.value);
oldPassword.value = "";
newPassword.value = "";
confirmPassword.value = "";
passwordSaved.value = true;
setTimeout(() => (passwordSaved.value = false), 2500);
} catch (e) {
passwordError.value = e.message;
} finally {
passwordSaving.value = false;
}
}
// --- Appearance (auto-saves on change) ---
const appearanceError = ref("");
const savingAppearance = ref(false);
async function saveAppearance(patch) {
appearanceError.value = "";
savingAppearance.value = true;
// Apply immediately for a responsive feel; roll back on failure.
const previous = { ...prefs };
applyProfilePrefs({ ...prefs, ...patch });
try {
profile.value = await api.updateMe(patch);
} catch (e) {
applyProfilePrefs(previous);
appearanceError.value = e.message;
} finally {
savingAppearance.value = false;
}
}
const dateFormatExample = computed(() => formatDate(new Date().toISOString()));
const currencyExample = computed(() => formatMoney(1234.5));
// Language and region are two controls over the one stored BCP-47 locale, so
// the pair can be mixed freely (English in Poland, say) rather than being
// limited to the handful of combinations a single list could offer.
//
// Europe here means the sovereign states of the Council of Europe, plus Belarus,
// Russia, Vatican City and Kosovo — geographically European but not members.
// Dependencies (Gibraltar, Faroes, Åland) are left out: they are not countries,
// and the languages/currencies they would add are already covered. US stays on
// for the region list because it was there before this became a Europe list.
const LANGUAGE_CODES = [
"sq", "hy", "az", "eu", "be", "bs", "bg", "ca", "hr", "cs", "da", "nl", "en",
"et", "fi", "fr", "gl", "ka", "de", "el", "hu", "is", "ga", "it", "lv", "lt",
"lb", "mk", "mt", "no", "pl", "pt", "ro", "rm", "ru", "sr", "sk", "sl", "es",
"sv", "tr", "uk", "cy",
];
const REGION_CODES = [
"AD", "AL", "AM", "AT", "AZ", "BA", "BE", "BG", "BY", "CH", "CY", "CZ", "DE",
"DK", "EE", "ES", "FI", "FR", "GB", "GE", "GR", "HR", "HU", "IE", "IS", "IT",
"LI", "LT", "LU", "LV", "MC", "MD", "ME", "MK", "MT", "NL", "NO", "PL", "PT",
"RO", "RS", "RU", "SE", "SI", "SK", "SM", "TR", "UA", "VA", "XK", "US",
];
// Mirrors validCurrencies in the API's me.go and the users.currency select in
// setup-pocketbase.mjs — all three have to list the same codes.
const CURRENCY_CODES = [
"EUR", "GBP", "CHF", "PLN", "CZK", "HUF", "RON", "BGN", "DKK", "SEK", "NOK",
"ISK", "ALL", "AMD", "AZN", "BAM", "BYN", "GEL", "MDL", "MKD", "RSD", "RUB",
"TRY", "UAH", "USD", "CAD", "AUD", "JPY",
];
// Labels come from Intl rather than a hand-kept translation table, so the lists
// read in the user's own language ("Deutschland" once German is picked) and
// sort by what is actually on screen. If a runtime cannot name a code it falls
// back to the code itself, which is still selectable.
function named(codes, type, withCode = false) {
let dn = null;
try {
dn = new Intl.DisplayNames([prefs.locale || "en-US"], { type });
} catch {
dn = null;
}
return codes
.map((code) => {
const name = dn?.of(code) || code;
return { code, label: withCode && name !== code ? `${name} (${code})` : name };
})
.sort((a, b) => a.label.localeCompare(b.label, prefs.locale || undefined));
}
const LANGUAGES = computed(() => named(LANGUAGE_CODES, "language"));
const REGIONS = computed(() => named(REGION_CODES, "region"));
const CURRENCIES = computed(() => named(CURRENCY_CODES, "currency", true));
const language = computed(() => (prefs.locale || "en-US").split("-")[0]);
const region = computed(() => (prefs.locale || "en-US").split("-")[1] || "US");
// The picker offers every European language because the choice also drives date
// and number formatting, which Intl handles for all of them. Only a few have a
// translation file, though, so say so rather than letting someone pick Georgian
// and wonder why the buttons are still English.
const languageTranslated = computed(() => TRANSLATED_LANGUAGES.includes(language.value));
function saveLocale({ lang = language.value, reg = region.value }) {
return saveAppearance({ locale: `${lang}-${reg}` });
}
// --- Profile: avatar + bio ---
const avatarUrl = ref("");
const avatarUploading = ref(false);
const avatarError = ref("");
const fileInput = ref(null);
async function loadAvatar() {
if (!profile.value?.hasAvatar) {
avatarUrl.value = "";
return;
}
try {
const { blob } = await api.getAvatarBlob();
avatarUrl.value = URL.createObjectURL(blob);
} catch {
avatarUrl.value = "";
}
}
function pickAvatar() {
fileInput.value?.click();
}
async function onAvatarChosen(e) {
const file = e.target.files?.[0];
e.target.value = "";
if (!file) return;
avatarUploading.value = true;
avatarError.value = "";
try {
profile.value = await api.uploadAvatar(file);
await loadAvatar();
} catch (err) {
avatarError.value = err.message;
} finally {
avatarUploading.value = false;
}
}
async function removeAvatar() {
avatarUploading.value = true;
avatarError.value = "";
try {
await api.deleteAvatar();
profile.value = { ...profile.value, hasAvatar: false };
avatarUrl.value = "";
} catch (err) {
avatarError.value = err.message;
} finally {
avatarUploading.value = false;
}
}
const bioDraft = ref("");
const bioSaving = ref(false);
const bioSaved = ref(false);
const bioError = ref("");
async function saveBio() {
bioSaving.value = true;
bioError.value = "";
try {
profile.value = await api.updateMe({ bio: bioDraft.value });
bioSaved.value = true;
setTimeout(() => (bioSaved.value = false), 2000);
} catch (e) {
bioError.value = e.message;
} finally {
bioSaving.value = false;
}
}
// --- Integrations: Toyota Connected (per-user, cascading settings) ---
//
// The server resolves a superadmin → org admin → user cascade and returns, per
// field, the effective value (secrets/inherited usernames masked), the caller's
// own-layer value, its source layer, and whether it's locked (set above us).
// An org admin gets a second "org" scope to edit organization-wide defaults.
const toyota = ref(null); // resolved view from the server
const toyotaScope = ref("user"); // "user" | "org" (org admins only)
const toyotaForm = ref({ username: "", password: "", brand: "" });
const toyotaSaving = ref(false);
const toyotaSaved = ref(false);
const toyotaError = ref("");
const toyotaTesting = ref(false);
const toyotaHealth = ref(null); // { status, detail } from the last test
// Superadmins manage the shared (global) layer in the API Server panel; here the
// credential fields are read-only (they may still toggle their own opt-in).
const toyotaReadOnly = computed(() => !!toyota.value?.isSuperadmin);
const toyotaScopeKey = computed(() => (toyota.value?.isSuperadmin ? "user" : toyotaScope.value));
const toyotaScopeData = computed(
() => toyota.value?.scopes?.[toyotaScopeKey.value] || { editableLayer: "user", fields: {} }
);
const toyotaEditingOrg = computed(() => toyotaScopeKey.value === "org");
function toyotaField(k) {
return toyotaScopeData.value.fields?.[k] || { effective: "", own: "", source: "unset", locked: false };
}
// A field is locked when it's set above the caller's editable layer, or the
// whole scope is read-only (superadmin).
function toyotaLocked(k) {
return toyotaReadOnly.value || toyotaField(k).locked;
}
// The toggle reflects the personal opt-in normally, or the org gate in org scope.
const toyotaEnabled = computed(() =>
toyotaEditingOrg.value ? toyota.value?.orgEnabled : toyota.value?.enabled
);
// A friendly "inherited from …" note for a locked field.
function toyotaSourceLabel(k) {
const map = { global: "sourceGlobal", org: "sourceOrg", user: "sourceUser" };
const key = map[toyotaField(k).source] || "sourceGlobal";
return t("settings.integrations.inheritedFrom", { source: t("settings.integrations." + key) });
}
// Load the editable inputs from the current scope's own-layer values. Locked
// fields and the secret password are never prefilled.
function fillToyotaForm() {
const f = toyotaScopeData.value.fields || {};
toyotaForm.value = {
username: f.username?.locked ? "" : f.username?.own || "",
password: "",
brand: f.brand?.locked ? "" : f.brand?.own || "",
};
}
function applyToyotaView(body) {
toyota.value = body;
// Keep the selected scope valid (fall back to personal when org isn't offered).
if (toyotaScope.value === "org" && !body.canEditOrg) toyotaScope.value = "user";
fillToyotaForm();
}
async function loadToyota() {
try {
applyToyotaView(await api.getToyota());
} catch (e) {
toyotaError.value = e.message;
}
}
// Refill the form when an admin flips between personal and organization scope.
watch(toyotaScope, () => {
toyotaError.value = "";
toyotaSaved.value = false;
toyotaHealth.value = null;
fillToyotaForm();
});
async function toggleToyota(v) {
toyotaError.value = "";
const org = toyotaEditingOrg.value;
try {
applyToyotaView(await api.saveToyota(org ? { scope: "org", enabled: v } : { scope: "user", enabled: v }));
} catch (e) {
toyotaError.value = e.message;
}
}
async function saveToyotaSettings() {
toyotaError.value = "";
toyotaSaving.value = true;
toyotaSaved.value = false;
const config = {};
for (const k of ["username", "password", "brand"]) {
if (toyotaLocked(k)) continue;
// Only send the password when the user actually typed a new one.
if (k === "password" && !toyotaForm.value.password) continue;
config[k] = toyotaForm.value[k];
}
try {
applyToyotaView(await api.saveToyota({ scope: toyotaScopeKey.value, config }));
toyotaSaved.value = true;
setTimeout(() => (toyotaSaved.value = false), 2000);
} catch (e) {
toyotaError.value = e.message;
} finally {
toyotaSaving.value = false;
}
}
async function testToyotaConnection() {
toyotaError.value = "";
toyotaHealth.value = null;
toyotaTesting.value = true;
try {
const { health } = await api.testToyota();
toyotaHealth.value = health;
} catch (e) {
toyotaError.value = e.message;
} finally {
toyotaTesting.value = false;
}
}
// --- Integrations: category tabs ---
//
// The API Server panel groups plugins into the same categories (see
// panel/src/components/PluginsCard.vue, and the Category* constants in
// internal/plugins/plugin.go that both read from), so grouping them the same way
// here means someone who has seen one list can read the other. Only a category
// that actually has an integration gets a tab — two tabs today, not six empty
// ones — and a category whose integrations are all still loading has none, which
// is why the active group falls back to the first rather than to a fixed id.
const INTEGRATION_CATEGORIES = ["vehicles", "chargers"];
const integrationsByCategory = computed(() => ({
vehicles: [toyota.value && "toyota"].filter(Boolean),
chargers: [anker.value && "anker", greencell.value && "greencell"].filter(Boolean),
}));
const integrationGroups = computed(() =>
INTEGRATION_CATEGORIES.filter((c) => integrationsByCategory.value[c].length).map((c) => ({
id: c,
label: t(`settings.integrations.categories.${c}`),
ids: integrationsByCategory.value[c],
}))
);
const integrationTab = ref("");
const activeIntegrationGroup = computed(
() => integrationGroups.value.find((g) => g.id === integrationTab.value) || integrationGroups.value[0]
);
function showsIntegration(id) {
return !!activeIntegrationGroup.value?.ids.includes(id);
}
// --- Integrations: Anker Solix (V1 Smart EV Charger) ---
//
// Same superadmin → org admin → user cascade as Toyota above; the fields are the
// Anker account email + password (resolved as a pair) and a country code.
const anker = ref(null); // resolved view from the server
const ankerScope = ref("user"); // "user" | "org" (org admins only)
const ankerForm = ref({ email: "", password: "", country: "", controlMode: "off" });
const ankerSaving = ref(false);
const ankerSaved = ref(false);
const ankerError = ref("");
const ankerTesting = ref(false);
const ankerHealth = ref(null); // { status, detail } from the last test
const ankerReadOnly = computed(() => !!anker.value?.isSuperadmin);
const ankerScopeKey = computed(() => (anker.value?.isSuperadmin ? "user" : ankerScope.value));
const ankerScopeData = computed(
() => anker.value?.scopes?.[ankerScopeKey.value] || { editableLayer: "user", fields: {} }
);
const ankerEditingOrg = computed(() => ankerScopeKey.value === "org");
function ankerField(k) {
return ankerScopeData.value.fields?.[k] || { effective: "", own: "", source: "unset", locked: false };
}
function ankerLocked(k) {
return ankerReadOnly.value || ankerField(k).locked;
}
const ankerEnabled = computed(() =>
ankerEditingOrg.value ? anker.value?.orgEnabled : anker.value?.enabled
);
function ankerSourceLabel(k) {
const map = { global: "sourceGlobal", org: "sourceOrg", user: "sourceUser" };
const key = map[ankerField(k).source] || "sourceGlobal";
return t("settings.integrations.inheritedFrom", { source: t("settings.integrations." + key) });
}
function fillAnkerForm() {
const f = ankerScopeData.value.fields || {};
ankerForm.value = {
email: f.email?.locked ? "" : f.email?.own || "",
password: "",
country: f.country?.locked ? "" : f.country?.own || "",
// controlMode isn't secret, so show the effective value when it's locked.
controlMode: f.controlMode?.locked ? f.controlMode?.effective || "off" : f.controlMode?.own || "off",
};
}
// Effective OCPP control mode (off | own | proxy) — gates the control panel.
const ankerControlMode = computed(() => anker.value?.controlMode || "off");
// --- Anker Solix OCPP control (per-charger provisioning + connection status) ---
const ankerCtlSerial = ref("");
const ankerCtl = ref(null); // { endpoint, hasToken, tokenHint, connected, status, ... }
const ankerCtlLoading = ref(false);
const ankerCtlError = ref("");
const ankerNewToken = ref(""); // freshly generated token, shown once
async function loadAnkerControl() {
const sn = ankerCtlSerial.value.trim();
if (!sn) return;
ankerCtlError.value = "";
ankerCtlLoading.value = true;
try {
ankerCtl.value = await api.getAnkerControl(sn);
} catch (e) {
ankerCtlError.value = e.message;
} finally {
ankerCtlLoading.value = false;
}
}
async function generateAnkerToken() {
const sn = ankerCtlSerial.value.trim();
if (!sn) return;
ankerCtlError.value = "";
ankerNewToken.value = "";
try {
// The token is returned exactly once — capture it here to show the operator.
const res = await api.ankerControlToken(sn);
ankerNewToken.value = res.token || "";
await loadAnkerControl();
} catch (e) {
ankerCtlError.value = e.message;
}
}
async function revokeAnkerToken() {
const sn = ankerCtlSerial.value.trim();
if (!sn) return;
if (!confirm(t("settings.integrations.controlRevokeConfirm"))) return;
ankerCtlError.value = "";
ankerNewToken.value = "";
try {
await api.ankerControlRevoke(sn);
await loadAnkerControl();
} catch (e) {
ankerCtlError.value = e.message;
}
}
// Clear the one-time token reveal whenever the operator switches charger.
watch(ankerCtlSerial, () => {
ankerNewToken.value = "";
});
// --- The chargers on the linked Anker account ---
// Proof that the login found something, and the source of the serial the control
// block needs — the serial is printed on the charger, but nobody wants to go and
// read it off the wall when the account already knows it.
const ankerChargers = ref([]);
const ankerChargersLoading = ref(false);
const ankerChargersError = ref("");
const ankerChargersDetail = ref(""); // why the list is empty, when the server says
const ankerChargersLoaded = ref(false);
async function loadAnkerChargers() {
ankerChargersError.value = "";
ankerChargersLoading.value = true;
try {
const res = await api.listAnkerChargers();
ankerChargers.value = res?.chargers || [];
ankerChargersDetail.value = res?.detail || "";
ankerChargersLoaded.value = true;
} catch (e) {
ankerChargers.value = [];
ankerChargersError.value = e.message;
} finally {
ankerChargersLoading.value = false;
}
}
// Load once the card is open and the integration is on — opening the card is the
// moment the user is asking "what is on my account?", and every other visit to
// Settings should not spend an Anker round trip.
watch([ankerOpen, () => anker.value?.enabled], ([open, enabled]) => {
if (open && enabled && !ankerChargersLoaded.value && !ankerChargersLoading.value) {
loadAnkerChargers();
}
});
// A charger's operating state arrives as the cloud's own slug (charging,
// standby, …); translate it, and fall back to the readable slug for a state we
// have no wording for yet.
function ankerStateLabel(slug) {
if (!slug) return "";
const key = `settings.integrations.states.${slug}`;
const label = t(key);
return label === key ? slug.replace(/_/g, " ") : label;
}
// Point the control block at a charger picked from the list.
function useAnkerCharger(sn) {
ankerCtlSerial.value = sn;
loadAnkerControl();
}
function applyAnkerView(body) {
anker.value = body;
if (ankerScope.value === "org" && !body.canEditOrg) ankerScope.value = "user";
fillAnkerForm();
}
async function loadAnkerSolix() {
try {
applyAnkerView(await api.getAnkerSolix());
} catch (e) {
ankerError.value = e.message;
}
}
watch(ankerScope, () => {
ankerError.value = "";
ankerSaved.value = false;
ankerHealth.value = null;
fillAnkerForm();
});
async function toggleAnker(v) {
ankerError.value = "";
const org = ankerEditingOrg.value;
try {
applyAnkerView(await api.saveAnkerSolix(org ? { scope: "org", enabled: v } : { scope: "user", enabled: v }));
} catch (e) {
ankerError.value = e.message;
}
}
async function saveAnkerSettings() {
ankerError.value = "";
ankerSaving.value = true;
ankerSaved.value = false;
const config = {};
for (const k of ["email", "password", "country", "controlMode"]) {
if (ankerLocked(k)) continue;
if (k === "password" && !ankerForm.value.password) continue;
config[k] = ankerForm.value[k];
}
try {
applyAnkerView(await api.saveAnkerSolix({ scope: ankerScopeKey.value, config }));
ankerSaved.value = true;
setTimeout(() => (ankerSaved.value = false), 2000);
} catch (e) {
ankerError.value = e.message;
} finally {
ankerSaving.value = false;
}
}
async function testAnkerConnection() {
ankerError.value = "";
ankerHealth.value = null;
ankerTesting.value = true;
try {
const { health } = await api.testAnkerSolix();
ankerHealth.value = health;
} catch (e) {
ankerError.value = e.message;
} finally {
ankerTesting.value = false;
}
}
// --- Integrations: Greencell (HabuDen EV charger) ---
//
// Same cascade once more, but what resolves here is an MQTT *broker*, not a
// cloud account: host/port/TLS/credentials describe one endpoint and the server
// resolves them together, so the form treats them as a group. The serial, the
// QUERY topic and the listen window resolve on their own.
//
// Only the password and — when inherited — the host and username are masked by
// the server; the rest are plain settings, so a locked one shows its effective
// value the way Anker's control mode does.
const GREENCELL_FIELDS = ["host", "port", "tls", "username", "password", "serial", "commandTopic", "timeout"];
const GREENCELL_MASKED = ["host", "username"]; // masked by the server when inherited
const greencell = ref(null); // resolved view from the server
const greencellScope = ref("user"); // "user" | "org" (org admins only)
const greencellForm = ref({
host: "", port: "", tls: "off", username: "", password: "", serial: "", commandTopic: "", timeout: "",
});
const greencellSaving = ref(false);
const greencellSaved = ref(false);
const greencellError = ref("");
const greencellTesting = ref(false);
const greencellHealth = ref(null); // { status, detail } from the last test
const greencellReadOnly = computed(() => !!greencell.value?.isSuperadmin);
const greencellScopeKey = computed(() =>
greencell.value?.isSuperadmin ? "user" : greencellScope.value
);
const greencellScopeData = computed(
() => greencell.value?.scopes?.[greencellScopeKey.value] || { editableLayer: "user", fields: {} }
);
const greencellEditingOrg = computed(() => greencellScopeKey.value === "org");
function greencellField(k) {
return greencellScopeData.value.fields?.[k] || { effective: "", own: "", source: "unset", locked: false };
}
function greencellLocked(k) {
return greencellReadOnly.value || greencellField(k).locked;
}
const greencellEnabled = computed(() =>
greencellEditingOrg.value ? greencell.value?.orgEnabled : greencell.value?.enabled
);
function greencellSourceLabel(k) {
const map = { global: "sourceGlobal", org: "sourceOrg", user: "sourceUser" };
const key = map[greencellField(k).source] || "sourceGlobal";
return t("settings.integrations.inheritedFrom", { source: t("settings.integrations." + key) });
}
function fillGreencellForm() {
const f = greencellScopeData.value.fields || {};
const value = (k, fallback = "") => {
const fv = f[k] || {};
// A locked field the server masked has nothing useful to show; a locked
// plain setting shows what is actually in force.
if (fv.locked) return GREENCELL_MASKED.includes(k) ? "" : fv.effective || fallback;
return fv.own || fallback;
};
greencellForm.value = {
host: value("host"),
port: value("port"),
tls: value("tls", "off"),
username: value("username"),
password: "",
serial: value("serial"),
commandTopic: value("commandTopic"),
timeout: value("timeout"),
};
}
function applyGreencellView(body) {
greencell.value = body;
if (greencellScope.value === "org" && !body.canEditOrg) greencellScope.value = "user";
fillGreencellForm();
}
async function loadGreencell() {
try {
applyGreencellView(await api.getGreencell());
} catch (e) {
greencellError.value = e.message;
}
}
watch(greencellScope, () => {
greencellError.value = "";
greencellSaved.value = false;
greencellHealth.value = null;
fillGreencellForm();
});
async function toggleGreencell(v) {
greencellError.value = "";
const scope = greencellEditingOrg.value ? "org" : "user";
try {
applyGreencellView(await api.saveGreencell({ scope, enabled: v }));
} catch (e) {
greencellError.value = e.message;
}
}
async function saveGreencellSettings() {
greencellError.value = "";
greencellSaving.value = true;
greencellSaved.value = false;
const config = {};
for (const k of GREENCELL_FIELDS) {
if (greencellLocked(k)) continue;
if (k === "password" && !greencellForm.value.password) continue;
config[k] = greencellForm.value[k];
}
try {
applyGreencellView(await api.saveGreencell({ scope: greencellScopeKey.value, config }));
greencellSaved.value = true;
setTimeout(() => (greencellSaved.value = false), 2000);
} catch (e) {
greencellError.value = e.message;
} finally {
greencellSaving.value = false;
}
}
async function testGreencellConnection() {
greencellError.value = "";
greencellHealth.value = null;
greencellTesting.value = true;
try {
const { health } = await api.testGreencell();
greencellHealth.value = health;
} catch (e) {
greencellError.value = e.message;
} finally {
greencellTesting.value = false;
}
}
function onLogout() {
logout();
router.replace({ name: "login" });
}
// --- Advanced: export ---
const exporting = ref(false);
const exportError = ref("");
async function exportData() {
exporting.value = true;
exportError.value = "";
try {
const { blob, filename } = await api.exportData();
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename || "drivervault-export.json";
a.click();
URL.revokeObjectURL(url);
} catch (e) {
exportError.value = e.message;
} finally {
exporting.value = false;
}
}
// --- Advanced: import ---
const importing = ref(false);
const importError = ref("");
const importResult = ref(null);
const importFileInput = ref(null);
function pickImportFile() {
importFileInput.value?.click();
}
async function onImportFileChosen(e) {
const file = e.target.files?.[0];
e.target.value = "";
if (!file) return;
importError.value = "";
importResult.value = null;
let payload;
try {
payload = JSON.parse(await file.text());
} catch {
importError.value = t("settings.advanced.notJson");
return;
}
if (!Array.isArray(payload?.cars) || payload.cars.length === 0) {
importError.value = t("settings.advanced.notExport");
return;
}
if (!confirm(t("settings.advanced.confirmImport", { count: payload.cars.length }))) {
return;
}
importing.value = true;
try {
importResult.value = await api.importData(payload);
} catch (err) {
importError.value = err.message;
} finally {
importing.value = false;
}
}
// --- Danger zone: delete account (typed confirmation + cooldown) ---
const showDeleteConfirm = ref(false);
const deleteConfirmEmail = ref("");
const deleteRequesting = ref(false);
const deleteError = ref("");
const eligibleAt = ref(null); // set once a deletion request succeeds this session
const deletionPending = computed(() => !!profile.value?.deletionRequestedAt);
const cooldownElapsed = computed(() => {
if (!deletionPending.value) return false;
const eligible = eligibleAt.value || new Date(new Date(profile.value.deletionRequestedAt).getTime() + 3 * 24 * 60 * 60 * 1000);
return new Date() >= eligible;
});
const canRequestDelete = computed(
() => deleteConfirmEmail.value.trim().toLowerCase() === (profile.value?.email || "").toLowerCase()
);
async function requestDeletion() {
if (!canRequestDelete.value) return;
deleteRequesting.value = true;
deleteError.value = "";
try {
const res = await api.requestAccountDeletion(deleteConfirmEmail.value.trim());
eligibleAt.value = new Date(res.eligibleAt);
profile.value = { ...profile.value, deletionRequestedAt: new Date().toISOString() };
showDeleteConfirm.value = false;
deleteConfirmEmail.value = "";
} catch (e) {
deleteError.value = e.message;
} finally {
deleteRequesting.value = false;
}
}
async function cancelDeletion() {
deleteError.value = "";
try {
await api.cancelAccountDeletion();
profile.value = { ...profile.value, deletionRequestedAt: null };
eligibleAt.value = null;
} catch (e) {
deleteError.value = e.message;
}
}
async function finalizeDeletion() {
if (!confirm(t("settings.danger.confirmFinalize"))) return;
deleteError.value = "";
try {
await api.finalizeAccountDeletion();
onLogout();
} catch (e) {
deleteError.value = e.message;
}
}
onMounted(async () => {
await load();
initDrafts();
await loadAvatar();
await loadToyota();
await loadAnkerSolix();
await loadGreencell();
});
onBeforeUnmount(() => {
if (avatarUrl.value) URL.revokeObjectURL(avatarUrl.value);
});
</script>
<template>
<div class="mx-auto max-w-3xl">
<div class="mb-6">
<p class="eyebrow">{{ t("settings.eyebrow") }}</p>
<h1 class="text-3xl font-bold tracking-[-0.03em] text-strong">{{ t("settings.title") }}</h1>
<p class="mt-1 text-sm text-muted">{{ t("settings.subtitle") }}</p>
</div>
<p v-if="loadError" class="mb-4 rounded-control bg-danger-soft px-4 py-3 text-sm font-medium text-danger">{{ loadError }}</p>
<p v-if="loading" class="text-muted">{{ t("common.loading") }}</p>
<div v-else-if="profile">
<!-- Tabs: personal settings, integrations, and users (admins only) -->
<div class="mb-6 flex gap-2 border-b border-subtle">
<button
v-for="tab in tabs"
:key="tab"
class="-mb-px border-b-2 px-1 pb-3 text-sm font-semibold transition-colors"
:class="activeTab === tab
? 'border-accent text-strong'
: 'border-transparent text-muted hover:text-body'"
@click="activeTab = tab"
>
{{ t(`settings.tabs.${tab}`) }}
</button>
</div>
<!-- Personal settings -->
<div v-show="activeTab === 'personal'" class="space-y-6">
<!-- Account -->
<section class="dh-card p-6">
<h2 class="mb-4 text-lg font-bold tracking-[-0.02em] text-strong">{{ t("settings.account.title") }}</h2>
<div class="mb-5">
<label class="dh-label">{{ t("settings.account.name") }}</label>
<div class="flex gap-2">
<input v-model="nameDraft" class="dh-input max-w-sm" />
<button class="dh-btn dh-btn-primary" :disabled="nameSaving || !nameDraft.trim()" @click="saveName">
{{ nameSaving ? t("common.saving") : nameSaved ? t("common.saved") : t("common.save") }}
</button>
</div>
<p v-if="nameError" class="mt-1 text-sm text-danger">{{ nameError }}</p>
</div>
<div class="mb-5">
<label class="dh-label">{{ t("settings.account.email") }}</label>
<div class="flex flex-wrap items-center gap-2">
<span class="data rounded-control border border-subtle bg-sunken px-3 py-2 text-sm text-body">
{{ profile.email }}
</span>
<span class="dh-badge" :class="profile.verified ? 'dh-badge-success' : 'dh-badge-warning'">
{{ profile.verified ? t("settings.account.verified") : t("settings.account.notVerified") }}
</span>
<button
v-if="!profile.verified && !verifySent"
class="text-sm font-medium text-brandtext hover:underline disabled:opacity-50"
:disabled="verifySending"
@click="sendVerification"
>
{{ verifySending ? t("settings.account.sending") : t("settings.account.resendVerification") }}
</button>
<span v-if="verifySent" class="text-sm text-muted">{{ t("settings.account.verificationRequested") }}</span>
</div>
<p v-if="verifyError" class="mt-1 text-sm text-danger">{{ verifyError }}</p>
</div>
<div>
<h3 class="mb-2 text-sm font-semibold text-strong">{{ t("settings.account.changePassword") }}</h3>
<div class="grid max-w-sm gap-2">
<input v-model="oldPassword" type="password" :placeholder="t('settings.account.currentPassword')" autocomplete="current-password" class="dh-input" />
<input v-model="newPassword" type="password" :placeholder="t('settings.account.newPassword')" autocomplete="new-password" class="dh-input" />
<input v-model="confirmPassword" type="password" :placeholder="t('settings.account.confirmNewPassword')" autocomplete="new-password" class="dh-input" />
</div>
<p v-if="passwordMismatch" class="mt-1 text-sm text-warning">{{ t("settings.account.mismatchYet") }}</p>
<p v-if="passwordError" class="mt-1 text-sm text-danger">{{ passwordError }}</p>
<button class="dh-btn dh-btn-ghost mt-2" :disabled="passwordSaving || !oldPassword || !newPassword" @click="savePassword">
{{ passwordSaving ? t("settings.account.updating") : passwordSaved ? t("settings.account.passwordUpdated") : t("settings.account.updatePassword") }}
</button>
</div>
</section>
<!-- Appearance -->
<section class="dh-card p-6">
<h2 class="mb-4 text-lg font-bold tracking-[-0.02em] text-strong">{{ t("settings.appearance.title") }}</h2>
<div class="mb-5">
<label class="dh-label">{{ t("settings.appearance.theme") }}</label>
<div class="flex gap-2">
<button
v-for="opt in ['light', 'dark', 'system']"
:key="opt"
class="rounded-control border px-3 py-1.5 text-sm font-medium transition-colors"
:class="prefs.theme === opt
? 'border-accent bg-accent text-white'
: 'border-subtle text-body hover:bg-sunken hover:text-strong'"
@click="saveAppearance({ theme: opt })"
>
{{ t(`settings.appearance.theme${opt.charAt(0).toUpperCase() + opt.slice(1)}`) }}
</button>
</div>
</div>
<div class="mb-5 grid gap-4 sm:grid-cols-2">
<div>
<label class="dh-label">{{ t("settings.appearance.language") }}</label>
<select :value="language" class="dh-input" @change="saveLocale({ lang: $event.target.value })">
<option v-for="l in LANGUAGES" :key="l.code" :value="l.code">{{ l.label }}</option>
</select>
<p class="mt-1 text-xs" :class="languageTranslated ? 'text-muted' : 'text-warning'">
{{ languageTranslated ? t("settings.appearance.languageHint") : t("settings.appearance.languageFallbackHint") }}
</p>
</div>
<div>
<label class="dh-label">{{ t("settings.appearance.region") }}</label>
<select :value="region" class="dh-input" @change="saveLocale({ reg: $event.target.value })">
<option v-for="r in REGIONS" :key="r.code" :value="r.code">{{ r.label }}</option>
</select>
<p class="mt-1 text-xs text-muted">{{ t("settings.appearance.regionHint") }}</p>
</div>
<div>
<label class="dh-label">{{ t("settings.appearance.dateFormat") }}</label>
<select :value="prefs.dateFormat" class="dh-input" @change="saveAppearance({ dateFormat: $event.target.value })">
<option value="YMD">YYYY-MM-DD</option>
<option value="DMY_NUM">DD-MM-YYYY</option>
<option value="DMY">DD Mon YYYY</option>
<option value="MDY">Mon DD, YYYY</option>
</select>
<p class="mt-1 text-xs text-muted">{{ t("settings.appearance.dateExample", { example: dateFormatExample }) }}</p>
</div>
<div>
<label class="dh-label">{{ t("settings.appearance.currency") }}</label>
<select :value="prefs.currency" class="dh-input" @change="saveAppearance({ currency: $event.target.value })">
<option v-for="c in CURRENCIES" :key="c.code" :value="c.code">{{ c.label }}</option>
</select>
<p class="mt-1 text-xs text-muted">{{ t("settings.appearance.currencyExample", { example: currencyExample }) }}</p>
</div>
</div>
<div>
<label class="dh-label">{{ t("settings.appearance.fontSize") }}</label>
<div class="flex gap-2">
<button
v-for="f in ['small', 'medium', 'large']"
:key="f"
class="rounded-control border px-3 py-1.5 text-sm font-medium transition-colors"
:class="prefs.fontSize === f
? 'border-accent bg-accent text-white'
: 'border-subtle text-body hover:bg-sunken hover:text-strong'"
@click="saveAppearance({ fontSize: f })"
>
{{ t(`settings.appearance.font${f.charAt(0).toUpperCase() + f.slice(1)}`) }}
</button>
</div>
</div>
<p v-if="appearanceError" class="mt-3 text-sm text-danger">{{ appearanceError }}</p>
</section>
<!-- Profile -->
<section class="dh-card p-6">
<h2 class="mb-4 text-lg font-bold tracking-[-0.02em] text-strong">{{ t("settings.profile.title") }}</h2>
<div class="mb-5 flex items-center gap-4">
<img v-if="avatarUrl" :src="avatarUrl" :alt="t('settings.profile.avatarAlt')" class="h-16 w-16 rounded-full object-cover ring-1 ring-subtle" />
<div v-else class="grid h-16 w-16 place-items-center rounded-full bg-brand-100 text-xl font-bold text-brandtext">
{{ (profile.name || profile.email || "?").charAt(0).toUpperCase() }}
</div>
<div class="flex gap-2">
<button class="dh-btn dh-btn-ghost !px-3 !py-1.5" :disabled="avatarUploading" @click="pickAvatar">
{{ avatarUploading ? t("settings.profile.uploading") : t("settings.profile.uploadPhoto") }}
</button>
<button v-if="profile.hasAvatar" class="dh-btn dh-btn-ghost !px-3 !py-1.5" :disabled="avatarUploading" @click="removeAvatar">
{{ t("common.remove") }}
</button>
</div>
<input ref="fileInput" type="file" accept="image/png,image/jpeg,image/gif,image/webp,image/svg+xml" class="hidden" @change="onAvatarChosen" />
</div>
<p v-if="avatarError" class="mb-4 text-sm text-danger">{{ avatarError }}</p>
<div>
<label class="dh-label">{{ t("settings.profile.bio") }}</label>
<textarea
v-model="bioDraft"
rows="3"
:placeholder="t('settings.profile.bioPlaceholder')"
class="dh-input"
/>
<p v-if="bioError" class="mt-1 text-sm text-danger">{{ bioError }}</p>
<button class="dh-btn dh-btn-ghost mt-2" :disabled="bioSaving" @click="saveBio">
{{ bioSaving ? t("common.saving") : bioSaved ? t("common.saved") : t("settings.profile.saveBio") }}
</button>
</div>
</section>
<!-- Privacy & Security -->
<section class="dh-card p-6">
<div class="mb-4 flex items-center justify-between">
<h2 class="text-lg font-bold tracking-[-0.02em] text-strong">{{ t("settings.privacy.title") }}</h2>
<button class="text-sm font-medium text-danger hover:underline" @click="onLogout">
{{ t("settings.privacy.signOut") }}
</button>
</div>
<p class="text-sm text-muted">{{ t("settings.privacy.body") }}</p>
</section>
<!-- Advanced / Danger Zone -->
<section class="dh-card p-6">
<h2 class="mb-4 text-lg font-bold tracking-[-0.02em] text-strong">{{ t("settings.advanced.title") }}</h2>
<div class="flex items-center justify-between gap-3">
<div>
<p class="text-sm font-medium text-strong">{{ t("settings.advanced.exportTitle") }}</p>
<p class="text-xs text-muted">{{ t("settings.advanced.exportBody") }}</p>
</div>
<button class="dh-btn dh-btn-ghost !px-3 !py-1.5 shrink-0" :disabled="exporting" @click="exportData">
{{ exporting ? t("settings.advanced.preparing") : t("settings.advanced.exportAction") }}
</button>
</div>
<p v-if="exportError" class="mt-2 text-sm text-danger">{{ exportError }}</p>
<div class="mt-4 flex items-center justify-between gap-3 border-t border-subtle pt-4">
<div>
<p class="text-sm font-medium text-strong">{{ t("settings.advanced.importTitle") }}</p>
<p class="text-xs text-muted">{{ t("settings.advanced.importBody") }}</p>
</div>
<button class="dh-btn dh-btn-ghost !px-3 !py-1.5 shrink-0" :disabled="importing" @click="pickImportFile">
{{ importing ? t("settings.advanced.importing") : t("settings.advanced.importAction") }}
</button>
<input ref="importFileInput" type="file" accept="application/json,.json" class="hidden" @change="onImportFileChosen" />
</div>
<p v-if="importResult" class="mt-2 text-sm font-medium text-success">
{{ t("settings.advanced.imported", { cars: importResult.carsImported, services: importResult.servicesImported, parts: importResult.partsImported }) }}
</p>
<p v-if="importError" class="mt-2 text-sm text-danger">{{ importError }}</p>
</section>
<section class="rounded-card border border-danger/30 bg-danger-soft p-6">
<h2 class="mb-2 text-lg font-bold tracking-[-0.02em] text-danger">{{ t("settings.danger.title") }}</h2>
<template v-if="!deletionPending">
<p class="mb-3 text-sm text-danger/90">{{ t("settings.danger.body") }}</p>
<button class="dh-btn !border !border-danger/40 !bg-transparent !text-danger hover:!bg-danger/10" @click="showDeleteConfirm = true">
{{ t("settings.danger.deleteAccount") }}
</button>
<div v-if="showDeleteConfirm" class="mt-4 rounded-control border border-danger/30 bg-card p-4">
<label class="dh-label">
{{ tSplit("settings.danger.typeToConfirm", "email").before
}}<span class="data text-strong">{{ profile.email }}</span>{{ tSplit("settings.danger.typeToConfirm", "email").after }}
</label>
<input v-model="deleteConfirmEmail" :placeholder="profile.email" class="dh-input mb-3 max-w-sm" />
<div class="flex gap-2">
<button class="dh-btn dh-btn-ghost" @click="showDeleteConfirm = false; deleteConfirmEmail = ''">{{ t("common.cancel") }}</button>
<button :disabled="!canRequestDelete || deleteRequesting" class="dh-btn dh-btn-danger" @click="requestDeletion">
{{ deleteRequesting ? t("settings.danger.requesting") : t("settings.danger.requestDeletion") }}
</button>
</div>
</div>
</template>
<template v-else>
<p class="mb-3 text-sm text-danger/90">
{{ t("settings.danger.requestedOn", { date: formatDate(profile.deletionRequestedAt) }) }}
{{ cooldownElapsed ? t("settings.danger.cooldownPassed") : t("settings.danger.canStillCancel") }}
</p>
<div class="flex gap-2">
<button class="dh-btn dh-btn-ghost !bg-card" @click="cancelDeletion">{{ t("settings.danger.cancelRequest") }}</button>
<button v-if="cooldownElapsed" class="dh-btn dh-btn-danger" @click="finalizeDeletion">
{{ t("settings.danger.finalize") }}
</button>
</div>
</template>
<p v-if="deleteError" class="mt-3 text-sm font-medium text-danger">{{ deleteError }}</p>
</section>
</div>
<!-- Integrations -->
<div v-show="activeTab === 'integrations'" class="space-y-6">
<section v-if="toyota || anker || greencell" class="dh-card p-6">
<h2 class="text-lg font-bold tracking-[-0.02em] text-strong">{{ t("settings.integrations.title") }}</h2>
<p class="mb-4 mt-1 text-sm text-muted">{{ t("settings.integrations.subtitle") }}</p>
<!-- Category tabs, mirroring the API Server panel's plugin list. -->
<nav v-if="integrationGroups.length > 1" class="mb-4 flex flex-wrap gap-1.5 border-b border-subtle pb-3">
<button
v-for="g in integrationGroups"
:key="g.id"
type="button"
class="dh-btn dh-btn-ghost !px-3 !py-1.5 text-sm"
:class="activeIntegrationGroup?.id === g.id ? 'border-accent text-brandtext' : ''"
@click="integrationTab = g.id"
>
{{ g.label }}
<span class="dh-badge dh-badge-neutral ml-2">{{ g.ids.length }}</span>
</button>
</nav>
<div class="space-y-4">
<div v-if="toyota && showsIntegration('toyota')" class="rounded-control border border-subtle p-4">
<button
type="button"
class="flex w-full items-start justify-between gap-3 text-left"
:aria-expanded="toyotaOpen"
@click="toyotaOpen = !toyotaOpen"
>
<div>
<p class="text-sm font-semibold text-strong">{{ t("settings.integrations.toyota") }}</p>
<p class="mt-0.5 text-xs text-muted">{{ t("settings.integrations.toyotaDesc") }}</p>
</div>
<div class="flex shrink-0 items-center gap-2">
<span
v-if="!toyotaEditingOrg"
class="dh-badge"
:class="toyota.enabled ? 'dh-badge-success' : 'dh-badge-warning'"
>
{{ toyota.enabled ? t("settings.integrations.connected") : t("settings.integrations.notConnected") }}
</span>
<svg
viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
class="h-4 w-4 text-muted transition-transform" :class="toyotaOpen ? 'rotate-180' : ''"
><path stroke-linecap="round" stroke-linejoin="round" d="m6 9 6 6 6-6" /></svg>
</div>
</button>
<div v-show="toyotaOpen">
<!-- Master / org gates -->
<p v-if="!toyota.available" class="mt-3 text-sm text-warning">{{ t("settings.integrations.unavailable") }}</p>
<p
v-else-if="toyota.orgId && !toyota.orgEnabled && !toyotaEditingOrg"
class="mt-3 text-sm text-warning"
>
{{ t("settings.integrations.orgDisabled") }}
</p>
<template v-else>
<!-- Scope switch (org admins) -->
<div v-if="toyota.canEditOrg" class="mt-4 flex gap-2">
<button
v-for="sc in ['user', 'org']"
:key="sc"
class="rounded-control border px-3 py-1.5 text-sm font-medium transition-colors"
:class="toyotaScope === sc
? 'border-accent bg-accent text-white'
: 'border-subtle text-body hover:bg-sunken hover:text-strong'"
@click="toyotaScope = sc"
>
{{ sc === 'org' ? t("settings.integrations.scopeOrg") : t("settings.integrations.scopeMy") }}
</button>
</div>
<p v-if="toyotaEditingOrg" class="mt-2 text-xs text-muted">{{ t("settings.integrations.scopeHint") }}</p>
<p v-if="toyotaReadOnly" class="mt-3 text-xs text-muted">{{ t("settings.integrations.readOnly") }}</p>
<!-- Enable toggle -->
<label class="mt-4 flex items-center gap-2 text-sm font-medium text-body">
<input
type="checkbox"
class="h-4 w-4 rounded border-subtle text-accent focus:ring-accent"
:checked="toyotaEnabled"
@change="toggleToyota($event.target.checked)"
/>
<span>{{ toyotaEditingOrg ? t("settings.integrations.enableOrg") : t("settings.integrations.enable") }}</span>
</label>
<!-- Credential fields -->
<div class="mt-4 grid max-w-sm gap-3">
<div>
<label class="dh-label">{{ t("settings.integrations.email") }}</label>
<input
v-model="toyotaForm.username"
class="dh-input"
:disabled="toyotaLocked('username')"
:placeholder="toyotaLocked('username') ? '••••••••' : ''"
autocomplete="off"
/>
<p v-if="toyotaField('username').locked" class="mt-1 text-xs text-muted">{{ toyotaSourceLabel('username') }}</p>
</div>
<div>
<label class="dh-label">{{ t("settings.integrations.password") }}</label>
<input
v-model="toyotaForm.password"
type="password"
class="dh-input"
:disabled="toyotaLocked('password')"
:placeholder="toyotaField('password').effective ? '••••••••' : ''"
autocomplete="new-password"
/>
<p v-if="toyotaField('password').locked" class="mt-1 text-xs text-muted">{{ toyotaSourceLabel('password') }}</p>
<p v-else class="mt-1 text-xs text-muted">{{ t("settings.integrations.passwordKeep") }}</p>
</div>
<div>
<label class="dh-label">{{ t("settings.integrations.brand") }}</label>
<select v-model="toyotaForm.brand" class="dh-input" :disabled="toyotaLocked('brand')">
<option value="">—</option>
<option value="T">{{ t("settings.integrations.brandToyota") }}</option>
<option value="L">{{ t("settings.integrations.brandLexus") }}</option>
</select>
<p v-if="toyotaField('brand').locked" class="mt-1 text-xs text-muted">{{ toyotaSourceLabel('brand') }}</p>
</div>
</div>
<div class="mt-4 flex items-center gap-2">
<button
v-if="!toyotaReadOnly"
class="dh-btn dh-btn-primary"
:disabled="toyotaSaving"
@click="saveToyotaSettings"
>
{{ toyotaSaving ? t("common.saving") : toyotaSaved ? t("settings.integrations.saved") : t("settings.integrations.save") }}
</button>
<button class="dh-btn dh-btn-ghost" :disabled="toyotaTesting" @click="testToyotaConnection">
{{ toyotaTesting ? t("settings.integrations.testing") : t("settings.integrations.test") }}
</button>
</div>
<p
v-if="toyotaHealth"
class="mt-2 text-sm"
:class="toyotaHealth.status === 'ok' ? 'text-success' : 'text-danger'"
>
{{ toyotaHealth.detail || toyotaHealth.status }}
</p>
</template>
<p v-if="toyotaError" class="mt-2 text-sm text-danger">{{ toyotaError }}</p>
</div>
</div>
<!-- Anker Solix (V1 Smart EV Charger) -->
<div v-if="anker && showsIntegration('anker')" class="rounded-control border border-subtle p-4">
<button
type="button"
class="flex w-full items-start justify-between gap-3 text-left"
:aria-expanded="ankerOpen"
@click="ankerOpen = !ankerOpen"
>
<div>
<p class="text-sm font-semibold text-strong">{{ t("settings.integrations.ankerSolix") }}</p>
<p class="mt-0.5 text-xs text-muted">{{ t("settings.integrations.ankerSolixDesc") }}</p>
</div>
<div class="flex shrink-0 items-center gap-2">
<span
v-if="!ankerEditingOrg"
class="dh-badge"
:class="anker.enabled ? 'dh-badge-success' : 'dh-badge-warning'"
>
{{ anker.enabled ? t("settings.integrations.connected") : t("settings.integrations.notConnected") }}
</span>
<svg
viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
class="h-4 w-4 text-muted transition-transform" :class="ankerOpen ? 'rotate-180' : ''"
><path stroke-linecap="round" stroke-linejoin="round" d="m6 9 6 6 6-6" /></svg>
</div>
</button>
<div v-show="ankerOpen">
<!-- Master / org gates -->
<p v-if="!anker.available" class="mt-3 text-sm text-warning">{{ t("settings.integrations.unavailable") }}</p>
<p
v-else-if="anker.orgId && !anker.orgEnabled && !ankerEditingOrg"
class="mt-3 text-sm text-warning"
>
{{ t("settings.integrations.orgDisabled") }}
</p>
<template v-else>
<!-- Scope switch (org admins) -->
<div v-if="anker.canEditOrg" class="mt-4 flex gap-2">
<button
v-for="sc in ['user', 'org']"
:key="sc"
class="rounded-control border px-3 py-1.5 text-sm font-medium transition-colors"
:class="ankerScope === sc
? 'border-accent bg-accent text-white'
: 'border-subtle text-body hover:bg-sunken hover:text-strong'"
@click="ankerScope = sc"
>
{{ sc === 'org' ? t("settings.integrations.scopeOrg") : t("settings.integrations.scopeMy") }}
</button>
</div>
<p v-if="ankerEditingOrg" class="mt-2 text-xs text-muted">{{ t("settings.integrations.scopeHint") }}</p>
<p v-if="ankerReadOnly" class="mt-3 text-xs text-muted">{{ t("settings.integrations.readOnly") }}</p>
<!-- Enable toggle -->
<label class="mt-4 flex items-center gap-2 text-sm font-medium text-body">
<input
type="checkbox"
class="h-4 w-4 rounded border-subtle text-accent focus:ring-accent"
:checked="ankerEnabled"
@change="toggleAnker($event.target.checked)"
/>
<span>{{ ankerEditingOrg ? t("settings.integrations.enableOrg") : t("settings.integrations.enable") }}</span>
</label>
<!-- Credential fields -->
<div class="mt-4 grid max-w-sm gap-3">
<div>
<label class="dh-label">{{ t("settings.integrations.ankerEmail") }}</label>
<input
v-model="ankerForm.email"
class="dh-input"
:disabled="ankerLocked('email')"
:placeholder="ankerLocked('email') ? '••••••••' : ''"
autocomplete="off"
/>
<p v-if="ankerField('email').locked" class="mt-1 text-xs text-muted">{{ ankerSourceLabel('email') }}</p>
</div>
<div>
<label class="dh-label">{{ t("settings.integrations.ankerPassword") }}</label>
<input
v-model="ankerForm.password"
type="password"
class="dh-input"
:disabled="ankerLocked('password')"
:placeholder="ankerField('password').effective ? '••••••••' : ''"
autocomplete="new-password"
/>
<p v-if="ankerField('password').locked" class="mt-1 text-xs text-muted">{{ ankerSourceLabel('password') }}</p>
<p v-else class="mt-1 text-xs text-muted">{{ t("settings.integrations.passwordKeep") }}</p>
</div>
<div>
<label class="dh-label">{{ t("settings.integrations.country") }}</label>
<input
v-model="ankerForm.country"
class="dh-input"
:disabled="ankerLocked('country')"
placeholder="DE"
maxlength="2"
autocomplete="off"
/>
<p v-if="ankerField('country').locked" class="mt-1 text-xs text-muted">{{ ankerSourceLabel('country') }}</p>
<p v-else class="mt-1 text-xs text-muted">{{ t("settings.integrations.countryHint") }}</p>
</div>
<div>
<label class="dh-label">{{ t("settings.integrations.controlMode") }}</label>
<select v-model="ankerForm.controlMode" class="dh-input" :disabled="ankerLocked('controlMode')">
<option value="off">{{ t("settings.integrations.controlOff") }}</option>
<option value="own">{{ t("settings.integrations.controlOwn") }}</option>
<option value="proxy">{{ t("settings.integrations.controlProxy") }}</option>
</select>
<p v-if="ankerField('controlMode').locked" class="mt-1 text-xs text-muted">{{ ankerSourceLabel('controlMode') }}</p>
<p v-else class="mt-1 text-xs text-muted">{{ t("settings.integrations.controlModeHint") }}</p>
</div>
</div>
<div class="mt-4 flex items-center gap-2">
<button
v-if="!ankerReadOnly"
class="dh-btn dh-btn-primary"
:disabled="ankerSaving"
@click="saveAnkerSettings"
>
{{ ankerSaving ? t("common.saving") : ankerSaved ? t("settings.integrations.saved") : t("settings.integrations.save") }}
</button>
<button class="dh-btn dh-btn-ghost" :disabled="ankerTesting" @click="testAnkerConnection">
{{ ankerTesting ? t("settings.integrations.testing") : t("settings.integrations.test") }}
</button>
</div>
<p
v-if="ankerHealth"
class="mt-2 text-sm"
:class="ankerHealth.status === 'ok'
? 'text-success'
: ankerHealth.status === 'degraded' ? 'text-warning' : 'text-danger'"
>
{{ ankerHealth.detail || ankerHealth.status }}
</p>
<!-- The chargers found on the account -->
<div v-if="anker.enabled" class="mt-5 rounded-control border border-subtle bg-sunken/40 p-4">
<div class="flex flex-wrap items-center justify-between gap-2">
<p class="text-sm font-semibold text-strong">{{ t("settings.integrations.chargersTitle") }}</p>
<button class="dh-btn dh-btn-ghost" :disabled="ankerChargersLoading" @click="loadAnkerChargers">
{{ ankerChargersLoading ? t("settings.integrations.chargersLoading") : t("settings.integrations.chargersRefresh") }}
</button>
</div>
<p class="mt-0.5 text-xs text-muted">{{ t("settings.integrations.chargersHint") }}</p>
<ul v-if="ankerChargers.length" class="mt-3 grid gap-2">
<li
v-for="c in ankerChargers"
:key="c.sn"
class="flex flex-wrap items-center justify-between gap-2 rounded-control border border-subtle bg-card px-3 py-2"
>
<div class="min-w-0">
<p class="truncate text-sm font-medium text-strong">
{{ c.name || t("settings.integrations.chargerUnnamed") }}
</p>
<p class="data truncate text-xs text-muted">
{{ c.sn }}<span v-if="c.model"> · {{ c.model }}</span><span v-if="c.siteName"> · {{ c.siteName }}</span>
</p>
</div>
<div class="flex shrink-0 items-center gap-2">
<!-- Reachability, said either way. A charger the cloud does
not report on at all (no `online` field) says nothing —
silence there is unknown, not online. -->
<span v-if="c.online === true" class="dh-badge dh-badge-success">
{{ t("settings.integrations.chargerOnline") }}
</span>
<span v-else-if="c.online === false" class="dh-badge dh-badge-warning">
{{ t("settings.integrations.chargerOffline") }}
</span>
<span v-if="c.statusDesc" class="dh-badge" :class="c.statusDesc === 'charging' ? 'dh-badge-success' : 'dh-badge-neutral'">
{{ ankerStateLabel(c.statusDesc) }}
</span>
<button
v-if="ankerControlMode !== 'off'"
class="dh-btn dh-btn-ghost"
@click="useAnkerCharger(c.sn)"
>
{{ t("settings.integrations.chargerUse") }}
</button>
</div>
</li>
</ul>
<p v-else-if="ankerChargersLoaded" class="mt-3 text-sm text-muted">
{{ ankerChargersDetail || t("settings.integrations.chargersEmpty") }}
</p>
<p v-if="ankerChargersError" class="mt-2 text-sm text-danger">{{ ankerChargersError }}</p>
</div>
<!-- OCPP control provisioning (only when a control mode is active) -->
<div v-if="ankerControlMode !== 'off'" class="mt-5 rounded-control border border-subtle bg-sunken/40 p-4">
<p class="text-sm font-semibold text-strong">{{ t("settings.integrations.controlTitle") }}</p>
<p class="mt-0.5 text-xs text-muted">
{{ ankerControlMode === 'own' ? t("settings.integrations.controlOwnHint") : t("settings.integrations.controlProxyHint") }}
</p>
<div class="mt-3 flex flex-wrap items-end gap-2">
<div class="grow">
<label class="dh-label">{{ t("settings.integrations.chargerSerial") }}</label>
<input v-model="ankerCtlSerial" class="dh-input" placeholder="A5191-XXXXXXXX" autocomplete="off" />
</div>
<button class="dh-btn dh-btn-ghost" :disabled="!ankerCtlSerial.trim() || ankerCtlLoading" @click="loadAnkerControl">
{{ ankerCtlLoading ? t("settings.integrations.testing") : t("settings.integrations.controlCheck") }}
</button>
<button class="dh-btn dh-btn-primary" :disabled="!ankerCtlSerial.trim()" @click="generateAnkerToken">
{{ t("settings.integrations.controlGenerate") }}
</button>
<button
v-if="ankerCtl && ankerCtl.hasToken"
class="dh-btn dh-btn-ghost !text-danger"
:disabled="!ankerCtlSerial.trim()"
@click="revokeAnkerToken"
>
{{ t("settings.integrations.controlRevoke") }}
</button>
</div>
<!-- The token is shown exactly once, right after generation. -->
<div v-if="ankerNewToken" class="mt-3 rounded-control border border-warning/40 bg-warning-soft px-3 py-2">
<p class="text-xs font-semibold text-warning">{{ t("settings.integrations.controlTokenOnce") }}</p>
<code class="data mt-1 block break-all text-sm text-body">{{ ankerNewToken }}</code>
</div>
<div v-if="ankerCtl" class="mt-3 grid gap-2 text-sm">
<div>
<span class="text-muted">{{ t("settings.integrations.controlEndpoint") }}: </span>
<code class="data break-all text-body">{{ ankerCtl.endpoint }}</code>
</div>
<div v-if="ankerCtl.hasToken">
<span class="text-muted">{{ t("settings.integrations.controlToken") }}: </span>
<code class="data text-body">••••{{ ankerCtl.tokenHint }}</code>
</div>
<div class="flex items-center gap-2">
<span class="dh-badge" :class="ankerCtl.connected ? 'dh-badge-success' : 'dh-badge-warning'">
{{ ankerCtl.connected ? t("settings.integrations.controlConnected") : t("settings.integrations.controlDisconnected") }}
</span>
<span v-if="ankerCtl.status?.connectorStatus" class="text-muted">{{ ankerCtl.status.connectorStatus }}</span>
</div>
<p class="text-xs text-muted">{{ t("settings.integrations.controlProvisionSteps") }}</p>
</div>
<p v-if="ankerCtlError" class="mt-2 text-sm text-danger">{{ ankerCtlError }}</p>
</div>
</template>
<p v-if="ankerError" class="mt-2 text-sm text-danger">{{ ankerError }}</p>
</div>
</div>
<!-- Greencell (HabuDen EV charger, read over your own MQTT broker) -->
<div v-if="greencell && showsIntegration('greencell')" class="rounded-control border border-subtle p-4">
<button
type="button"
class="flex w-full items-start justify-between gap-3 text-left"
:aria-expanded="greencellOpen"
@click="greencellOpen = !greencellOpen"
>
<div>
<p class="text-sm font-semibold text-strong">{{ t("settings.integrations.greencell") }}</p>
<p class="mt-0.5 text-xs text-muted">{{ t("settings.integrations.greencellDesc") }}</p>
</div>
<div class="flex shrink-0 items-center gap-2">
<span
v-if="!greencellEditingOrg"
class="dh-badge"
:class="greencell.enabled ? 'dh-badge-success' : 'dh-badge-warning'"
>
{{ greencell.enabled ? t("settings.integrations.connected") : t("settings.integrations.notConnected") }}
</span>
<svg
viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
class="h-4 w-4 text-muted transition-transform" :class="greencellOpen ? 'rotate-180' : ''"
><path stroke-linecap="round" stroke-linejoin="round" d="m6 9 6 6 6-6" /></svg>
</div>
</button>
<div v-show="greencellOpen">
<!-- Master / org gates -->
<p v-if="!greencell.available" class="mt-3 text-sm text-warning">{{ t("settings.integrations.unavailable") }}</p>
<p
v-else-if="greencell.orgId && !greencell.orgEnabled && !greencellEditingOrg"
class="mt-3 text-sm text-warning"
>
{{ t("settings.integrations.orgDisabled") }}
</p>
<template v-else>
<!-- Scope switch (org admins) -->
<div v-if="greencell.canEditOrg" class="mt-4 flex gap-2">
<button
v-for="sc in ['user', 'org']"
:key="sc"
class="rounded-control border px-3 py-1.5 text-sm font-medium transition-colors"
:class="greencellScope === sc
? 'border-accent bg-accent text-white'
: 'border-subtle text-body hover:bg-sunken hover:text-strong'"
@click="greencellScope = sc"
>
{{ sc === 'org' ? t("settings.integrations.scopeOrg") : t("settings.integrations.scopeMy") }}
</button>
</div>
<p v-if="greencellEditingOrg" class="mt-2 text-xs text-muted">{{ t("settings.integrations.scopeHint") }}</p>
<p v-if="greencellReadOnly" class="mt-3 text-xs text-muted">{{ t("settings.integrations.readOnly") }}</p>
<!-- Enable toggle -->
<label class="mt-4 flex items-center gap-2 text-sm font-medium text-body">
<input
type="checkbox"
class="h-4 w-4 rounded border-subtle text-accent focus:ring-accent"
:checked="greencellEnabled"
@change="toggleGreencell($event.target.checked)"
/>
<span>{{ greencellEditingOrg ? t("settings.integrations.enableOrg") : t("settings.integrations.enable") }}</span>
</label>
<!-- Broker: host, port, TLS and credentials resolve together -->
<p class="mt-5 text-xs font-semibold uppercase tracking-wide text-muted">
{{ t("settings.integrations.greencellBroker") }}
</p>
<div class="mt-2 grid max-w-sm gap-3">
<div>
<label class="dh-label">{{ t("settings.integrations.greencellHost") }}</label>
<input
v-model="greencellForm.host"
class="dh-input"
:disabled="greencellLocked('host')"
:placeholder="greencellLocked('host') ? '••••••••' : '10.2.1.10'"
autocomplete="off"
/>
<p v-if="greencellField('host').locked" class="mt-1 text-xs text-muted">{{ greencellSourceLabel('host') }}</p>
<p v-else class="mt-1 text-xs text-muted">{{ t("settings.integrations.greencellHostHint") }}</p>
</div>
<div>
<label class="dh-label">{{ t("settings.integrations.greencellPort") }}</label>
<input
v-model="greencellForm.port"
class="dh-input"
:disabled="greencellLocked('port')"
placeholder="1883"
inputmode="numeric"
autocomplete="off"
/>
<p v-if="greencellField('port').locked" class="mt-1 text-xs text-muted">{{ greencellSourceLabel('port') }}</p>
<p v-else class="mt-1 text-xs text-muted">{{ t("settings.integrations.greencellPortHint") }}</p>
</div>
<div>
<label class="dh-label">{{ t("settings.integrations.greencellTls") }}</label>
<select v-model="greencellForm.tls" class="dh-input" :disabled="greencellLocked('tls')">
<option value="off">{{ t("settings.integrations.greencellTlsOff") }}</option>
<option value="on">{{ t("settings.integrations.greencellTlsOn") }}</option>
</select>
<p v-if="greencellField('tls').locked" class="mt-1 text-xs text-muted">{{ greencellSourceLabel('tls') }}</p>
</div>
<div>
<label class="dh-label">{{ t("settings.integrations.greencellUsername") }}</label>
<input
v-model="greencellForm.username"
class="dh-input"
:disabled="greencellLocked('username')"
:placeholder="greencellLocked('username') ? '••••••••' : ''"
autocomplete="off"
/>
<p v-if="greencellField('username').locked" class="mt-1 text-xs text-muted">{{ greencellSourceLabel('username') }}</p>
<p v-else class="mt-1 text-xs text-muted">{{ t("settings.integrations.greencellUsernameHint") }}</p>
</div>
<div>
<label class="dh-label">{{ t("settings.integrations.greencellPassword") }}</label>
<input
v-model="greencellForm.password"
type="password"
class="dh-input"
:disabled="greencellLocked('password')"
:placeholder="greencellField('password').effective ? '••••••••' : ''"
autocomplete="new-password"
/>
<p v-if="greencellField('password').locked" class="mt-1 text-xs text-muted">{{ greencellSourceLabel('password') }}</p>
<p v-else class="mt-1 text-xs text-muted">{{ t("settings.integrations.passwordKeep") }}</p>
</div>
</div>
<!-- Charger: resolves independently of the broker -->
<p class="mt-5 text-xs font-semibold uppercase tracking-wide text-muted">
{{ t("settings.integrations.greencellCharger") }}
</p>
<div class="mt-2 grid max-w-sm gap-3">
<div>
<label class="dh-label">{{ t("settings.integrations.greencellSerial") }}</label>
<input
v-model="greencellForm.serial"
class="dh-input"
:disabled="greencellLocked('serial')"
placeholder="EVGC021B22752405ZM0018"
autocomplete="off"
/>
<p v-if="greencellField('serial').locked" class="mt-1 text-xs text-muted">{{ greencellSourceLabel('serial') }}</p>
<p v-else class="mt-1 text-xs text-muted">{{ t("settings.integrations.greencellSerialHint") }}</p>
</div>
<div>
<label class="dh-label">{{ t("settings.integrations.greencellTimeout") }}</label>
<input
v-model="greencellForm.timeout"
class="dh-input"
:disabled="greencellLocked('timeout')"
placeholder="12"
inputmode="numeric"
autocomplete="off"
/>
<p v-if="greencellField('timeout').locked" class="mt-1 text-xs text-muted">{{ greencellSourceLabel('timeout') }}</p>
<p v-else class="mt-1 text-xs text-muted">{{ t("settings.integrations.greencellTimeoutHint") }}</p>
</div>
<div>
<label class="dh-label">{{ t("settings.integrations.greencellCommandTopic") }}</label>
<input
v-model="greencellForm.commandTopic"
class="dh-input"
:disabled="greencellLocked('commandTopic')"
placeholder="/greencell/evse/{sn}/command"
autocomplete="off"
/>
<p v-if="greencellField('commandTopic').locked" class="mt-1 text-xs text-muted">{{ greencellSourceLabel('commandTopic') }}</p>
<p v-else class="mt-1 text-xs text-muted">{{ t("settings.integrations.greencellCommandTopicHint") }}</p>
</div>
</div>
<div class="mt-4 flex items-center gap-2">
<button
v-if="!greencellReadOnly"
class="dh-btn dh-btn-primary"
:disabled="greencellSaving"
@click="saveGreencellSettings"
>
{{ greencellSaving ? t("common.saving") : greencellSaved ? t("settings.integrations.saved") : t("settings.integrations.save") }}
</button>
<button class="dh-btn dh-btn-ghost" :disabled="greencellTesting" @click="testGreencellConnection">
{{ greencellTesting ? t("settings.integrations.testing") : t("settings.integrations.test") }}
</button>
</div>
<!-- A reachable broker with no charger on it is "degraded", not down:
the half we configure works and the missing half is the device. -->
<p
v-if="greencellHealth"
class="mt-2 text-sm"
:class="greencellHealth.status === 'ok'
? 'text-success'
: greencellHealth.status === 'degraded' ? 'text-warning' : 'text-danger'"
>
{{ greencellHealth.detail || greencellHealth.status }}
</p>
</template>
<p v-if="greencellError" class="mt-2 text-sm text-danger">{{ greencellError }}</p>
</div>
</div>
</div>
</section>
</div>
<!-- Users (admins + superadmins) -->
<div v-if="isAdmin" v-show="activeTab === 'users'">
<AdminUsers />
</div>
<!-- Organization: create your own (becoming its admin), or manage it -->
<div v-show="activeTab === 'organization'">
<OrgManager />
</div>
</div>
</div>
</template>