Config UI: fix editing existing OAuth devices (#28012)
This commit is contained in:
parent
20de0b9da1
commit
b28be96d0a
8 changed files with 98 additions and 9 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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, method, redirectUri, secret string) (oauth2.TokenSource, error) {
|
||||
if secret != "topsecret" {
|
||||
return nil, errors.New("invalid secret")
|
||||
}
|
||||
|
||||
func NewDemo(server string, method string, redirectUri string) (oauth2.TokenSource, error) {
|
||||
// reuse instance (similar to oauth.go getInstance pattern)
|
||||
if demoInstance != nil {
|
||||
// update existing instance with new values
|
||||
|
|
|
|||
|
|
@ -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},
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue