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:
co-authored by
Claude Opus 4.8
parent
0793b5ec8e
commit
52d9614bad
@@ -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) }),
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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 -->
|
||||
|
||||
Reference in New Issue
Block a user