Wire the Anker Solix EV charger into the per-user integration cascade

Expose the anker-solix plugin to end users the same way as Toyota: a
global -> org -> user settings cascade so everyone can run it under their
own Anker account, with a superadmin (and org admin) able to impose
settings from above.

API Server: integrations_ankersolix.go mirrors integrations.go with the
Anker fields (email + password resolve as a pair from the highest layer,
country resolves on its own), plus GET/PUT/health/chargers routes under
/api/integrations/anker-solix. Secrets and inherited emails are masked;
the live probe runs server-side under the resolved credentials.

Web App: api.js client calls, a second Integrations card in Settings.vue
(scope switch, enable toggle, email/password/country with locked-field
inheritance notes, save + test), and the en.json strings.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
tajniak81
2026-07-18 12:46:16 +02:00
co-authored by Claude Opus 4.8
parent 0793b5ec8e
commit 52d9614bad
5 changed files with 734 additions and 3 deletions
+8
View File
@@ -231,6 +231,14 @@ export const api = {
saveToyota: (body) => request("/integrations/toyota", { method: "PUT", body: JSON.stringify(body) }),
testToyota: () => request("/integrations/toyota/health", { method: "POST" }),
// Anker Solix (V1 Smart EV Charger) — same cascade as Toyota. getAnkerSolix
// returns the resolved view (effective/own/locked per field, secrets and
// inherited emails masked); saveAnkerSolix writes the caller's editable layer;
// testAnkerSolix runs a live login probe under the resolved credentials.
getAnkerSolix: () => request("/integrations/anker-solix"),
saveAnkerSolix: (body) => request("/integrations/anker-solix", { method: "PUT", body: JSON.stringify(body) }),
testAnkerSolix: () => request("/integrations/anker-solix/health", { method: "POST" }),
// Settings — advanced / danger zone
exportData: () => requestBlob("/me/export"),
importData: (payload) => request("/me/import", { method: "POST", body: JSON.stringify(payload) }),
+7 -1
View File
@@ -187,7 +187,13 @@
"save": "Save",
"saved": "Saved ✓",
"connected": "Connected",
"notConnected": "Not connected"
"notConnected": "Not connected",
"ankerSolix": "Anker Solix (V1 Smart EV Charger)",
"ankerSolixDesc": "Sign in with your Anker account to pull EV charger data from the Anker Solix cloud.",
"ankerEmail": "Anker account email",
"ankerPassword": "Anker account password",
"country": "Country",
"countryHint": "Two-letter country code of your Anker account (e.g. DE, GB, US)."
},
"privacy": {
+235 -2
View File
@@ -398,6 +398,117 @@ async function testToyotaConnection() {
}
}
// --- 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: "" });
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 || "",
};
}
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"]) {
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;
}
}
function onLogout() {
logout();
router.replace({ name: "login" });
@@ -532,6 +643,7 @@ onMounted(async () => {
initDrafts();
await loadAvatar();
await loadToyota();
await loadAnkerSolix();
});
onBeforeUnmount(() => {
@@ -720,11 +832,11 @@ onBeforeUnmount(() => {
</section>
<!-- Integrations -->
<section v-if="toyota" class="dh-card p-6">
<section v-if="toyota || anker" 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>
<div class="rounded-control border border-subtle p-4">
<div v-if="toyota" class="rounded-control border border-subtle p-4">
<div class="flex items-start justify-between gap-3">
<div>
<p class="text-sm font-semibold text-strong">{{ t("settings.integrations.toyota") }}</p>
@@ -839,6 +951,127 @@ onBeforeUnmount(() => {
<p v-if="toyotaError" class="mt-2 text-sm text-danger">{{ toyotaError }}</p>
</div>
<!-- Anker Solix (V1 Smart EV Charger) -->
<div v-if="anker" class="mt-4 rounded-control border border-subtle p-4">
<div class="flex items-start justify-between gap-3">
<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>
<span
v-if="!ankerEditingOrg"
class="dh-badge shrink-0"
:class="anker.enabled ? 'dh-badge-success' : 'dh-badge-warning'"
>
{{ anker.enabled ? t("settings.integrations.connected") : t("settings.integrations.notConnected") }}
</span>
</div>
<!-- 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>
<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' : 'text-danger'"
>
{{ ankerHealth.detail || ankerHealth.status }}
</p>
</template>
<p v-if="ankerError" class="mt-2 text-sm text-danger">{{ ankerError }}</p>
</div>
</section>
<!-- Privacy & Security -->