Cardata: display oauth user code (#25818)
This commit is contained in:
parent
157f9d207c
commit
c5fc4b4f99
28 changed files with 993 additions and 220 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
53
assets/js/components/Config/AuthCodeDisplay.vue
Normal file
53
assets/js/components/Config/AuthCodeDisplay.vue
Normal 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>
|
||||
50
assets/js/components/Config/AuthConnectButton.vue
Normal file
50
assets/js/components/Config/AuthConnectButton.vue
Normal 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>
|
||||
100
assets/js/components/Config/AuthProvidersCard.vue
Normal file
100
assets/js/components/Config/AuthProvidersCard.vue
Normal 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>
|
||||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
103
assets/js/components/Config/utils/authProvider.ts
Normal file
103
assets/js/components/Config/utils/authProvider.ts
Normal 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;
|
||||
}
|
||||
};
|
||||
46
assets/js/components/Helper/CopyLink.vue
Normal file
46
assets/js/components/Helper/CopyLink.vue
Normal 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>
|
||||
18
assets/js/components/MaterialIcon/Key.vue
Normal file
18
assets/js/components/MaterialIcon/Key.vue
Normal 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>
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
207
assets/js/components/Top/AuthProviderModal.vue
Normal file
207
assets/js/components/Top/AuthProviderModal.vue
Normal 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>
|
||||
|
|
@ -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);
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
81
assets/js/components/Top/TopNavigationArea.vue
Normal file
81
assets/js/components/Top/TopNavigationArea.vue
Normal 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>
|
||||
3
assets/js/components/Top/types.d.ts
vendored
3
assets/js/components/Top/types.d.ts
vendored
|
|
@ -1,6 +1,5 @@
|
|||
export interface Provider {
|
||||
title: string;
|
||||
authenticated: boolean;
|
||||
loginPath: string;
|
||||
logoutPath: string;
|
||||
id: string;
|
||||
}
|
||||
|
|
|
|||
29
assets/js/utils/clipboard.ts
Normal file
29
assets/js/utils/clipboard.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
26
i18n/de.json
26
i18n/de.json
|
|
@ -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",
|
||||
|
|
|
|||
26
i18n/en.json
26
i18n/en.json
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue