Files
DriverVault/Web App/web/src/views/Settings.vue
T
tajniak81andClaude Opus 5 c1b76a801d An inherited field shows what it inherited, not an example
Country read "DE" under the words "inherited from your organization",
while the organization it was inheriting from said DK. The DE was never
a value at all — it was the example placeholder, left in place when the
field locked, and an example in that position is not a hint. It is a
wrong answer to the question the box is being asked: which country am I
inheriting?

The server had already settled what may be shown. It sends the secrets
back as dots and everything else in the clear, country included, and only
the panel was throwing that away. So a locked field now placeholders its
effective value, and the example is kept for the case it was written for:
an empty box waiting to be filled in.

Applied to the non-secret cascading fields rather than to the one that
was noticed — Green Cell's port, serial, timeout and command topic had
the same example hardcoded a card further down, and would have told the
same lie the moment an org set them.

The dots are left alone. They are the panel's own masking, and rerouting
them through the server's effective value would be the same result by a
different path — not worth changing how a secret is displayed as a side
effect of this.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 20:51:57 +02:00

1976 lines
82 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, formatTime } from "../lib/format.js";
import { t, tSplit, TRANSLATED_LANGUAGES } from "../i18n";
import { TAB_SURFACES, SETTINGS_TABS, defaultTabFor } from "../lib/tabs.js";
import { askConfirm } from "../lib/confirm.js";
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. Without
// one the page opens on the account's default (below, in Appearance), and
// otherwise on the first tab.
const ALL_TABS = SETTINGS_TABS;
const tabs = computed(() => ALL_TABS.filter((tab) => tab !== "users" || isAdmin.value));
const tabPicked = ref(ALL_TABS.includes(route.query.tab)); // `?tab=` is an explicit ask
const activeTab = ref(tabPicked.value ? route.query.tab : "personal");
// The admin gate only settles once the profile is loaded — which is also when
// the default arrives — so until then the bar is still being decided: a
// non-admin who asked for ?tab=users lands back on the personal tab rather than
// on nothing, and a default of Users does the same.
watch(
[tabs, () => prefs.defaultTabs],
([list]) => {
if (!tabPicked.value) activeTab.value = defaultTabFor("settings", list);
else if (!list.includes(activeTab.value)) activeTab.value = "personal";
},
{ immediate: true }
);
function selectTab(tab) {
tabPicked.value = true;
activeTab.value = tab;
}
// 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;
}
}
// Which tab each tabbed page opens on. Empty means "whichever tab leads the
// bar", so somebody who has already dragged their tabs into the order they want
// never has to come here at all. Saved through saveAppearance like everything
// else on this card, as a whole map — the API stores it as one field.
function defaultTabValue(surface) {
return prefs.defaultTabs?.[surface] || "";
}
function saveDefaultTab(surface, key) {
saveAppearance({ defaultTabs: { ...prefs.defaultTabs, [surface]: key } });
}
const dateFormatExample = computed(() => formatDate(new Date().toISOString()));
// Thirteen-something rather than now: an example at 09:00 reads the same in
// both conventions, which is the one time of day that cannot show the choice.
const timeFormatExample = computed(() => {
const d = new Date();
d.setHours(13, 45, 0, 0);
return formatTime(d);
});
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;
}
}
// What an inherited field shows when it is empty.
//
// A locked field is standing in for a value set above the caller, and the server
// has already decided which of those may be read: it sends the secrets back as
// dots and everything else in the clear. So the placeholder is that effective
// value — the thing the field will actually use — and the example is for the
// other case, an empty box waiting to be filled in.
//
// The example belongs only there. A country field placeholdered "DE" under the
// words "inherited from your organization" is not a hint, it is a wrong answer
// to the question the user is asking it: which country am I inheriting?
function inheritedPlaceholder(field, example = "") {
return field.locked ? field.effective || "" : example;
}
// --- 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",
};
}
// The effective control mode (off | mqtt | modbus | own | proxy) — it gates the
// control panel.
const ankerControlMode = computed(() => anker.value?.controlMode || "off");
// Only the OCPP modes are provisioned here. Neither of the others hands the
// charger anything: Modbus wants the charger's own address, asked for beside the
// controls on the Charging page, and the cloud wants nothing at all beyond the
// account credentials already on this screen.
const ankerControlIsOcpp = computed(
() => ankerControlMode.value === "own" || ankerControlMode.value === "proxy",
);
// --- 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 (!(await askConfirm(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 (!(await askConfirm(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 (!(await askConfirm(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="selectTab(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>
<!-- Beside the date rather than under the region, because it is the
same question asked about the other half of a timestamp. -->
<div>
<label class="dh-label">{{ t("settings.appearance.timeFormat") }}</label>
<select :value="prefs.timeFormat" class="dh-input" @change="saveAppearance({ timeFormat: $event.target.value })">
<option value="auto">{{ t("settings.appearance.timeAuto") }}</option>
<option value="24">{{ t("settings.appearance.time24") }}</option>
<option value="12">{{ t("settings.appearance.time12") }}</option>
</select>
<p class="mt-1 text-xs text-muted">{{ t("settings.appearance.timeExample", { example: timeFormatExample }) }}</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>
<div class="mt-5">
<label class="dh-label">{{ t("settings.appearance.defaultTab") }}</label>
<div class="grid gap-4 sm:grid-cols-3">
<div v-for="page in TAB_SURFACES" :key="page.surface">
<p class="mb-1.5 text-xs text-muted">{{ t(`settings.appearance.defaultTabPage.${page.surface}`) }}</p>
<select
:value="defaultTabValue(page.surface)"
class="dh-input"
@change="saveDefaultTab(page.surface, $event.target.value)"
>
<option value="">{{ t("settings.appearance.defaultTabFirst") }}</option>
<option v-for="key in page.keys" :key="key" :value="key">{{ t(page.labelKey(key)) }}</option>
</select>
</div>
</div>
<p class="mt-1 text-xs text-muted">{{ t("settings.appearance.defaultTabHint") }}</p>
</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="inheritedPlaceholder(ankerField('country'), '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="mqtt">{{ t("settings.integrations.controlCloud") }}</option>
<option value="modbus">{{ t("settings.integrations.controlModbus") }}</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.firmware"> · {{ c.firmware }}</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="ankerControlIsOcpp"
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>
<!-- Modbus is reached rather than provisioned, so this card gives
the address it wants instead of an endpoint to point it at. The
cloud mode asks for nothing beyond the account above, which is
the whole point of it — so it says so rather than showing an
empty setup card. -->
<p
v-if="ankerControlMode === 'modbus'"
class="mt-5 rounded-control border border-subtle bg-sunken/40 p-4 text-xs text-muted"
>
{{ t("settings.integrations.controlModbusHint") }}
</p>
<p
v-else-if="ankerControlMode === 'mqtt'"
class="mt-5 rounded-control border border-subtle bg-sunken/40 p-4 text-xs text-muted"
>
{{ t("settings.integrations.controlCloudHint") }}
</p>
<!-- OCPP control provisioning (only in the modes the charger dials) -->
<div v-if="ankerControlIsOcpp" 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="inheritedPlaceholder(greencellField('port'), '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="inheritedPlaceholder(greencellField('serial'), '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="inheritedPlaceholder(greencellField('timeout'), '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="inheritedPlaceholder(greencellField('commandTopic'), '/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>