Organization writes were superadmin-only, so standing up a tenant needed an out-of-band superadmin. Creating one is now self-service, and an admin manages the org they belong to. - POST /api/orgs is open to any authenticated user. A creator who isn't a superadmin must have no organization yet (a single-valued membership relation means a second one would abandon the first), and is promoted to the new org's admin and first member in the same request. If that promotion fails the org is rolled back, so it is never left stranded with nobody able to administer it. Superadmins still create tenants without joining them. - PATCH/DELETE are manager-gated and scope an admin to their own org. An admin deletes theirs only as its sole member: they are detached and demoted to a plain user before the record goes, so the org is empty when it is removed. Other members still block deletion with a 409. - /api/me now carries organization + organizationName, which the clients need to tell "no org yet" from "org you administer". The panel, Web App (new OrgManager.vue in Settings) and Phone App (new _OrganizationSection) all mirror the server's gates rather than re-deciding them. The Phone App cached its role at login and gates the Users tab on it, so AuthService.adoptRole refreshes that from the profile instead of making a freshly promoted admin sign in again. Covered by orgs_test.go, which drives the real handler + middleware chain against a stand-in PocketBase: promotion, the already-a-member refusal, superadmin staying unattached, the rollback, own-org scoping, the detach-and-demote, and the blocking-member 409. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1350 lines
54 KiB
Vue
1350 lines
54 KiB
Vue
<script setup>
|
|
import { ref, computed, watch, onMounted, onBeforeUnmount } from "vue";
|
|
import { useRouter } from "vue-router";
|
|
import { api } from "../api";
|
|
import { state, logout, refreshProfile } from "../auth";
|
|
import { prefs, applyProfilePrefs } from "../prefs";
|
|
import { formatDate, formatMoney } from "../lib/format.js";
|
|
import { t, tSplit, TRANSLATED_LANGUAGES } from "../i18n";
|
|
import OrgManager from "../components/OrgManager.vue";
|
|
|
|
const router = useRouter();
|
|
|
|
const loading = ref(true);
|
|
const loadError = ref("");
|
|
const profile = ref(null);
|
|
|
|
// Settings is split into two tabs: personal account settings and external
|
|
// integrations. The panels stay mounted (v-show) so their loaded state and
|
|
// in-flight edits survive a tab switch.
|
|
const activeTab = ref("personal"); // "personal" | "integrations"
|
|
|
|
// 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);
|
|
|
|
async function load() {
|
|
loading.value = true;
|
|
loadError.value = "";
|
|
try {
|
|
profile.value = await refreshProfile();
|
|
} catch (e) {
|
|
loadError.value = e.message;
|
|
} finally {
|
|
loading.value = false;
|
|
}
|
|
}
|
|
|
|
// --- Account: name ---
|
|
|
|
const nameDraft = ref("");
|
|
const nameSaving = ref(false);
|
|
const nameSaved = ref(false);
|
|
const nameError = ref("");
|
|
|
|
function initDrafts() {
|
|
nameDraft.value = profile.value.name || "";
|
|
bioDraft.value = profile.value.bio || "";
|
|
}
|
|
|
|
async function saveName() {
|
|
nameSaving.value = true;
|
|
nameError.value = "";
|
|
nameSaved.value = false;
|
|
try {
|
|
profile.value = await api.updateMe({ name: nameDraft.value.trim() });
|
|
// Keep the header's displayed name in sync.
|
|
state.user = { ...state.user, name: profile.value.name };
|
|
localStorage.setItem("cc_user", JSON.stringify(state.user));
|
|
nameSaved.value = true;
|
|
setTimeout(() => (nameSaved.value = false), 2000);
|
|
} catch (e) {
|
|
nameError.value = e.message;
|
|
} finally {
|
|
nameSaving.value = false;
|
|
}
|
|
}
|
|
|
|
// --- Account: email verification ---
|
|
|
|
const verifySending = ref(false);
|
|
const verifySent = ref(false);
|
|
const verifyError = ref("");
|
|
|
|
async function sendVerification() {
|
|
verifySending.value = true;
|
|
verifyError.value = "";
|
|
try {
|
|
await api.requestVerification();
|
|
verifySent.value = true;
|
|
} catch (e) {
|
|
verifyError.value = e.message;
|
|
} finally {
|
|
verifySending.value = false;
|
|
}
|
|
}
|
|
|
|
// --- Account: password change ---
|
|
|
|
const oldPassword = ref("");
|
|
const newPassword = ref("");
|
|
const confirmPassword = ref("");
|
|
const passwordSaving = ref(false);
|
|
const passwordSaved = ref(false);
|
|
const passwordError = ref("");
|
|
|
|
const passwordMismatch = computed(
|
|
() => confirmPassword.value.length > 0 && newPassword.value !== confirmPassword.value
|
|
);
|
|
|
|
async function savePassword() {
|
|
passwordError.value = "";
|
|
if (newPassword.value.length < 8) {
|
|
passwordError.value = t("settings.account.tooShort");
|
|
return;
|
|
}
|
|
if (passwordMismatch.value) {
|
|
passwordError.value = t("settings.account.mismatch");
|
|
return;
|
|
}
|
|
passwordSaving.value = true;
|
|
try {
|
|
await api.changePassword(oldPassword.value, newPassword.value);
|
|
oldPassword.value = "";
|
|
newPassword.value = "";
|
|
confirmPassword.value = "";
|
|
passwordSaved.value = true;
|
|
setTimeout(() => (passwordSaved.value = false), 2500);
|
|
} catch (e) {
|
|
passwordError.value = e.message;
|
|
} finally {
|
|
passwordSaving.value = false;
|
|
}
|
|
}
|
|
|
|
// --- Appearance (auto-saves on change) ---
|
|
|
|
const appearanceError = ref("");
|
|
const savingAppearance = ref(false);
|
|
|
|
async function saveAppearance(patch) {
|
|
appearanceError.value = "";
|
|
savingAppearance.value = true;
|
|
// Apply immediately for a responsive feel; roll back on failure.
|
|
const previous = { ...prefs };
|
|
applyProfilePrefs({ ...prefs, ...patch });
|
|
try {
|
|
profile.value = await api.updateMe(patch);
|
|
} catch (e) {
|
|
applyProfilePrefs(previous);
|
|
appearanceError.value = e.message;
|
|
} finally {
|
|
savingAppearance.value = false;
|
|
}
|
|
}
|
|
|
|
const dateFormatExample = computed(() => formatDate(new Date().toISOString()));
|
|
const currencyExample = computed(() => formatMoney(1234.5));
|
|
|
|
// Language and region are two controls over the one stored BCP-47 locale, so
|
|
// the pair can be mixed freely (English in Poland, say) rather than being
|
|
// limited to the handful of combinations a single list could offer.
|
|
//
|
|
// Europe here means the sovereign states of the Council of Europe, plus Belarus,
|
|
// Russia, Vatican City and Kosovo — geographically European but not members.
|
|
// Dependencies (Gibraltar, Faroes, Åland) are left out: they are not countries,
|
|
// and the languages/currencies they would add are already covered. US stays on
|
|
// for the region list because it was there before this became a Europe list.
|
|
const LANGUAGE_CODES = [
|
|
"sq", "hy", "az", "eu", "be", "bs", "bg", "ca", "hr", "cs", "da", "nl", "en",
|
|
"et", "fi", "fr", "gl", "ka", "de", "el", "hu", "is", "ga", "it", "lv", "lt",
|
|
"lb", "mk", "mt", "no", "pl", "pt", "ro", "rm", "ru", "sr", "sk", "sl", "es",
|
|
"sv", "tr", "uk", "cy",
|
|
];
|
|
const REGION_CODES = [
|
|
"AD", "AL", "AM", "AT", "AZ", "BA", "BE", "BG", "BY", "CH", "CY", "CZ", "DE",
|
|
"DK", "EE", "ES", "FI", "FR", "GB", "GE", "GR", "HR", "HU", "IE", "IS", "IT",
|
|
"LI", "LT", "LU", "LV", "MC", "MD", "ME", "MK", "MT", "NL", "NO", "PL", "PT",
|
|
"RO", "RS", "RU", "SE", "SI", "SK", "SM", "TR", "UA", "VA", "XK", "US",
|
|
];
|
|
// Mirrors validCurrencies in the API's me.go and the users.currency select in
|
|
// setup-pocketbase.mjs — all three have to list the same codes.
|
|
const CURRENCY_CODES = [
|
|
"EUR", "GBP", "CHF", "PLN", "CZK", "HUF", "RON", "BGN", "DKK", "SEK", "NOK",
|
|
"ISK", "ALL", "AMD", "AZN", "BAM", "BYN", "GEL", "MDL", "MKD", "RSD", "RUB",
|
|
"TRY", "UAH", "USD", "CAD", "AUD", "JPY",
|
|
];
|
|
|
|
// Labels come from Intl rather than a hand-kept translation table, so the lists
|
|
// read in the user's own language ("Deutschland" once German is picked) and
|
|
// sort by what is actually on screen. If a runtime cannot name a code it falls
|
|
// back to the code itself, which is still selectable.
|
|
function named(codes, type, withCode = false) {
|
|
let dn = null;
|
|
try {
|
|
dn = new Intl.DisplayNames([prefs.locale || "en-US"], { type });
|
|
} catch {
|
|
dn = null;
|
|
}
|
|
return codes
|
|
.map((code) => {
|
|
const name = dn?.of(code) || code;
|
|
return { code, label: withCode && name !== code ? `${name} (${code})` : name };
|
|
})
|
|
.sort((a, b) => a.label.localeCompare(b.label, prefs.locale || undefined));
|
|
}
|
|
|
|
const LANGUAGES = computed(() => named(LANGUAGE_CODES, "language"));
|
|
const REGIONS = computed(() => named(REGION_CODES, "region"));
|
|
const CURRENCIES = computed(() => named(CURRENCY_CODES, "currency", true));
|
|
|
|
const language = computed(() => (prefs.locale || "en-US").split("-")[0]);
|
|
const region = computed(() => (prefs.locale || "en-US").split("-")[1] || "US");
|
|
|
|
// The picker offers every European language because the choice also drives date
|
|
// and number formatting, which Intl handles for all of them. Only a few have a
|
|
// translation file, though, so say so rather than letting someone pick Georgian
|
|
// and wonder why the buttons are still English.
|
|
const languageTranslated = computed(() => TRANSLATED_LANGUAGES.includes(language.value));
|
|
|
|
function saveLocale({ lang = language.value, reg = region.value }) {
|
|
return saveAppearance({ locale: `${lang}-${reg}` });
|
|
}
|
|
|
|
// --- Profile: avatar + bio ---
|
|
|
|
const avatarUrl = ref("");
|
|
const avatarUploading = ref(false);
|
|
const avatarError = ref("");
|
|
const fileInput = ref(null);
|
|
|
|
async function loadAvatar() {
|
|
if (!profile.value?.hasAvatar) {
|
|
avatarUrl.value = "";
|
|
return;
|
|
}
|
|
try {
|
|
const { blob } = await api.getAvatarBlob();
|
|
avatarUrl.value = URL.createObjectURL(blob);
|
|
} catch {
|
|
avatarUrl.value = "";
|
|
}
|
|
}
|
|
|
|
function pickAvatar() {
|
|
fileInput.value?.click();
|
|
}
|
|
|
|
async function onAvatarChosen(e) {
|
|
const file = e.target.files?.[0];
|
|
e.target.value = "";
|
|
if (!file) return;
|
|
avatarUploading.value = true;
|
|
avatarError.value = "";
|
|
try {
|
|
profile.value = await api.uploadAvatar(file);
|
|
await loadAvatar();
|
|
} catch (err) {
|
|
avatarError.value = err.message;
|
|
} finally {
|
|
avatarUploading.value = false;
|
|
}
|
|
}
|
|
|
|
async function removeAvatar() {
|
|
avatarUploading.value = true;
|
|
avatarError.value = "";
|
|
try {
|
|
await api.deleteAvatar();
|
|
profile.value = { ...profile.value, hasAvatar: false };
|
|
avatarUrl.value = "";
|
|
} catch (err) {
|
|
avatarError.value = err.message;
|
|
} finally {
|
|
avatarUploading.value = false;
|
|
}
|
|
}
|
|
|
|
const bioDraft = ref("");
|
|
const bioSaving = ref(false);
|
|
const bioSaved = ref(false);
|
|
const bioError = ref("");
|
|
|
|
async function saveBio() {
|
|
bioSaving.value = true;
|
|
bioError.value = "";
|
|
try {
|
|
profile.value = await api.updateMe({ bio: bioDraft.value });
|
|
bioSaved.value = true;
|
|
setTimeout(() => (bioSaved.value = false), 2000);
|
|
} catch (e) {
|
|
bioError.value = e.message;
|
|
} finally {
|
|
bioSaving.value = false;
|
|
}
|
|
}
|
|
|
|
// --- Integrations: Toyota Connected (per-user, cascading settings) ---
|
|
//
|
|
// The server resolves a superadmin → org admin → user cascade and returns, per
|
|
// field, the effective value (secrets/inherited usernames masked), the caller's
|
|
// own-layer value, its source layer, and whether it's locked (set above us).
|
|
// An org admin gets a second "org" scope to edit organization-wide defaults.
|
|
|
|
const toyota = ref(null); // resolved view from the server
|
|
const toyotaScope = ref("user"); // "user" | "org" (org admins only)
|
|
const toyotaForm = ref({ username: "", password: "", brand: "" });
|
|
const toyotaSaving = ref(false);
|
|
const toyotaSaved = ref(false);
|
|
const toyotaError = ref("");
|
|
const toyotaTesting = ref(false);
|
|
const toyotaHealth = ref(null); // { status, detail } from the last test
|
|
|
|
// Superadmins manage the shared (global) layer in the API Server panel; here the
|
|
// credential fields are read-only (they may still toggle their own opt-in).
|
|
const toyotaReadOnly = computed(() => !!toyota.value?.isSuperadmin);
|
|
const toyotaScopeKey = computed(() => (toyota.value?.isSuperadmin ? "user" : toyotaScope.value));
|
|
const toyotaScopeData = computed(
|
|
() => toyota.value?.scopes?.[toyotaScopeKey.value] || { editableLayer: "user", fields: {} }
|
|
);
|
|
const toyotaEditingOrg = computed(() => toyotaScopeKey.value === "org");
|
|
|
|
function toyotaField(k) {
|
|
return toyotaScopeData.value.fields?.[k] || { effective: "", own: "", source: "unset", locked: false };
|
|
}
|
|
// A field is locked when it's set above the caller's editable layer, or the
|
|
// whole scope is read-only (superadmin).
|
|
function toyotaLocked(k) {
|
|
return toyotaReadOnly.value || toyotaField(k).locked;
|
|
}
|
|
// The toggle reflects the personal opt-in normally, or the org gate in org scope.
|
|
const toyotaEnabled = computed(() =>
|
|
toyotaEditingOrg.value ? toyota.value?.orgEnabled : toyota.value?.enabled
|
|
);
|
|
// A friendly "inherited from …" note for a locked field.
|
|
function toyotaSourceLabel(k) {
|
|
const map = { global: "sourceGlobal", org: "sourceOrg", user: "sourceUser" };
|
|
const key = map[toyotaField(k).source] || "sourceGlobal";
|
|
return t("settings.integrations.inheritedFrom", { source: t("settings.integrations." + key) });
|
|
}
|
|
|
|
// Load the editable inputs from the current scope's own-layer values. Locked
|
|
// fields and the secret password are never prefilled.
|
|
function fillToyotaForm() {
|
|
const f = toyotaScopeData.value.fields || {};
|
|
toyotaForm.value = {
|
|
username: f.username?.locked ? "" : f.username?.own || "",
|
|
password: "",
|
|
brand: f.brand?.locked ? "" : f.brand?.own || "",
|
|
};
|
|
}
|
|
|
|
function applyToyotaView(body) {
|
|
toyota.value = body;
|
|
// Keep the selected scope valid (fall back to personal when org isn't offered).
|
|
if (toyotaScope.value === "org" && !body.canEditOrg) toyotaScope.value = "user";
|
|
fillToyotaForm();
|
|
}
|
|
|
|
async function loadToyota() {
|
|
try {
|
|
applyToyotaView(await api.getToyota());
|
|
} catch (e) {
|
|
toyotaError.value = e.message;
|
|
}
|
|
}
|
|
|
|
// Refill the form when an admin flips between personal and organization scope.
|
|
watch(toyotaScope, () => {
|
|
toyotaError.value = "";
|
|
toyotaSaved.value = false;
|
|
toyotaHealth.value = null;
|
|
fillToyotaForm();
|
|
});
|
|
|
|
async function toggleToyota(v) {
|
|
toyotaError.value = "";
|
|
const org = toyotaEditingOrg.value;
|
|
try {
|
|
applyToyotaView(await api.saveToyota(org ? { scope: "org", enabled: v } : { scope: "user", enabled: v }));
|
|
} catch (e) {
|
|
toyotaError.value = e.message;
|
|
}
|
|
}
|
|
|
|
async function saveToyotaSettings() {
|
|
toyotaError.value = "";
|
|
toyotaSaving.value = true;
|
|
toyotaSaved.value = false;
|
|
const config = {};
|
|
for (const k of ["username", "password", "brand"]) {
|
|
if (toyotaLocked(k)) continue;
|
|
// Only send the password when the user actually typed a new one.
|
|
if (k === "password" && !toyotaForm.value.password) continue;
|
|
config[k] = toyotaForm.value[k];
|
|
}
|
|
try {
|
|
applyToyotaView(await api.saveToyota({ scope: toyotaScopeKey.value, config }));
|
|
toyotaSaved.value = true;
|
|
setTimeout(() => (toyotaSaved.value = false), 2000);
|
|
} catch (e) {
|
|
toyotaError.value = e.message;
|
|
} finally {
|
|
toyotaSaving.value = false;
|
|
}
|
|
}
|
|
|
|
async function testToyotaConnection() {
|
|
toyotaError.value = "";
|
|
toyotaHealth.value = null;
|
|
toyotaTesting.value = true;
|
|
try {
|
|
const { health } = await api.testToyota();
|
|
toyotaHealth.value = health;
|
|
} catch (e) {
|
|
toyotaError.value = e.message;
|
|
} finally {
|
|
toyotaTesting.value = false;
|
|
}
|
|
}
|
|
|
|
// --- Integrations: Anker Solix (V1 Smart EV Charger) ---
|
|
//
|
|
// Same superadmin → org admin → user cascade as Toyota above; the fields are the
|
|
// Anker account email + password (resolved as a pair) and a country code.
|
|
|
|
const anker = ref(null); // resolved view from the server
|
|
const ankerScope = ref("user"); // "user" | "org" (org admins only)
|
|
const ankerForm = ref({ email: "", password: "", country: "", controlMode: "off" });
|
|
const ankerSaving = ref(false);
|
|
const ankerSaved = ref(false);
|
|
const ankerError = ref("");
|
|
const ankerTesting = ref(false);
|
|
const ankerHealth = ref(null); // { status, detail } from the last test
|
|
|
|
const ankerReadOnly = computed(() => !!anker.value?.isSuperadmin);
|
|
const ankerScopeKey = computed(() => (anker.value?.isSuperadmin ? "user" : ankerScope.value));
|
|
const ankerScopeData = computed(
|
|
() => anker.value?.scopes?.[ankerScopeKey.value] || { editableLayer: "user", fields: {} }
|
|
);
|
|
const ankerEditingOrg = computed(() => ankerScopeKey.value === "org");
|
|
|
|
function ankerField(k) {
|
|
return ankerScopeData.value.fields?.[k] || { effective: "", own: "", source: "unset", locked: false };
|
|
}
|
|
function ankerLocked(k) {
|
|
return ankerReadOnly.value || ankerField(k).locked;
|
|
}
|
|
const ankerEnabled = computed(() =>
|
|
ankerEditingOrg.value ? anker.value?.orgEnabled : anker.value?.enabled
|
|
);
|
|
function ankerSourceLabel(k) {
|
|
const map = { global: "sourceGlobal", org: "sourceOrg", user: "sourceUser" };
|
|
const key = map[ankerField(k).source] || "sourceGlobal";
|
|
return t("settings.integrations.inheritedFrom", { source: t("settings.integrations." + key) });
|
|
}
|
|
|
|
function fillAnkerForm() {
|
|
const f = ankerScopeData.value.fields || {};
|
|
ankerForm.value = {
|
|
email: f.email?.locked ? "" : f.email?.own || "",
|
|
password: "",
|
|
country: f.country?.locked ? "" : f.country?.own || "",
|
|
// controlMode isn't secret, so show the effective value when it's locked.
|
|
controlMode: f.controlMode?.locked ? f.controlMode?.effective || "off" : f.controlMode?.own || "off",
|
|
};
|
|
}
|
|
|
|
// Effective OCPP control mode (off | own | proxy) — gates the control panel.
|
|
const ankerControlMode = computed(() => anker.value?.controlMode || "off");
|
|
|
|
// --- Anker Solix OCPP control (per-charger provisioning + connection status) ---
|
|
const ankerCtlSerial = ref("");
|
|
const ankerCtl = ref(null); // { endpoint, hasToken, tokenHint, connected, status, ... }
|
|
const ankerCtlLoading = ref(false);
|
|
const ankerCtlError = ref("");
|
|
const ankerNewToken = ref(""); // freshly generated token, shown once
|
|
|
|
async function loadAnkerControl() {
|
|
const sn = ankerCtlSerial.value.trim();
|
|
if (!sn) return;
|
|
ankerCtlError.value = "";
|
|
ankerCtlLoading.value = true;
|
|
try {
|
|
ankerCtl.value = await api.getAnkerControl(sn);
|
|
} catch (e) {
|
|
ankerCtlError.value = e.message;
|
|
} finally {
|
|
ankerCtlLoading.value = false;
|
|
}
|
|
}
|
|
|
|
async function generateAnkerToken() {
|
|
const sn = ankerCtlSerial.value.trim();
|
|
if (!sn) return;
|
|
ankerCtlError.value = "";
|
|
ankerNewToken.value = "";
|
|
try {
|
|
// The token is returned exactly once — capture it here to show the operator.
|
|
const res = await api.ankerControlToken(sn);
|
|
ankerNewToken.value = res.token || "";
|
|
await loadAnkerControl();
|
|
} catch (e) {
|
|
ankerCtlError.value = e.message;
|
|
}
|
|
}
|
|
|
|
async function revokeAnkerToken() {
|
|
const sn = ankerCtlSerial.value.trim();
|
|
if (!sn) return;
|
|
if (!confirm(t("settings.integrations.controlRevokeConfirm"))) return;
|
|
ankerCtlError.value = "";
|
|
ankerNewToken.value = "";
|
|
try {
|
|
await api.ankerControlRevoke(sn);
|
|
await loadAnkerControl();
|
|
} catch (e) {
|
|
ankerCtlError.value = e.message;
|
|
}
|
|
}
|
|
|
|
// Clear the one-time token reveal whenever the operator switches charger.
|
|
watch(ankerCtlSerial, () => {
|
|
ankerNewToken.value = "";
|
|
});
|
|
|
|
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;
|
|
}
|
|
}
|
|
|
|
function onLogout() {
|
|
logout();
|
|
router.replace({ name: "login" });
|
|
}
|
|
|
|
// --- Advanced: export ---
|
|
|
|
const exporting = ref(false);
|
|
const exportError = ref("");
|
|
|
|
async function exportData() {
|
|
exporting.value = true;
|
|
exportError.value = "";
|
|
try {
|
|
const { blob, filename } = await api.exportData();
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement("a");
|
|
a.href = url;
|
|
a.download = filename || "drivervault-export.json";
|
|
a.click();
|
|
URL.revokeObjectURL(url);
|
|
} catch (e) {
|
|
exportError.value = e.message;
|
|
} finally {
|
|
exporting.value = false;
|
|
}
|
|
}
|
|
|
|
// --- Advanced: import ---
|
|
|
|
const importing = ref(false);
|
|
const importError = ref("");
|
|
const importResult = ref(null);
|
|
const importFileInput = ref(null);
|
|
|
|
function pickImportFile() {
|
|
importFileInput.value?.click();
|
|
}
|
|
|
|
async function onImportFileChosen(e) {
|
|
const file = e.target.files?.[0];
|
|
e.target.value = "";
|
|
if (!file) return;
|
|
|
|
importError.value = "";
|
|
importResult.value = null;
|
|
|
|
let payload;
|
|
try {
|
|
payload = JSON.parse(await file.text());
|
|
} catch {
|
|
importError.value = t("settings.advanced.notJson");
|
|
return;
|
|
}
|
|
if (!Array.isArray(payload?.cars) || payload.cars.length === 0) {
|
|
importError.value = t("settings.advanced.notExport");
|
|
return;
|
|
}
|
|
if (!confirm(t("settings.advanced.confirmImport", { count: payload.cars.length }))) {
|
|
return;
|
|
}
|
|
|
|
importing.value = true;
|
|
try {
|
|
importResult.value = await api.importData(payload);
|
|
} catch (err) {
|
|
importError.value = err.message;
|
|
} finally {
|
|
importing.value = false;
|
|
}
|
|
}
|
|
|
|
// --- Danger zone: delete account (typed confirmation + cooldown) ---
|
|
|
|
const showDeleteConfirm = ref(false);
|
|
const deleteConfirmEmail = ref("");
|
|
const deleteRequesting = ref(false);
|
|
const deleteError = ref("");
|
|
const eligibleAt = ref(null); // set once a deletion request succeeds this session
|
|
|
|
const deletionPending = computed(() => !!profile.value?.deletionRequestedAt);
|
|
const cooldownElapsed = computed(() => {
|
|
if (!deletionPending.value) return false;
|
|
const eligible = eligibleAt.value || new Date(new Date(profile.value.deletionRequestedAt).getTime() + 3 * 24 * 60 * 60 * 1000);
|
|
return new Date() >= eligible;
|
|
});
|
|
const canRequestDelete = computed(
|
|
() => deleteConfirmEmail.value.trim().toLowerCase() === (profile.value?.email || "").toLowerCase()
|
|
);
|
|
|
|
async function requestDeletion() {
|
|
if (!canRequestDelete.value) return;
|
|
deleteRequesting.value = true;
|
|
deleteError.value = "";
|
|
try {
|
|
const res = await api.requestAccountDeletion(deleteConfirmEmail.value.trim());
|
|
eligibleAt.value = new Date(res.eligibleAt);
|
|
profile.value = { ...profile.value, deletionRequestedAt: new Date().toISOString() };
|
|
showDeleteConfirm.value = false;
|
|
deleteConfirmEmail.value = "";
|
|
} catch (e) {
|
|
deleteError.value = e.message;
|
|
} finally {
|
|
deleteRequesting.value = false;
|
|
}
|
|
}
|
|
|
|
async function cancelDeletion() {
|
|
deleteError.value = "";
|
|
try {
|
|
await api.cancelAccountDeletion();
|
|
profile.value = { ...profile.value, deletionRequestedAt: null };
|
|
eligibleAt.value = null;
|
|
} catch (e) {
|
|
deleteError.value = e.message;
|
|
}
|
|
}
|
|
|
|
async function finalizeDeletion() {
|
|
if (!confirm(t("settings.danger.confirmFinalize"))) return;
|
|
deleteError.value = "";
|
|
try {
|
|
await api.finalizeAccountDeletion();
|
|
onLogout();
|
|
} catch (e) {
|
|
deleteError.value = e.message;
|
|
}
|
|
}
|
|
|
|
onMounted(async () => {
|
|
await load();
|
|
initDrafts();
|
|
await loadAvatar();
|
|
await loadToyota();
|
|
await loadAnkerSolix();
|
|
});
|
|
|
|
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 vs. integrations -->
|
|
<div class="mb-6 flex gap-2 border-b border-subtle">
|
|
<button
|
|
v-for="tab in ['personal', 'integrations']"
|
|
:key="tab"
|
|
class="-mb-px border-b-2 px-1 pb-3 text-sm font-semibold transition-colors"
|
|
:class="activeTab === tab
|
|
? 'border-accent text-strong'
|
|
: 'border-transparent text-muted hover:text-body'"
|
|
@click="activeTab = tab"
|
|
>
|
|
{{ t(`settings.tabs.${tab}`) }}
|
|
</button>
|
|
</div>
|
|
|
|
<!-- Personal settings -->
|
|
<div v-show="activeTab === 'personal'" class="space-y-6">
|
|
<!-- Account -->
|
|
<section class="dh-card p-6">
|
|
<h2 class="mb-4 text-lg font-bold tracking-[-0.02em] text-strong">{{ t("settings.account.title") }}</h2>
|
|
|
|
<div class="mb-5">
|
|
<label class="dh-label">{{ t("settings.account.name") }}</label>
|
|
<div class="flex gap-2">
|
|
<input v-model="nameDraft" class="dh-input max-w-sm" />
|
|
<button class="dh-btn dh-btn-primary" :disabled="nameSaving || !nameDraft.trim()" @click="saveName">
|
|
{{ nameSaving ? t("common.saving") : nameSaved ? t("common.saved") : t("common.save") }}
|
|
</button>
|
|
</div>
|
|
<p v-if="nameError" class="mt-1 text-sm text-danger">{{ nameError }}</p>
|
|
</div>
|
|
|
|
<div class="mb-5">
|
|
<label class="dh-label">{{ t("settings.account.email") }}</label>
|
|
<div class="flex flex-wrap items-center gap-2">
|
|
<span class="data rounded-control border border-subtle bg-sunken px-3 py-2 text-sm text-body">
|
|
{{ profile.email }}
|
|
</span>
|
|
<span class="dh-badge" :class="profile.verified ? 'dh-badge-success' : 'dh-badge-warning'">
|
|
{{ profile.verified ? t("settings.account.verified") : t("settings.account.notVerified") }}
|
|
</span>
|
|
<button
|
|
v-if="!profile.verified && !verifySent"
|
|
class="text-sm font-medium text-brandtext hover:underline disabled:opacity-50"
|
|
:disabled="verifySending"
|
|
@click="sendVerification"
|
|
>
|
|
{{ verifySending ? t("settings.account.sending") : t("settings.account.resendVerification") }}
|
|
</button>
|
|
<span v-if="verifySent" class="text-sm text-muted">{{ t("settings.account.verificationRequested") }}</span>
|
|
</div>
|
|
<p v-if="verifyError" class="mt-1 text-sm text-danger">{{ verifyError }}</p>
|
|
</div>
|
|
|
|
<div>
|
|
<h3 class="mb-2 text-sm font-semibold text-strong">{{ t("settings.account.changePassword") }}</h3>
|
|
<div class="grid max-w-sm gap-2">
|
|
<input v-model="oldPassword" type="password" :placeholder="t('settings.account.currentPassword')" autocomplete="current-password" class="dh-input" />
|
|
<input v-model="newPassword" type="password" :placeholder="t('settings.account.newPassword')" autocomplete="new-password" class="dh-input" />
|
|
<input v-model="confirmPassword" type="password" :placeholder="t('settings.account.confirmNewPassword')" autocomplete="new-password" class="dh-input" />
|
|
</div>
|
|
<p v-if="passwordMismatch" class="mt-1 text-sm text-warning">{{ t("settings.account.mismatchYet") }}</p>
|
|
<p v-if="passwordError" class="mt-1 text-sm text-danger">{{ passwordError }}</p>
|
|
<button class="dh-btn dh-btn-ghost mt-2" :disabled="passwordSaving || !oldPassword || !newPassword" @click="savePassword">
|
|
{{ passwordSaving ? t("settings.account.updating") : passwordSaved ? t("settings.account.passwordUpdated") : t("settings.account.updatePassword") }}
|
|
</button>
|
|
</div>
|
|
</section>
|
|
|
|
<!-- Appearance -->
|
|
<section class="dh-card p-6">
|
|
<h2 class="mb-4 text-lg font-bold tracking-[-0.02em] text-strong">{{ t("settings.appearance.title") }}</h2>
|
|
|
|
<div class="mb-5">
|
|
<label class="dh-label">{{ t("settings.appearance.theme") }}</label>
|
|
<div class="flex gap-2">
|
|
<button
|
|
v-for="opt in ['light', 'dark', 'system']"
|
|
:key="opt"
|
|
class="rounded-control border px-3 py-1.5 text-sm font-medium transition-colors"
|
|
:class="prefs.theme === opt
|
|
? 'border-accent bg-accent text-white'
|
|
: 'border-subtle text-body hover:bg-sunken hover:text-strong'"
|
|
@click="saveAppearance({ theme: opt })"
|
|
>
|
|
{{ t(`settings.appearance.theme${opt.charAt(0).toUpperCase() + opt.slice(1)}`) }}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="mb-5 grid gap-4 sm:grid-cols-2">
|
|
<div>
|
|
<label class="dh-label">{{ t("settings.appearance.language") }}</label>
|
|
<select :value="language" class="dh-input" @change="saveLocale({ lang: $event.target.value })">
|
|
<option v-for="l in LANGUAGES" :key="l.code" :value="l.code">{{ l.label }}</option>
|
|
</select>
|
|
<p class="mt-1 text-xs" :class="languageTranslated ? 'text-muted' : 'text-warning'">
|
|
{{ languageTranslated ? t("settings.appearance.languageHint") : t("settings.appearance.languageFallbackHint") }}
|
|
</p>
|
|
</div>
|
|
|
|
<div>
|
|
<label class="dh-label">{{ t("settings.appearance.region") }}</label>
|
|
<select :value="region" class="dh-input" @change="saveLocale({ reg: $event.target.value })">
|
|
<option v-for="r in REGIONS" :key="r.code" :value="r.code">{{ r.label }}</option>
|
|
</select>
|
|
<p class="mt-1 text-xs text-muted">{{ t("settings.appearance.regionHint") }}</p>
|
|
</div>
|
|
|
|
<div>
|
|
<label class="dh-label">{{ t("settings.appearance.dateFormat") }}</label>
|
|
<select :value="prefs.dateFormat" class="dh-input" @change="saveAppearance({ dateFormat: $event.target.value })">
|
|
<option value="YMD">YYYY-MM-DD</option>
|
|
<option value="DMY_NUM">DD-MM-YYYY</option>
|
|
<option value="DMY">DD Mon YYYY</option>
|
|
<option value="MDY">Mon DD, YYYY</option>
|
|
</select>
|
|
<p class="mt-1 text-xs text-muted">{{ t("settings.appearance.dateExample", { example: dateFormatExample }) }}</p>
|
|
</div>
|
|
|
|
<div>
|
|
<label class="dh-label">{{ t("settings.appearance.currency") }}</label>
|
|
<select :value="prefs.currency" class="dh-input" @change="saveAppearance({ currency: $event.target.value })">
|
|
<option v-for="c in CURRENCIES" :key="c.code" :value="c.code">{{ c.label }}</option>
|
|
</select>
|
|
<p class="mt-1 text-xs text-muted">{{ t("settings.appearance.currencyExample", { example: currencyExample }) }}</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<label class="dh-label">{{ t("settings.appearance.fontSize") }}</label>
|
|
<div class="flex gap-2">
|
|
<button
|
|
v-for="f in ['small', 'medium', 'large']"
|
|
:key="f"
|
|
class="rounded-control border px-3 py-1.5 text-sm font-medium transition-colors"
|
|
:class="prefs.fontSize === f
|
|
? 'border-accent bg-accent text-white'
|
|
: 'border-subtle text-body hover:bg-sunken hover:text-strong'"
|
|
@click="saveAppearance({ fontSize: f })"
|
|
>
|
|
{{ t(`settings.appearance.font${f.charAt(0).toUpperCase() + f.slice(1)}`) }}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<p v-if="appearanceError" class="mt-3 text-sm text-danger">{{ appearanceError }}</p>
|
|
</section>
|
|
|
|
<!-- Profile -->
|
|
<section class="dh-card p-6">
|
|
<h2 class="mb-4 text-lg font-bold tracking-[-0.02em] text-strong">{{ t("settings.profile.title") }}</h2>
|
|
|
|
<div class="mb-5 flex items-center gap-4">
|
|
<img v-if="avatarUrl" :src="avatarUrl" :alt="t('settings.profile.avatarAlt')" class="h-16 w-16 rounded-full object-cover ring-1 ring-subtle" />
|
|
<div v-else class="grid h-16 w-16 place-items-center rounded-full bg-brand-100 text-xl font-bold text-brandtext">
|
|
{{ (profile.name || profile.email || "?").charAt(0).toUpperCase() }}
|
|
</div>
|
|
<div class="flex gap-2">
|
|
<button class="dh-btn dh-btn-ghost !px-3 !py-1.5" :disabled="avatarUploading" @click="pickAvatar">
|
|
{{ avatarUploading ? t("settings.profile.uploading") : t("settings.profile.uploadPhoto") }}
|
|
</button>
|
|
<button v-if="profile.hasAvatar" class="dh-btn dh-btn-ghost !px-3 !py-1.5" :disabled="avatarUploading" @click="removeAvatar">
|
|
{{ t("common.remove") }}
|
|
</button>
|
|
</div>
|
|
<input ref="fileInput" type="file" accept="image/png,image/jpeg,image/gif,image/webp,image/svg+xml" class="hidden" @change="onAvatarChosen" />
|
|
</div>
|
|
<p v-if="avatarError" class="mb-4 text-sm text-danger">{{ avatarError }}</p>
|
|
|
|
<div>
|
|
<label class="dh-label">{{ t("settings.profile.bio") }}</label>
|
|
<textarea
|
|
v-model="bioDraft"
|
|
rows="3"
|
|
:placeholder="t('settings.profile.bioPlaceholder')"
|
|
class="dh-input"
|
|
/>
|
|
<p v-if="bioError" class="mt-1 text-sm text-danger">{{ bioError }}</p>
|
|
<button class="dh-btn dh-btn-ghost mt-2" :disabled="bioSaving" @click="saveBio">
|
|
{{ bioSaving ? t("common.saving") : bioSaved ? t("common.saved") : t("settings.profile.saveBio") }}
|
|
</button>
|
|
</div>
|
|
</section>
|
|
|
|
</div>
|
|
|
|
<!-- Integrations -->
|
|
<div v-show="activeTab === 'integrations'" class="space-y-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 v-if="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" class="mt-4 rounded-control border border-subtle p-4">
|
|
<button
|
|
type="button"
|
|
class="flex w-full items-start justify-between gap-3 text-left"
|
|
:aria-expanded="ankerOpen"
|
|
@click="ankerOpen = !ankerOpen"
|
|
>
|
|
<div>
|
|
<p class="text-sm font-semibold text-strong">{{ t("settings.integrations.ankerSolix") }}</p>
|
|
<p class="mt-0.5 text-xs text-muted">{{ t("settings.integrations.ankerSolixDesc") }}</p>
|
|
</div>
|
|
<div class="flex shrink-0 items-center gap-2">
|
|
<span
|
|
v-if="!ankerEditingOrg"
|
|
class="dh-badge"
|
|
:class="anker.enabled ? 'dh-badge-success' : 'dh-badge-warning'"
|
|
>
|
|
{{ anker.enabled ? t("settings.integrations.connected") : t("settings.integrations.notConnected") }}
|
|
</span>
|
|
<svg
|
|
viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
|
|
class="h-4 w-4 text-muted transition-transform" :class="ankerOpen ? 'rotate-180' : ''"
|
|
><path stroke-linecap="round" stroke-linejoin="round" d="m6 9 6 6 6-6" /></svg>
|
|
</div>
|
|
</button>
|
|
|
|
<div v-show="ankerOpen">
|
|
<!-- Master / org gates -->
|
|
<p v-if="!anker.available" class="mt-3 text-sm text-warning">{{ t("settings.integrations.unavailable") }}</p>
|
|
<p
|
|
v-else-if="anker.orgId && !anker.orgEnabled && !ankerEditingOrg"
|
|
class="mt-3 text-sm text-warning"
|
|
>
|
|
{{ t("settings.integrations.orgDisabled") }}
|
|
</p>
|
|
|
|
<template v-else>
|
|
<!-- Scope switch (org admins) -->
|
|
<div v-if="anker.canEditOrg" class="mt-4 flex gap-2">
|
|
<button
|
|
v-for="sc in ['user', 'org']"
|
|
:key="sc"
|
|
class="rounded-control border px-3 py-1.5 text-sm font-medium transition-colors"
|
|
:class="ankerScope === sc
|
|
? 'border-accent bg-accent text-white'
|
|
: 'border-subtle text-body hover:bg-sunken hover:text-strong'"
|
|
@click="ankerScope = sc"
|
|
>
|
|
{{ sc === 'org' ? t("settings.integrations.scopeOrg") : t("settings.integrations.scopeMy") }}
|
|
</button>
|
|
</div>
|
|
<p v-if="ankerEditingOrg" class="mt-2 text-xs text-muted">{{ t("settings.integrations.scopeHint") }}</p>
|
|
<p v-if="ankerReadOnly" class="mt-3 text-xs text-muted">{{ t("settings.integrations.readOnly") }}</p>
|
|
|
|
<!-- Enable toggle -->
|
|
<label class="mt-4 flex items-center gap-2 text-sm font-medium text-body">
|
|
<input
|
|
type="checkbox"
|
|
class="h-4 w-4 rounded border-subtle text-accent focus:ring-accent"
|
|
:checked="ankerEnabled"
|
|
@change="toggleAnker($event.target.checked)"
|
|
/>
|
|
<span>{{ ankerEditingOrg ? t("settings.integrations.enableOrg") : t("settings.integrations.enable") }}</span>
|
|
</label>
|
|
|
|
<!-- Credential fields -->
|
|
<div class="mt-4 grid max-w-sm gap-3">
|
|
<div>
|
|
<label class="dh-label">{{ t("settings.integrations.ankerEmail") }}</label>
|
|
<input
|
|
v-model="ankerForm.email"
|
|
class="dh-input"
|
|
:disabled="ankerLocked('email')"
|
|
:placeholder="ankerLocked('email') ? '••••••••' : ''"
|
|
autocomplete="off"
|
|
/>
|
|
<p v-if="ankerField('email').locked" class="mt-1 text-xs text-muted">{{ ankerSourceLabel('email') }}</p>
|
|
</div>
|
|
<div>
|
|
<label class="dh-label">{{ t("settings.integrations.ankerPassword") }}</label>
|
|
<input
|
|
v-model="ankerForm.password"
|
|
type="password"
|
|
class="dh-input"
|
|
:disabled="ankerLocked('password')"
|
|
:placeholder="ankerField('password').effective ? '••••••••' : ''"
|
|
autocomplete="new-password"
|
|
/>
|
|
<p v-if="ankerField('password').locked" class="mt-1 text-xs text-muted">{{ ankerSourceLabel('password') }}</p>
|
|
<p v-else class="mt-1 text-xs text-muted">{{ t("settings.integrations.passwordKeep") }}</p>
|
|
</div>
|
|
<div>
|
|
<label class="dh-label">{{ t("settings.integrations.country") }}</label>
|
|
<input
|
|
v-model="ankerForm.country"
|
|
class="dh-input"
|
|
:disabled="ankerLocked('country')"
|
|
placeholder="DE"
|
|
maxlength="2"
|
|
autocomplete="off"
|
|
/>
|
|
<p v-if="ankerField('country').locked" class="mt-1 text-xs text-muted">{{ ankerSourceLabel('country') }}</p>
|
|
<p v-else class="mt-1 text-xs text-muted">{{ t("settings.integrations.countryHint") }}</p>
|
|
</div>
|
|
<div>
|
|
<label class="dh-label">{{ t("settings.integrations.controlMode") }}</label>
|
|
<select v-model="ankerForm.controlMode" class="dh-input" :disabled="ankerLocked('controlMode')">
|
|
<option value="off">{{ t("settings.integrations.controlOff") }}</option>
|
|
<option value="own">{{ t("settings.integrations.controlOwn") }}</option>
|
|
<option value="proxy">{{ t("settings.integrations.controlProxy") }}</option>
|
|
</select>
|
|
<p v-if="ankerField('controlMode').locked" class="mt-1 text-xs text-muted">{{ ankerSourceLabel('controlMode') }}</p>
|
|
<p v-else class="mt-1 text-xs text-muted">{{ t("settings.integrations.controlModeHint") }}</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="mt-4 flex items-center gap-2">
|
|
<button
|
|
v-if="!ankerReadOnly"
|
|
class="dh-btn dh-btn-primary"
|
|
:disabled="ankerSaving"
|
|
@click="saveAnkerSettings"
|
|
>
|
|
{{ ankerSaving ? t("common.saving") : ankerSaved ? t("settings.integrations.saved") : t("settings.integrations.save") }}
|
|
</button>
|
|
<button class="dh-btn dh-btn-ghost" :disabled="ankerTesting" @click="testAnkerConnection">
|
|
{{ ankerTesting ? t("settings.integrations.testing") : t("settings.integrations.test") }}
|
|
</button>
|
|
</div>
|
|
|
|
<p
|
|
v-if="ankerHealth"
|
|
class="mt-2 text-sm"
|
|
:class="ankerHealth.status === 'ok' ? 'text-success' : 'text-danger'"
|
|
>
|
|
{{ ankerHealth.detail || ankerHealth.status }}
|
|
</p>
|
|
|
|
<!-- OCPP control provisioning (only when a control mode is active) -->
|
|
<div v-if="ankerControlMode !== 'off'" class="mt-5 rounded-control border border-subtle bg-sunken/40 p-4">
|
|
<p class="text-sm font-semibold text-strong">{{ t("settings.integrations.controlTitle") }}</p>
|
|
<p class="mt-0.5 text-xs text-muted">
|
|
{{ ankerControlMode === 'own' ? t("settings.integrations.controlOwnHint") : t("settings.integrations.controlProxyHint") }}
|
|
</p>
|
|
|
|
<div class="mt-3 flex flex-wrap items-end gap-2">
|
|
<div class="grow">
|
|
<label class="dh-label">{{ t("settings.integrations.chargerSerial") }}</label>
|
|
<input v-model="ankerCtlSerial" class="dh-input" placeholder="A5191-XXXXXXXX" autocomplete="off" />
|
|
</div>
|
|
<button class="dh-btn dh-btn-ghost" :disabled="!ankerCtlSerial.trim() || ankerCtlLoading" @click="loadAnkerControl">
|
|
{{ ankerCtlLoading ? t("settings.integrations.testing") : t("settings.integrations.controlCheck") }}
|
|
</button>
|
|
<button class="dh-btn dh-btn-primary" :disabled="!ankerCtlSerial.trim()" @click="generateAnkerToken">
|
|
{{ t("settings.integrations.controlGenerate") }}
|
|
</button>
|
|
<button
|
|
v-if="ankerCtl && ankerCtl.hasToken"
|
|
class="dh-btn dh-btn-ghost !text-danger"
|
|
:disabled="!ankerCtlSerial.trim()"
|
|
@click="revokeAnkerToken"
|
|
>
|
|
{{ t("settings.integrations.controlRevoke") }}
|
|
</button>
|
|
</div>
|
|
|
|
<!-- The token is shown exactly once, right after generation. -->
|
|
<div v-if="ankerNewToken" class="mt-3 rounded-control border border-warning/40 bg-warning-soft px-3 py-2">
|
|
<p class="text-xs font-semibold text-warning">{{ t("settings.integrations.controlTokenOnce") }}</p>
|
|
<code class="data mt-1 block break-all text-sm text-body">{{ ankerNewToken }}</code>
|
|
</div>
|
|
|
|
<div v-if="ankerCtl" class="mt-3 grid gap-2 text-sm">
|
|
<div>
|
|
<span class="text-muted">{{ t("settings.integrations.controlEndpoint") }}: </span>
|
|
<code class="data break-all text-body">{{ ankerCtl.endpoint }}</code>
|
|
</div>
|
|
<div v-if="ankerCtl.hasToken">
|
|
<span class="text-muted">{{ t("settings.integrations.controlToken") }}: </span>
|
|
<code class="data text-body">••••{{ ankerCtl.tokenHint }}</code>
|
|
</div>
|
|
<div class="flex items-center gap-2">
|
|
<span class="dh-badge" :class="ankerCtl.connected ? 'dh-badge-success' : 'dh-badge-warning'">
|
|
{{ ankerCtl.connected ? t("settings.integrations.controlConnected") : t("settings.integrations.controlDisconnected") }}
|
|
</span>
|
|
<span v-if="ankerCtl.status?.connectorStatus" class="text-muted">{{ ankerCtl.status.connectorStatus }}</span>
|
|
</div>
|
|
<p class="text-xs text-muted">{{ t("settings.integrations.controlProvisionSteps") }}</p>
|
|
</div>
|
|
<p v-if="ankerCtlError" class="mt-2 text-sm text-danger">{{ ankerCtlError }}</p>
|
|
</div>
|
|
</template>
|
|
|
|
<p v-if="ankerError" class="mt-2 text-sm text-danger">{{ ankerError }}</p>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
</div>
|
|
|
|
<!-- Personal settings (continued) -->
|
|
<div v-show="activeTab === 'personal'" class="space-y-6">
|
|
<!-- Organization: create your own (becoming its admin), or manage it -->
|
|
<OrgManager />
|
|
|
|
<!-- 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>
|
|
</div>
|
|
</div>
|
|
</template>
|