Cardata: display oauth user code (#25818)

This commit is contained in:
andig 2025-12-09 08:32:05 +01:00 • committed by GitHub
parent 157f9d207c
commit c5fc4b4f99
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
28 changed files with 993 additions and 220 deletions

View file

@ -5,6 +5,8 @@ import (
"io"
"net/url"
"time"
"golang.org/x/oauth2"
)
//go:generate go tool mockgen -package api -destination mock.go github.com/evcc-io/evcc/api Charger,ChargeState,CurrentLimiter,CurrentGetter,PhaseSwitcher,PhaseGetter,FeatureDescriber,Identifier,Meter,MeterEnergy,PhaseCurrents,Vehicle,ConnectionTimer,ChargeRater,Battery,BatteryController,BatterySocLimiter,Circuit,Dimmer,Tariff
@ -214,7 +216,7 @@ type Tariff interface {
// AuthProvider is the ability to provide OAuth authentication through the ui
type AuthProvider interface {
Login(state string) (string, error)
Login(state string) (string, *oauth2.DeviceAuthResponse, error)
Logout() error
HandleCallback(params url.Values) error
Authenticated() bool

View file

@ -564,8 +564,12 @@ input::-webkit-datetime-edit {
}
input.form-control:disabled,
input.form-control:read-only,
textarea.form-control:disabled,
textarea.form-control:read-only,
.dark input.form-control:disabled,
.dark input.form-control:read-only {
.dark input.form-control:read-only,
.dark textarea.form-control:disabled,
.dark textarea.form-control:read-only {
background-color: var(--evcc-gray-10);
color: var(--bs-default-text);
opacity: 1;
@ -701,11 +705,29 @@ html.app .modal-dialog {
}
.dark .round-box--error {
box-shadow: 0 0 0.5rem var(--bs-danger);
border: 1px solid var(--bs-danger);
}
.dark .round-box--error .tags * {
color: var(--bs-danger) !important;
}
.round-box--warning {
box-shadow: 0 0 0.5rem var(--bs-warning);
border: 1px solid var(--bs-warning);
}
.dark .round-box--error .tags * {
.round-box--warning .tags * {
color: var(--bs-warning-text-emphasis) !important;
}
.dark .round-box--warning {
box-shadow: 0 0 0.5rem var(--bs-warning);
border: 1px solid var(--bs-warning);
}
.dark .round-box--warning .tags * {
color: var(--bs-warning) !important;
}

View file

@ -0,0 +1,53 @@
<template>
<FormRow
:id="id"
:label="$t('authProviders.authCode')"
:help="
computedValidityDuration
? $t('authProviders.authCodeHelp', {
duration: computedValidityDuration,
})
: undefined
"
>
<input
:id="id"
type="text"
class="form-control fs-2 border font-monospace"
:value="code"
readonly
/>
<CopyLink :text="code" />
</FormRow>
</template>
<script lang="ts">
import { defineComponent, type PropType } from "vue";
import FormRow from "./FormRow.vue";
import CopyLink from "../Helper/CopyLink.vue";
import formatter from "@/mixins/formatter";
export default defineComponent({
name: "AuthCodeDisplay",
components: {
FormRow,
CopyLink,
},
mixins: [formatter],
props: {
id: { type: String, required: true },
code: { type: String, required: true },
expiry: { type: Date as PropType<Date | null>, default: null },
},
computed: {
computedValidityDuration(): string | null {
if (!this.expiry) return null;
const seconds = Math.max(
0,
Math.floor((this.expiry.getTime() - new Date().getTime()) / 1000)
);
return this.fmtDurationLong(seconds);
},
},
});
</script>

View file

@ -0,0 +1,50 @@
<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">
{{
$t("authProviders.buttonConnect", {
provider: providerDomain,
})
}}
</a>
<small v-if="showHint" class="d-block">{{ $t("config.general.authPerformHint") }}</small>
</div>
<!-- Prepare authentication button -->
<button
v-else
type="button"
class="btn btn-outline-primary"
:disabled="loading"
@click="$emit('prepare')"
>
<span
v-if="loading"
class="spinner-border spinner-border-sm me-2"
role="status"
aria-hidden="true"
></span>
{{ $t("config.general.authPrepare") }}
</button>
</template>
<script lang="ts">
import { defineComponent } from "vue";
import { extractDomain } from "@/utils/extractDomain";
export default defineComponent({
name: "AuthConnectButton",
props: {
providerUrl: { type: String, default: undefined },
loading: { type: Boolean, default: false },
showHint: { type: Boolean, default: true },
},
emits: ["prepare"],
computed: {
providerDomain(): string | null {
return this.providerUrl ? extractDomain(this.providerUrl) : null;
},
},
});
</script>

View file

@ -0,0 +1,100 @@
<template>
<DeviceCard
v-if="hasProviders"
:title="$t('authProviders.title')"
:warning="hasUnauthenticated"
no-edit-button
data-testid="auth-providers"
>
<template #icon>
<KeyIcon />
</template>
<template #tags>
<div class="auth-providers-list">
<div
v-for="provider in providerList"
:key="provider.id"
class="auth-provider-item d-flex align-items-baseline justify-content-between"
>
<div class="d-flex align-items-baseline">
<span
class="d-inline-block p-1 rounded-circle me-2"
:class="provider.authenticated ? 'bg-success' : 'bg-warning'"
></span>
<span>{{ provider.title }}</span>
</div>
<button
type="button"
class="btn btn-link btn-sm p-0"
:class="provider.authenticated ? 'text-muted' : 'text-warning'"
@click="handleProviderAction(provider)"
>
{{
provider.authenticated
? $t("authProviders.disconnect")
: $t("authProviders.connect")
}}
</button>
</div>
</div>
</template>
</DeviceCard>
</template>
<script lang="ts">
import { defineComponent, type PropType } from "vue";
import DeviceCard from "./DeviceCard.vue";
import KeyIcon from "../MaterialIcon/Key.vue";
import type { AuthProviders } from "@/types/evcc";
export interface Provider {
title: string;
authenticated: boolean;
id: string;
}
export default defineComponent({
name: "AuthProvidersCard",
components: {
DeviceCard,
KeyIcon,
},
props: {
providers: {
type: Object as PropType<AuthProviders>,
default: () => ({}),
},
},
emits: ["auth-request"],
computed: {
hasProviders(): boolean {
return Object.keys(this.providers).length > 0;
},
hasUnauthenticated(): boolean {
return Object.values(this.providers).some((p) => !p.authenticated);
},
providerList(): Provider[] {
return Object.entries(this.providers).map(([title, { authenticated, id }]) => ({
title,
authenticated,
id,
}));
},
},
methods: {
handleProviderAction(provider: Provider) {
this.$emit("auth-request", provider.id);
},
},
});
</script>
<style scoped>
.auth-providers-list {
width: 100%;
}
.auth-provider-item {
padding: 0.5rem 0;
}
</style>

View file

@ -1,6 +1,9 @@
<template>
<div class="alert alert-success my-4 pb-0" role="alert" data-testid="auth-success-banner">
<p>{{ $t("header.authProviders.success", { title: providerName }) }}</p>
<p>
{{ $t("authProviders.success", { title: providerName }) }}
{{ $t("authProviders.successCloseTab") }}
</p>
</div>
</template>

View file

@ -1,5 +1,8 @@
<template>
<div class="root round-box" :class="{ 'round-box--error': error }">
<div
class="root round-box"
:class="{ 'round-box--error': error, 'round-box--warning': warning }"
>
<div class="d-flex align-items-center mb-2">
<div class="icon me-2">
<slot name="icon" />
@ -14,12 +17,12 @@
ref="tooltip"
type="button"
class="btn btn-sm btn-outline-secondary position-relative border-0 p-2"
:class="{ 'opacity-25': !editable }"
:class="{ 'opacity-25': !editable, invisible: noEditButton }"
data-bs-toggle="tooltip"
data-bs-html="true"
:title="tooltipTitle"
:aria-label="editable ? $t('config.main.edit') : null"
:disabled="!editable"
:disabled="!editable || noEditButton"
@click="edit"
>
<shopicon-regular-adjust size="s"></shopicon-regular-adjust>
@ -42,6 +45,8 @@ export default {
title: String,
editable: Boolean,
error: Boolean,
warning: Boolean,
noEditButton: Boolean,
},
emits: ["edit"],
data() {

View file

@ -54,14 +54,26 @@
v-model="values[param.Name]"
:service-values="serviceValues[param.Name]"
/>
<p v-if="authError" class="text-danger">{{ authError }}</p>
<div class="my-4 d-flex justify-content-between align-items-baseline">
<div v-if="auth.code">
<hr class="my-5" />
<AuthCodeDisplay
:id="`${deviceType}AuthCode`"
:code="auth.code"
:expiry="auth.expiry"
/>
</div>
<p v-if="auth.error" class="text-danger">{{ auth.error }}</p>
<div
class="my-4 d-flex align-items-stretch justify-content-sm-between align-items-sm-baseline flex-column-reverse flex-sm-row gap-2"
>
<!-- delete / cancel -->
<button
v-if="isDeletable"
type="button"
class="btn btn-link text-danger"
class="btn btn-link text-danger align-self-start"
tabindex="0"
@click.prevent="handleRemove"
>
@ -70,40 +82,18 @@
<button
v-else
type="button"
class="btn btn-link text-muted"
class="btn btn-link text-muted align-self-start"
data-bs-dismiss="modal"
tabindex="0"
>
{{ $t("config.general.cancel") }}
</button>
<!-- perform auth -->
<div
v-if="authProviderUrl"
class="d-flex flex-column align-items-end gap-2"
>
<a :href="authProviderUrl" target="_blank" class="btn btn-primary">
{{
$t("config.general.authPerform", {
provider: authProviderDomain,
})
}}
</a>
<small>{{ $t("config.general.authPerformHint") }}</small>
</div>
<button
v-else
class="btn btn-outline-primary"
:disabled="authLoading"
@click.prevent="checkAuthStatus"
>
<span
v-if="authLoading"
class="spinner-border spinner-border-sm me-2"
role="status"
aria-hidden="true"
></span>
{{ $t("config.general.authPrepare") }}
</button>
<AuthConnectButton
:provider-url="auth.providerUrl ?? undefined"
:loading="auth.loading"
@prepare="checkAuthStatus"
/>
</div>
</div>
<div v-else>
@ -180,9 +170,11 @@ import Markdown from "../Markdown.vue";
import SponsorTokenRequired from "./SponsorTokenRequired.vue";
import TemplateSelector, { type TemplateGroup } from "./TemplateSelector.vue";
import YamlEntry from "./YamlEntry.vue";
import AuthCodeDisplay from "../AuthCodeDisplay.vue";
import AuthConnectButton from "../AuthConnectButton.vue";
import { initialTestState, performTest } from "../utils/test";
import { initialAuthState, prepareAuthLogin } from "../utils/authProvider";
import sleep from "@/utils/sleep";
import { extractDomain } from "@/utils/extractDomain";
import { ConfigType } from "@/types/evcc";
import type { DeviceType, Timeout } from "@/types/evcc";
import {
@ -214,6 +206,8 @@ export default defineComponent({
SponsorTokenRequired,
TemplateSelector,
YamlEntry,
AuthCodeDisplay,
AuthConnectButton,
},
props: {
deviceType: { type: String as PropType<DeviceType>, required: true },
@ -266,10 +260,7 @@ export default defineComponent({
templateName: null as string | null,
template: null as Template | null,
saving: false,
authOk: false,
authLoading: false,
authError: null as string | null,
authProviderUrl: null as string | null,
auth: initialAuthState(),
succeeded: false,
loadingTemplate: false,
values: { ...this.initialValues } as DeviceValues,
@ -387,9 +378,10 @@ export default defineComponent({
return this.isTypeDeprecated && this.isTypeDeprecated(this.values.type);
},
authRequired() {
return this.template?.Auth && !this.authOk;
return this.template?.Auth && !this.auth.ok;
},
authValuesMissing() {
console.log("authValuesMissing", this.authValues);
return this.template?.Auth && Object.values(this.authValues).some((value) => !value);
},
authValues() {
@ -402,9 +394,6 @@ export default defineComponent({
{} as Record<string, any>
);
},
authProviderDomain() {
return this.authProviderUrl ? extractDomain(this.authProviderUrl) : null;
},
},
watch: {
isModalVisible(visible) {
@ -571,10 +560,7 @@ export default defineComponent({
this.loadingTemplate = false;
},
resetAuthStatus() {
this.authOk = false;
this.authProviderUrl = null;
this.authError = null;
this.authLoading = false;
this.auth = initialAuthState();
},
async checkAuthStatus() {
this.resetAuthStatus();
@ -594,33 +580,22 @@ export default defineComponent({
const { type } = this.template.Auth;
const values = this.authValues;
this.authLoading = true;
this.auth.loading = true;
const result = await this.device.checkAuth(type, values);
this.authLoading = false;
this.auth.loading = false;
if (result.success) {
// login already exists
this.authError = null;
this.authOk = true;
this.auth.error = null;
this.auth.ok = true;
} else if (result.authId) {
// todo, save form field state and restore on callback
await this.performAuthLogin(result.authId);
await this.prepareAuthLogin(result.authId);
} else {
// something else failed
this.authError = result.error ?? "unknown error";
this.auth.error = result.error ?? "unknown error";
}
},
async performAuthLogin(authId: string) {
// trigger external login flow
try {
this.authLoading = true;
this.authProviderUrl = await this.device.getAuthProviderUrl(authId);
this.authLoading = false;
} catch (e) {
console.error("performAuthLogin failed", e);
this.authError = (e as any).message;
} finally {
this.authLoading = false;
}
async prepareAuthLogin(authId: string) {
await prepareAuthLogin(this.auth, authId);
},
async create(force = false) {
if (this.test.isUnknown && !force) {

View file

@ -1,6 +1,6 @@
import type { DeviceType, MODBUS_COMSET, MeterTemplateUsage } from "@/types/evcc";
import { ConfigType } from "@/types/evcc";
import api, { baseApi } from "@/api";
import api from "@/api";
import { extractPlaceholders, replacePlaceholders } from "@/utils/placeholder";
export type Product = {
@ -78,11 +78,6 @@ export type AuthCheckResponse = {
authId?: string;
};
export type ProviderLoginResponse = {
loginUri?: string;
error?: string;
};
export function handleError(e: any, msg: string) {
console.error(e);
let message = msg;
@ -251,22 +246,6 @@ export function createDeviceUtils(deviceType: DeviceType) {
return { success: false, error: "unexpected error" };
}
async function getAuthProviderUrl(authId: string): Promise<string> {
try {
const url = `providerauth/login?id=${encodeURIComponent(authId)}`;
const { status, data = {} } = await baseApi.get(url, {
validateStatus: (code) => [200, 400].includes(code),
});
//return "https://test.example.org/auth";
if (status === 200) {
return data?.loginUri;
}
throw new Error(data?.error ?? "unknown error");
} catch (error) {
throw new Error((error as Error).message ?? "unknown error");
}
}
return {
test,
update,
@ -277,6 +256,5 @@ export function createDeviceUtils(deviceType: DeviceType) {
loadTemplate,
loadServiceValues,
checkAuth,
getAuthProviderUrl,
};
}

View file

@ -0,0 +1,103 @@
import { baseApi } from "@/api";
export type AuthState = {
ok: boolean;
loading: boolean;
error: string | null;
providerUrl: string | null;
code: string | null;
expiry: Date | null;
};
export type ProviderLoginResponse = {
loginUri?: string;
code?: string;
expiry?: string;
error?: string;
};
export const initialAuthState = (): AuthState => ({
ok: false,
loading: false,
error: null,
providerUrl: null,
code: null,
expiry: null,
});
export const prepareAuthLogin = async (state: AuthState, providerId: string) => {
try {
state.loading = true;
state.error = null;
const url = `providerauth/login?id=${encodeURIComponent(providerId)}`;
const { status, data } = await baseApi.get<ProviderLoginResponse>(url, {
validateStatus: (code) => [200, 400].includes(code),
});
if (status === 200) {
state.providerUrl = data.loginUri || null;
state.code = data.code || null;
state.expiry = data.expiry ? new Date(data.expiry) : null;
return { success: true, data };
} else {
state.error = data?.error ?? "Login failed";
return { success: false, error: state.error };
}
} catch (e: any) {
console.error("prepareAuthLogin failed", e);
state.error = e.message || "Unexpected login error";
return { success: false, error: state.error };
} finally {
state.loading = false;
}
};
export const performAuthLogout = async (providerId: string) => {
try {
const url = `providerauth/logout?id=${encodeURIComponent(providerId)}`;
const { status, data } = await baseApi.get(url, {
validateStatus: (code) => [200, 400, 500].includes(code),
});
if (status === 200) {
return { success: true };
} else {
return { success: false, error: data?.error || "Logout failed" };
}
} catch (e: any) {
console.error("performAuthLogout failed", e);
return { success: false, error: e.message || "Unexpected logout error" };
}
};
// Device authentication utilities (used in DeviceModalBase)
export type DeviceAuthResponse = {
success: boolean;
authId?: string;
loginUri?: string;
code?: string;
expiry?: string;
error?: string;
};
export const prepareAuthRedirect = async (state: AuthState, authId: string) => {
try {
state.loading = true;
state.error = null;
const url = `providerauth/redirect?id=${encodeURIComponent(authId)}`;
const { data } = await baseApi.get<ProviderLoginResponse>(url);
state.providerUrl = data.loginUri || null;
state.code = data.code || null;
state.expiry = data.expiry ? new Date(data.expiry) : null;
return { success: true, data };
} catch (e: any) {
console.error("prepareAuthRedirect failed", e);
state.error = e.message || "Unexpected login error";
return { success: false, error: state.error };
} finally {
state.loading = false;
}
};

View file

@ -0,0 +1,46 @@
<template>
<div v-if="isSupported" class="text-end mt-1">
<a v-if="!copied" href="#" class="text-primary small" @click.prevent="handleCopy">
{{ $t("config.general.copy") }}
</a>
<span v-else class="text-primary small">
{{ $t("config.general.copied") }}
</span>
</div>
</template>
<script lang="ts">
import { defineComponent } from "vue";
import { copyToClipboard, isClipboardSupported } from "@/utils/clipboard";
export default defineComponent({
name: "CopyLink",
props: {
text: {
type: String,
required: true,
},
},
data() {
return {
copied: false,
};
},
computed: {
isSupported() {
return isClipboardSupported();
},
},
methods: {
async handleCopy() {
const success = await copyToClipboard(this.text);
if (success) {
this.copied = true;
setTimeout(() => {
this.copied = false;
}, 2000);
}
},
},
});
</script>

View file

@ -0,0 +1,18 @@
<template>
<svg :style="svgStyle" viewBox="0 0 24 24">
<path
fill="currentColor"
d="M10.5 7q0-.825.588-1.412T12.5 5t1.413.588T14.5 7t-.587 1.413T12.5 9t-1.412-.587T10.5 7m2 17L8 19.5l1.5-2l-1.5-2l1.5-2.125V12.2q-1.35-.8-2.175-2.162T6.5 7q0-2.5 1.75-4.25T12.5 1t4.25 1.75T18.5 7q0 1.675-.825 3.038T15.5 12.2V21zm-4-17q0 1.4.85 2.463t2.15 1.412V14l-1.025 1.45L12 17.5l-1.375 1.775L12.5 21.15l1-1v-9.275q1.3-.35 2.15-1.412T16.5 7q0-1.65-1.175-2.825T12.5 3T9.675 4.175T8.5 7"
/>
</svg>
</template>
<script lang="ts">
import { defineComponent } from "vue";
import icon from "@/mixins/icon";
export default defineComponent({
name: "Key",
mixins: [icon],
});
</script>

View file

@ -10,14 +10,7 @@
{{ siteTitle || "evcc" }}
</span>
</h1>
<div class="d-flex">
<Notifications
:notifications="notifications"
:loadpoints="loadpoints"
class="me-2"
/>
<TopNavigation v-bind="topNavigation" />
</div>
<TopNavigationArea :notifications="notifications" />
</div>
<HemsWarning :circuits="circuits" />
<Energyflow v-if="!setupRequired && !hasFatalError" v-bind="energyflow" />
@ -83,8 +76,7 @@
<script lang="ts">
import "@h2d2/shopicons/es/regular/arrowup";
import Navigation from "../Top/Navigation.vue";
import Notifications from "../Top/Notifications.vue";
import TopNavigationArea from "../Top/TopNavigationArea.vue";
import Energyflow from "../Energyflow/Energyflow.vue";
import HemsWarning from "../HemsWarning.vue";
import Loadpoints from "../Loadpoints/Loadpoints.vue";
@ -116,8 +108,7 @@ export default defineComponent({
Energyflow,
Footer,
HemsWarning,
Notifications,
TopNavigation: Navigation,
TopNavigationArea,
WelcomeIcons,
},
mixins: [formatter, collector],
@ -198,9 +189,6 @@ export default defineComponent({
const vehicles = this.vehicles || {};
return Object.entries(vehicles).map(([name, vehicle]) => ({ name, ...vehicle }));
},
topNavigation() {
return this.collectProps(Navigation);
},
showParkingLot() {
// work in progess
return false;

View file

@ -0,0 +1,207 @@
<template>
<GenericModal
id="authProviderModal"
ref="modal"
:title="modalTitle"
data-testid="auth-provider-modal"
@close="handleClose"
>
<div class="container mx-0 px-0">
<!-- Success message after authentication -->
<template v-if="showAuthenticationSuccess">
<p class="mb-4 text-success">
{{ $t("authProviders.success", { title: providerTitle }) }}<br />
{{ $t("authProviders.successCloseModal") }}
</p>
<div class="d-flex justify-content-end">
<button type="button" class="btn btn-primary" data-bs-dismiss="modal">
{{ $t("config.general.close") }}
</button>
</div>
</template>
<!-- Login flow -->
<template v-else-if="showAuthentication">
<p class="mb-4">
{{
$t("authProviders.modalDescriptionLogin", {
provider: providerTitle,
})
}}
</p>
<!-- Auth code display (device flow) -->
<div v-if="auth.code">
<hr class="my-4" />
<AuthCodeDisplay
id="authProviderCode"
:code="auth.code"
:expiry="auth.expiry"
/>
</div>
<!-- Error display -->
<p v-if="auth.error" class="text-danger mt-3">{{ auth.error }}</p>
<!-- Action buttons -->
<div
class="my-4 d-flex align-items-stretch justify-content-sm-between align-items-sm-baseline flex-column-reverse flex-sm-row gap-2"
>
<button type="button" class="btn btn-link text-muted" data-bs-dismiss="modal">
{{ $t("config.general.cancel") }}
</button>
<!-- Authentication buttons -->
<AuthConnectButton
:provider-url="auth.providerUrl ?? undefined"
:loading="auth.loading"
@prepare="prepareAuthentication"
/>
</div>
</template>
<!-- Logout flow -->
<template v-else>
<p class="mb-4">
{{
$t("authProviders.modalDescriptionLogout", {
provider: providerTitle,
})
}}
</p>
<!-- Error display -->
<p v-if="logoutError" class="text-danger mt-3">{{ logoutError }}</p>
<!-- Action buttons -->
<div
class="my-4 d-flex align-items-stretch justify-content-sm-between align-items-sm-baseline flex-column-reverse flex-sm-row gap-2"
>
<button type="button" class="btn btn-link text-muted" data-bs-dismiss="modal">
{{ $t("config.general.cancel") }}
</button>
<button
type="button"
class="btn btn-danger"
:disabled="logoutLoading"
@click="performLogout"
>
<span
v-if="logoutLoading"
class="spinner-border spinner-border-sm me-2"
role="status"
aria-hidden="true"
></span>
{{ $t("authProviders.buttonDisconnect") }}
</button>
</div>
</template>
</div>
</GenericModal>
</template>
<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";
import {
initialAuthState,
prepareAuthLogin,
performAuthLogout,
} from "../Config/utils/authProvider";
import type { Provider } from "./types";
export default defineComponent({
name: "AuthProviderModal",
components: {
GenericModal,
AuthCodeDisplay,
AuthConnectButton,
},
props: {
provider: {
type: Object as PropType<Provider | null>,
default: null,
},
},
data() {
return {
logoutLoading: false,
logoutError: null as string | null,
auth: initialAuthState(),
waitingForAuthentication: false,
};
},
computed: {
isAuthenticated(): boolean {
return this.provider?.authenticated || false;
},
showAuthentication(): boolean {
return !this.isAuthenticated;
},
showAuthenticationSuccess(): boolean {
return this.isAuthenticated && this.waitingForAuthentication;
},
modalTitle(): string {
return this.providerTitle;
},
providerTitle(): string {
return this.provider?.title || "Unknown";
},
providerId(): string {
return this.provider?.id || "";
},
},
watch: {
provider(newProvider) {
if (newProvider) {
this.reset();
}
},
},
methods: {
reset() {
this.auth = initialAuthState();
this.logoutLoading = false;
this.logoutError = null;
this.waitingForAuthentication = false;
},
handleClose() {
this.reset();
},
async prepareAuthentication() {
if (!this.providerId) return;
this.waitingForAuthentication = true;
await prepareAuthLogin(this.auth, this.providerId);
},
async performLogout() {
if (!this.providerId) return;
this.logoutLoading = true;
this.logoutError = null;
const result = await performAuthLogout(this.providerId);
if (result.success) {
const modalElement = (this.$refs["modal"] as any)?.$el;
if (modalElement) {
Modal.getInstance(modalElement)?.hide();
}
} else {
this.logoutError = result.error || this.$t("authProviders.logoutFailed");
}
this.logoutLoading = false;
},
},
});
</script>
<style scoped>
.container {
margin-left: calc(var(--bs-gutter-x) * -0.5);
margin-right: calc(var(--bs-gutter-x) * -0.5);
padding-right: 0;
}
</style>

View file

@ -20,31 +20,33 @@
<div size="s" class="mx-2 flex-grow-0 flex-shrink-0 fw-normal">/</div>
<span class="text-truncate">{{ title }}</span>
</h1>
<TopNavigation v-bind="topNavigation" />
<TopNavigationArea ref="navigationArea" :notifications="notifications" />
</header>
</template>
<script lang="ts">
import "@h2d2/shopicons/es/regular/home";
import "@h2d2/shopicons/es/regular/settings";
import Navigation from "./Navigation.vue";
import collector from "@/mixins/collector";
import store from "@/store";
import { defineComponent } from "vue";
import TopNavigationArea from "./TopNavigationArea.vue";
import { defineComponent, type PropType } from "vue";
import type { Notification } from "@/types/evcc";
export default defineComponent({
name: "TopHeader",
components: {
TopNavigation: Navigation,
TopNavigationArea,
},
mixins: [collector],
props: {
showConfig: Boolean,
title: String,
notifications: { type: Array as PropType<Notification[]>, default: () => [] },
},
computed: {
topNavigation(): any {
return this.collectProps(Navigation, store.state);
methods: {
requestAuthProvider(providerId: string) {
const navigationArea = this.$refs["navigationArea"] as
| InstanceType<typeof TopNavigationArea>
| undefined;
navigationArea?.requestAuthProvider(providerId);
},
},
});

View file

@ -80,21 +80,21 @@
</router-link>
</li>
<li><hr class="dropdown-divider" /></li>
<template v-if="providerLogins.length > 0">
<template v-if="authorizationRequired">
<li>
<h6 class="dropdown-header">{{ $t("header.authProviders.title") }}</h6>
<h6 class="dropdown-header">{{ $t("authProviders.authorizationRequired") }}</h6>
</li>
<li v-for="l in providerLogins" :key="l.title">
<li v-for="provider in providers" :key="provider.id">
<button
type="button"
class="dropdown-item"
@click="handleProviderAuthorization(l)"
@click="handleAuthRequired(provider.id)"
>
<span
class="d-inline-block p-1 rounded-circle border border-light rounded-circle"
:class="l.authenticated ? 'bg-success' : 'bg-warning'"
class="d-inline-block p-1 rounded-circle border border-light"
:class="'bg-warning'"
></span>
{{ l.title }}
{{ provider.title }}
</button>
</li>
<li><hr class="dropdown-divider" /></li>
@ -136,12 +136,10 @@ import "@h2d2/shopicons/es/regular/menu";
import "@h2d2/shopicons/es/regular/newtab";
import collector from "@/mixins/collector";
import { logout, isLoggedIn, openLoginModal } from "../Auth/auth";
import { baseApi } from "@/api";
import { isApp, sendToApp } from "@/utils/native";
import { isUserConfigError } from "@/utils/fatal";
import { defineComponent, type PropType } from "vue";
import type { FatalError, Sponsor, AuthProviders, EvOpt } from "@/types/evcc";
import type { Provider as Provider } from "./types";
import type { FatalError, Sponsor, EvOpt, AuthProviders } from "@/types/evcc";
export default defineComponent({
name: "TopNavigation",
@ -154,6 +152,7 @@ export default defineComponent({
evopt: { type: Object as PropType<EvOpt>, required: false },
fatal: { type: Array as PropType<FatalError[]>, default: () => [] },
},
emits: ["auth-required"],
data() {
return {
isApp: isApp(),
@ -164,23 +163,24 @@ export default defineComponent({
batteryConfigured() {
return this.battery?.length;
},
providerLogins(): Provider[] {
return Object.entries(this.authProviders).map(([title, { authenticated, id }]) => ({
title,
authenticated,
loginPath: "providerauth/login?id=" + id,
logoutPath: "providerauth/logout?id=" + id,
}));
providers() {
return Object.entries(this.authProviders)
.filter(([, provider]) => !provider.authenticated)
.map(([title, { authenticated, id }]) => ({
title,
authenticated,
id,
}));
},
loginRequired() {
return Object.values(this.authProviders).some((p) => !p.authenticated);
authorizationRequired() {
return this.providers.length > 0;
},
showConfigBadge() {
const userConfigError = isUserConfigError(this.fatal);
return this.sponsor.expiresSoon || userConfigError;
},
showRootBadge() {
return this.loginRequired || this.showConfigBadge;
return this.authorizationRequired || this.showConfigBadge;
},
badgeClass() {
if (this.fatal.length > 0) {
@ -215,39 +215,8 @@ export default defineComponent({
this.dropdown?.dispose();
},
methods: {
async handleProviderAuthorization(provider: Provider) {
const { title, authenticated, loginPath, logoutPath } = provider;
if (!authenticated) {
try {
const response = await baseApi.get(loginPath, {
validateStatus: (code) => [200, 400].includes(code),
});
if (response.status === 200) {
window.location.href = response.data?.loginUri;
} else {
alert(`Failed to login: ${response.data?.error}`);
}
} catch (error: any) {
console.error(error);
alert("Unexpected login error: " + error.message);
}
} else {
if (window.confirm(this.$t("header.authProviders.confirmLogout", { title }))) {
try {
const response = await baseApi.get(logoutPath, {
validateStatus: (code) => [200, 400, 500].includes(code),
});
if (response.status === 200) {
alert(this.$t("header.authProviders.loggedOut"));
} else {
alert(`Failed to logout: ${response.data?.error}`);
}
} catch (error: any) {
console.error(error);
alert(`Unexpected logout error: ${error.response?.data}`);
}
}
}
handleAuthRequired(providerId: string) {
this.$emit("auth-required", providerId);
},
openSettingsModal() {
const modal = Modal.getOrCreateInstance(

View file

@ -0,0 +1,81 @@
<template>
<div class="d-flex">
<Notifications :notifications="notifications" :loadpoints="loadpoints" class="me-2" />
<TopNavigation v-bind="topNavigation" @auth-required="openAuthModal" />
<AuthProviderModal :provider="authProvider" />
</div>
</template>
<script lang="ts">
import { defineComponent, type PropType } from "vue";
import Modal from "bootstrap/js/dist/modal";
import Navigation from "./Navigation.vue";
import Notifications from "./Notifications.vue";
import AuthProviderModal from "./AuthProviderModal.vue";
import type { Provider } from "./types";
import type { Notification } from "@/types/evcc";
import collector from "@/mixins/collector";
import store from "@/store";
export default defineComponent({
name: "TopNavigationArea",
components: {
TopNavigation: Navigation,
Notifications,
AuthProviderModal,
},
mixins: [collector],
props: {
notifications: { type: Array as PropType<Notification[]>, default: () => [] },
},
data() {
return {
authProviderId: null as string | null,
isUnmounted: false,
};
},
computed: {
topNavigation(): any {
return this.collectProps(Navigation, store.state);
},
loadpoints() {
return store.uiLoadpoints.value || [];
},
authProviders() {
return store.state?.authProviders || {};
},
authProvider(): Provider | null {
if (!this.authProviderId) return null;
const entry = Object.entries(this.authProviders).find(
([, value]) => value.id === this.authProviderId
);
if (!entry) return null;
const [title, { id, authenticated }] = entry;
return {
id,
title,
authenticated,
};
},
},
unmounted() {
this.isUnmounted = true;
},
methods: {
openAuthModal(providerId: string) {
this.authProviderId = providerId;
this.$nextTick(() => {
if (this.isUnmounted) return;
const modalElement = document.getElementById("authProviderModal");
if (!modalElement) return;
const modal = Modal.getOrCreateInstance(modalElement);
modal?.show();
});
},
// Public method for imperative calls (e.g., from Config page)
requestAuthProvider(providerId: string) {
this.openAuthModal(providerId);
},
},
});
</script>

View file

@ -1,6 +1,5 @@
export interface Provider {
title: string;
authenticated: boolean;
loginPath: string;
logoutPath: string;
id: string;
}

View file

@ -0,0 +1,29 @@
export function isClipboardSupported(): boolean {
return !!(navigator.clipboard && window.isSecureContext);
}
export async function copyToClipboard(text: string): Promise<boolean> {
if (!isClipboardSupported()) {
return false;
}
try {
await navigator.clipboard.writeText(text);
return true;
} catch (err) {
console.error("Failed to copy to clipboard:", err);
return false;
}
}
export async function copyWithFeedback(
text: string,
setCopiedState: (value: boolean) => void
): Promise<void> {
const success = await copyToClipboard(text);
if (success) {
setCopiedState(true);
setTimeout(() => {
setCopiedState(false);
}, 2000);
}
}

View file

@ -1,7 +1,11 @@
<template>
<div class="root safe-area-inset">
<div class="container px-4">
<TopHeader :title="$t('config.main.title')" />
<TopHeader
ref="header"
:title="$t('config.main.title')"
:notifications="notifications"
/>
<div class="wrapper pb-5">
<AuthSuccessBanner
v-if="callbackCompleted"
@ -177,6 +181,11 @@
<h2 class="my-4 mt-5">{{ $t("config.section.integrations") }} 🧪</h2>
<div class="p-0 config-list">
<AuthProvidersCard
:providers="authProviders"
data-testid="auth-providers"
@auth-request="handleProviderAuthRequest"
/>
<DeviceCard
:title="$t('config.mqtt.title')"
editable
@ -423,7 +432,7 @@ import TelemetryModal from "../components/Config/TelemetryModal.vue";
import Header from "../components/Top/Header.vue";
import VehicleIcon from "../components/VehicleIcon";
import VehicleModal from "../components/Config/VehicleModal.vue";
import { defineComponent } from "vue";
import { defineComponent, type PropType } from "vue";
import type {
Circuit,
ConfigCharger,
@ -436,6 +445,7 @@ import type {
MeterType,
SiteConfig,
DeviceType,
Notification,
} from "@/types/evcc";
type DeviceValuesMap = Record<DeviceType, Record<string, any>>;
@ -444,6 +454,7 @@ import WelcomeBanner from "../components/Config/WelcomeBanner.vue";
import ExperimentalBanner from "../components/Config/ExperimentalBanner.vue";
import AuthSuccessBanner from "../components/Config/AuthSuccessBanner.vue";
import PasswordModal from "../components/Auth/PasswordModal.vue";
import AuthProvidersCard from "../components/Config/AuthProvidersCard.vue";
export default defineComponent({
name: "Config",
@ -486,11 +497,12 @@ export default defineComponent({
VehicleModal,
WelcomeBanner,
PasswordModal,
AuthProvidersCard,
},
mixins: [formatter, collector],
props: {
offline: Boolean,
notifications: Array,
notifications: { type: Array as PropType<Notification[]>, default: () => [] },
},
data() {
return {
@ -1063,6 +1075,10 @@ export default defineComponent({
return charger?.config?.icon || this.deviceValues["charger"][chargerName]?.icon?.value;
},
handleProviderAuthRequest(providerId: string) {
const header = this.$refs["header"] as InstanceType<typeof Header> | undefined;
header?.requestAuthProvider(providerId);
},
},
});
</script>

View file

@ -1,6 +1,6 @@
<template>
<div class="container px-4 safe-area-inset">
<TopHeader :title="$t('sessions.title')" />
<TopHeader :title="$t('sessions.title')" :notifications="notifications" />
<div class="row">
<main class="col-12">
<div class="header-outer sticky-top">
@ -207,7 +207,7 @@ import DynamicPriceIcon from "../components/MaterialIcon/DynamicPrice.vue";
import TotalIcon from "../components/MaterialIcon/Total.vue";
import { TYPES, GROUPS, PERIODS, type Session } from "../components/Sessions/types";
import { defineComponent, type PropType } from "vue";
import { CURRENCY } from "@/types/evcc";
import { CURRENCY, type Notification } from "@/types/evcc";
export default defineComponent({
name: "Sessions",

View file

@ -1,4 +1,22 @@
{
"authProviders": {
"authCode": "Autorisierungscode",
"authCodeHelp": "Kopiere diesen Code und verwende ihn im nächsten Schritt. Gültig für {duration}.",
"authorizationRequired": "Autorisierung erforderlich",
"buttonConnect": "Mit {provider} verbinden",
"buttonDisconnect": "Trennen",
"confirmLogout": "Sicher, dass du {title} trennen möchtest?",
"connect": "verbinden",
"disconnect": "trennen",
"loggedOut": "Erfolgreich abgemeldet",
"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.",
"successCloseModal": "Du kannst diesen Dialog jetzt schließen.",
"successCloseTab": "Du kannst diesen Tab jetzt schließen.",
"title": "Autorisierungsstatus"
},
"batterySettings": {
"batteryLevel": "Ladestand der Batterie",
"bufferStart": {
@ -164,6 +182,8 @@
"cancel": "Abbrechen",
"clear": "Löschen",
"close": "Schließen",
"copied": "Kopiert!",
"copy": "Kopieren",
"customHelp": "Erstelle ein benutzerdefiniertes Gerät mit evcc's Plugin-System.",
"customOption": "Benutzerdefiniertes Gerät",
"delete": "Löschen",
@ -694,12 +714,6 @@
},
"header": {
"about": "Über",
"authProviders": {
"confirmLogout": "Sicher, dass du {title} trennen möchtest?",
"loggedOut": "Erfolgreich abgemeldet",
"success": "Die Autorisierung mit {title} war erfolgreich. Du kannst diesen Tab jetzt schließen.",
"title": "Autorisierungsstatus"
},
"blog": "Blog",
"docs": "Dokumentation",
"github": "GitHub",

View file

@ -1,4 +1,22 @@
{
"authProviders": {
"authCode": "Authentication Code",
"authCodeHelp": "Copy this code and use it in the next step. Valid for {duration}.",
"authorizationRequired": "Authorization Required",
"buttonConnect": "Connect to {provider}",
"buttonDisconnect": "Disconnect",
"confirmLogout": "Are you sure you want to disconnect {title}?",
"connect": "connect",
"disconnect": "disconnect",
"loggedOut": "Successfully logged out",
"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.",
"successCloseModal": "You can now close this dialog.",
"successCloseTab": "You can now close this tab.",
"title": "Authorization Status"
},
"batterySettings": {
"batteryLevel": "Battery level",
"bufferStart": {
@ -164,6 +182,8 @@
"cancel": "Cancel",
"clear": "Clear",
"close": "Close",
"copied": "Copied!",
"copy": "Copy",
"customHelp": "Create a user-defined device using evcc's plugin system.",
"customOption": "User-defined device",
"delete": "Delete",
@ -694,12 +714,6 @@
},
"header": {
"about": "About",
"authProviders": {
"confirmLogout": "Are you sure you want to disconnect {title}?",
"loggedOut": "Successfully logged out",
"success": "Authorization with {title} was successful. You can now close this tab.",
"title": "Authorization Status"
},
"blog": "Blog",
"docs": "Documentation",
"github": "GitHub",

View file

@ -2,11 +2,13 @@ package auth
import (
"context"
"fmt"
"net/url"
"time"
"github.com/evcc-io/evcc/api"
"github.com/evcc-io/evcc/server/providerauth"
"github.com/evcc-io/evcc/util"
"golang.org/x/oauth2"
)
@ -15,22 +17,39 @@ func init() {
}
type demo struct {
token *oauth2.Token
token *oauth2.Token
region string
method string
}
var demoInstance *demo
func NewDemoFromConfig(_ context.Context, _ map[string]any) (oauth2.TokenSource, error) {
return NewDemo()
func NewDemoFromConfig(_ context.Context, other map[string]any) (oauth2.TokenSource, error) {
var cc struct {
Region string
Method string
}
if err := util.DecodeOther(other, &cc); err != nil {
return nil, err
}
return NewDemo(cc.Region, cc.Method)
}
func NewDemo() (oauth2.TokenSource, error) {
func NewDemo(region string, method 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.method = method
return demoInstance, nil
}
demoInstance = new(demo)
demoInstance = &demo{
region: region,
method: method,
}
if _, err := providerauth.Register("demo", demoInstance); err != nil {
return nil, err
@ -46,14 +65,32 @@ func (o *demo) Token() (*oauth2.Token, error) {
return o.token, nil
}
func (o *demo) Login(_ string) (string, error) {
// for demo, immediately authenticate without requiring external flow
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")
}
// For demo, immediately authenticate without requiring external flow
o.token = &oauth2.Token{
AccessToken: "demo-token",
Expiry: time.Now().Add(24 * time.Hour),
}
// TODO use network settings after https://github.com/evcc-io/evcc/pull/25141
return "http://localhost:7070/providerauth/callback", nil
uri := "http://localhost:7070/providerauth/callback"
if o.method == "device-code" {
// Device code flow: URI comes from DeviceAuthResponse
return "", &oauth2.DeviceAuthResponse{
UserCode: "12AB345",
VerificationURI: uri,
Expiry: time.Now().Add(10 * time.Minute),
}, nil
}
// Redirect flow: URI in first return value
return uri, nil, nil
}
func (o *demo) Logout() error {

View file

@ -220,7 +220,7 @@ func (o *OAuth) HandleCallback(params url.Values) error {
}
// Login implements api.AuthProvider.
func (o *OAuth) Login(state string) (string, error) {
func (o *OAuth) Login(state string) (string, *oauth2.DeviceAuthResponse, error) {
o.mu.Lock()
defer o.mu.Unlock()
@ -229,7 +229,7 @@ func (o *OAuth) Login(state string) (string, error) {
if o.deviceFlow {
da, err := o.oc.DeviceAuth(o.ctx, oauth2.S256ChallengeOption(o.cv))
if err != nil {
return "", err
return "", nil, err
}
go func() {
@ -248,14 +248,14 @@ func (o *OAuth) Login(state string) (string, error) {
o.updateToken(token)
}()
return da.VerificationURIComplete, nil
return "", da, nil
}
if o.oc.Endpoint.AuthURL == "" {
return "", errors.New("missing auth url")
return "", nil, errors.New("missing auth url")
}
return o.oc.AuthCodeURL(state, oauth2.S256ChallengeOption(o.cv)), nil
return o.oc.AuthCodeURL(state, oauth2.S256ChallengeOption(o.cv)), nil, nil
}
// Logout implements api.AuthProvider.

View file

@ -18,7 +18,9 @@ type errorResponse struct {
}
type loginResponse struct {
LoginUri string `json:"loginUri"`
LoginUri string `json:"loginUri"`
Code string `json:"code,omitempty"`
Expiry *time.Time `json:"expiry,omitempty"`
}
// jsonWrite writes a JSON response
@ -104,13 +106,27 @@ func (a *Handler) handleLogin(w http.ResponseWriter, r *http.Request) {
delete(a.states, encryptedState)
})
uri, err := provider.Login(encryptedState)
uri, da, err := provider.Login(encryptedState)
if err != nil {
jsonError(w, http.StatusBadRequest, err.Error())
return
}
jsonWrite(w, loginResponse{LoginUri: uri})
res := loginResponse{
LoginUri: uri,
}
if da != nil {
res.Expiry = &da.Expiry
if da.VerificationURIComplete != "" {
res.LoginUri = da.VerificationURIComplete
} else {
res.LoginUri = da.VerificationURI
res.Code = da.UserCode
}
}
jsonWrite(w, res)
}
func (a *Handler) handleLogout(w http.ResponseWriter, r *http.Request) {

View file

@ -6,18 +6,18 @@ products:
en: Auth Demo Meter
auth:
type: demo
params: ["region", "server"]
params: ["region", "method"]
params:
- name: usage
choice: ["grid"]
- name: region
description:
generic: Region
type: choice
choice: ["EU", "US", "CN"]
- name: server
choice: ["EU", "US", "CN", "ERROR"]
- name: method
description:
generic: Server
generic: Authentication Method
choice: ["redirect", "device-code"]
- name: power
description:
generic: Power

View file

@ -20,7 +20,7 @@ test.afterEach(async () => {
});
test.describe("config device auth", async () => {
test("create grid meter with demo auth", async ({ page }) => {
test("create grid meter with redirect auth", async ({ page }) => {
await page.goto("/#/config");
await enableExperimental(page, true);
@ -36,14 +36,15 @@ test.describe("config device auth", async () => {
// step 1: auth view
await expect(meterModal.getByLabel("Region")).toBeVisible();
await expect(meterModal.getByLabel("Server")).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("my.server.org");
await meterModal.getByLabel("Authentication Method").selectOption("redirect");
await meterModal.getByRole("button", { name: "Prepare connection" }).click();
await expect(meterModal.getByRole("link", { name: "Connect with localhost" })).toBeVisible();
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(() => {
@ -52,7 +53,7 @@ test.describe("config device auth", async () => {
// step 2: show regular device form
await expect(meterModal.getByLabel("Region")).toHaveValue("EU");
await expect(meterModal.getByLabel("Server")).toHaveValue("my.server.org");
await expect(meterModal.getByLabel("Authentication Method")).toHaveValue("redirect");
await expect(meterModal.getByLabel("Power")).toBeVisible();
await meterModal.getByLabel("Power").fill("5000");
await expect(meterModal.getByRole("button", { name: "Validate & save" })).toBeVisible();
@ -70,7 +71,7 @@ test.describe("config device auth", async () => {
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("my.server.org");
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();
await expect(meterModal.getByRole("button", { name: "Validate & save" })).toBeVisible();
@ -85,10 +86,55 @@ test.describe("config device auth", async () => {
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("my.server.org");
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 (server, token) are already present
await expect(meterModal.getByRole("link", { name: "Connect with localhost" })).toBeVisible();
// note: prepare connection step is auto-executed, since all required fields are already present
await expect(meterModal.getByRole("link", { name: "Connect to localhost" })).toBeVisible();
await expect(meterModal.getByRole("button", { name: "Validate & save" })).not.toBeVisible();
});
test("create grid meter with device-code auth", async ({ page }) => {
await page.goto("/#/config");
await enableExperimental(page, true);
// create a grid meter with device-code 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");
// select device-code method
await meterModal.getByLabel("Region").selectOption("EU");
await meterModal.getByLabel("Authentication Method").selectOption("device-code");
await meterModal.getByRole("button", { name: "Prepare connection" }).click();
// verify device code is displayed
await expect(meterModal.getByLabel("Authentication Code")).toHaveValue("12AB345");
await expect(meterModal).toContainText("Valid for");
await expect(meterModal).toContainText("Copy this code");
await expect(meterModal.getByRole("link", { name: "Connect to localhost" })).toBeVisible();
});
test("error region shows auth error", async ({ page }) => {
await page.goto("/#/config");
await enableExperimental(page, true);
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("Region").selectOption("ERROR");
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.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");
});
});