Auth UI: auto-prepare, success message, extend tests (#26077)

This commit is contained in:
Michael Geers 2025-12-20 15:52:10 +01:00 • committed by GitHub
parent 86aeb8e542
commit 4d09af22f7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 376 additions and 89 deletions

View file

@ -1,7 +1,12 @@
<template>
<!-- Connect to provider button (when URL is available) -->
<div v-if="providerUrl" class="d-flex flex-column align-items-end gap-2">
<a :href="providerUrl" target="_blank" class="btn btn-primary">
<a
:href="providerUrl"
target="_blank"
class="btn btn-primary"
@click="$emit('external-click')"
>
{{
$t("authProviders.buttonConnect", {
provider: providerDomain,
@ -40,7 +45,7 @@ export default defineComponent({
loading: { type: Boolean, default: false },
showHint: { type: Boolean, default: true },
},
emits: ["prepare"],
emits: ["prepare", "external-click"],
computed: {
providerDomain(): string | null {
return this.providerUrl ? extractDomain(this.providerUrl) : null;

View file

@ -14,7 +14,7 @@
<div
v-for="provider in providerList"
:key="provider.id"
class="auth-provider-item d-flex align-items-baseline justify-content-between"
class="d-flex align-items-baseline justify-content-between mb-2"
>
<div class="d-flex align-items-baseline">
<span
@ -25,7 +25,7 @@
</div>
<button
type="button"
class="btn btn-link btn-sm p-0"
class="btn btn-link btn-sm p-0 lh-1"
:class="provider.authenticated ? 'text-muted' : 'text-warning'"
@click="handleProviderAction(provider)"
>
@ -93,8 +93,4 @@ export default defineComponent({
.auth-providers-list {
width: 100%;
}
.auth-provider-item {
padding: 0.5rem 0;
}
</style>

View file

@ -1,8 +1,19 @@
<template>
<div class="alert alert-success my-4 pb-0" role="alert" data-testid="auth-success-banner">
<p>
<div
class="alert my-4 pb-0"
:class="isError ? 'alert-danger' : 'alert-success'"
role="alert"
:data-testid="testId"
>
<p v-if="isError">
<strong>{{ $t("authProviders.authorizationFailed") }}</strong
><br />
{{ errorMessage }}
</p>
<p v-else>
<strong>{{ $t("authProviders.authorizationSuccessful") }}</strong
><br />
{{ $t("authProviders.success", { title: providerName }) }}
{{ $t("authProviders.successCloseTab") }}
</p>
</div>
</template>
@ -14,10 +25,17 @@ import { defineComponent, type PropType } from "vue";
export default defineComponent({
name: "AuthSuccessBanner",
props: {
providerId: { type: String, required: true },
providerId: { type: String, default: "" },
error: { type: String, default: "" },
authProviders: { type: Object as PropType<AuthProviders>, default: () => ({}) },
},
computed: {
isError() {
return !!this.error;
},
errorMessage() {
return this.error || "";
},
providerName() {
for (const [name, provider] of Object.entries(this.authProviders)) {
if (provider.id === this.providerId) {
@ -26,6 +44,9 @@ export default defineComponent({
}
return "Unknown";
},
testId() {
return this.isError ? "auth-error-banner" : "auth-success-banner";
},
},
});
</script>

View file

@ -647,7 +647,7 @@ export default defineComponent({
const { name } = await this.device.create(this.apiData, force);
this.saving = false;
this.succeeded = true;
await sleep(1000);
await sleep(500);
this.$emit("added", name);
(this.$refs["modal"] as any).close();
} catch (e) {
@ -681,7 +681,7 @@ export default defineComponent({
console.log("update succeeded, closing modal");
this.saving = false;
this.succeeded = true;
await sleep(1000);
await sleep(500);
this.$emit("updated");
(this.$refs["modal"] as any).close();
} catch (e) {

View file

@ -4,7 +4,7 @@
ref="modal"
:title="modalTitle"
data-testid="auth-provider-modal"
@close="handleClose"
@closed="handleClosed"
>
<div class="container mx-0 px-0">
<!-- Success message after authentication -->
@ -56,6 +56,7 @@
:provider-url="auth.providerUrl ?? undefined"
:loading="auth.loading"
@prepare="prepareAuthentication"
@external-click="waitingForAuthentication = true"
/>
</div>
</template>
@ -103,7 +104,6 @@
<script lang="ts">
import { defineComponent, type PropType } from "vue";
import Modal from "bootstrap/js/dist/modal";
import GenericModal from "../Helper/GenericModal.vue";
import AuthCodeDisplay from "../Config/AuthCodeDisplay.vue";
import AuthConnectButton from "../Config/AuthConnectButton.vue";
@ -156,9 +156,11 @@ export default defineComponent({
},
},
watch: {
provider(newProvider) {
if (newProvider) {
providerId(newId) {
if (newId) {
this.reset();
// auto-run the prepare step. no user input needed
this.prepareAuthentication();
}
},
},
@ -169,12 +171,11 @@ export default defineComponent({
this.logoutError = null;
this.waitingForAuthentication = false;
},
handleClose() {
handleClosed() {
this.reset();
},
async prepareAuthentication() {
if (!this.providerId) return;
this.waitingForAuthentication = true;
if (!this.providerId || this.isAuthenticated) return;
await prepareAuthLogin(this.auth, this.providerId);
},
async performLogout() {
@ -185,10 +186,7 @@ export default defineComponent({
const result = await performAuthLogout(this.providerId);
if (result.success) {
const modalElement = (this.$refs["modal"] as any)?.$el;
if (modalElement) {
Modal.getInstance(modalElement)?.hide();
}
(this.$refs["modal"] as any)?.close();
} else {
this.logoutError = result.error || this.$t("authProviders.logoutFailed");
}

View file

@ -8,8 +8,9 @@
/>
<div class="wrapper pb-5">
<AuthSuccessBanner
v-if="callbackCompleted"
v-if="callbackCompleted || callbackError"
:provider-id="callbackCompleted"
:error="callbackError"
:auth-providers="authProviders"
/>
<WelcomeBanner v-if="loadpointsRequired" />
@ -563,6 +564,9 @@ export default defineComponent({
callbackCompleted() {
return this.$route.query["callbackCompleted"] as string | undefined;
},
callbackError() {
return this.$route.query["callbackError"] as string | undefined;
},
authProviders() {
return store.state?.authProviders;
},

View file

@ -2,7 +2,9 @@
"authProviders": {
"authCode": "Autorisierungscode",
"authCodeHelp": "Kopiere diesen Code und verwende ihn im nächsten Schritt. Gültig für {duration}.",
"authorizationFailed": "Autorisierung fehlgeschlagen",
"authorizationRequired": "Autorisierung erforderlich",
"authorizationSuccessful": "Autorisierung erfolgreich",
"buttonConnect": "Mit {provider} verbinden",
"buttonDisconnect": "Trennen",
"confirmLogout": "Sicher, dass du {title} trennen möchtest?",
@ -12,7 +14,7 @@
"logoutFailed": "Abmeldung fehlgeschlagen",
"modalDescriptionLogin": "Schließe die Autorisierung ab, um eine Verbindung mit {provider} herzustellen.",
"modalDescriptionLogout": "Dies trennt dein {provider}-Konto und entfernt den Zugriff auf dessen Daten.",
"success": "Die Autorisierung mit {title} war erfolgreich.",
"success": "{title} ist jetzt verbunden und einsatzbereit.",
"successCloseModal": "Du kannst diesen Dialog jetzt schließen.",
"successCloseTab": "Du kannst diesen Tab jetzt schließen.",
"title": "Autorisierungsstatus"

View file

@ -2,7 +2,9 @@
"authProviders": {
"authCode": "Authentication Code",
"authCodeHelp": "Copy this code and use it in the next step. Valid for {duration}.",
"authorizationFailed": "Authorization Failed",
"authorizationRequired": "Authorization Required",
"authorizationSuccessful": "Authorization Successful",
"buttonConnect": "Connect to {provider}",
"buttonDisconnect": "Disconnect",
"confirmLogout": "Are you sure you want to disconnect {title}?",
@ -12,7 +14,7 @@
"logoutFailed": "Failed to logout",
"modalDescriptionLogin": "Complete the authorization process to establish connection with {provider}.",
"modalDescriptionLogout": "This will disconnect your {provider} account and remove access to its data.",
"success": "Authorization with {title} was successful.",
"success": "{title} is now connected and ready to use.",
"successCloseModal": "You can now close this dialog.",
"successCloseTab": "You can now close this tab.",
"title": "Authorization Status"

View file

@ -4,6 +4,7 @@ import (
"context"
"fmt"
"net/url"
"strings"
"time"
"github.com/evcc-io/evcc/api"
@ -17,43 +18,53 @@ func init() {
}
type demo struct {
token *oauth2.Token
region string
method string
token *oauth2.Token
server string
method string
redirectUri string
onlineC chan<- bool
}
var demoInstance *demo
func NewDemoFromConfig(_ context.Context, other map[string]any) (oauth2.TokenSource, error) {
var cc struct {
Region string
Method string
Server string
Method string
RedirectUri string
}
if err := util.DecodeOther(other, &cc); err != nil {
return nil, err
}
return NewDemo(cc.Region, cc.Method)
return NewDemo(cc.Server, cc.Method, cc.RedirectUri)
}
func NewDemo(region string, method string) (oauth2.TokenSource, error) {
func NewDemo(server string, method string, redirectUri string) (oauth2.TokenSource, error) {
// reuse instance (similar to oauth.go getInstance pattern)
if demoInstance != nil {
// update existing instance with new values
demoInstance.region = region
demoInstance.server = server
demoInstance.method = method
demoInstance.redirectUri = redirectUri
return demoInstance, nil
}
demoInstance = &demo{
region: region,
method: method,
server: server,
method: method,
redirectUri: redirectUri,
}
if _, err := providerauth.Register("demo", demoInstance); err != nil {
onlineC, err := providerauth.Register("demo", demoInstance)
if err != nil {
return nil, err
}
demoInstance.onlineC = onlineC
// Send initial auth status
demoInstance.onlineC <- false
return demoInstance, nil
}
@ -65,41 +76,63 @@ func (o *demo) Token() (*oauth2.Token, error) {
return o.token, nil
}
func (o *demo) Login(_ string) (string, *oauth2.DeviceAuthResponse, error) {
// Simulate error for ERROR region
if o.region == "ERROR" {
return "", nil, fmt.Errorf("region not supported")
func (o *demo) Login(state string) (string, *oauth2.DeviceAuthResponse, error) {
// Validate server URL has proper scheme
if !strings.HasPrefix(o.server, "http://") && !strings.HasPrefix(o.server, "https://") {
return "", nil, fmt.Errorf("server must start with http:// or https://")
}
// For demo, immediately authenticate without requiring external flow
o.token = &oauth2.Token{
AccessToken: "demo-token",
Expiry: time.Now().Add(24 * time.Hour),
// Validate redirect URI has proper scheme
if !strings.HasPrefix(o.redirectUri, "http://") && !strings.HasPrefix(o.redirectUri, "https://") {
return "", nil, fmt.Errorf("redirectUri must start with http:// or https://")
}
// TODO use network settings after https://github.com/evcc-io/evcc/pull/25141
uri := "http://localhost:7070/providerauth/callback"
// Build mock login URL with state and redirectUri (complete callback URL)
values := url.Values{}
values.Set("state", state)
values.Set("redirectUri", o.redirectUri)
mockLoginURL := fmt.Sprintf("%s/mock-login?%s", o.server, values.Encode())
if o.method == "device-code" {
// Device code flow: URI comes from DeviceAuthResponse
return "", &oauth2.DeviceAuthResponse{
UserCode: "12AB345",
VerificationURI: uri,
VerificationURI: mockLoginURL,
Expiry: time.Now().Add(10 * time.Minute),
}, nil
}
// Redirect flow: URI in first return value
return uri, nil, nil
return mockLoginURL, nil, nil
}
func (o *demo) Logout() error {
o.token = nil
if o.onlineC != nil {
o.onlineC <- false
}
return nil
}
func (o *demo) HandleCallback(params url.Values) error {
// no-op: token already set in Login()
// Extract code from callback parameters
code := params.Get("code")
if code == "" {
return fmt.Errorf("missing code parameter")
}
// Create token based on code (for demo, we use a fixed token)
o.token = &oauth2.Token{
AccessToken: code, // Use the code as the access token
Expiry: time.Now().Add(24 * time.Hour),
}
// Notify that authentication succeeded
if o.onlineC != nil {
o.onlineC <- true
}
return nil
}
@ -108,5 +141,5 @@ func (o *demo) Authenticated() bool {
}
func (o *demo) DisplayName() string {
return "demo"
return "Demo Auth"
}

View file

@ -152,20 +152,23 @@ func (a *Handler) handleLogout(w http.ResponseWriter, r *http.Request) {
jsonWrite(w, "OK")
}
func (a *Handler) redirectToError(w http.ResponseWriter, r *http.Request, message string) {
http.Redirect(w, r, "/#/config?callbackError="+url.QueryEscape(message), http.StatusFound)
}
func (a *Handler) handleCallback(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
if q.Has("error") {
w.WriteHeader(http.StatusBadRequest)
fmt.Fprintf(w, "error: %s: %s\n", q.Get("error"), q.Get("error_description"))
errorMsg := q.Get("error") + ": " + q.Get("error_description")
a.redirectToError(w, r, errorMsg)
return
}
encryptedState := q.Get("state")
state, err := DecryptState(encryptedState, a.secret)
if err != nil || !state.Valid() {
w.WriteHeader(http.StatusBadRequest)
fmt.Fprintf(w, "invalid state")
a.redirectToError(w, r, "invalid state")
return
}
@ -175,15 +178,13 @@ func (a *Handler) handleCallback(w http.ResponseWriter, r *http.Request) {
// Find the corresponding provider
id, ok := a.states[encryptedState]
if !ok {
w.WriteHeader(http.StatusBadRequest)
fmt.Fprintf(w, "no provider found for state")
a.redirectToError(w, r, "no provider found for state")
return
}
provider, ok := a.providers[id]
if !ok {
w.WriteHeader(http.StatusInternalServerError)
fmt.Fprintf(w, "internal provider state unexpected")
a.redirectToError(w, r, "internal provider state unexpected")
return
}
@ -193,8 +194,7 @@ func (a *Handler) handleCallback(w http.ResponseWriter, r *http.Request) {
// Handle the callback
if err := provider.HandleCallback(r.URL.Query()); err != nil {
a.log.ERROR.Printf("callback for provider %s failed: %v", id, err)
w.WriteHeader(http.StatusInternalServerError)
fmt.Fprintln(w, "callback failed")
a.redirectToError(w, r, "callback failed")
return
}

View file

@ -6,18 +6,25 @@ products:
en: Auth Demo Meter
auth:
type: demo
params: ["region", "method"]
params: ["server", "method", "redirectUri"]
params:
- name: usage
choice: ["grid"]
- name: region
- name: server
description:
generic: Region
choice: ["EU", "US", "CN", "ERROR"]
generic: Server
default: "http://localhost:7072"
required: true
- name: redirectUri
description:
generic: Redirect URI
example: "http://localhost:7070/providerauth/callback"
required: true
- name: method
description:
generic: Authentication Method
choice: ["redirect", "device-code"]
required: true
- name: power
description:
generic: Power

View file

@ -1,6 +1,7 @@
import { test, expect } from "@playwright/test";
import { start, stop, restart, baseUrl } from "./evcc";
import { enableExperimental, expectModalVisible, expectModalHidden } from "./utils";
import { simulatorUrl, startSimulator, stopSimulator } from "./simulator";
test.use({ baseURL: baseUrl() });
@ -12,11 +13,18 @@ const templateFlags = [
"tests/config-device-auth-demo.tpl.yaml",
];
// Build complete redirect URI with callback path
function getRedirectUri(url: string): string {
return new URL(url).origin + "/providerauth/callback";
}
test.beforeEach(async () => {
await startSimulator();
await start(undefined, undefined, templateFlags);
});
test.afterEach(async () => {
await stop();
await stopSimulator();
});
test.describe("config device auth", async () => {
@ -35,25 +43,55 @@ test.describe("config device auth", async () => {
await meterModal.getByLabel("Manufacturer").selectOption("Auth Demo Meter");
// step 1: auth view
await expect(meterModal.getByLabel("Region")).toBeVisible();
await expect(meterModal.getByLabel("Server")).toBeVisible();
await expect(meterModal.getByLabel("Redirect URI")).toBeVisible();
await expect(meterModal.getByLabel("Authentication Method")).toBeVisible();
await expect(meterModal.getByLabel("Power")).not.toBeVisible();
await expect(meterModal.getByRole("button", { name: "Validate & save" })).not.toBeVisible();
await expect(meterModal.getByRole("button", { name: "Save" })).not.toBeVisible();
await meterModal.getByLabel("Region").selectOption("EU");
await meterModal.getByLabel("Server").fill(simulatorUrl());
await meterModal.getByLabel("Redirect URI").fill(getRedirectUri(page.url()));
await meterModal.getByLabel("Authentication Method").selectOption("redirect");
await meterModal.getByRole("button", { name: "Prepare connection" }).click();
await expect(meterModal.getByRole("link", { name: "Connect to localhost" })).toBeVisible();
await expect(meterModal.getByLabel("Authentication Code")).not.toBeVisible();
// we dont navigate to localhost, just trigger ui update because demo auth state is already established
await page.evaluate(() => {
document.dispatchEvent(new Event("visibilitychange"));
});
// Get the login link and remove target="_blank" to keep it in same page
const loginLink = meterModal.getByRole("link", { name: "Connect to localhost" });
await expect(loginLink).toBeVisible();
await loginLink.evaluate((el) => el.removeAttribute("target"));
await loginLink.click();
// step 2: show regular device form
await expect(meterModal.getByLabel("Region")).toHaveValue("EU");
await expect(meterModal.getByLabel("Authentication Method")).toHaveValue("redirect");
// Wait for navigation to mock login page
await page.waitForLoadState("networkidle");
// Click login button on mock page - use evaluate to ensure JS executes
const loginButton = page.getByRole("button", { name: "Login Successfully" });
await expect(loginButton).toBeVisible();
await loginButton.evaluate((btn: HTMLButtonElement) => btn.click());
// Wait for redirect back to config page
await page.waitForURL(/.*\/#\/config.*/);
// Verify success banner appears
const successBanner = page.getByTestId("auth-success-banner");
await expect(successBanner).toBeVisible();
await expect(successBanner).toContainText("Authorization Successful");
await expect(successBanner).toContainText("Demo Auth is now connected and ready to use");
// After successful auth, reopen the meter modal to continue configuration
await page.getByRole("button", { name: "Add grid meter" }).click();
await expectModalVisible(meterModal);
await meterModal.getByLabel("Manufacturer").selectOption("Auth Demo Meter");
// step 2: show regular device form - auth is complete, fill in server details again
await meterModal.getByLabel("Server").fill(simulatorUrl());
await meterModal.getByLabel("Redirect URI").fill(getRedirectUri(page.url()));
await meterModal.getByLabel("Authentication Method").selectOption("redirect");
// Even though auth is already done, still need to click prepare connection to proceed to device fields
await meterModal.getByRole("button", { name: "Prepare connection" }).click();
await page.waitForTimeout(500);
// Now the Power field should be visible since auth is already complete
await expect(meterModal.getByLabel("Power")).toBeVisible();
await meterModal.getByLabel("Power").fill("5000");
await expect(meterModal.getByRole("button", { name: "Validate & save" })).toBeVisible();
@ -70,7 +108,7 @@ test.describe("config device auth", async () => {
// re-open meter for editing
await page.getByTestId("grid").getByRole("button", { name: "edit" }).click();
await expectModalVisible(meterModal);
await expect(meterModal.getByLabel("Region")).toHaveValue("EU");
await expect(meterModal.getByLabel("Server")).toHaveValue(simulatorUrl());
await expect(meterModal.getByLabel("Authentication Method")).toHaveValue("redirect");
await expect(meterModal.getByLabel("Power")).toHaveValue("5000");
await expect(meterModal.getByRole("button", { name: "Prepare connection" })).not.toBeVisible();
@ -82,10 +120,10 @@ test.describe("config device auth", async () => {
await restart(undefined, templateFlags);
await page.reload();
// re-open meter for editing after restart, auth status as to be reestablished
// re-open meter for editing after restart, auth status has to be reestablished
await page.getByTestId("grid").getByRole("button", { name: "edit" }).click();
await expectModalVisible(meterModal);
await expect(meterModal.getByLabel("Region")).toHaveValue("EU");
await expect(meterModal.getByLabel("Server")).toHaveValue(simulatorUrl());
await expect(meterModal.getByLabel("Authentication Method")).toHaveValue("redirect");
await expect(meterModal.getByLabel("Power")).not.toBeVisible();
// note: prepare connection step is auto-executed, since all required fields are already present
@ -104,7 +142,8 @@ test.describe("config device auth", async () => {
await meterModal.getByLabel("Manufacturer").selectOption("Auth Demo Meter");
// select device-code method
await meterModal.getByLabel("Region").selectOption("EU");
await meterModal.getByLabel("Server").fill(simulatorUrl());
await meterModal.getByLabel("Redirect URI").fill(getRedirectUri(page.url()));
await meterModal.getByLabel("Authentication Method").selectOption("device-code");
await meterModal.getByRole("button", { name: "Prepare connection" }).click();
@ -115,7 +154,7 @@ test.describe("config device auth", async () => {
await expect(meterModal.getByRole("link", { name: "Connect to localhost" })).toBeVisible();
});
test("error region shows auth error", async ({ page }) => {
test("error server shows auth error", async ({ page }) => {
await page.goto("/#/config");
await enableExperimental(page, true);
@ -123,18 +162,141 @@ test.describe("config device auth", async () => {
const meterModal = page.getByTestId("meter-modal");
await expectModalVisible(meterModal);
await meterModal.getByLabel("Manufacturer").selectOption("Auth Demo Meter");
await meterModal.getByLabel("Region").selectOption("ERROR");
await meterModal.getByLabel("Server").fill("invalid-url-without-scheme");
await meterModal.getByLabel("Redirect URI").fill(getRedirectUri(page.url()));
await meterModal.getByLabel("Authentication Method").selectOption("redirect");
await meterModal.getByRole("button", { name: "Prepare connection" }).click();
await expect(meterModal).toContainText("region not supported");
await expect(meterModal).toContainText("server must start with http:// or https://");
await expect(meterModal.getByRole("button", { name: "Prepare connection" })).toBeVisible();
await expect(meterModal.getByRole("link", { name: "Connect to localhost" })).not.toBeVisible();
await expect(meterModal.getByLabel("Authentication Code")).not.toBeVisible();
await expect(meterModal.getByLabel("Power")).not.toBeVisible();
// clear error on input change
await meterModal.getByLabel("Region").selectOption("EU");
await expect(meterModal).not.toContainText("region not supported");
await meterModal.getByLabel("Server").fill(simulatorUrl());
await expect(meterModal).not.toContainText("server must start with http:// or https://");
// test invalid redirect URI
await meterModal.getByLabel("Redirect URI").fill("invalid-redirect");
await meterModal.getByRole("button", { name: "Prepare connection" }).click();
await expect(meterModal).toContainText("redirectUri must start with http:// or https://");
await expect(meterModal.getByRole("button", { name: "Prepare connection" })).toBeVisible();
await expect(meterModal.getByRole("link", { name: "Connect to localhost" })).not.toBeVisible();
await expect(meterModal.getByLabel("Authentication Code")).not.toBeVisible();
await expect(meterModal.getByLabel("Power")).not.toBeVisible();
// clear error on input change
await meterModal.getByLabel("Redirect URI").fill(getRedirectUri(page.url()));
await expect(meterModal).not.toContainText("redirectUri must start with http:// or https://");
});
test("user denies authorization shows error banner", async ({ page }) => {
await page.goto("/#/config");
await enableExperimental(page, true);
// create a grid meter with auth
await page.getByRole("button", { name: "Add grid meter" }).click();
const meterModal = page.getByTestId("meter-modal");
await expectModalVisible(meterModal);
await meterModal.getByLabel("Manufacturer").selectOption("Auth Demo Meter");
await meterModal.getByLabel("Server").fill(simulatorUrl());
await meterModal.getByLabel("Redirect URI").fill(getRedirectUri(page.url()));
await meterModal.getByLabel("Authentication Method").selectOption("redirect");
await meterModal.getByRole("button", { name: "Prepare connection" }).click();
// Get the login link and remove target="_blank" to keep it in same page
const loginLink = meterModal.getByRole("link", { name: "Connect to localhost" });
await expect(loginLink).toBeVisible();
await loginLink.evaluate((el) => el.removeAttribute("target"));
await loginLink.click();
// Wait for navigation to mock login page
await page.waitForLoadState("networkidle");
// Click deny button on mock page
const denyButton = page.getByRole("button", { name: "Deny Access" });
await expect(denyButton).toBeVisible();
await denyButton.evaluate((btn: HTMLButtonElement) => btn.click());
// Wait for redirect back to config page (first goes through callback, then to config)
await page.waitForURL(/.*\/#\/config.*callbackError.*/);
// Wait for the error banner to appear
const errorBanner = page.getByTestId("auth-error-banner");
await expect(errorBanner).toBeVisible();
await expect(errorBanner).toContainText("access_denied: User denied authorization");
});
test("authorization card connect and disconnect flow", async ({ page }) => {
await page.goto("/#/config");
await enableExperimental(page, true);
// create a grid meter with auth and prepare connection
await page.getByRole("button", { name: "Add grid meter" }).click();
const meterModal = page.getByTestId("meter-modal");
await expectModalVisible(meterModal);
await meterModal.getByLabel("Manufacturer").selectOption("Auth Demo Meter");
await meterModal.getByLabel("Server").fill(simulatorUrl());
await meterModal.getByLabel("Redirect URI").fill(getRedirectUri(page.url()));
await meterModal.getByLabel("Authentication Method").selectOption("redirect");
await meterModal.getByRole("button", { name: "Prepare connection" }).click();
// verify connection link is ready but close modal instead of clicking it
await expect(meterModal.getByRole("link", { name: "Connect to localhost" })).toBeVisible();
await meterModal.getByRole("button", { name: "Close" }).click();
await expectModalHidden(meterModal);
// check that authorization card appeared with Demo Auth entry
const authCard = page.getByTestId("auth-providers");
await expect(authCard).toBeVisible();
await expect(authCard).toContainText("Demo Auth");
const connectButton = authCard.getByRole("button", { name: "Connect" });
await expect(connectButton).toBeVisible();
// click connect button and verify auth provider modal opens
await connectButton.click();
const authModal = page.getByTestId("auth-provider-modal");
await expectModalVisible(authModal);
const loginLink = authModal.getByRole("link", { name: "Connect to localhost" });
await expect(loginLink).toBeVisible();
// complete authentication flow
await loginLink.evaluate((el) => el.removeAttribute("target"));
await loginLink.click();
// wait for navigation to mock login page
await page.waitForLoadState("networkidle");
// click login button on mock page
const loginButton = page.getByRole("button", { name: "Login Successfully" });
await expect(loginButton).toBeVisible();
await loginButton.evaluate((btn: HTMLButtonElement) => btn.click());
// wait for redirect back to config page
await page.waitForURL(/.*\/#\/config.*/);
// verify card still exists and Demo Auth now shows disconnect button
await expect(authCard).toBeVisible();
await expect(authCard).toContainText("Demo Auth");
const disconnectButton = authCard.getByRole("button", { name: "disconnect" });
await expect(disconnectButton).toBeVisible();
// click disconnect and verify modal contents
await disconnectButton.click();
await expectModalVisible(authModal);
await expect(authModal).toContainText("Demo Auth");
const confirmDisconnectButton = authModal.getByRole("button", { name: "disconnect" });
await expect(confirmDisconnectButton).toBeVisible();
// confirm disconnect
await confirmDisconnectButton.click();
await expectModalHidden(authModal);
// verify card still exists and Demo Auth shows connect button again
await expect(authCard).toBeVisible();
await expect(authCard).toContainText("Demo Auth");
await expect(authCard.getByRole("button", { name: "Connect" })).toBeVisible();
});
});

View file

@ -135,6 +135,16 @@ const updateOcppState = () => {
}));
};
const demoAuthMiddleware = (
_req: Connect.IncomingMessage,
_res: ServerResponse,
next: Connect.NextFunction
) => {
// Mock login requests are now handled by the Vue app
// This middleware is kept for potential future extensions
next();
};
const ocppMiddleware = (
req: Connect.IncomingMessage,
res: ServerResponse,
@ -213,6 +223,7 @@ export default () => ({
server.middlewares.use(openemsMiddleware);
server.middlewares.use(teslaloggerMiddleware);
server.middlewares.use(shellyMiddleware);
server.middlewares.use(demoAuthMiddleware);
server.middlewares.use(ocppMiddleware);
};
},

View file

@ -1,6 +1,25 @@
<template>
<!-- Mock Login View -->
<div v-if="mockLoginMode" class="container" style="max-width: 400px; margin-top: 5rem">
<div class="alert alert-info mb-4">
<strong>Mock External Login Page</strong>
<p class="mb-0">This simulates an external OAuth provider login screen.</p>
</div>
<h1 class="mb-4">Select Action</h1>
<button type="button" class="btn btn-success w-100 mb-3" @click="mockLogin('demo-token')">
Login Successfully
</button>
<button type="button" class="btn btn-danger w-100 mb-3" @click="mockDeny()">
Deny Access
</button>
</div>
<!-- Regular Simulator View -->
<form
v-if="state"
v-else-if="state"
class="container"
style="max-width: 500px; margin-bottom: 8rem"
@submit.prevent="save"
@ -338,6 +357,9 @@ export default defineComponent({
name: "Simulator",
data() {
return {
mockLoginMode: false,
mockLoginState: "",
mockLoginRedirectUri: "",
state: null as {
site: {
grid: { power: number };
@ -362,9 +384,33 @@ export default defineComponent({
};
},
mounted() {
this.load();
this.checkMockLoginMode();
if (!this.mockLoginMode) {
this.load();
}
},
methods: {
checkMockLoginMode() {
const urlParams = new URLSearchParams(window.location.search);
const state = urlParams.get("state");
const redirectUri = urlParams.get("redirectUri");
if (state && redirectUri) {
this.mockLoginMode = true;
this.mockLoginState = state;
this.mockLoginRedirectUri = redirectUri;
}
},
mockLogin(code: string) {
const callbackUrl = `${this.mockLoginRedirectUri}?state=${this.mockLoginState}&code=${code}`;
console.log("Redirecting to:", callbackUrl);
window.location.href = callbackUrl;
},
mockDeny() {
const callbackUrl = `${this.mockLoginRedirectUri}?state=${this.mockLoginState}&error=access_denied&error_description=User denied authorization`;
console.log("Redirecting to:", callbackUrl);
window.location.href = callbackUrl;
},
async save() {
await axios.post("/api/state", this.state);
},