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:
parent
efa34402b5
commit
f927d5fa18
20 changed files with 289 additions and 96 deletions
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
18
assets/js/components/Config/ExperimentalBanner.vue
Normal file
18
assets/js/components/Config/ExperimentalBanner.vue
Normal 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>
|
||||
75
assets/js/components/Config/WelcomeBanner.vue
Normal file
75
assets/js/components/Config/WelcomeBanner.vue
Normal 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>
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -234,7 +234,7 @@ vehicles:
|
|||
icon: waterheater
|
||||
|
||||
site:
|
||||
title: Zuhause
|
||||
title: Demo Mode
|
||||
meters:
|
||||
grid: grid
|
||||
pv: pv
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
||||
|
|
|
|||
28
cmd/root.go
28
cmd/root.go
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -18,4 +18,5 @@ const (
|
|||
Startup = "startup"
|
||||
Plant = "plant"
|
||||
Telemetry = "telemetry"
|
||||
DemoMode = "demoMode"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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
44
tests/demo.spec.ts
Normal 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.");
|
||||
});
|
||||
});
|
||||
|
|
@ -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"],
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue