@@ -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"
>
@@ -42,6 +45,8 @@ export default {
title: String,
editable: Boolean,
error: Boolean,
+ warning: Boolean,
+ noEditButton: Boolean,
},
emits: ["edit"],
data() {
diff --git a/assets/js/components/Config/DeviceModal/DeviceModalBase.vue b/assets/js/components/Config/DeviceModal/DeviceModalBase.vue
index 5a4c6b7cf..47cd54ce6 100644
--- a/assets/js/components/Config/DeviceModal/DeviceModalBase.vue
+++ b/assets/js/components/Config/DeviceModal/DeviceModalBase.vue
@@ -54,14 +54,26 @@
v-model="values[param.Name]"
:service-values="serviceValues[param.Name]"
/>
-
{{ authError }}
-
+
+
+
{{ auth.error }}
+
+
@@ -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
, 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
);
},
- 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) {
diff --git a/assets/js/components/Config/DeviceModal/index.ts b/assets/js/components/Config/DeviceModal/index.ts
index b6f1fa536..597e6b101 100644
--- a/assets/js/components/Config/DeviceModal/index.ts
+++ b/assets/js/components/Config/DeviceModal/index.ts
@@ -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 {
- 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,
};
}
diff --git a/assets/js/components/Config/utils/authProvider.ts b/assets/js/components/Config/utils/authProvider.ts
new file mode 100644
index 000000000..e542cfba6
--- /dev/null
+++ b/assets/js/components/Config/utils/authProvider.ts
@@ -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(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(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;
+ }
+};
diff --git a/assets/js/components/Helper/CopyLink.vue b/assets/js/components/Helper/CopyLink.vue
new file mode 100644
index 000000000..ddf941c51
--- /dev/null
+++ b/assets/js/components/Helper/CopyLink.vue
@@ -0,0 +1,46 @@
+
+
+
+ {{ $t("config.general.copy") }}
+
+
+ {{ $t("config.general.copied") }}
+
+
+
+
+
diff --git a/assets/js/components/MaterialIcon/Key.vue b/assets/js/components/MaterialIcon/Key.vue
new file mode 100644
index 000000000..e07959e1c
--- /dev/null
+++ b/assets/js/components/MaterialIcon/Key.vue
@@ -0,0 +1,18 @@
+
+
+
+
+
diff --git a/assets/js/components/Site/Site.vue b/assets/js/components/Site/Site.vue
index 7c8810168..b8d07ff33 100644
--- a/assets/js/components/Site/Site.vue
+++ b/assets/js/components/Site/Site.vue
@@ -10,14 +10,7 @@
{{ siteTitle || "evcc" }}
-
-
-
-
+
@@ -83,8 +76,7 @@
+
+
diff --git a/assets/js/components/Top/Header.vue b/assets/js/components/Top/Header.vue
index 8e09d6473..9728b9161 100644
--- a/assets/js/components/Top/Header.vue
+++ b/assets/js/components/Top/Header.vue
@@ -20,31 +20,33 @@
/
{{ title }}
-
+
diff --git a/assets/js/components/Top/types.d.ts b/assets/js/components/Top/types.d.ts
index 92494593d..68d55b2e5 100644
--- a/assets/js/components/Top/types.d.ts
+++ b/assets/js/components/Top/types.d.ts
@@ -1,6 +1,5 @@
export interface Provider {
title: string;
authenticated: boolean;
- loginPath: string;
- logoutPath: string;
+ id: string;
}
diff --git a/assets/js/utils/clipboard.ts b/assets/js/utils/clipboard.ts
new file mode 100644
index 000000000..e9ff7c987
--- /dev/null
+++ b/assets/js/utils/clipboard.ts
@@ -0,0 +1,29 @@
+export function isClipboardSupported(): boolean {
+ return !!(navigator.clipboard && window.isSecureContext);
+}
+
+export async function copyToClipboard(text: string): Promise
{
+ 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 {
+ const success = await copyToClipboard(text);
+ if (success) {
+ setCopiedState(true);
+ setTimeout(() => {
+ setCopiedState(false);
+ }, 2000);
+ }
+}
diff --git a/assets/js/views/Config.vue b/assets/js/views/Config.vue
index 30c85804e..401b8b9c5 100644
--- a/assets/js/views/Config.vue
+++ b/assets/js/views/Config.vue
@@ -1,7 +1,11 @@
-
+
{{ $t("config.section.integrations") }} 🧪
+
>;
@@ -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, 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 | undefined;
+ header?.requestAuthProvider(providerId);
+ },
},
});
diff --git a/assets/js/views/Sessions.vue b/assets/js/views/Sessions.vue
index a2d079686..cbfdfb2ef 100644
--- a/assets/js/views/Sessions.vue
+++ b/assets/js/views/Sessions.vue
@@ -1,6 +1,6 @@