Config UI: fix editing existing OAuth devices (#28012)

This commit is contained in:
lehmanju 2026-04-12 14:04:40 +02:00 • committed by GitHub
parent 20de0b9da1
commit b28be96d0a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 98 additions and 9 deletions

View file

@ -429,7 +429,6 @@ export default defineComponent({
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() {
@ -628,9 +627,10 @@ export default defineComponent({
if (this.authValuesMissing) return;
const { type } = this.template.Auth;
const values = this.authValues;
// include the template name so the backend can resolve masked fields
const values = { ...this.authValues, template: this.templateName };
this.auth.loading = true;
const result = await this.device.checkAuth(type, values);
const result = await this.device.checkAuth(type, values, this.id);
this.auth.loading = false;
if (result.success) {
// login already exists

View file

@ -236,10 +236,18 @@ export function createDeviceUtils(deviceType: DeviceType) {
return response.data;
}
async function checkAuth(type: string, values: Record<string, any>): Promise<AuthCheckResponse> {
const params = { type, ...values };
async function checkAuth(
type: string,
values: Record<string, any>,
id?: number
): Promise<AuthCheckResponse> {
const body = { type, ...values };
let url = `config/auth`;
if (id !== undefined) {
url += `/${deviceType}/merge/${id}`;
}
try {
const { status, data = {} } = await api.post(`config/auth`, params, {
const { status, data = {} } = await api.post(url, body, {
validateStatus: (status) => [204, 400].includes(status),
});
// already set up

View file

@ -2,6 +2,7 @@ package auth
import (
"context"
"errors"
"fmt"
"net/url"
"strings"
@ -32,16 +33,21 @@ func NewDemoFromConfig(_ context.Context, other map[string]any) (oauth2.TokenSou
Server string
Method string
RedirectUri string
Secret string
}
if err := util.DecodeOther(other, &cc); err != nil {
return nil, err
}
return NewDemo(cc.Server, cc.Method, cc.RedirectUri)
return NewDemo(cc.Server, cc.Method, cc.RedirectUri, cc.Secret)
}
func NewDemo(server string, method string, redirectUri string) (oauth2.TokenSource, error) {
func NewDemo(server, method, redirectUri, secret string) (oauth2.TokenSource, error) {
if secret != "topsecret" {
return nil, errors.New("invalid secret")
}
// reuse instance (similar to oauth.go getInstance pattern)
if demoInstance != nil {
// update existing instance with new values

View file

@ -279,6 +279,7 @@ func (s *HTTPd) RegisterSystemHandler(site *core.Site, pub publisher, cache *uti
routes := map[string]route{
"auth": {"POST", "/auth", authHandler},
"authmerged": {"POST", "/auth/{class:[a-z]+}/merge/{id:[0-9.]+}", authHandler},
"templates": {"GET", "/templates/{class:[a-z]+}", templatesHandler},
"products": {"GET", "/products/{class:[a-z]+}", productsHandler},
"devices": {"GET", "/devices/{class:[a-z]+}", devicesConfigHandler},

View file

@ -107,6 +107,7 @@ func templateForConfig(class templates.Class, conf map[string]any) (templates.Te
return templates.ByName(class, typ)
}
// filterValidTemplateParams removes all configuration properties that are not part of the template definition
func filterValidTemplateParams(tmpl *templates.Template, conf map[string]any) map[string]any {
res := make(map[string]any)
@ -169,6 +170,32 @@ func mergeMasked(class templates.Class, conf, old map[string]any) (map[string]an
})
}
// deviceOther looks up a stored device's `Other` config by class and id.
func deviceOther(class templates.Class, id int) (map[string]any, error) {
name := config.NameForID(id)
switch class {
case templates.Charger:
return deviceOtherFromHandler(name, config.Chargers())
case templates.Meter:
return deviceOtherFromHandler(name, config.Meters())
case templates.Vehicle:
return deviceOtherFromHandler(name, config.Vehicles())
case templates.Tariff:
return deviceOtherFromHandler(name, config.Tariffs())
case templates.Messenger:
return deviceOtherFromHandler(name, config.Messengers())
}
return nil, errors.New("unsupported class: " + class.String())
}
func deviceOtherFromHandler[T any](name string, h config.Handler[T]) (map[string]any, error) {
dev, err := h.ByName(name)
if err != nil {
return nil, err
}
return dev.Config().Other, nil
}
func startDeviceTimeout() (context.Context, context.CancelFunc, chan struct{}) {
done := make(chan struct{})
ctx, cancel := context.WithCancel(context.Background())

View file

@ -5,6 +5,7 @@ import (
"encoding/json"
"net/http"
"slices"
"strconv"
"strings"
"github.com/evcc-io/evcc/plugin/auth"
@ -42,6 +43,38 @@ func authHandler(w http.ResponseWriter, r *http.Request) {
return
}
// when editing existing device, merge masked values with stored config
if vars := mux.Vars(r); vars["class"] != "" && vars["id"] != "" {
id, err := strconv.Atoi(vars["id"])
if err != nil {
jsonError(w, http.StatusBadRequest, err)
return
}
class, err := templates.ClassString(vars["class"])
if err != nil {
jsonError(w, http.StatusBadRequest, err)
return
}
old, err := deviceOther(class, id)
if err != nil {
jsonError(w, http.StatusBadRequest, err)
return
}
merged, err := mergeMasked(class, cc.Other, old)
if err != nil {
jsonError(w, http.StatusBadRequest, err)
return
}
cc.Other = merged
}
// template is only needed by mergeMasked above; the auth decoder is strict
delete(cc.Other, typeTemplate)
ts, err := auth.NewFromConfig(context.Background(), cc.Type, cc.Other)
if err != nil {
jsonError(w, http.StatusBadRequest, err)

View file

@ -6,7 +6,7 @@ products:
en: Auth Demo Meter
auth:
type: demo
params: ["server", "method", "redirectUri"]
params: ["server", "method", "redirectUri", "secret"]
params:
- name: usage
choice: ["grid"]
@ -25,6 +25,11 @@ params:
generic: Authentication Method
choice: ["redirect", "device-code"]
required: true
- name: secret
description:
generic: Secret
required: true
mask: true
- name: power
description:
generic: Power

View file

@ -5,6 +5,8 @@ import { simulatorUrl, startSimulator, stopSimulator } from "./simulator";
test.use({ baseURL: baseUrl() });
const secret = "topsecret";
const templateFlags = [
"--disable-auth",
"--template-type",
@ -51,6 +53,7 @@ test.describe("config device auth", async () => {
await meterModal.getByLabel("Server").fill(simulatorUrl());
await meterModal.getByLabel("Redirect URI").fill(getRedirectUri(page.url()));
await meterModal.getByLabel("Authentication Method").selectOption("redirect");
await meterModal.getByLabel("Secret").fill(secret);
await meterModal.getByRole("button", { name: "Prepare connection" }).click();
// Get the login link and remove target="_blank" to keep it in same page
@ -85,6 +88,7 @@ test.describe("config device auth", async () => {
await meterModal.getByLabel("Server").fill(simulatorUrl());
await meterModal.getByLabel("Redirect URI").fill(getRedirectUri(page.url()));
await meterModal.getByLabel("Authentication Method").selectOption("redirect");
await meterModal.getByLabel("Secret").fill(secret);
// Even though auth is already done, still need to click prepare connection to proceed to device fields
await meterModal.getByRole("button", { name: "Prepare connection" }).click();
@ -109,6 +113,7 @@ test.describe("config device auth", async () => {
await expectModalVisible(meterModal);
await expect(meterModal.getByLabel("Server")).toHaveValue(simulatorUrl());
await expect(meterModal.getByLabel("Authentication Method")).toHaveValue("redirect");
await expect(meterModal.getByLabel("Secret")).toHaveValue("***");
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();
@ -143,6 +148,7 @@ test.describe("config device auth", async () => {
await meterModal.getByLabel("Server").fill(simulatorUrl());
await meterModal.getByLabel("Redirect URI").fill(getRedirectUri(page.url()));
await meterModal.getByLabel("Authentication Method").selectOption("device-code");
await meterModal.getByLabel("Secret").fill(secret);
await meterModal.getByRole("button", { name: "Prepare connection" }).click();
// verify device code is displayed
@ -162,6 +168,7 @@ test.describe("config device auth", async () => {
await meterModal.getByLabel("Server").fill("invalid-url-without-scheme");
await meterModal.getByLabel("Redirect URI").fill(getRedirectUri(page.url()));
await meterModal.getByLabel("Authentication Method").selectOption("redirect");
await meterModal.getByLabel("Secret").fill(secret);
await meterModal.getByRole("button", { name: "Prepare connection" }).click();
await expect(meterModal).toContainText("server must start with http:// or https://");
@ -200,6 +207,7 @@ test.describe("config device auth", async () => {
await meterModal.getByLabel("Server").fill(simulatorUrl());
await meterModal.getByLabel("Redirect URI").fill(getRedirectUri(page.url()));
await meterModal.getByLabel("Authentication Method").selectOption("redirect");
await meterModal.getByLabel("Secret").fill(secret);
await meterModal.getByRole("button", { name: "Prepare connection" }).click();
// Get the login link and remove target="_blank" to keep it in same page
@ -236,6 +244,7 @@ test.describe("config device auth", async () => {
await meterModal.getByLabel("Server").fill(simulatorUrl());
await meterModal.getByLabel("Redirect URI").fill(getRedirectUri(page.url()));
await meterModal.getByLabel("Authentication Method").selectOption("redirect");
await meterModal.getByLabel("Secret").fill(secret);
await meterModal.getByRole("button", { name: "Prepare connection" }).click();
// verify connection link is ready but close modal instead of clicking it