Repository: evcc-io/evcc
Pull request #13253 (2024-04-01 16:15:19)

Add check for phaseSwitchCompleted to avoid potential unnecessary warning


Patch:
diff --git a/.github/workflows/docs-issue.yml b/.github/workflows/docs-issue.yml
new file mode 100644
index 00000000..17c6f734
--- /dev/null
+++ b/.github/workflows/docs-issue.yml
@@ -0,0 +1,25 @@
+name: Create Documentation Issue
+
+on:
+  pull_request:
+    types: [labeled]
+
+jobs:
+  check-label-and-create-issue:
+    runs-on: ubuntu-latest
+    if: github.event.label.name == 'needs documentation'
+    steps:
+      - name: Create Docs Issue
+        uses: actions/github-script@v7
+        with:
+          github-token: ${{ secrets.GH_TOKEN }}
+          script: |
+            const title = `Document: ${context.payload.pull_request.title}`;
+            const body = `We need to document the new feature introduced in this PR: ${context.payload.pull_request.html_url}`;
+
+            await github.rest.issues.create({
+              owner: 'evcc-io',
+              repo: 'docs',
+              title: title,
+              body: body
+            });
diff --git a/assets/js/api.js b/assets/js/api.js
index 9ba3b5b4..45859733 100644
--- a/assets/js/api.js
+++ b/assets/js/api.js
@@ -1,4 +1,5 @@
 import axios from "axios";
+import { openLoginModal } from "./auth";
 
 const { protocol, hostname, port, pathname } = window.location;
 
@@ -15,13 +16,19 @@ const api = axios.create({
 api.interceptors.response.use(
   (response) => response,
   (error) => {
+    // handle unauthorized errors
+    if (error.response?.status === 401) {
+      openLoginModal();
+      return Promise.reject(error);
+    }
+
     const message = [`${error.message}.`];
     if (error.response?.data?.error) {
       message.push(`${error.response.data.error}.`);
     }
     if (error.config) {
       const method = error.config.method.toUpperCase();
-      const url = error.config.baseURL + error.config.url;
+      const url = error.request.responseURL;
       message.push(`${method} ${url}`);
     }
     window.app.raise({ message });
diff --git a/assets/js/auth.js b/assets/js/auth.js
new file mode 100644
index 00000000..ba85b006
--- /dev/null
+++ b/assets/js/auth.js
@@ -0,0 +1,58 @@
+import { reactive, watch } from "vue";
+import api from "./api";
+import Modal from "bootstrap/js/dist/modal";
+
+const auth = reactive({
+  configured: true,
+  loggedIn: false,
+});
+
+export async function updateAuthStatus() {
+  try {
+    const res = await api.get("/auth/status", {
+      validateStatus: (code) => [200, 501].includes(code),
+    });
+    if (res.status === 501) {
+      auth.configured = false;
+    }
+    if (res.status === 200) {
+      auth.configured = true;
+      auth.loggedIn = res.data === true;
+    }
+  } catch (e) {
+    console.log("unable to fetch auth status", e);
+  }
+}
+
+export async function logout() {
+  try {
+    await api.post("/auth/logout");
+    await updateAuthStatus();
+  } catch (e) {
+    console.log("unable to logout", e);
+  }
+}
+
+export function isLoggedIn() {
+  return auth.loggedIn;
+}
+
+export function isConfigured() {
+  return auth.configured;
+}
+
+export function openLoginModal() {
+  const modal = Modal.getOrCreateInstance(document.getElementById("loginModal"));
+  modal.show();
+}
+
+// show/hide password modal based on auth status
+watch(
+  () => auth.configured,
+  (configured) => {
+    const modal = Modal.getOrCreateInstance(document.getElementById("passwordModal"));
+    configured ? modal.hide() : modal.show();
+  }
+);
+
+export default auth;
diff --git a/assets/js/components/Config/GeneralConfig.vue b/assets/js/components/Config/GeneralConfig.vue
index cf9ef3bb..ee4eba81 100644
--- a/assets/js/components/Config/GeneralConfig.vue
+++ b/assets/js/components/Config/GeneralConfig.vue
@@ -27,11 +27,17 @@
 				</a>
 			</dd>
 		</dl>
-		<dl class="row wip">
+		<dl class="row wip" data-testid="generalconfig-password">
 			<dt class="col-sm-4">Password</dt>
 			<dd class="col-sm-8">
 				*******
-				<a href="#" class="ms-2 d-inline-block text-muted" @click.prevent="todo">edit</a>
+				<!-- TODO: update this once auth is released -->
+				<a
+					href="#"
+					class="ms-2 d-inline-block text-muted"
+					@click.prevent="todo() /* openModal('passwordModal')*/"
+					>edit</a
+				>
 			</dd>
 		</dl>
 		<dl class="row wip">
diff --git a/assets/js/components/GenericModal.vue b/assets/js/components/GenericModal.vue
index fbd3a0d5..8e4c0ba0 100644
--- a/assets/js/components/GenericModal.vue
+++ b/assets/js/components/GenericModal.vue
@@ -4,10 +4,12 @@
 			:id="id"
 			ref="modal"
 			class="modal fade text-dark"
-			data-bs-backdrop="true"
+			:class="sizeClass"
 			tabindex="-1"
 			role="dialog"
 			aria-hidden="true"
+			:data-bs-backdrop="uncloseable ? 'static' : 'true'"
+			:data-bs-keyboard="uncloseable ? 'false' : 'true'"
 			:data-testid="dataTestid"
 		>
 			<div class="modal-dialog modal-dialog-centered" role="document">
@@ -17,6 +19,7 @@
 							{{ title }}
 						</h5>
 						<button
+							v-if="!uncloseable"
 							type="button"
 							class="btn-close"
 							data-bs-dismiss="modal"
@@ -41,27 +44,29 @@ export default {
 		id: String,
 		title: String,
 		dataTestid: String,
+		uncloseable: Boolean,
+		size: String,
 	},
-	emits: ["visibilityChanged"],
-	watch: {
-		isModalVisible(visible) {
-			this.$emit("changed", visible);
-		},
-	},
+	emits: ["open", "closed"],
 	mounted() {
-		this.$refs.modal.addEventListener("show.bs.modal", this.modalVisible);
-		this.$refs.modal.addEventListener("hide.bs.modal", this.modalInvisible);
+		this.$refs.modal.addEventListener("show.bs.modal", this.handleShow);
+		this.$refs.modal.addEventListener("hidden.bs.modal", this.handleHidden);
 	},
 	unmounted() {
-		this.$refs.modal?.removeEventListener("show.bs.modal", this.modalVisible);
-		this.$refs.modal?.removeEventListener("hide.bs.modal", this.modalInvisible);
+		this.$refs.modal?.removeEventListener("show.bs.modal", this.handleShow);
+		this.$refs.modal?.removeEventListener("hidden.bs.modal", this.handleHidden);
+	},
+	computed: {
+		sizeClass() {
+			return this.size ? `modal-${this.size}` : "";
+		},
 	},
 	methods: {
-		modalVisible() {
-			this.isModalVisible = true;
+		handleShow() {
+			this.$emit("open");
 		},
-		modalInvisible() {
-			this.isModalVisible = false;
+		handleHidden() {
+			this.$emit("closed");
 		},
 		open() {
 			Modal.getOrCreateInstance(this.$refs.modal).show();
diff --git a/assets/js/components/HelpModal.vue b/assets/js/components/HelpModal.vue
index 45b99d93..e367d568 100644
--- a/assets/js/components/HelpModal.vue
+++ b/assets/js/components/HelpModal.vue
@@ -105,7 +105,7 @@
 					<div class="modal-footer d-flex justify-content-between">
 						<button
 							type="button"
-							class="btn btn-outline-secondary"
+							class="btn btn-link text-muted"
 							data-bs-dismiss="modal"
 							@click="openHelpModal"
 						>
diff --git a/assets/js/components/LoginModal.vue b/assets/js/components/LoginModal.vue
new file mode 100644
index 00000000..430d5050
--- /dev/null
+++ b/assets/js/components/LoginModal.vue
@@ -0,0 +1,98 @@
+<template>
+	<GenericModal
+		id="loginModal"
+		:title="$t('loginModal.title')"
+		size="sm"
+		data-testid="login-modal"
+		@open="open"
+		@closed="closed"
+	>
+		<form v-if="modalVisible" @submit.prevent="login">
+			<p v-if="error" class="text-danger">{{ $t("loginModal.error") }}{{ error }}</p>
+
+			<div class="mb-4">
+				<label for="loginPassword" class="col-form-label">
+					<div class="w-100">
+						<span class="label">{{ $t("loginModal.password") }}</span>
+					</div>
+				</label>
+				<input
+					id="loginPassword"
+					v-model="password"
+					ref="password"
+					class="form-control"
+					autocomplete="current-password"
+					type="password"
+					required
+				/>
+			</div>
+
+			<button type="submit" class="btn btn-primary w-100 mb-3" :disabled="loading">
+				{{ $t("loginModal.login") }}
+			</button>
+		</form>
+	</GenericModal>
+</template>
+
+<script>
+import GenericModal from "./GenericModal.vue";
+import Modal from "bootstrap/js/dist/modal";
+import api from "../api";
+import { updateAuthStatus } from "../auth";
+
+export default {
+	name: "LoginModal",
+	components: { GenericModal },
+	data: () => {
+		return {
+			modalVisible: false,
+			password: "",
+			loading: false,
+			error: "",
+		};
+	},
+	methods: {
+		open() {
+			this.modalVisible = true;
+		},
+		closed() {
+			this.modalVisible = false;
+			this.password = "";
+			this.loading = false;
+			this.error = "";
+		},
+		focus() {
+			console.log(this.$refs.password);
+			this.$refs.password.focus();
+		},
+		closeModal() {
+			Modal.getOrCreateInstance(document.getElementById("loginModal")).hide();
+		},
+		async login() {
+			this.loading = true;
+			this.error = "";
+
+			try {
+				const data = { password: this.password };
+				const res = await api.post("/auth/login", data, {
+					validateStatus: (code) => [200, 401].includes(code),
+				});
+				if (res.status === 200) {
+					this.closeModal();
+					await updateAuthStatus();
+					this.$router.push({ path: "/config" });
+					this.password = "";
+				}
+				if (res.status === 401) {
+					this.error = this.$t("loginModal.invalid");
+				}
+			} catch (err) {
+				console.error(err);
+				this.error = err.message;
+			} finally {
+				this.loading = false;
+			}
+		},
+	},
+};
+</script>
diff --git a/assets/js/components/PasswordModal.vue b/assets/js/components/PasswordModal.vue
new file mode 100644
index 00000000..a49091f6
--- /dev/null
+++ b/assets/js/components/PasswordModal.vue
@@ -0,0 +1,177 @@
+<template>
+	<GenericModal
+		id="passwordModal"
+		:title="title"
+		ref="modal"
+		data-testid="password-modal"
+		:uncloseable="!updateMode"
+		@open="open"
+		@closed="closed"
+	>
+		<p v-if="error" class="text-danger">{{ $t("passwordModal.error") }} "{{ error.trim() }}"</p>
+		<p v-if="!updateMode" class="mb-4">{{ $t("passwordModal.description") }}</p>
+		<form
+			v-if="modalVisible"
+			ref="form"
+			:class="{ 'was-validated': showValidation }"
+			novalidate
+			@submit="setPassword"
+		>
+			<!-- password manager hint -->
+			<input
+				class="d-none"
+				type="text"
+				name="username"
+				id="username"
+				autocomplete="username"
+				value="admin"
+			/>
+			<FormRow
+				v-if="updateMode"
+				id="passwordCurrent"
+				:label="$t('passwordModal.labelCurrent')"
+			>
+				<input
+					id="passwordCurrent"
+					type="password"
+					v-model="passwordCurrent"
+					class="form-control"
+					autocomplete="current-password"
+				/>
+			</FormRow>
+			<FormRow id="passwordNew" :label="$t('passwordModal.labelNew')">
+				<input
+					id="passwordNew"
+					type="password"
+					v-model="passwordNew"
+					class="form-control"
+					autocomplete="new-password"
+					required
+				/>
+				<div class="invalid-feedback">
+					{{ $t("passwordModal.empty") }}
+				</div>
+			</FormRow>
+			<FormRow id="passwordRepeat" :label="$t('passwordModal.labelRepeat')">
+				<input
+					id="passwordRepeat"
+					type="password"
+					ref="passwordRepeat"
+					v-model="passwordRepeat"
+					class="form-control"
+					autocomplete="new-password"
+				/>
+				<div v-if="!passwordsMatch" class="invalid-feedback">
+					{{ $t("passwordModal.noMatch") }}
+				</div>
+			</FormRow>
+
+			<div class="d-flex justify-content-end">
+				<button
+					type="submit"
+					class="btn btn-primary justify-self-right"
+					:disabled="loading"
+				>
+					<span
+						v-if="loading"
+						class="spinner-border spinner-border-sm"
+						role="status"
+						aria-hidden="true"
+					></span>
+					{{ submitText }}
+				</button>
+			</div>
+		</form>
+	</GenericModal>
+</template>
+
+<script>
+import GenericModal from "./GenericModal.vue";
+import FormRow from "./FormRow.vue";
+import api from "../api";
+import { updateAuthStatus, isConfigured } from "../auth";
+
+export default {
+	name: "PasswordModal",
+	components: { FormRow, GenericModal },
+	data: () => {
+		return {
+			modalVisible: false,
+			passwordCurrent: "",
+			passwordNew: "",
+			passwordRepeat: "",
+			showValidation: false,
+			loading: false,
+			error: false,
+		};
+	},
+	computed: {
+		passwordsMatch: function () {
+			return this.passwordNew === this.passwordRepeat;
+		},
+		passwordEmpty: function () {
+			return this.passwordNew.length === 0;
+		},
+		updateMode: function () {
+			return isConfigured();
+		},
+		title: function () {
+			return this.updateMode
+				? this.$t("passwordModal.titleUpdate")
+				: this.$t("passwordModal.titleNew");
+		},
+		submitText: function () {
+			return this.updateMode
+				? this.$t("passwordModal.updatePassword")
+				: this.$t("passwordModal.newPassword");
+		},
+	},
+	methods: {
+		open: function () {
+			this.modalVisible = true;
+		},
+		closed: function () {
+			this.modalVisible = false;
+			this.passwordCurrent = "";
+			this.passwordNew = "";
+			this.passwordRepeat = "";
+			this.error = false;
+			this.loading = false;
+			this.showValidation = false;
+		},
+		setPassword: async function (e) {
+			this.$refs.passwordRepeat.setCustomValidity(
+				this.passwordsMatch && !this.passwordEmpty ? "" : "invalid"
+			);
+
+			e.preventDefault();
+			e.stopPropagation();
+			this.showValidation = true;
+
+			if (this.$refs.form.checkValidity()) {
+				await this.savePassword();
+				await updateAuthStatus();
+			}
+		},
+		savePassword: async function () {
+			this.loading = true;
+			this.error = null;
+			try {
+				const data = {
+					current: this.passwordCurrent,
+					new: this.passwordNew,
+				};
+				await api.put("/auth/password", data);
+				this.loading = false;
+				this.$refs.modal?.close();
+			} catch (error) {
+				console.error(error);
+				this.error = error.response.data;
+				this.showValidation = false;
+			} finally {
+				this.loading = false;
+			}
+		},
+	},
+};
+</script>
diff --git a/assets/js/components/Savings.vue b/assets/js/components/Savings.vue
index 66e0496d..21fb7d46 100644
--- a/assets/js/components/Savings.vue
+++ b/assets/js/components/Savings.vue
@@ -172,7 +172,7 @@
 									</div>
 									<div v-if="!priceConfigured || !co2Configured">
 										<a
-											href="https://docs.evcc.io/en/docs/reference/configuration/tariffs/"
+											href="https://docs.evcc.io/en/docs/reference/configuration/tariffs"
 											class="evcc-gray"
 											target="_blank"
 										>
diff --git a/assets/js/components/TopNavigation.vue b/assets/js/components/TopNavigation.vue
index 102a0d40..9a6da9d2 100644
--- a/assets/js/components/TopNavigation.vue
+++ b/assets/js/components/TopNavigation.vue
@@ -57,6 +57,12 @@
 				</router-link>
 			</li>
 			<li><hr class="dropdown-divider" /></li>
+			<li v-if="showLogout">
+				<button type="button" class="dropdown-item" @click="logout">
+					{{ $t("header.logout") }}
+				</button>
+			</li>
+
 			<template v-if="providerLogins.length > 0">
 				<li><hr class="dropdown-divider" /></li>
 				<li>
@@ -108,6 +114,7 @@ import "@h2d2/shopicons/es/regular/moonstars";
 import "@h2d2/shopicons/es/regular/menu";
 import "@h2d2/shopicons/es/regular/newtab";
 import collector from "../mixins/collector";
+import { logout, isLoggedIn, openLoginModal } from "../auth";
 
 import baseAPI from "../baseapi";
 import { isApp, sendToApp } from "../utils/native";
@@ -155,6 +162,9 @@ export default {
 		batteryModalAvailable() {
 			return this.batteryConfigured;
 		},
+		showLogout() {
+			return isLoggedIn();
+		},
 	},
 	mounted() {
 		const $el = document.getElementById("topNavigatonDropdown");
@@ -193,6 +203,13 @@ export default {
 		openNativeSettings() {
 			sendToApp({ type: "settings" });
 		},
+		async login() {
+			openLoginModal();
+		},
+		async logout() {
+			await logout();
+			this.$router.push({ path: "/" });
+		},
 	},
 };
 </script>
diff --git a/assets/js/router.js b/assets/js/router.js
index 373c98a3..5afe30d9 100644
--- a/assets/js/router.js
+++ b/assets/js/router.js
@@ -2,6 +2,7 @@ import { createRouter, createWebHashHistory } from "vue-router";
 import Modal from "bootstrap/js/dist/modal";
 import Main from "./views/Main.vue";
 import { ensureCurrentLocaleMessages } from "./i18n";
+import { openLoginModal, updateAuthStatus, isLoggedIn } from "./auth";
 
 function hideAllModals() {
   [...document.querySelectorAll(".modal.show")].forEach((modal) => {
@@ -17,7 +18,18 @@ export default function setupRouter(i18n) {
     history: createWebHashHistory(),
     routes: [
       { path: "/", component: Main, props: true },
-      { path: "/config", component: () => import("./views/Config.vue"), props: true },
+      {
+        path: "/config",
+        component: () => import("./views/Config.vue"),
+        beforeEnter: async () => {
+          await updateAuthStatus();
+          if (!isLoggedIn()) {
+            openLoginModal();
+            return false;
+          }
+        },
+        props: true,
+      },
       {
         path: "/sessions",
         component: () => import("./views/ChargingSessions.vue"),
diff --git a/assets/js/theme.js b/assets/js/theme.js
index 3cb51dcc..130532d2 100644
--- a/assets/js/theme.js
+++ b/assets/js/theme.js
@@ -58,7 +58,6 @@ function updateMetaThemeForBackdrop() {
   const $backdrop = document.querySelector("[data-bs-backdrop=true][aria-modal=true]");
   // dark if there is a backdrop, otherwise use the current theme
   const theme = $backdrop ? THEME_DARK : getCurrentTheme();
-  console.log("backdrop", theme);
   setMetaThemeColor(theme);
 }
 
diff --git a/assets/js/views/App.vue b/assets/js/views/App.vue
index 9a01e69a..ad67f082 100644
--- a/assets/js/views/App.vue
+++ b/assets/js/views/App.vue
@@ -5,6 +5,8 @@
 		<GlobalSettingsModal v-bind="globalSettingsProps" />
 		<BatterySettingsModal v-if="batteryModalAvailabe" v-bind="batterySettingsProps" />
 		<HelpModal />
+		<PasswordModal />
+		<LoginModal />
 	</div>
 </template>
 
@@ -12,8 +14,11 @@
 import store from "../store";
 import GlobalSettingsModal from "../components/GlobalSettingsModal.vue";
 import BatterySettingsModal from "../components/BatterySettingsModal.vue";
+import PasswordModal from "../components/PasswordModal.vue";
+import LoginModal from "../components/LoginModal.vue";
 import HelpModal from "../components/HelpModal.vue";
 import collector from "../mixins/collector";
+import { updateAuthStatus } from "../auth";
 
 // assume offline if not data received for 60 seconds
 let lastDataReceived = new Date();
@@ -27,14 +32,20 @@ setInterval(() => {
 
 export default {
 	name: "App",
-	components: { GlobalSettingsModal, HelpModal, BatterySettingsModal },
+	components: {
+		GlobalSettingsModal,
+		HelpModal,
+		BatterySettingsModal,
+		PasswordModal,
+		LoginModal,
+	},
 	mixins: [collector],
 	props: {
 		notifications: Array,
 		offline: Boolean,
 	},
 	data: () => {
-		return { reconnectTimeout: null, ws: null };
+		return { reconnectTimeout: null, ws: null, authNotConfigured: false };
 	},
 	head() {
 		const siteTitle = store.state.siteTitle;
@@ -47,6 +58,9 @@ export default {
 				this.reload();
 			}
 		},
+		offline: function () {
+			updateAuthStatus();
+		},
 	},
 	computed: {
 		version: function () {
@@ -65,6 +79,7 @@ export default {
 	mounted: function () {
 		this.connect();
 		document.addEventListener("visibilitychange", this.pageVisibilityChanged, false);
+		updateAuthStatus();
 	},
 	unmounted: function () {
 		this.disconnect();
@@ -78,6 +93,7 @@ export default {
 				this.disconnect();
 			} else {
 				this.connect();
+				updateAuthStatus();
 			}
 		},
 		reconnect: function () {
diff --git a/charger/solax.go b/charger/solax.go
new file mode 100644
index 00000000..aae44414
--- /dev/null
+++ b/charger/solax.go
@@ -0,0 +1,228 @@
+package charger
+
+// LICENSE
+
+// Copyright (c) 2024 premultiply
+
+// This module is NOT covered by the MIT license. All rights reserved.
+
+// The above copyright notice and this permission notice shall be included in all
+// copies or substantial portions of the Software.
+
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+// SOFTWARE.
+
+import (
+	"encoding/binary"
+	"fmt"
+
+	"github.com/evcc-io/evcc/api"
+	"github.com/evcc-io/evcc/util"
+	"github.com/evcc-io/evcc/util/modbus"
+	"github.com/evcc-io/evcc/util/sponsor"
+	"github.com/volkszaehler/mbmd/encoding"
+)
+
+// Solax charger implementation
+type Solax struct {
+	log  *util.Logger
+	conn *modbus.Connection
+}
+
+const (
+	// holding (FC 0x03, 0x06, 0x10)
+	solaxRegDeviceMode      = 0x060D // uint16
+	solaxRegStartChargeMode = 0x0610 // uint16
+	solaxRegPhases          = 0x0625 // uint16
+	solaxRegCommandControl  = 0x0627 // uint16
+	solaxRegMaxCurrent      = 0x0628 // uint16 0.01A
+
+	// input (FC 0x04)
+	solaxRegVoltages    = 0x0000 // 3x uint16 0.01V
+	solaxRegCurrents    = 0x0004 // 3x uint16 0.01A
+	solaxRegActivePower = 0x000B // uint16 1W
+	solaxRegTotalEnergy = 0x0010 // uint32s 0.1kWh
+	solaxRegState       = 0x001D // uint16
+)
+
+func init() {
+	registry.Add("solax", NewSolaxFromConfig)
+}
+
+// NewSolaxFromConfig creates a Solax charger from generic config
+func NewSolaxFromConfig(other map[string]interface{}) (api.Charger, error) {
+	cc := modbus.Settings{
+		ID: 1,
+	}
+
+	if err := util.DecodeOther(other, &cc); err != nil {
+		return nil, err
+	}
+
+	return NewSolax(cc.URI, cc.Device, cc.Comset, cc.Baudrate, modbus.ProtocolFromRTU(cc.RTU), cc.ID)
+}
+
+// NewSolax creates Solax charger
+func NewSolax(uri, device, comset string, baudrate int, proto modbus.Protocol, id uint8) (api.Charger, error) {
+	conn, err := modbus.NewConnection(uri, device, comset, baudrate, proto, id)
+	if err != nil {
+		return nil, err
+	}
+
+	if !sponsor.IsAuthorized() {
+		return nil, api.ErrSponsorRequired
+	}
+
+	log := util.NewLogger("solax")
+	conn.Logger(log.TRACE)
+
+	wb := &Solax{
+		log:  log,
+		conn: conn,
+	}
+
+	return wb, err
+}
+
+// getPhaseValues returns 3 sequential register values
+func (wb *Solax) getPhaseValues(reg uint16) (float64, float64, float64, error) {
+	b, err := wb.conn.ReadInputRegisters(reg, 3)
+	if err != nil {
+		return 0, 0, 0, err
+	}
+
+	var res [3]float64
+	for i := range res {
+		res[i] = float64(binary.BigEndian.Uint16(b[2*i:])) / 100
+	}
+
+	return res[0], res[1], res[2], nil
+}
+
+// Status implements the api.Charger interface
+func (wb *Solax) Status() (api.ChargeStatus, error) {
+	b, err := wb.conn.ReadInputRegisters(solaxRegState, 1)
+	if err != nil {
+		return api.StatusNone, err
+	}
+
+	switch s := encoding.Uint16(b); s {
+	case
+		0, // "Available"
+		5: // "Unavailable"
+		return api.StatusA, nil
+	case
+		1, // "Preparing"
+		8, // "SuspendedEVSE"
+		7, // "SuspendedEV"
+		3: // "Finishing"
+		return api.StatusB, nil
+	case
+		2: // "Charging"
+		return api.StatusC, nil
+	case
+		6, // "Reserved"
+		4: // "Faulted"
+		return api.StatusF, nil
+	default:
+		return api.StatusNone, fmt.Errorf("invalid status: %d", s)
+	}
+}
+
+// Enabled implements the api.Charger interface
+func (wb *Solax) Enabled() (bool, error) {
+	b, err := wb.conn.ReadHoldingRegisters(solaxRegDeviceMode, 1)
+	if err != nil {
+		return false, err
+	}
+
+	return binary.BigEndian.Uint16(b) != 0, nil
+}
+
+// Enable implements the api.Charger interface
+func (wb *Solax) Enable(enable bool) error {
+	var mode uint16 = 0 // "STOP"
+	if enable {
+		mode = 1 // "FAST"
+	}
+
+	_, err := wb.conn.WriteSingleRegister(solaxRegDeviceMode, mode)
+
+	return err
+}
+
+// MaxCurrent implements the api.Charger interface
+func (wb *Solax) MaxCurrent(current int64) error {
+	return wb.MaxCurrentMillis(float64(current))
+}
+
+var _ api.ChargerEx = (*Solax)(nil)
+
+// MaxCurrentMillis implements the api.ChargerEx interface
+func (wb *Solax) MaxCurrentMillis(current float64) error {
+	if current < 6 {
+		return fmt.Errorf("invalid current %.1f", current)
+	}
+
+	_, err := wb.conn.WriteSingleRegister(solaxRegMaxCurrent, uint16(current*100))
+
+	return err
+}
+
+var _ api.Meter = (*Solax)(nil)
+
+// CurrentPower implements the api.Meter interface
+func (wb *Solax) CurrentPower() (float64, error) {
+	b, err := wb.conn.ReadInputRegisters(solaxRegActivePower, 1)
+	if err != nil {
+		return 0, err
+	}
+
+	return float64(binary.BigEndian.Uint16(b)), err
+}
+
+var _ api.MeterEnergy = (*Solax)(nil)
+
+// TotalEnergy implements the api.MeterEnergy interface
+func (wb *Solax) TotalEnergy() (float64, error) {
+	b, err := wb.conn.ReadInputRegisters(solaxRegTotalEnergy, 2)
+	if err != nil {
+		return 0, err
+	}
+
+	return float64(binary.BigEndian.Uint32(b)) / 10, err
+}
+
+var _ api.PhaseCurrents = (*Solax)(nil)
+
+// Currents implements the api.PhaseCurrents interface
+func (wb *Solax) Currents() (float64, float64, float64, error) {
+	return wb.getPhaseValues(solaxRegCurrents)
+}
+
+var _ api.PhaseVoltages = (*Solax)(nil)
+
+// Voltages implements the api.PhaseVoltages interface
+func (wb *Solax) Voltages() (float64, float64, float64, error) {
+	return wb.getPhaseValues(solaxRegVoltages)
+}
+
+var _ api.PhaseSwitcher = (*Solax)(nil)
+
+// Phases1p3p implements the api.PhaseSwitcher interface
+func (wb *Solax) Phases1p3p(phases int) error {
+	var u uint16
+
+	if phases == 1 {
+		u = 1
+	}
+
+	_, err := wb.conn.WriteSingleRegister(solaxRegPhases, u)
+
+	return err
+}
diff --git a/cmd/configure/localization/de.toml b/cmd/configure/localization/de.toml
index 598e31fc..f6bbf080 100644
--- a/cmd/configure/localization/de.toml
+++ b/cmd/configure/localization/de.toml
@@ -69,7 +69,7 @@ Config_ModbusInterface = "Wähle die ModBus Schnittstelle aus"
 Config_AddAnotherValue = "Möchtest du einen weiteren Wert hinzufügen?"
 Config_Yes = "Ja"
 Config_No = "Nein"
-Cancel = "Die Konfiguration wurde abgebrochen.\n\nFalls diese geführte Konfiguration für dich noch nicht funktioniert, versuche es doch mal mit der manuellen Konfiguration. Details findest du auf der folgenden Webseite: https://docs.evcc.io/docs/\n"
+Cancel = "Die Konfiguration wurde abgebrochen.\n\nFalls diese geführte Konfiguration für dich noch nicht funktioniert, versuche es doch mal mit der manuellen Konfiguration. Details findest du auf der folgenden Webseite: https://docs.evcc.io \n"
 InputError = "Bei der Eingabe ist ein Fehler aufgetreten:"
 Value_Choice = "Auswahl:"
 Value_Help = "Hilfe:"
diff --git a/cmd/configure/localization/en.toml b/cmd/configure/localization/en.toml
index c4effe76..4205a1a9 100644
--- a/cmd/configure/localization/en.toml
+++ b/cmd/configure/localization/en.toml
@@ -69,7 +69,7 @@ Config_ModbusInterface = "Choose the ModBus interface"
 Config_AddAnotherValue = "Do you want to add another value?"
 Config_Yes = "Yes"
 Config_No = "No"
-Cancel = "The configuration was cancelled.\n\nIf this guided configuration process doesn't work for you, please try the manual configuration. You can find more details about that on our website: https://docs.evcc.io/en/docs/\n"
+Cancel = "The configuration was cancelled.\n\nIf this guided configuration process doesn't work for you, please try the manual configuration. You can find more details about that on our website: https://docs.evcc.io/en/ \n"
 InputError = "An input error occurred:"
 Value_Choice = "Choose:"
 Value_Help = "Help:"
diff --git a/cmd/password.go b/cmd/password.go
new file mode 100644
index 00000000..eb815eb1
--- /dev/null
+++ b/cmd/password.go
@@ -0,0 +1,14 @@
+package cmd
+
+import (
+	"github.com/spf13/cobra"
+)
+
+var passwordCmd = &cobra.Command{
+	Use:   "password",
+	Short: "Password administration",
+}
+
+func init() {
+	rootCmd.AddCommand(passwordCmd)
+}
diff --git a/cmd/password_reset.go b/cmd/password_reset.go
new file mode 100644
index 00000000..78a6d205
--- /dev/null
+++ b/cmd/password_reset.go
@@ -0,0 +1,47 @@
+package cmd
+
+import (
+	"github.com/AlecAivazis/survey/v2"
+	"github.com/evcc-io/evcc/util/auth"
+	"github.com/spf13/cobra"
+)
+
+var passwordResetCmd = &cobra.Command{
+	Use:   "reset",
+	Short: "Reset password",
+	Args:  cobra.ExactArgs(0),
+	Run:   runPasswordReset,
+}
+
+func init() {
+	passwordCmd.AddCommand(passwordResetCmd)
+}
+
+func runPasswordReset(cmd *cobra.Command, args []string) {
+	// load config
+	if err := loadConfigFile(&conf); err != nil {
+		log.FATAL.Fatal(err)
+	}
+
+	// setup environment
+	if err := configureEnvironment(cmd, conf); err != nil {
+		log.FATAL.Fatal(err)
+	}
+
+	prompt := &survey.Confirm{
+		Message: "Are you sure?",
+		Help:    "help",
+	}
+
+	var confirm bool
+	if err := survey.AskOne(prompt, &confirm); err != nil {
+		log.FATAL.Fatal(err)
+	}
+
+	if confirm {
+		auth.New().RemoveAdminPassword()
+	}
+
+	// wait for shutdown
+	<-shutdownDoneC()
+}
diff --git a/cmd/password_set.go b/cmd/password_set.go
new file mode 100644
index 00000000..1ee504a5
--- /dev/null
+++ b/cmd/password_set.go
@@ -0,0 +1,49 @@
+package cmd
+
+import (
+	"github.com/AlecAivazis/survey/v2"
+	"github.com/evcc-io/evcc/util/auth"
+	"github.com/spf13/cobra"
+)
+
+var passwordSetCmd = &cobra.Command{
+	Use:   "set",
+	Short: "Set password",
+	Args:  cobra.ExactArgs(0),
+	Run:   runPasswordSet,
+}
+
+func init() {
+	passwordCmd.AddCommand(passwordSetCmd)
+}
+
+func runPasswordSet(cmd *cobra.Command, args []string) {
+	// load config
+	if err := loadConfigFile(&conf); err != nil {
+		log.FATAL.Fatal(err)
+	}
+
+	// setup environment
+	if err := configureEnvironment(cmd, conf); err != nil {
+		log.FATAL.Fatal(err)
+	}
+
+	prompt := &survey.Password{
+		Message: "Password",
+		Help:    "help",
+	}
+
+	var password string
+	if err := survey.AskOne(prompt, &password); err != nil {
+		log.FATAL.Fatal(err)
+	}
+
+	if password == "" {
+		log.FATAL.Fatal("password cannot be empty")
+	} else {
+		auth.New().SetAdminPassword(password)
+	}
+
+	// wait for shutdown
+	<-shutdownDoneC()
+}
diff --git a/core/keys/auth.go b/core/keys/auth.go
new file mode 100644
index 00000000..0cd32361
--- /dev/null
+++ b/core/keys/auth.go
@@ -0,0 +1,6 @@
+package keys
+
+const (
+	AdminPassword = "adminPassword"
+	JwtSecret     = "jwtSecretKey"
+)
diff --git a/core/keys/site.go b/core/keys/site.go
index 8138e2df..dc50cec7 100644
--- a/core/keys/site.go
+++ b/core/keys/site.go
@@ -28,6 +28,7 @@ const (
 	TariffPriceHome       = "tariffPriceHome"
 	TariffPriceLoadpoints = "tariffPriceLoadpoints"
 	Vehicles              = "vehicles"
+	PasswordConfigured    = "passwordConfigured"
 
 	// meters
 	GridMeter     = "gridMeter"
diff --git a/core/loadpoint.go b/core/loadpoint.go
index 01731f5e..eb5d5858 100644
--- a/core/loadpoint.go
+++ b/core/loadpoint.go
@@ -1072,7 +1072,7 @@ func (lp *Loadpoint) pvScalePhases(sitePower, minCurrent, maxCurrent float64) bo
 	// - https://github.com/evcc-io/evcc/issues/2613
 	measuredPhases := lp.getMeasuredPhases()
 	if phases > 0 && phases < measuredPhases {
-		if lp.chargerUpdateCompleted() {
+		if lp.chargerUpdateCompleted() && lp.phaseSwitchCompleted() {
 			lp.log.WARN.Printf("ignoring inconsistent phases: %dp < %dp observed active", phases, measuredPhases)
 		}
 		lp.resetMeasuredPhases()
diff --git a/go.mod b/go.mod
index 2857020e..be137471 100644
--- a/go.mod
+++ b/go.mod
@@ -184,7 +184,7 @@ require (
 	github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect
 	gitlab.com/c0b/go-ordered-json v0.0.0-20201030195603-febf46534d5a // indirect
 	go.uber.org/multierr v1.11.0 // indirect
-	golang.org/x/crypto v0.21.0 // indirect
+	golang.org/x/crypto v0.21.0
 	golang.org/x/mod v0.16.0 // indirect
 	golang.org/x/sys v0.18.0 // indirect
 	golang.org/x/term v0.18.0 // indirect
diff --git a/i18n/de.toml b/i18n/de.toml
index 80928cbb..5a3e4f4a 100644
--- a/i18n/de.toml
+++ b/i18n/de.toml
@@ -197,6 +197,14 @@ description = "Unter normalen Umständen sollte ein Neustart nicht notwendig sei
 disclaimer = "Hinweis: evcc beendet sich und verlässt sich darauf, vom Betriebssystem neu gestartet zu werden."
 modalTitle = "Sicher, dass du neu starten möchtest?"
 
+[loginModal]
+cancel = "Abbrechen"
+error = "Login fehlgeschlagen: "
+invalid = "Passwort ist ungültig."
+login = "Anmelden"
+password = "Passwort"
+title = "Authentifizierung"
+
 [main]
 vehicles = "Parkplatz"
 
@@ -380,6 +388,19 @@ modalTitle = "Meldungen"
 message = "Keine Verbindung zum Server."
 reload = "Erneut laden?"
 
+[passwordModal]
+description = "Setze ein Passwort, um die Konfiguration zu schützen. Die Hauptansicht bleibt ohne Login zugänglich."
+empty = "Passwort darf nicht leer sein"
+error = "Fehler: "
+labelCurrent = "Aktuelles Passwort"
+labelNew = "Neues Passwort"
+labelRepeat = "Neues Passwort wiederholen"
+newPassword = "Passwort setzen"
+noMatch = "Passwörter stimmen nicht überein"
+titleNew = "Administrator Passwort setzen"
+titleUpdate = "Administrator Passwort ändern"
+updatePassword = "Passwort ändern"
+
 [session]
 cancel = "Abbrechen"
 co2 = "CO₂"
diff --git a/i18n/en.toml b/i18n/en.toml
index 0c922293..c4aa20a0 100644
--- a/i18n/en.toml
+++ b/i18n/en.toml
@@ -172,6 +172,7 @@ blog = "Blog"
 docs = "Documentation"
 github = "GitHub"
 login = "Vehicle Logins"
+logout = "Logout"
 nativeSettings = "Change Server"
 needHelp = "Need Help?"
 sessions = "Charging Sessions"
@@ -194,6 +195,14 @@ description = "Under normal circumstances restarting should not be necessary. Pl
 disclaimer = "Note: evcc will terminate and rely on the operating system to restart the service."
 modalTitle = "Are you sure you want to restart?"
 
+[loginModal]
+cancel = "Cancel"
+error = "Login failed: "
+invalid = "Password is invalid."
+login = "Login"
+password = "Password"
+title = "Authentication"
+
 [main]
 vehicles = "Parking"
 
@@ -377,6 +386,19 @@ modalTitle = "Notifications"
 message = "Not connected to a server."
 reload = "Reload?"
 
+[passwordModal]
+description = "Set a password to protect the configuration settings. Using the main screen is still possible without login."
+empty = "Password should not be empty"
+error = "Error: "
+labelCurrent = "Current password"
+labelNew = "New password"
+labelRepeat = "Repeat password"
+newPassword = "Create password"
+noMatch = "Passwords do not match"
+titleNew = "Set Administrator Password"
+titleUpdate = "Update Administrator Password"
+updatePassword = "Update password"
+
 [session]
 cancel = "Cancel"
 co2 = "CO₂"
diff --git a/server/db/settings/api.go b/server/db/settings/api.go
new file mode 100644
index 00000000..832c1880
--- /dev/null
+++ b/server/db/settings/api.go
@@ -0,0 +1,8 @@
+package settings
+
+//go:generate mockgen -package settings -destination mock.go -mock_names API=MockAPI github.com/evcc-io/evcc/server/db/settings API
+
+type API interface {
+	String(key string) (string, error)
+	SetString(key string, value string)
+}
diff --git a/server/db/settings/mock.go b/server/db/settings/mock.go
new file mode 100644
index 00000000..378587fb
--- /dev/null
+++ b/server/db/settings/mock.go
@@ -0,0 +1,66 @@
+// Code generated by MockGen. DO NOT EDIT.
+// Source: github.com/evcc-io/evcc/server/db/settings (interfaces: API)
+//
+// Generated by this command:
+//
+//	mockgen -package settings -destination mock.go -mock_names API=MockAPI github.com/evcc-io/evcc/server/db/settings API
+//
+
+// Package settings is a generated GoMock package.
+package settings
+
+import (
+	reflect "reflect"
+
+	gomock "go.uber.org/mock/gomock"
+)
+
+// MockAPI is a mock of API interface.
+type MockAPI struct {
+	ctrl     *gomock.Controller
+	recorder *MockAPIMockRecorder
+}
+
+// MockAPIMockRecorder is the mock recorder for MockAPI.
+type MockAPIMockRecorder struct {
+	mock *MockAPI
+}
+
+// NewMockAPI creates a new mock instance.
+func NewMockAPI(ctrl *gomock.Controller) *MockAPI {
+	mock := &MockAPI{ctrl: ctrl}
+	mock.recorder = &MockAPIMockRecorder{mock}
+	return mock
+}
+
+// EXPECT returns an object that allows the caller to indicate expected use.
+func (m *MockAPI) EXPECT() *MockAPIMockRecorder {
+	return m.recorder
+}
+
+// SetString mocks base method.
+func (m *MockAPI) SetString(arg0, arg1 string) {
+	m.ctrl.T.Helper()
+	m.ctrl.Call(m, "SetString", arg0, arg1)
+}
+
+// SetString indicates an expected call of SetString.
+func (mr *MockAPIMockRecorder) SetString(arg0, arg1 any) *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetString", reflect.TypeOf((*MockAPI)(nil).SetString), arg0, arg1)
+}
+
+// String mocks base method.
+func (m *MockAPI) String(arg0 string) (string, error) {
+	m.ctrl.T.Helper()
+	ret := m.ctrl.Call(m, "String", arg0)
+	ret0, _ := ret[0].(string)
+	ret1, _ := ret[1].(error)
+	return ret0, ret1
+}
+
+// String indicates an expected call of String.
+func (mr *MockAPIMockRecorder) String(arg0 any) *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "String", reflect.TypeOf((*MockAPI)(nil).String), arg0)
+}
diff --git a/server/db/settings/setting.go b/server/db/settings/setting.go
index 2d3451ad..26a4e6b6 100644
--- a/server/db/settings/setting.go
+++ b/server/db/settings/setting.go
@@ -149,3 +149,14 @@ func Json(key string, res any) error {
 	}
 	return err
 }
+
+// wrapping Settings into a struct for better decoupling
+type Settings struct{}
+
+func (s Settings) String(key string) (string, error) {
+	return String(key)
+}
+
+func (s Settings) SetString(key string, value string) {
+	SetString(key, value)
+}
diff --git a/server/http.go b/server/http.go
index a8b0b3f7..0724677d 100644
--- a/server/http.go
+++ b/server/http.go
@@ -9,6 +9,7 @@ import (
 	"github.com/evcc-io/evcc/core/site"
 	"github.com/evcc-io/evcc/server/assets"
 	"github.com/evcc-io/evcc/util"
+	"github.com/evcc-io/evcc/util/auth"
 	"github.com/evcc-io/evcc/util/telemetry"
 	"github.com/go-http-utils/etag"
 	"github.com/gorilla/handlers"
@@ -94,23 +95,12 @@ func (s *HTTPd) RegisterSiteHandlers(site site.API, cache *util.Cache) {
 		handlers.AllowedHeaders([]string{"Content-Type"}),
 	))
 
+	auth := auth.New()
+
 	// site api
 	routes := map[string]route{
 		"health":                  {"GET", "/health", healthHandler(site)},
 		"state":                   {"GET", "/state", stateHandler(cache)},
-		"config":                  {"GET", "/config/templates/{class:[a-z]+}", templatesHandler},
-		"products":                {"GET", "/config/products/{class:[a-z]+}", productsHandler},
-		"devices":                 {"GET", "/config/devices/{class:[a-z]+}", devicesHandler},
-		"device":                  {"GET", "/config/devices/{class:[a-z]+}/{id:[0-9.]+}", deviceConfigHandler},
-		"devicestatus":            {"GET", "/config/devices/{class:[a-z]+}/{name:[a-zA-Z0-9_.:-]+}/status", deviceStatusHandler},
-		"site":                    {"GET", "/config/site", siteHandler(site)},
-		"dirty":                   {"GET", "/config/dirty", boolGetHandler(ConfigDirty)},
-		"updatesite":              {"PUT", "/config/site", updateSiteHandler(site)},
-		"newdevice":               {"POST", "/config/devices/{class:[a-z]+}", newDeviceHandler},
-		"updatedevice":            {"PUT", "/config/devices/{class:[a-z]+}/{id:[0-9.]+}", updateDeviceHandler},
-		"deletedevice":            {"DELETE", "/config/devices/{class:[a-z]+}/{id:[0-9.]+}", deleteDeviceHandler},
-		"testconfig":              {"POST", "/config/test/{class:[a-z]+}", testConfigHandler},
-		"testmerged":              {"POST", "/config/test/{class:[a-z]+}/merge/{id:[0-9.]+}", testConfigHandler},
 		"buffersoc":               {"POST", "/buffersoc/{value:[0-9.]+}", floatHandler(site.SetBufferSoc, site.GetBufferSoc)},
 		"bufferstartsoc":          {"POST", "/bufferstartsoc/{value:[0-9.]+}", floatHandler(site.SetBufferStartSoc, site.GetBufferStartSoc)},
 		"batterydischargecontrol": {"POST", "/batterydischargecontrol/{value:[a-z]+}", boolHandler(site.SetBatteryDischargeControl, site.GetBatteryDischargeControl)},
@@ -123,12 +113,40 @@ func (s *HTTPd) RegisterSiteHandlers(site site.API, cache *util.Cache) {
 		"deletesession":           {"DELETE", "/session/{id:[0-9]+}", deleteSessionHandler},
 		"telemetry":               {"GET", "/settings/telemetry", boolGetHandler(telemetry.Enabled)},
 		"telemetry2":              {"POST", "/settings/telemetry/{value:[a-z]+}", boolHandler(telemetry.Enable, telemetry.Enabled)},
+		"password":                {"PUT", "/auth/password", updatePasswordHandler(auth)},
+		"auth":                    {"GET", "/auth/status", authStatusHandler(auth)},
+		"login":                   {"POST", "/auth/login", loginHandler(auth)},
+		"logout":                  {"POST", "/auth/logout", logoutHandler},
 	}
 
 	for _, r := range routes {
 		api.Methods(r.Methods()...).Path(r.Pattern).Handler(r.HandlerFunc)
 	}
 
+	// config ui (secured)
+	configApi := api.PathPrefix("/config").Subrouter()
+	configApi.Use(ensureAuthHandler(auth))
+
+	configRoutes := map[string]route{
+		"templates":    {"GET", "/templates/{class:[a-z]+}", templatesHandler},
+		"products":     {"GET", "/products/{class:[a-z]+}", productsHandler},
+		"devices":      {"GET", "/devices/{class:[a-z]+}", devicesHandler},
+		"device":       {"GET", "/devices/{class:[a-z]+}/{id:[0-9.]+}", deviceConfigHandler},
+		"devicestatus": {"GET", "/devices/{class:[a-z]+}/{name:[a-zA-Z0-9_.:-]+}/status", deviceStatusHandler},
+		"site":         {"GET", "/site", siteHandler(site)},
+		"dirty":        {"GET", "/dirty", boolGetHandler(ConfigDirty)},
+		"updatesite":   {"PUT", "/site", updateSiteHandler(site)},
+		"newdevice":    {"POST", "/devices/{class:[a-z]+}", newDeviceHandler},
+		"updatedevice": {"PUT", "/devices/{class:[a-z]+}/{id:[0-9.]+}", updateDeviceHandler},
+		"deletedevice": {"DELETE", "/devices/{class:[a-z]+}/{id:[0-9.]+}", deleteDeviceHandler},
+		"testconfig":   {"POST", "/test/{class:[a-z]+}", testConfigHandler},
+		"testmerged":   {"POST", "/test/{class:[a-z]+}/merge/{id:[0-9.]+}", testConfigHandler},
+	}
+
+	for _, r := range configRoutes {
+		configApi.Methods(r.Methods()...).Path(r.Pattern).Handler(r.HandlerFunc)
+	}
+
 	// vehicle api
 	vehicles := map[string]route{
 		"minsoc":   {"POST", "/vehicles/{name:[a-zA-Z0-9_.:-]+}/minsoc/{value:[0-9]+}", minSocHandler(site)},
diff --git a/server/http_auth.go b/server/http_auth.go
new file mode 100644
index 00000000..e7b93089
--- /dev/null
+++ b/server/http_auth.go
@@ -0,0 +1,138 @@
+package server
+
+import (
+	"encoding/json"
+	"net/http"
+	"time"
+
+	"github.com/evcc-io/evcc/util/auth"
+	"github.com/gorilla/mux"
+)
+
+const authCookieName = "auth"
+
+type updatePasswordRequest struct {
+	Current string `json:"current"`
+	New     string `json:"new"`
+}
+
+type loginRequest struct {
+	Password string `json:"password"`
+}
+
+func updatePasswordHandler(auth auth.Auth) http.HandlerFunc {
+	return func(w http.ResponseWriter, r *http.Request) {
+		var req updatePasswordRequest
+		if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+			http.Error(w, err.Error(), http.StatusBadRequest)
+			return
+		}
+
+		// update password
+		if auth.IsAdminPasswordConfigured() {
+			if !auth.IsAdminPasswordValid(req.Current) {
+				http.Error(w, "Invalid password", http.StatusBadRequest)
+				return
+			}
+
+			if err := auth.SetAdminPassword(req.New); err != nil {
+				http.Error(w, err.Error(), http.StatusInternalServerError)
+				return
+			}
+
+			w.WriteHeader(http.StatusAccepted)
+			return
+		}
+
+		// create new password
+		if err := auth.SetAdminPassword(req.New); err != nil {
+			http.Error(w, err.Error(), http.StatusInternalServerError)
+			return
+		}
+		w.WriteHeader(http.StatusCreated)
+	}
+}
+
+// read jwt from header and cookie
+func jwtFromRequest(r *http.Request) string {
+	tokenString := r.Header.Get("Authorization")
+	if tokenString == "" {
+		if cookie, _ := r.Cookie(authCookieName); cookie != nil {
+			tokenString = cookie.Value
+		}
+	}
+
+	return tokenString
+}
+
+// authStatusHandler login status (true/false) based on jwt token. Error if admin password is not configured
+func authStatusHandler(auth auth.Auth) http.HandlerFunc {
+	return func(w http.ResponseWriter, r *http.Request) {
+		if !auth.IsAdminPasswordConfigured() {
+			http.Error(w, "Not implemented", http.StatusNotImplemented)
+			return
+		}
+
+		w.Header().Set("Content-Type", "application/json")
+		ok, err := auth.ValidateJwtToken(jwtFromRequest(r))
+		if err != nil || !ok {
+			w.Write([]byte("false"))
+			return
+		}
+		w.Write([]byte("true"))
+	}
+}
+
+func loginHandler(auth auth.Auth) http.HandlerFunc {
+	return func(w http.ResponseWriter, r *http.Request) {
+		var req loginRequest
+		if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+			http.Error(w, err.Error(), http.StatusBadRequest)
+			return
+		}
+
+		if !auth.IsAdminPasswordValid(req.Password) {
+			http.Error(w, "Invalid password", http.StatusUnauthorized)
+			return
+		}
+
+		lifetime := time.Hour * 24 * 90 // 90 day valid
+		tokenString, err := auth.GenerateJwtToken(lifetime)
+		if err != nil {
+			http.Error(w, "Failed to generate JWT token.", http.StatusInternalServerError)
+			return
+		}
+
+		http.SetCookie(w, &http.Cookie{
+			Name:     authCookieName,
+			Value:    tokenString,
+			Path:     "/",
+			HttpOnly: true,
+			Expires:  time.Now().Add(lifetime),
+		})
+	}
+}
+
+func logoutHandler(w http.ResponseWriter, r *http.Request) {
+	http.SetCookie(w, &http.Cookie{
+		Name:     authCookieName,
+		Path:     "/",
+		HttpOnly: true,
+	})
+}
+
+func ensureAuthHandler(auth auth.Auth) mux.MiddlewareFunc {
+	return func(next http.Handler) http.Handler {
+		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+			// check jwt token
+			ok, err := auth.ValidateJwtToken(jwtFromRequest(r))
+			if !ok || err != nil {
+				http.Error(w, "Unauthorized", http.StatusUnauthorized)
+				return
+			}
+
+			// all clear, continue
+			next.ServeHTTP(w, r)
+		})
+	}
+}
diff --git a/templates/definition/charger/go-e-v3.yaml b/templates/definition/charger/go-e-v3.yaml
index 10c8de97..4b130838 100644
--- a/templates/definition/charger/go-e-v3.yaml
+++ b/templates/definition/charger/go-e-v3.yaml
@@ -19,7 +19,7 @@ requirements:
       For 1P/3P-Phase switching the HTTP API v2 in the charger setup needs to be activated.
 
       The “simulate unplugging” option should be activated in the Go-E app ("Car" menu item).
-  uri: https://docs.evcc.io/docs/devices/chargers#go-echarger
+  uri: https://docs.evcc.io/docs/devices/chargers#go-e
   evcc: ["sponsorship"]
 params:
   - name: host
diff --git a/templates/definition/charger/solax.yaml b/templates/definition/charger/solax.yaml
new file mode 100644
index 00000000..bea7614e
--- /dev/null
+++ b/templates/definition/charger/solax.yaml
@@ -0,0 +1,26 @@
+template: solax
+products:
+  - brand: Solax
+    description:
+      generic: X3-EVC
+  - brand: Tigo
+    description:
+      generic: GO EV Charger
+  - brand: Qcells
+    description:
+      generic: Q.HOME EDRIVE A
+capabilities: ["mA", "1p3p"]
+requirements:
+  evcc: ["sponsorship"]
+  description:
+    de: Die Wallbox muss sich im Modus "Schnell" befinden und vom Wechselrichtersystem entkoppelt sein.
+    en: The charger must be in “Fast” mode and decoupled from the inverter system.
+params:
+  - name: modbus
+    choice: ["rs485"]
+    baudrate: 9600
+    comset: 8N1
+    id: 70
+render: |
+  type: solax
+  {{- include "modbus" . }}
diff --git a/util/auth/auth.go b/util/auth/auth.go
new file mode 100644
index 00000000..01597b89
--- /dev/null
+++ b/util/auth/auth.go
@@ -0,0 +1,160 @@
+package auth
+
+import (
+	"crypto/rand"
+	"encoding/hex"
+	"errors"
+	"time"
+
+	"github.com/evcc-io/evcc/core/keys"
+	"github.com/evcc-io/evcc/server/db/settings"
+	"github.com/golang-jwt/jwt/v5"
+	"golang.org/x/crypto/bcrypt"
+)
+
+// TODO: remove this once auth is released
+const disabled = true
+
+const admin = "admin"
+
+// Auth is the Auth api
+type Auth interface {
+	RemoveAdminPassword()
+	SetAdminPassword(string) error
+	IsAdminPasswordValid(string) bool
+	GenerateJwtToken(time.Duration) (string, error)
+	ValidateJwtToken(string) (bool, error)
+	IsAdminPasswordConfigured() bool
+}
+
+type auth struct {
+	settings settings.API
+}
+
+func New() Auth {
+	return &auth{settings: new(settings.Settings)}
+}
+
+func NewMock(settings settings.API) Auth {
+	return &auth{settings: settings}
+}
+
+func (a *auth) hashPassword(password string) (string, error) {
+	bytes, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
+	return string(bytes), err
+}
+
+func (a *auth) getAdminPasswordHash() string {
+	if pw, err := a.settings.String(keys.AdminPassword); err == nil {
+		return pw
+	}
+	return ""
+}
+
+// RemoveAdminPassword resets the admin password. For recovery mode via cli.
+func (a *auth) RemoveAdminPassword() {
+	a.settings.SetString(keys.AdminPassword, "")
+	a.settings.SetString(keys.JwtSecret, "")
+}
+
+// IsAdminPasswordConfigured checks if the admin password is already set
+func (a *auth) IsAdminPasswordConfigured() bool {
+	// TODO: remove this once auth is released
+	if disabled {
+		return true
+	}
+
+	return a.getAdminPasswordHash() != ""
+}
+
+// SetAdminPassword sets the admin password if not already set
+func (a *auth) SetAdminPassword(password string) error {
+	// TODO: remove this once auth is released
+	if disabled {
+		return errors.New("not implemented")
+	}
+
+	if password == "" {
+		return errors.New("password cannot be empty")
+	}
+
+	hashed, err := a.hashPassword(password)
+	if err != nil {
+		return err
+	}
+
+	a.settings.SetString(keys.AdminPassword, hashed)
+	return nil
+}
+
+// IsAdminPasswordValid checks if the given password matches the admin password
+func (a *auth) IsAdminPasswordValid(password string) bool {
+	adminHash := a.getAdminPasswordHash()
+	if adminHash == "" {
+		return false
+	}
+
+	return bcrypt.CompareHashAndPassword([]byte(adminHash), []byte(password)) == nil
+}
+
+func (a *auth) generateRandomKey(length int) (string, error) {
+	bytes := make([]byte, length)
+	if _, err := rand.Read(bytes); err != nil {
+		return "", err
+	}
+	return hex.EncodeToString(bytes), nil
+}
+
+// getJwtSecret returns the JWT secret from the settings or generates a new one
+func (a *auth) getJwtSecret() ([]byte, error) {
+	jwtSecret, err := a.settings.String(keys.JwtSecret)
+
+	// generate new secret if it doesn't exist yet -> new installation
+	if err != nil || jwtSecret == "" {
+		jwtSecret, err = a.generateRandomKey(32)
+		if err != nil {
+			return nil, err
+		}
+		a.settings.SetString(keys.JwtSecret, jwtSecret)
+	}
+
+	return []byte(jwtSecret), nil
+}
+
+// GenerateJwtToken generates an admin user JWT token with the given lifetime
+func (a *auth) GenerateJwtToken(lifetime time.Duration) (string, error) {
+	claims := &jwt.RegisteredClaims{
+		Subject:   admin,
+		ExpiresAt: jwt.NewNumericDate(time.Now().Add(lifetime)),
+	}
+
+	if jwtSecret, err := a.getJwtSecret(); err != nil {
+		return "", err
+	} else {
+		token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
+		return token.SignedString(jwtSecret)
+	}
+}
+
+// ValidateJwtToken validates the given JWT token
+func (a *auth) ValidateJwtToken(tokenString string) (bool, error) {
+	// TODO: remove this once auth is released
+	if disabled {
+		return true, nil
+	}
+
+	jwtSecret, err := a.getJwtSecret()
+	if err != nil {
+		return false, err
+	}
+
+	// read token
+	var claims jwt.RegisteredClaims
+	if _, err := jwt.ParseWithClaims(tokenString, &claims, func(token *jwt.Token) (interface{}, error) {
+		return jwtSecret, nil
+	}, jwt.WithSubject(admin)); err != nil {
+		return false, err
+	}
+
+	return true, nil
+}
diff --git a/util/templates/defaults.yaml b/util/templates/defaults.yaml
index 0f76b31c..58103ae7 100644
--- a/util/templates/defaults.yaml
+++ b/util/templates/defaults.yaml
@@ -207,8 +207,8 @@ params:
       de: Identifikation
       en: Identification
     help:
-      de: "Kann meist erst später eingetragen werden, siehe: https://docs.evcc.io/docs/guides/vehicles/#erkennung-des-fahrzeugs-an-der-wallbox"
-      en: "Mostly this can be added later, see: https://docs.evcc.io/en/docs/guides/vehicles/#determining-which-vehicle-is-at-which-charger"
+      de: "Kann meist erst später eingetragen werden, siehe: https://docs.evcc.io/docs/features/vehicle"
+      en: "Mostly this can be added later, see: https://docs.evcc.io/en/docs/features/vehicle"
     type: stringlist
   - name: priority
     description:
diff --git a/vehicle/bluelink/provider.go b/vehicle/bluelink/provider.go
index 4b5ddb18..c6b3549a 100644
--- a/vehicle/bluelink/provider.go
+++ b/vehicle/bluelink/provider.go
@@ -96,22 +96,29 @@ var _ api.Battery = (*Provider)(nil)
 // Soc implements the api.Battery interface
 func (v *Provider) Soc() (float64, error) {
 	res, err := v.statusG()
+	if err != nil {
+		return 0, err
+	}
 
-	if err == nil {
+	if res.EvStatus != nil {
 		return res.EvStatus.BatteryStatus, nil
 	}
 
-	return 0, err
+	return 0, api.ErrNotAvailable
 }
 
 var _ api.ChargeState = (*Provider)(nil)
 
 // Status implements the api.Battery interface
 func (v *Provider) Status() (api.ChargeStatus, error) {
+	status := api.StatusNone
+
 	res, err := v.statusG()
+	if err != nil {
+		return status, err
+	}
 
-	status := api.StatusNone
-	if err == nil {
+	if res.EvStatus != nil {
 		status = api.StatusA
 		if res.EvStatus.BatteryPlugin > 0 || res.EvStatus.ChargePortDoorOpenStatus == 1 {
 			status = api.StatusB
@@ -121,7 +128,7 @@ func (v *Provider) Status() (api.ChargeStatus, error) {
 		}
 	}
 
-	return status, err
+	return status, api.ErrNotAvailable
 }
 
 var _ api.VehicleFinishTimer = (*Provider)(nil)
@@ -129,8 +136,11 @@ var _ api.VehicleFinishTimer = (*Provider)(nil)
 // FinishTime implements the api.VehicleFinishTimer interface
 func (v *Provider) FinishTime() (time.Time, error) {
 	res, err := v.statusG()
+	if err != nil {
+		return time.Time{}, err
+	}
 
-	if err == nil {
+	if res.EvStatus != nil {
 		remaining := res.EvStatus.RemainTime2.Atc.Value
 
 		if remaining == 0 {
@@ -141,7 +151,7 @@ func (v *Provider) FinishTime() (time.Time, error) {
 		return ts.Add(time.Duration(remaining) * time.Minute), err
 	}
 
-	return time.Time{}, err
+	return time.Time{}, api.ErrNotAvailable
 }
 
 var _ api.VehicleRange = (*Provider)(nil)
@@ -149,16 +159,17 @@ var _ api.VehicleRange = (*Provider)(nil)
 // Range implements the api.VehicleRange interface
 func (v *Provider) Range() (int64, error) {
 	res, err := v.statusG()
+	if err != nil {
+		return 0, err
+	}
 
-	if err == nil {
+	if res.EvStatus != nil {
 		if dist := res.EvStatus.DrvDistance; len(dist) == 1 {
 			return int64(dist[0].RangeByFuel.EvModeRange.Value), nil
 		}
-
-		return 0, api.ErrNotAvailable
 	}
 
-	return 0, err
+	return 0, api.ErrNotAvailable
 }
 
 var _ api.VehicleOdometer = (*Provider)(nil)
@@ -166,7 +177,15 @@ var _ api.VehicleOdometer = (*Provider)(nil)
 // Range implements the api.VehicleRange interface
 func (v *Provider) Odometer() (float64, error) {
 	res, err := v.statusLG()
-	return res.ResMsg.VehicleStatusInfo.Odometer.Value, err
+	if err != nil {
+		return 0, err
+	}
+
+	if res.ResMsg.VehicleStatusInfo.Odometer != nil {
+		return res.ResMsg.VehicleStatusInfo.Odometer.Value, err
+	}
+
+	return 0, api.ErrNotAvailable
 }
 
 var _ api.SocLimiter = (*Provider)(nil)
@@ -174,8 +193,11 @@ var _ api.SocLimiter = (*Provider)(nil)
 // GetLimitSoc implements the api.SocLimiter interface
 func (v *Provider) GetLimitSoc() (int64, error) {
 	res, err := v.statusG()
+	if err != nil {
+		return 0, err
+	}
 
-	if err == nil {
+	if res.EvStatus != nil {
 		for _, targetSOC := range res.EvStatus.ReservChargeInfos.TargetSocList {
 			if targetSOC.PlugType == plugTypeAC {
 				return int64(targetSOC.TargetSocLevel), nil
@@ -183,7 +205,7 @@ func (v *Provider) GetLimitSoc() (int64, error) {
 		}
 	}
 
-	return 0, err
+	return 0, api.ErrNotAvailable
 }
 
 var _ api.VehiclePosition = (*Provider)(nil)
@@ -191,8 +213,16 @@ var _ api.VehiclePosition = (*Provider)(nil)
 // Position implements the api.VehiclePosition interface
 func (v *Provider) Position() (float64, float64, error) {
 	res, err := v.statusLG()
-	coord := res.ResMsg.VehicleStatusInfo.VehicleLocation.Coord
-	return coord.Lat, coord.Lon, err
+	if err != nil {
+		return 0, 0, err
+	}
+
+	if res.ResMsg.VehicleStatusInfo.VehicleLocation != nil {
+		coord := res.ResMsg.VehicleStatusInfo.VehicleLocation.Coord
+		return coord.Lat, coord.Lon, err
+	}
+
+	return 0, 0, api.ErrNotAvailable
 }
 
 var _ api.Resurrector = (*Provider)(nil)
diff --git a/vehicle/bluelink/types.go b/vehicle/bluelink/types.go
index d3652078..fd658cba 100644
--- a/vehicle/bluelink/types.go
+++ b/vehicle/bluelink/types.go
@@ -21,15 +21,15 @@ type StatusLatestResponse struct {
 	ResMsg  struct {
 		VehicleStatusInfo struct {
 			VehicleStatus   VehicleStatus
-			VehicleLocation VehicleLocation
-			Odometer        Odometer
+			VehicleLocation *VehicleLocation
+			Odometer        *Odometer
 		}
 	}
 }
 
 type VehicleStatus struct {
 	Time     string
-	EvStatus struct {
+	EvStatus *struct {
 		BatteryCharge bool
 		BatteryStatus float64
 		BatteryPlugin int


Test patch:
diff --git a/tests/auth.spec.js b/tests/auth.spec.js
new file mode 100644
index 00000000..c1e78d10
--- /dev/null
+++ b/tests/auth.spec.js
@@ -0,0 +1,106 @@
+import { test, expect } from "@playwright/test";
+import { start, stop } from "./evcc";
+
+test.beforeEach(async ({ page }) => {
+  await start("basics.evcc.yaml");
+  await page.goto("/");
+});
+
+test.afterEach(async () => {
+  await stop();
+});
+
+// TODO: activate this once auth is released
+test.skip("set initial password", async ({ page }) => {
+  const modal = page.getByTestId("password-modal");
+
+  await expect(modal).toBeVisible();
+  await expect(modal.getByRole("heading", { name: "Set Administrator Password" })).toBeVisible();
+
+  // empty password
+  await modal.getByRole("button", { name: "Create Password" }).click();
+  await expect(modal.getByText("Password should not be empty")).toBeVisible();
+
+  // invalid repeat
+  await modal.getByLabel("New password").fill("foo");
+  await modal.getByLabel("Repeat password").fill("bar");
+  await modal.getByRole("button", { name: "Create Password" }).click();
+  await expect(modal.getByText("Passwords do not match")).toBeVisible();
+
+  // success
+  await modal.getByLabel("New password").fill("secret");
+  await modal.getByLabel("Repeat password").fill("secret");
+  await modal.getByRole("button", { name: "Create Password" }).click();
+  await expect(modal).not.toBeVisible();
+});
+
+test.skip("login", async ({ page }) => {
+  // set initial password
+  const modal = page.getByTestId("password-modal");
+  await modal.getByLabel("New password").fill("secret");
+  await modal.getByLabel("Repeat password").fill("secret");
+  await modal.getByRole("button", { name: "Create Password" }).click();
+
+  // go to config
+  await page.getByTestId("topnavigation-button").click();
+  await page.getByRole("button", { name: "Settings" }).click();
+  await page.getByLabel("Experimental 🧪").click();
+  await page.getByRole("button", { name: "Close" }).click();
+  await page.getByTestId("topnavigation-button").click();
+  await page.getByRole("link", { name: "Configuration" }).click();
+
+  // login modal
+  const login = page.getByTestId("login-modal");
+  await expect(login).toBeVisible();
+  await expect(login.getByRole("heading", { name: "Authentication" })).toBeVisible();
+
+  // enter wrong password
+  await login.getByLabel("Password").fill("wrong");
+  await login.getByRole("button", { name: "Login" }).click();
+  await expect(login.getByText("Login failed: Password is invalid.")).toBeVisible();
+
+  // enter correct password
+  await login.getByLabel("Password").fill("secret");
+  await login.getByRole("button", { name: "Login" }).click();
+  await expect(login).not.toBeVisible();
+  await expect(page.getByRole("heading", { name: "Configuration" })).toBeVisible();
+});
+
+test.skip("update password", async ({ page }) => {
+  const oldPassword = "secret";
+  const newPassword = "newsecret";
+
+  // set initial password
+  const modal = page.getByTestId("password-modal");
+  await modal.getByLabel("New password").fill(oldPassword);
+  await modal.getByLabel("Repeat password").fill(oldPassword);
+  await modal.getByRole("button", { name: "Create Password" }).click();
+
+  // login modal
+  page.goto("/#/config");
+  const loginOld = page.getByTestId("login-modal");
+  await loginOld.getByLabel("Password").fill(oldPassword);
+  await loginOld.getByRole("button", { name: "Login" }).click();
+
+  // update password
+  await page.getByTestId("generalconfig-password").getByRole("link", { name: "edit" }).click();
+  await expect(modal.getByRole("heading", { name: "Update Administrator Password" })).toBeVisible();
+  await modal.getByLabel("Current password").fill(oldPassword);
+  await modal.getByLabel("New password").fill(newPassword);
+  await modal.getByLabel("Repeat password").fill(newPassword);
+  await modal.getByRole("button", { name: "Update Password" }).click();
+  await expect(
+    modal.getByRole("heading", { name: "Update Administrator Password" })
+  ).not.toBeVisible();
+
+  // logout
+  await page.getByTestId("topnavigation-button").click();
+  await page.getByRole("button", { name: "Logout" }).click();
+
+  // login modal
+  page.goto("/#/config");
+  const loginNew = page.getByTestId("login-modal");
+  await loginNew.getByLabel("Password").fill(newPassword);
+  await loginNew.getByRole("button", { name: "Login" }).click();
+  await expect(page.getByRole("heading", { name: "Configuration" })).toBeVisible();
+});
diff --git a/tests/basics.spec.js b/tests/basics.spec.js
index 3da7488a..490d7c79 100644
--- a/tests/basics.spec.js
+++ b/tests/basics.spec.js
@@ -2,7 +2,7 @@ import { test, expect } from "@playwright/test";
 import { start, stop } from "./evcc";
 
 test.beforeAll(async () => {
-  await start("basics.evcc.yaml");
+  await start("basics.evcc.yaml", "password.sql");
 });
 test.afterAll(async () => {
   await stop();
diff --git a/tests/config.spec.js b/tests/config.spec.js
index 207eabc9..d27fc5e7 100644
--- a/tests/config.spec.js
+++ b/tests/config.spec.js
@@ -6,12 +6,18 @@ const CONFIG_EMPTY = "config-empty.evcc.yaml";
 const CONFIG_WITH_VEHICLE = "config-with-vehicle.evcc.yaml";
 
 test.beforeAll(async () => {
-  await start(CONFIG_EMPTY);
+  await start(CONFIG_EMPTY, "password.sql");
 });
 test.afterAll(async () => {
   await stop();
 });
 
+async function login() {
+  // TODO: uncomment this once auth is released
+  // await page.locator("#loginPassword").fill("secret");
+  // await page.getByRole("button", { name: "Login" }).click();
+}
+
 test.describe("basics", async () => {
   test("navigation to config", async ({ page }) => {
     await page.goto("/");
@@ -21,10 +27,12 @@ test.describe("basics", async () => {
     await page.getByRole("button", { name: "Close" }).click();
     await page.getByTestId("topnavigation-button").click();
     await page.getByRole("link", { name: "Configuration" }).click();
+    await login(page);
     await expect(page.getByRole("heading", { name: "Configuration" })).toBeVisible();
   });
   test("alert box should always be visible", async ({ page }) => {
     await page.goto("/#/config");
+    await login(page);
     await expect(page.getByRole("alert")).toBeVisible();
   });
 });
@@ -32,6 +40,7 @@ test.describe("basics", async () => {
 test.describe("vehicles", async () => {
   test("create, edit and delete vehicles", async ({ page }) => {
     await page.goto("/#/config");
+    await login(page);
 
     await expect(page.getByTestId("vehicle")).toHaveCount(0);
     const vehicleModal = page.getByTestId("vehicle-modal");
@@ -79,6 +88,7 @@ test.describe("vehicles", async () => {
 
   test("config should survive restart", async ({ page }) => {
     await page.goto("/#/config");
+    await login(page);
 
     await expect(page.getByTestId("vehicle")).toHaveCount(0);
     const vehicleModal = page.getByTestId("vehicle-modal");
@@ -108,9 +118,10 @@ test.describe("vehicles", async () => {
   });
 
   test("mixed config (yaml + db)", async ({ page }) => {
-    await cleanRestart(CONFIG_WITH_VEHICLE);
+    await cleanRestart(CONFIG_WITH_VEHICLE, "password.sql");
 
     await page.goto("/#/config");
+    await login(page);
 
     await expect(page.getByTestId("vehicle")).toHaveCount(1);
     const vehicleModal = page.getByTestId("vehicle-modal");
@@ -143,6 +154,7 @@ test.describe("meters", async () => {
     await page.getByRole("button", { name: "Apply changes" }).click();
 
     await page.goto("/#/config");
+    await login(page);
 
     await expect(page.getByTestId("battery")).toHaveCount(0);
 
@@ -195,6 +207,8 @@ test.describe("general", async () => {
 
     // change value in config
     await page.goto("/#/config");
+    await login(page);
+
     await expect(page.getByTestId("generalconfig-title")).toContainText("Hello World");
     await page.getByTestId("generalconfig-title").getByRole("link", { name: "edit" }).click();
     const modal = page.getByTestId("title-modal");
diff --git a/tests/evcc.js b/tests/evcc.js
index 06198598..da69d327 100644
--- a/tests/evcc.js
+++ b/tests/evcc.js
@@ -9,10 +9,10 @@ const BASE_URL = playwrightConfig.use.baseURL;
 const DB_PATH = "./evcc.db";
 const BINARY = "./evcc";
 
-export async function start(config, database) {
+export async function start(config, sqlDumps) {
   await _clean();
-  if (database) {
-    await _restoreDatabase(database);
+  if (sqlDumps) {
+    await _restoreDatabase(sqlDumps);
   }
   await _start(config);
 }
@@ -27,15 +27,21 @@ export async function restart(config) {
   await _start(config);
 }
 
-export async function cleanRestart(config) {
+export async function cleanRestart(config, sqlDumps) {
   await _stop();
   await _clean();
+  if (sqlDumps) {
+    await _restoreDatabase(sqlDumps);
+  }
   await _start(config);
 }
 
-async function _restoreDatabase(database) {
-  console.log("loading database", { database });
-  execSync(`sqlite3 ${DB_PATH} < tests/${database}`);
+async function _restoreDatabase(sqlDumps) {
+  const dumps = Array.isArray(sqlDumps) ? sqlDumps : [sqlDumps];
+  for (const dump of dumps) {
+    console.log("loading database", dump);
+    execSync(`sqlite3 ${DB_PATH} < tests/${dump}`);
+  }
 }
 
 async function _start(config) {
diff --git a/tests/heating.spec.js b/tests/heating.spec.js
index e4e03285..4cafc832 100644
--- a/tests/heating.spec.js
+++ b/tests/heating.spec.js
@@ -2,7 +2,7 @@ import { test, expect } from "@playwright/test";
 import { start, stop } from "./evcc";
 
 test.beforeAll(async () => {
-  await start("heating.evcc.yaml");
+  await start("heating.evcc.yaml", "password.sql");
 });
 test.afterAll(async () => {
   await stop();
diff --git a/tests/limits.spec.js b/tests/limits.spec.js
index 302ad179..e7244f71 100644
--- a/tests/limits.spec.js
+++ b/tests/limits.spec.js
@@ -12,7 +12,7 @@ test.afterAll(async () => {
 });
 
 test.beforeEach(async ({ page }) => {
-  await start(CONFIG);
+  await start(CONFIG, "password.sql");
 
   await page.goto(SIMULATOR_URL);
   await page.getByLabel("Grid Power").fill("500");
diff --git a/tests/modals.spec.js b/tests/modals.spec.js
index 076f1ea0..d21d9f00 100644
--- a/tests/modals.spec.js
+++ b/tests/modals.spec.js
@@ -7,9 +7,15 @@ const SIMULATOR_CONFIG = "simulator.evcc.yaml";
 
 const UI_ROUTES = ["/", "/#/sessions", "/#/config"];
 
+async function login() {
+  // TODO: uncomment this once auth is released
+  // await page.locator("#loginPassword").fill("secret");
+  // await page.getByRole("button", { name: "Login" }).click();
+}
+
 test.describe("Basics", async () => {
   test.beforeAll(async () => {
-    await start(BASICS_CONFIG);
+    await start(BASICS_CONFIG, "password.sql");
   });
 
   test.afterAll(async () => {
@@ -19,6 +25,9 @@ test.describe("Basics", async () => {
   test("Menu options. No battery and grid.", async ({ page }) => {
     for (const route of UI_ROUTES) {
       await page.goto(route);
+      if (route === "/#/config") {
+        await login(page);
+      }
 
       await page.getByTestId("topnavigation-button").click();
       await expect(page.getByRole("button", { name: "General Settings" })).toBeVisible();
@@ -47,7 +56,7 @@ test.describe("Basics", async () => {
 
 test.describe("Advanced", async () => {
   test.beforeAll(async () => {
-    await start(SIMULATOR_CONFIG);
+    await start(SIMULATOR_CONFIG, "password.sql");
     await startSimulator();
   });
 
@@ -59,6 +68,9 @@ test.describe("Advanced", async () => {
   test("Menu options. All available.", async ({ page }) => {
     for (const route of UI_ROUTES) {
       await page.goto(route);
+      if (route === "/#/config") {
+        await login(page);
+      }
 
       await page.getByTestId("topnavigation-button").click();
       await expect(page.getByRole("button", { name: "General Settings" })).toBeVisible();
diff --git a/tests/password.sql b/tests/password.sql
new file mode 100644
index 00000000..f0aa8794
--- /dev/null
+++ b/tests/password.sql
@@ -0,0 +1,8 @@
+CREATE TABLE IF NOT EXISTS `settings` (
+    `key` text
+  , `value` text
+  , PRIMARY KEY(`key`)
+);
+
+-- password: secret
+INSERT INTO settings("key", value) VALUES('adminPassword', '$2a$10$HNLoqiTO5oLwopczA/wcPOebfO79S.hnAA5HOkx5p6o3g5a2E30v2');
diff --git a/tests/plan.spec.js b/tests/plan.spec.js
index b2a48d84..db0e0ce7 100644
--- a/tests/plan.spec.js
+++ b/tests/plan.spec.js
@@ -4,7 +4,7 @@ import { start, stop } from "./evcc";
 const CONFIG = "plan.evcc.yaml";
 
 test.beforeEach(async () => {
-  await start(CONFIG);
+  await start(CONFIG, "password.sql");
 });
 
 test.afterEach(async () => {
diff --git a/tests/sessions.spec.js b/tests/sessions.spec.js
index 85c3a1ad..20f98bb2 100644
--- a/tests/sessions.spec.js
+++ b/tests/sessions.spec.js
@@ -5,7 +5,7 @@ const mobile = devices["iPhone 12 Mini"].viewport;
 const desktop = devices["Desktop Chrome"].viewport;
 
 test.beforeAll(async () => {
-  await start("basics.evcc.yaml", "sessions.sql");
+  await start("basics.evcc.yaml", ["password.sql", "sessions.sql"]);
 });
 test.afterAll(async () => {
   await stop();
diff --git a/tests/smart-cost.spec.js b/tests/smart-cost.spec.js
index 68229525..dc2dd845 100644
--- a/tests/smart-cost.spec.js
+++ b/tests/smart-cost.spec.js
@@ -5,7 +5,7 @@ import { startSimulator, stopSimulator, SIMULATOR_URL } from "./simulator";
 const CONFIG = "simulator.evcc.yaml";
 
 test.beforeAll(async () => {
-  await start(CONFIG);
+  await start(CONFIG, "password.sql");
   await startSimulator();
 });
 test.afterAll(async () => {
diff --git a/tests/statistics.spec.js b/tests/statistics.spec.js
index 00202e46..24951750 100644
--- a/tests/statistics.spec.js
+++ b/tests/statistics.spec.js
@@ -2,7 +2,7 @@ import { test, expect } from "@playwright/test";
 import { start, stop } from "./evcc";
 
 test.beforeAll(async () => {
-  await start("statistics.evcc.yaml", "statistics.sql");
+  await start("statistics.evcc.yaml", ["password.sql", "statistics.sql"]);
 });
 test.afterAll(async () => {
   await stop();
diff --git a/tests/vehicle-error.spec.js b/tests/vehicle-error.spec.js
index 9eb0ea40..381655f7 100644
--- a/tests/vehicle-error.spec.js
+++ b/tests/vehicle-error.spec.js
@@ -2,7 +2,7 @@ import { test, expect } from "@playwright/test";
 import { start, stop } from "./evcc";
 
 test.beforeAll(async () => {
-  await start("vehicle-error.evcc.yaml");
+  await start("vehicle-error.evcc.yaml", "password.sql");
 });
 test.afterAll(async () => {
   await stop();
diff --git a/tests/vehicle-settings.spec.js b/tests/vehicle-settings.spec.js
index 42cde807..a02f8375 100644
--- a/tests/vehicle-settings.spec.js
+++ b/tests/vehicle-settings.spec.js
@@ -12,7 +12,7 @@ test.afterAll(async () => {
 });
 
 test.beforeEach(async ({ page }) => {
-  await start(CONFIG);
+  await start(CONFIG, "password.sql");
 
   await page.goto(SIMULATOR_URL);
   await page.getByLabel("Grid Power").fill("500");
diff --git a/util/auth/auth_test.go b/util/auth/auth_test.go
new file mode 100644
index 00000000..415bff7c
--- /dev/null
+++ b/util/auth/auth_test.go
@@ -0,0 +1,82 @@
+package auth
+
+import (
+	"testing"
+	"time"
+
+	"github.com/evcc-io/evcc/core/keys"
+	"github.com/evcc-io/evcc/server/db/settings"
+	"github.com/stretchr/testify/assert"
+	"go.uber.org/mock/gomock"
+)
+
+func TestSetAdminPassword(t *testing.T) {
+	t.Skip("skipped until auth is released")
+
+	ctrl := gomock.NewController(t)
+	defer ctrl.Finish()
+
+	mock := settings.NewMockAPI(ctrl)
+	auth := NewMock(mock)
+	password := "testpassword"
+
+	mock.EXPECT().SetString(keys.AdminPassword, gomock.Not(gomock.Eq("")))
+	assert.Nil(t, auth.SetAdminPassword(password)) // success
+}
+
+func TestRemoveAdminPassword(t *testing.T) {
+	ctrl := gomock.NewController(t)
+	defer ctrl.Finish()
+
+	mock := settings.NewMockAPI(ctrl)
+	auth := NewMock(mock)
+
+	mock.EXPECT().SetString(keys.JwtSecret, "")
+	mock.EXPECT().SetString(keys.AdminPassword, "")
+	auth.RemoveAdminPassword()
+}
+
+func TestIsAdminPasswordValid(t *testing.T) {
+	t.Skip("skipped until auth is released")
+
+	ctrl := gomock.NewController(t)
+	defer ctrl.Finish()
+
+	mock := settings.NewMockAPI(ctrl)
+	auth := NewMock(mock)
+
+	validPw := "testpassword"
+	invalidPw := "wrongpassword"
+
+	// password not set, reject
+	mock.EXPECT().String(keys.AdminPassword).Return("", nil).Times(1)
+	assert.False(t, auth.IsAdminPasswordValid(validPw))
+
+	// password set, accept
+	var storedHash string
+	mock.EXPECT().SetString(keys.AdminPassword, gomock.Not(gomock.Eq(""))).Do(func(_ string, hash string) { storedHash = hash })
+	auth.SetAdminPassword(validPw)
+	mock.EXPECT().String(keys.AdminPassword).Return(storedHash, nil).Times(2)
+	assert.True(t, auth.IsAdminPasswordValid(validPw))
+
+	// password set, wrong password
+	assert.False(t, auth.IsAdminPasswordValid(invalidPw))
+}
+
+func TestJwtToken(t *testing.T) {
+	ctrl := gomock.NewController(t)
+	defer ctrl.Finish()
+
+	mock := settings.NewMockAPI(ctrl)
+	auth := NewMock(mock)
+
+	mock.EXPECT().String(keys.JwtSecret).Return("somesecret", nil).AnyTimes()
+
+	lifetime := time.Hour
+	tokenString, err := auth.GenerateJwtToken(lifetime)
+	assert.Nil(t, err, "token generation failed")
+	assert.NotEmpty(t, tokenString, "token is empty")
+
+	ok, err := auth.ValidateJwtToken(tokenString)
+	assert.True(t, ok && err == nil, "token is invalid")
+}