Auth UI: auto-prepare, success message, extend tests (#26077)
This commit is contained in:
parent
86aeb8e542
commit
4d09af22f7
14 changed files with 376 additions and 89 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
};
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
},
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue