Add explicit demo mode (#21848)

Co-authored-by: Michael Geers <michael@geers.tv>
Co-authored-by: sourcery-ai[bot] <58596630+sourcery-ai[bot]@users.noreply.github.com>
This commit is contained in:
guido4096 2025-06-20 12:05:08 +02:00 • committed by GitHub
parent efa34402b5
commit f927d5fa18
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 289 additions and 96 deletions

View file

@ -2,12 +2,15 @@
<GenericModal
id="loginModal"
:title="$t('loginModal.title')"
size="sm"
:size="modalSize"
data-testid="login-modal"
@open="open"
@closed="closed"
>
<form v-if="modalVisible" @submit.prevent="login">
<div v-if="demoMode" class="alert alert-warning" role="alert">
{{ $t("loginModal.demoMode") }}
</div>
<form v-else-if="modalVisible" @submit.prevent="login">
<div class="mb-4">
<label for="loginPassword" class="col-form-label">
<div class="w-100">
@ -68,6 +71,9 @@ import { defineComponent } from "vue";
export default defineComponent({
name: "LoginModal",
components: { GenericModal },
props: {
demoMode: Boolean,
},
data: () => {
return {
modalVisible: false,
@ -85,6 +91,9 @@ export default defineComponent({
evccUrl() {
return window.location.href;
},
modalSize() {
return this.demoMode ? "md" : "sm";
},
},
methods: {
open() {
@ -111,7 +120,7 @@ export default defineComponent({
try {
const data = { password: this.password };
const res = await api.post("/auth/login", data, {
validateStatus: (code) => [200, 401].includes(code),
validateStatus: (code) => [200, 401, 403].includes(code),
});
this.resetHint = false;
this.iframeHint = false;
@ -135,6 +144,9 @@ export default defineComponent({
this.error = this.$t("loginModal.invalid");
this.resetHint = true;
}
if (res.status === 403) {
this.error = this.$t("loginModal.demoMode");
}
} catch (err) {
console.error(err);
if (err instanceof Error) {

View file

@ -1,6 +1,7 @@
import { reactive, watch } from "vue";
import api from "@/api";
import store from "@/store";
import Modal from "bootstrap/js/dist/modal";
import { isSystemError } from "@/utils/fatal.js";
@ -19,7 +20,7 @@ export async function updateAuthStatus() {
try {
const res = await api.get("/auth/status", {
validateStatus: (code) => [200, 501, 500].includes(code),
validateStatus: (code) => [200, 403, 501, 500].includes(code),
});
if (res.status === 501) {
auth.configured = false;
@ -28,6 +29,10 @@ export async function updateAuthStatus() {
auth.configured = true;
auth.loggedIn = res.data === true;
}
if (res.status === 403) {
auth.configured = true;
auth.loggedIn = false;
}
if (res.status === 500) {
auth.loggedIn = null;
console.log("unable to fetch auth status", res);

View file

@ -0,0 +1,18 @@
<template>
<div class="alert alert-warning my-4 pb-0" role="alert" data-testid="experimental-banner">
<p>
<strong>Experimental! 🧪</strong>
The UI configuration is still in development. Found an issue? Please
<a href="https://github.com/evcc-io/evcc/issues" target="_blank">report it on GitHub</a
>. Need to reset? Delete <code>evcc.db</code> or run <code>evcc migrate --reset</code>.
Note: Disabling experimental features only hides the UI features. Your configuration
stays untouched.
</p>
</div>
</template>
<script>
export default {
name: "ExperimentalBanner",
};
</script>

View file

@ -0,0 +1,75 @@
<template>
<div class="alert alert-success" data-testid="welcome-banner">
<h5 class="alert-heading">Welcome to evcc!</h5>
<p class="mb-4">Ready to get started? Choose how you'd like to configure your devices:</p>
<div class="row g-5 mb-4">
<div class="col-12 col-md-6">
<strong>Configuration File</strong>
<p>The traditional and <u>recommended</u> approach.</p>
<p>
Create an evcc.yaml file with all your device settings. Once set up, you can
make day-to-day adjustments right here in the UI.
</p>
<p>Need help getting started?</p>
<a
href="https://docs.evcc.io/en/docs/installation/configuration"
class="btn btn-outline-primary btn-sm"
tabindex="0"
target="_blank"
>Configuration Guide</a
>
</div>
<div class="col-12 col-md-6">
<div>
<strong>UI Configuration 🧪</strong>
<p>The new but still <u>experimental</u> approach.</p>
<p>
Configure everything directly in this interface. While functional, we're
still working on
<a target="_blank" href="https://github.com/evcc-io/evcc/issues/6029"
>improvements</a
>
and fixing
<a
target="_blank"
href="https://github.com/evcc-io/evcc/issues?q=is%3Aissue%20state%3Aopen%20label%3Aexperimental"
>known issues</a
>.
</p>
<p>Want to try it out?</p>
<button class="btn btn-sm" :class="buttonClass" tabindex="0" @click="toggle">
{{ buttonLabel }}
</button>
</div>
</div>
</div>
<p class="small mb-0">
<strong>Note:</strong> This message disappears once you configure your first charging
point (loadpoint).
</p>
</div>
</template>
<script>
import { setHiddenFeatures, getHiddenFeatures } from "@/featureflags.ts";
export default {
name: "WelcomeBanner",
computed: {
buttonClass() {
return this.hiddenFeatures() ? "btn-outline-secondary" : "btn-outline-primary";
},
buttonLabel() {
return this.hiddenFeatures() ? "✔︎ enabled" : "Enable Experimental Features";
},
},
methods: {
toggle() {
setHiddenFeatures(!this.hiddenFeatures());
},
hiddenFeatures() {
return getHiddenFeatures();
},
},
};
</script>

View file

@ -7,7 +7,7 @@
<ForecastModal v-bind="forecastModalProps" />
<HelpModal />
<PasswordModal />
<LoginModal />
<LoginModal v-bind="loginModalProps" />
<OfflineIndicator v-bind="offlineIndicatorProps" />
</div>
</template>
@ -74,6 +74,9 @@ export default {
forecastModalProps() {
return this.collectProps(ForecastModal, store.state);
},
loginModalProps() {
return this.collectProps(LoginModal, store.state);
},
},
watch: {
version(now, prev) {

View file

@ -3,30 +3,8 @@
<div class="container px-4">
<TopHeader :title="$t('config.main.title')" />
<div class="wrapper pb-5">
<div v-if="$hiddenFeatures()" class="alert alert-danger my-4 pb-0" role="alert">
<p>
<strong>Experimental! 🧪</strong>
Only use these features if you are in the mood for adventure and not afraid
of debugging. Unexpected things and data loss may happen.
</p>
<p>
We are in the progress of replacing <code>evcc.yaml</code> with UI-based
configuration. Any changes made here will be written to the database. After
that, the corresponding <code>evcc.yaml</code>-values (e.g. network
settings) will be ignored.
</p>
<p class="mb-1"><strong>Missing features</strong></p>
<ul>
<li>migration for loadpoints</li>
</ul>
<p>
<strong>Migration and repair.</strong> Run <code>evcc migrate</code> to copy
configuration from <code>evcc.yaml</code> to the database. Existing database
configurations will be overwritten. Session and statistics data will not be
touched. Run <code>evcc migrate --reset</code> to remove all database
configurations.
</p>
</div>
<WelcomeBanner v-if="loadpointsRequired" />
<ExperimentalBanner v-else-if="$hiddenFeatures()" />
<h2 class="my-4 mt-5">{{ $t("config.section.general") }}</h2>
<GeneralConfig @site-changed="siteChanged" />
@ -433,6 +411,8 @@ import TariffsModal from "../components/Config/TariffsModal.vue";
import Header from "../components/Top/Header.vue";
import VehicleIcon from "../components/VehicleIcon";
import VehicleModal from "../components/Config/VehicleModal.vue";
import WelcomeBanner from "../components/Config/WelcomeBanner.vue";
import ExperimentalBanner from "../components/Config/ExperimentalBanner.vue";
export default {
name: "Config",
@ -446,6 +426,7 @@ export default {
DeviceTags,
EebusIcon,
EebusModal,
ExperimentalBanner,
GeneralConfig,
HemsIcon,
HemsModal,
@ -466,6 +447,7 @@ export default {
TopHeader: Header,
VehicleIcon,
VehicleModal,
WelcomeBanner,
},
mixins: [formatter, collector],
props: {

View file

@ -234,7 +234,7 @@ vehicles:
icon: waterheater
site:
title: Zuhause
title: Demo Mode
meters:
grid: grid
pv: pv

View file

@ -12,6 +12,9 @@ const (
flagHeaders = "log-headers"
flagHeadersDescription = "Log headers"
flagDemoMode = "demo"
flagDemoModeDescription = "Enter demo mode. Disables auth, config ui and restart"
flagIgnoreDatabase = "ignore-db"
flagIgnoreDatabaseDescription = "Run command ignoring service database"

View file

@ -72,6 +72,7 @@ func init() {
rootCmd.PersistentFlags().StringVarP(&cfgFile, "config", "c", "", "Config file (default \"~/evcc.yaml\" or \"/etc/evcc.yaml\")")
rootCmd.PersistentFlags().StringVar(&cfgDatabase, "database", "", "Database location (default \"~/.evcc/evcc.db\")")
rootCmd.PersistentFlags().BoolP("help", "h", false, "Help")
rootCmd.PersistentFlags().Bool(flagDemoMode, false, flagDemoModeDescription)
rootCmd.PersistentFlags().Bool(flagHeaders, false, flagHeadersDescription)
rootCmd.PersistentFlags().Bool(flagIgnoreDatabase, false, flagIgnoreDatabaseDescription)
rootCmd.PersistentFlags().String(flagTemplate, "", flagTemplateDescription)
@ -129,13 +130,22 @@ func runRoot(cmd *cobra.Command, args []string) {
// load config and re-configure logging after reading config file
var err error
if cfgErr := loadConfigFile(&conf, !cmd.Flag(flagIgnoreDatabase).Changed); errors.As(cfgErr, &vpr.ConfigFileNotFoundError{}) {
log.INFO.Println("missing config file - switching into demo mode")
if ok, _ := cmd.Flags().GetBool(flagDemoMode); ok {
log.INFO.Println("switching into demo mode as requested through the --demo flag")
if err := demoConfig(&conf); err != nil {
log.FATAL.Fatal(err)
}
} else {
err = wrapErrorWithClass(ClassConfigFile, cfgErr)
if cfgErr := loadConfigFile(&conf, !cmd.Flag(flagIgnoreDatabase).Changed); errors.As(cfgErr, &vpr.ConfigFileNotFoundError{}) {
log.INFO.Println("config file missing, create configuration using config UI")
// evcc.yaml not found, use default configuration
if err := viper.UnmarshalExact(&conf); err != nil {
log.FATAL.Fatalf("failed to unmarshal default configuration: %v", err)
}
} else {
// evcc.yaml found, might have errors
err = wrapErrorWithClass(ClassConfigFile, cfgErr)
}
}
// setup environment
@ -296,13 +306,19 @@ func runRoot(cmd *cobra.Command, args []string) {
// allow web access for vehicles
configureAuth(httpd.Router())
auth := auth.New()
authObject := auth.New()
if ok, _ := cmd.Flags().GetBool(flagDisableAuth); ok {
log.WARN.Println("❗❗❗ Authentication is disabled. This is dangerous. Your data and credentials are not protected.")
auth.Disable()
authObject.SetAuthMode(auth.Disabled)
}
httpd.RegisterSystemHandler(site, valueChan, cache, auth, func() {
if ok, _ := cmd.Flags().GetBool(flagDemoMode); ok {
log.WARN.Println("Authentication is locked in demo mode. Login-features are disabled.")
authObject.SetAuthMode(auth.Locked)
valueChan <- util.Param{Key: keys.DemoMode, Val: true}
}
httpd.RegisterSystemHandler(site, valueChan, cache, authObject, func() {
log.INFO.Println("evcc was stopped by user. OS should restart the service. Or restart manually.")
err = errors.New("restart required") // https://gokrazy.org/development/process-interface/
once.Do(func() { close(stopC) }) // signal loop to end

View file

@ -18,4 +18,5 @@ const (
Startup = "startup"
Plant = "plant"
Telemetry = "telemetry"
DemoMode = "demoMode"
)

View file

@ -581,6 +581,7 @@
},
"loginModal": {
"cancel": "Abbrechen",
"demoMode": "Login ist in Demo-Modus nicht verfügbar.",
"error": "Login fehlgeschlagen: ",
"iframeHint": "Öffne evcc in einem neuen Tab.",
"iframeIssue": "Das Passwort ist korrekt, aber dein Browser hat das Authentifizierungscookie abgelehnt. Dies kann passieren, wenn du evcc in einem iframe über HTTP verwendest.",

View file

@ -581,6 +581,7 @@
},
"loginModal": {
"cancel": "Cancel",
"demoMode": "Login is not supported in demo mode.",
"error": "Login failed: ",
"iframeHint": "Open evcc in a new tab.",
"iframeIssue": "Your password is correct, but your browser seems to have dropped the authentication cookie. This can happen if you run evcc in an iframe via HTTP.",

View file

@ -581,6 +581,7 @@
},
"loginModal": {
"cancel": "Annuleren",
"demoMode": "Login niet ondersteund in demo mode.",
"error": "Login mislukt: ",
"iframeHint": "Open evcc in een nieuw tabblad.",
"iframeIssue": "Uw wachtwoord is correct, maar het lijkt erop dat uw browser de authenticatiecookie heeft verwijderd. Dit kan gebeuren als u evcc via HTTP in een iframe uitvoert.",

View file

@ -21,8 +21,13 @@ type loginRequest struct {
Password string `json:"password"`
}
func updatePasswordHandler(auth auth.Auth) http.HandlerFunc {
func updatePasswordHandler(authObject auth.Auth) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if authObject.GetAuthMode() == auth.Locked {
http.Error(w, "Forbidden in demo mode", http.StatusForbidden)
return
}
var req updatePasswordRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
@ -30,13 +35,13 @@ func updatePasswordHandler(auth auth.Auth) http.HandlerFunc {
}
// update password
if auth.IsAdminPasswordConfigured() {
if !auth.IsAdminPasswordValid(req.Current) {
if authObject.IsAdminPasswordConfigured() {
if !authObject.IsAdminPasswordValid(req.Current) {
http.Error(w, "Invalid password", http.StatusBadRequest)
return
}
if err := auth.SetAdminPassword(req.New); err != nil {
if err := authObject.SetAdminPassword(req.New); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
@ -46,7 +51,7 @@ func updatePasswordHandler(auth auth.Auth) http.HandlerFunc {
}
// create new password
if err := auth.SetAdminPassword(req.New); err != nil {
if err := authObject.SetAdminPassword(req.New); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
@ -71,20 +76,25 @@ func jwtFromRequest(r *http.Request) string {
}
// authStatusHandler login status (true/false) based on jwt token. Error if admin password is not configured
func authStatusHandler(auth auth.Auth) http.HandlerFunc {
func authStatusHandler(authObject auth.Auth) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if auth.Disabled() {
if authObject.GetAuthMode() == auth.Disabled {
w.Write([]byte("true"))
return
}
if !auth.IsAdminPasswordConfigured() {
if authObject.GetAuthMode() == auth.Locked {
http.Error(w, "Forbidden in demo mode", http.StatusForbidden)
return
}
if !authObject.IsAdminPasswordConfigured() {
http.Error(w, "Not implemented", http.StatusNotImplemented)
return
}
w.Header().Set("Content-Type", "application/json")
ok, err := auth.ValidateJwtToken(jwtFromRequest(r))
ok, err := authObject.ValidateJwtToken(jwtFromRequest(r))
if err != nil || !ok {
w.Write([]byte("false"))
return
@ -93,21 +103,26 @@ func authStatusHandler(auth auth.Auth) http.HandlerFunc {
}
}
func loginHandler(auth auth.Auth) http.HandlerFunc {
func loginHandler(authObject auth.Auth) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if authObject.GetAuthMode() == auth.Locked {
http.Error(w, "Forbidden in demo mode", http.StatusForbidden)
return
}
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) {
if !authObject.IsAdminPasswordValid(req.Password) {
http.Error(w, "Invalid password", http.StatusUnauthorized)
return
}
lifetime := time.Hour * 24 * 90 // 90 day valid
tokenString, err := auth.GenerateJwtToken(lifetime)
tokenString, err := authObject.GenerateJwtToken(lifetime)
if err != nil {
http.Error(w, "Failed to generate JWT token.", http.StatusInternalServerError)
return
@ -132,16 +147,21 @@ func logoutHandler(w http.ResponseWriter, r *http.Request) {
})
}
func ensureAuthHandler(auth auth.Auth) mux.MiddlewareFunc {
func ensureAuthHandler(authObject auth.Auth) mux.MiddlewareFunc {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if auth.Disabled() {
if authObject.GetAuthMode() == auth.Disabled {
next.ServeHTTP(w, r)
return
}
if authObject.GetAuthMode() == auth.Locked {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
// check jwt token
ok, err := auth.ValidateJwtToken(jwtFromRequest(r))
ok, err := authObject.ValidateJwtToken(jwtFromRequest(r))
if !ok || err != nil {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return

View file

@ -9,7 +9,6 @@ import {
} from "./utils";
const CONFIG_YAML = "config-circuit.evcc.yaml";
const CONFIG_EMPTY = "config-empty.evcc.yaml";
test.use({ baseURL: baseUrl() });
@ -41,7 +40,7 @@ test.describe("circuit", async () => {
});
test("via ui", async ({ page }) => {
await start(CONFIG_EMPTY);
await start();
await page.goto("/#/config");
await enableExperimental(page);
@ -110,7 +109,7 @@ test.describe("circuit", async () => {
.getByTestId("bottom-banner")
.getByRole("button", { name: "Restart" });
await expect(restartButton).toBeVisible();
await restart(CONFIG_EMPTY);
await restart();
await page.reload();
// assign loadpoint to circuit
@ -123,7 +122,7 @@ test.describe("circuit", async () => {
// save, restart and check values
await expect(restartButton).toBeVisible();
await restart(CONFIG_EMPTY);
await restart();
await page.reload();
// verify the configuration matches the yaml test
@ -156,7 +155,7 @@ test.describe("circuit", async () => {
// save, restart and check values
await expect(restartButton).toBeVisible();
await restart(CONFIG_EMPTY);
await restart();
await page.reload();
// verify circuits

View file

@ -2,7 +2,6 @@ import { test, expect, type Page } from "@playwright/test";
import { start, stop, restart, baseUrl } from "./evcc";
import { expectModalVisible, expectModalHidden, editorClear, editorPaste } from "./utils";
const CONFIG_EMPTY = "config-empty.evcc.yaml";
const CONFIG_ONE_LP = "config-one-lp.evcc.yaml";
test.use({ baseURL: baseUrl() });
@ -64,7 +63,7 @@ async function newLoadpoint(page: Page, title: string) {
test.describe("loadpoint", async () => {
test("create, update and delete", async ({ page }) => {
await start(CONFIG_EMPTY);
await start();
await page.goto("/#/config");
await enableExperimental(page);
@ -99,7 +98,7 @@ test.describe("loadpoint", async () => {
await expect(restartButton).toBeVisible();
// restart
await restart(CONFIG_EMPTY);
await restart();
await page.reload();
await expect(page.getByTestId("loadpoint")).toHaveCount(1);
await expect(page.getByTestId("loadpoint")).toContainText("Solar Carport");
@ -112,7 +111,7 @@ test.describe("loadpoint", async () => {
await expect(page.getByTestId("loadpoint")).toContainText("Solar Carport 2");
// restart
await restart(CONFIG_EMPTY);
await restart();
await page.reload();
await expect(page.getByTestId("loadpoint")).toHaveCount(1);
await expect(page.getByTestId("loadpoint")).toContainText("Solar Carport 2");
@ -135,7 +134,7 @@ test.describe("loadpoint", async () => {
await expectModalHidden(lpModal);
// restart
await restart(CONFIG_EMPTY);
await restart();
await page.reload();
await expect(page.getByTestId("loadpoint")).toHaveCount(1);
await expect(page.getByTestId("loadpoint")).toContainText("not connected");
@ -153,7 +152,7 @@ test.describe("loadpoint", async () => {
await expect(page.getByTestId("loadpoint")).toHaveCount(0);
// restart
await restart(CONFIG_EMPTY);
await restart();
await page.reload();
await expect(page.getByTestId("loadpoint")).toHaveCount(0);
});
@ -213,7 +212,7 @@ test.describe("loadpoint", async () => {
});
test("vehicle", async ({ page }) => {
await start(CONFIG_EMPTY);
await start();
await page.goto("/#/config");
await enableExperimental(page);
@ -258,7 +257,7 @@ test.describe("loadpoint", async () => {
await lpModal.getByRole("button", { name: "Save" }).click();
// restart
await restart(CONFIG_EMPTY);
await restart();
await page.reload();
// check loadpoint default vehicles
@ -272,7 +271,7 @@ test.describe("loadpoint", async () => {
});
test("keep mode", async ({ page }) => {
await start(CONFIG_EMPTY);
await start();
await page.goto("/#/config");
await enableExperimental(page);
@ -291,13 +290,13 @@ test.describe("loadpoint", async () => {
await lpModal.getByLabel("Default mode").selectOption("---");
await lpModal.getByRole("button", { name: "Save" }).click();
await expectModalHidden(lpModal);
await restart(CONFIG_EMPTY);
await restart();
// change on main ui
await page.goto("/");
await expect(page.getByRole("button", { name: "Off" })).toHaveClass(/active/);
await page.getByRole("button", { name: "Solar", exact: true }).click();
await restart(CONFIG_EMPTY);
await restart();
await page.reload();
await expect(page.getByRole("button", { name: "Solar", exact: true })).toHaveClass(/active/);
@ -309,7 +308,7 @@ test.describe("loadpoint", async () => {
await lpModal.getByLabel("Default mode").selectOption("Fast");
await lpModal.getByRole("button", { name: "Save" }).click();
await expectModalHidden(lpModal);
await restart(CONFIG_EMPTY);
await restart();
// check loadpoint mode
await page.goto("/");
@ -317,7 +316,7 @@ test.describe("loadpoint", async () => {
});
test("delete vehicle references", async ({ page }) => {
await start(CONFIG_EMPTY);
await start();
await page.goto("/#/config");
await enableExperimental(page);
@ -340,7 +339,7 @@ test.describe("loadpoint", async () => {
await expectModalHidden(vehicleModal);
// restart
await restart(CONFIG_EMPTY);
await restart();
await page.reload();
// check loadpoint default vehicle
@ -350,7 +349,7 @@ test.describe("loadpoint", async () => {
});
test("delete charger references", async ({ page }) => {
await start(CONFIG_EMPTY);
await start();
await page.goto("/#/config");
await enableExperimental(page);
@ -371,7 +370,7 @@ test.describe("loadpoint", async () => {
await expectModalHidden(chargerModal);
// restart without saving loadpoint
await restart(CONFIG_EMPTY);
await restart();
await page.reload();
// check loadpoint default vehicle
@ -382,7 +381,7 @@ test.describe("loadpoint", async () => {
});
test("delete meter references", async ({ page }) => {
await start(CONFIG_EMPTY);
await start();
await page.goto("/#/config");
await enableExperimental(page);
@ -404,7 +403,7 @@ test.describe("loadpoint", async () => {
await expectModalHidden(meterModal);
// restart without saving loadpoint
await restart(CONFIG_EMPTY);
await restart();
await page.reload();
// check loadpoint default vehicle
@ -418,7 +417,7 @@ test.describe("loadpoint", async () => {
});
test("user-defined charger", async ({ page }) => {
await start(CONFIG_EMPTY);
await start();
await page.goto("/#/config");
await enableExperimental(page);
@ -475,7 +474,7 @@ power:
await expect(page.getByTestId("loadpoint")).toContainText("Carport");
// restart evcc
await restart(CONFIG_EMPTY);
await restart();
await page.reload();
const lpEntry = page.getByTestId("loadpoint");

View file

@ -1,13 +1,11 @@
import { test, expect } from "@playwright/test";
import { start, stop, restart, baseUrl } from "./evcc";
import { enableExperimental, expectModalHidden, expectModalVisible } from "./utils";
const CONFIG = "config-empty.evcc.yaml";
import { expectModalHidden, expectModalVisible } from "./utils";
test.use({ baseURL: baseUrl() });
test.beforeAll(async () => {
await start(CONFIG, null, "");
await start(undefined, undefined, "");
});
test.afterAll(async () => {
await stop();
@ -40,7 +38,11 @@ test.describe("onboarding", async () => {
// config page
await expect(page.getByRole("heading", { name: "Configuration" })).toBeVisible();
await enableExperimental(page);
await expect(page.getByTestId("welcome-banner")).toBeVisible();
await page
.getByTestId("welcome-banner")
.getByRole("button", { name: "Enable experimental features" })
.click();
// create loadpoint with charger
await expect(page.getByTestId("loadpoint-required")).toBeVisible();
@ -58,7 +60,9 @@ test.describe("onboarding", async () => {
await expectModalVisible(lpModal);
await lpModal.getByRole("button", { name: "Save" }).click();
await expectModalHidden(lpModal);
await expect(page.getByTestId("welcome-banner")).not.toBeVisible();
await expect(page.getByTestId("loadpoint-required")).not.toBeVisible();
await expect(page.getByTestId("experimental-banner")).toBeVisible();
// create grid meter
await page.getByRole("button", { name: "Add grid meter" }).click();
@ -85,7 +89,7 @@ test.describe("onboarding", async () => {
.getByTestId("bottom-banner")
.getByRole("button", { name: "Restart" });
await expect(restartButton).toBeVisible();
await restart(CONFIG);
await restart();
await expect(restartButton).not.toBeVisible();
// navigate to main screen

44
tests/demo.spec.ts Normal file
View file

@ -0,0 +1,44 @@
import { test, expect } from "@playwright/test";
import { start, stop, baseUrl } from "./evcc";
import { expectModalHidden } from "./utils";
import { ChildProcess } from "child_process";
test.use({ baseURL: baseUrl() });
let instance: ChildProcess;
test.beforeAll(async () => {
instance = await start(undefined, undefined, "--demo");
});
test.afterAll(async () => {
// force quit by instance, shutdown endpoint disabled
await stop(instance);
});
test.beforeEach(async ({ page }) => {
await page.goto("/");
});
test.describe("demo mode", async () => {
test("no admin password prompt", async ({ page }) => {
await expectModalHidden(page.getByTestId("password-modal"));
});
test("site title", async ({ page }) => {
await expect(page.getByRole("heading", { name: "Demo Mode" })).toBeVisible();
});
test("two loadpoints", async ({ page }) => {
await expect(page.getByTestId("loadpoint")).toHaveCount(2);
await expect(page.getByTestId("loadpoint").nth(0)).toContainText("Carport");
await expect(page.getByTestId("loadpoint").nth(1)).toContainText("Garage");
});
test("auth is locked", async ({ page }) => {
await page.getByTestId("topnavigation-button").click();
await page.getByRole("link", { name: "Configuration" }).click();
const loginModal = page.getByTestId("login-modal");
await expect(loginModal).toBeVisible();
await expect(loginModal).toContainText("Login is not supported in demo mode.");
});
});

View file

@ -46,7 +46,7 @@ function dbPath() {
}
export async function start(
config: string,
config?: string,
sqlDumps?: string | null,
flags: string | string[] = "--disable-auth"
) {
@ -62,7 +62,7 @@ export async function stop(instance?: ChildProcess) {
await _clean();
}
export async function restart(config: string, flags = "--disable-auth") {
export async function restart(config?: string, flags = "--disable-auth") {
await _stop();
await _start(config, flags);
}
@ -84,8 +84,8 @@ async function _restoreDatabase(sqlDumps: string) {
}
}
async function _start(config: string, flags: string | string[] = []) {
const configFile = config.includes("/") ? config : `tests/${config}`;
async function _start(config?: string, flags: string | string[] = []) {
const configArgs = config ? ["--config", config.includes("/") ? config : `tests/${config}`] : [];
const port = workerPort();
log(`wait until port ${port} is available`);
// wait for port to be available
@ -93,7 +93,7 @@ async function _start(config: string, flags: string | string[] = []) {
const additionalFlags = typeof flags === "string" ? [flags] : flags;
additionalFlags.push("--log", "debug,httpd:trace");
log("starting evcc", { config, port, additionalFlags });
const instance = spawn(BINARY, ["--config", configFile, ...additionalFlags], {
const instance = spawn(BINARY, [...configArgs, ...additionalFlags], {
env: { EVCC_NETWORK_PORT: port.toString(), EVCC_DATABASE_DSN: dbPath() },
stdio: ["pipe", "pipe", "pipe"],
});

View file

@ -14,6 +14,15 @@ import (
const admin = "admin"
// Possible authentication modes
type AuthMode int
const (
Enabled AuthMode = iota // normal operation
Disabled // auth checks are skipped (free for all)
Locked // auth features are blocked (demo mode)
)
// Auth is the Auth api
type Auth interface {
RemoveAdminPassword()
@ -22,21 +31,21 @@ type Auth interface {
GenerateJwtToken(time.Duration) (string, error)
ValidateJwtToken(string) (bool, error)
IsAdminPasswordConfigured() bool
Disable()
Disabled() bool
SetAuthMode(AuthMode)
GetAuthMode() AuthMode
}
type auth struct {
settings settings.API
disabled bool
authMode AuthMode
}
func New() Auth {
return &auth{settings: new(settings.Settings), disabled: false}
return &auth{settings: new(settings.Settings), authMode: Enabled}
}
func NewMock(settings settings.API) Auth {
return &auth{settings: settings, disabled: false}
return &auth{settings: settings, authMode: Enabled}
}
func (a *auth) hashPassword(password string) (string, error) {
@ -144,10 +153,10 @@ func (a *auth) ValidateJwtToken(tokenString string) (bool, error) {
return true, nil
}
func (a *auth) Disable() {
a.disabled = true
func (a *auth) SetAuthMode(authMode AuthMode) {
a.authMode = authMode
}
func (a *auth) Disabled() bool {
return a.disabled
func (a *auth) GetAuthMode() AuthMode {
return a.authMode
}