Config UI: improve sponsor token flow (#22989)

Co-authored-by: andig <andi@evcc.io>
Co-authored-by: andig <cpuidle@gmail.com>
This commit is contained in:
Michael Geers 2025-08-15 12:02:14 +02:00 • committed by GitHub
parent a539dd7452
commit 4163748e50
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 289 additions and 69 deletions

View file

@ -33,15 +33,16 @@
v-if="$hiddenFeatures()"
test-id="generalconfig-sponsoring"
:label="$t('config.sponsor.title')"
:text="sponsorStatus.name || '---'"
:text-class="sponsorStatus.cssClass"
:text="sponsorStatus.title"
:text-class="sponsorStatus.textClass"
modal-id="sponsorModal"
experimental
>
<template #text-prefix>
<span
v-if="sponsorStatus.expiresSoon && sponsorStatus.name"
class="d-inline-block me-1 p-1 rounded-circle bg-warning rounded-circle"
v-if="sponsorStatus.badgeClass"
class="d-inline-block me-1 p-1 rounded-circle"
:class="sponsorStatus.badgeClass"
></span>
</template>
</GeneralConfigEntry>
@ -79,6 +80,9 @@ export default {
name: "GeneralConfig",
components: { TitleModal, GeneralConfigEntry },
mixins: [formatter],
props: {
sponsorError: Boolean,
},
emits: ["site-changed"],
data() {
return {
@ -103,12 +107,27 @@ export default {
sponsorStatus() {
const sponsor = store.state?.sponsor || {};
const { name, expiresSoon } = sponsor;
let cssClass = "";
let textClass = "";
let badgeClass = "";
let title = name;
if (name) {
cssClass = expiresSoon ? "text-warning" : "text-primary";
if (expiresSoon) {
textClass = "text-warning";
badgeClass = "bg-warning";
} else {
badgeClass = "bg-primary";
}
} else {
title = "---";
}
return { name, expiresSoon, cssClass };
if (this.sponsorError) {
textClass = "text-danger";
badgeClass = "bg-danger";
title = this.$t("config.sponsor.invalid");
}
return { title, expiresSoon, textClass, badgeClass };
},
},
async mounted() {

View file

@ -21,6 +21,7 @@
class="d-flex justify-content-between order-2 order-sm-1 gap-2 flex-grow-1 flex-sm-grow-0"
>
<button
v-if="!disableCancel"
type="button"
class="btn btn-link text-muted btn-cancel"
data-bs-dismiss="modal"
@ -77,6 +78,7 @@ export default {
errorMessage: String,
docs: String,
endpoint: String,
disableCancel: Boolean,
disableRemove: Boolean,
noButtons: Boolean,
transformReadValues: Function,

View file

@ -8,54 +8,77 @@
:transform-read-values="transformReadValues"
data-testid="sponsor-modal"
size="lg"
:no-buttons="!showForm"
:disable-remove="!hasToken"
:no-buttons="notUiEditable"
:disable-remove="!hasUiToken"
disable-cancel
@changed="$emit('changed')"
@open="showForm = false"
>
<template #default="{ values }">
<SponsorTokenExpires v-bind="sponsor" />
<div class="mt-4 mb-3">
<Sponsor v-bind="sponsor" />
</div>
<div v-if="!showForm" class="d-flex gap-1 justify-content-between flex-wrap">
<button
type="button"
class="btn btn-link text-muted text-truncate"
@click="showForm = !showForm"
>
{{ sponsorTokenLabel }}
</button>
<a
v-if="!hasToken"
class="btn btn-link text-muted text-truncate"
:href="trialTokenLink"
target="_blank"
>
{{ $t("config.sponsor.trialToken") }}
</a>
</div>
<div v-else>
<hr />
<FormRow
<hr class="my-4" />
<div v-if="showTokenForm">
<div class="d-flex justify-content-between align-items-center">
<label for="sponsorToken" class="fw-bold my-2">{{
$t("config.sponsor.enterYourToken")
}}</label>
<button
v-if="hasUiToken"
type="button"
class="btn btn-link btn-sm text-nowrap text-muted"
@click="
editMode = false;
values.token = '';
"
>
{{ $t("config.general.cancel") }}
</button>
</div>
<textarea
id="sponsorToken"
:label="sponsorTokenLabel"
:help="
$t('config.sponsor.descriptionToken', { url: 'https://sponsor.evcc.io/' })
"
docs-link="/docs/sponsorship#trial"
class="mt-4"
>
<textarea
id="sponsorToken"
v-model="values.token"
required
rows="5"
spellcheck="false"
v-model="values.token"
class="form-control mb-1"
required
rows="5"
spellcheck="false"
@paste="(event) => handlePaste(event, values)"
/>
<small
>{{ $t("config.sponsor.descriptionToken", { url: "sponsor.evcc.io" }) }}
<a :href="trialTokenLink" target="_blank">
{{ $t("config.sponsor.trialToken") }}.</a
>
</small>
</div>
<div v-else-if="token">
<label for="existingToken" class="fw-bold my-2">{{
$t("config.sponsor.yourToken")
}}</label>
<div class="d-flex align-items-start gap-2 text-muted">
<input
id="existingToken"
:value="token"
disabled
rows="1"
class="form-control"
@paste="(event) => handlePaste(event, values)"
:class="{ 'is-invalid': error }"
/>
</FormRow>
<span v-if="fromYaml" class="text-muted text-nowrap align-self-center ms-2">
{{ $t("config.sponsor.viaYaml") }}
</span>
<button
v-else
type="button"
class="btn btn-link text-nowrap"
:class="error ? 'text-danger' : 'text-muted'"
@click="editMode = true"
>
{{ $t("config.sponsor.changeToken") }}
</button>
</div>
<SponsorTokenExpires v-bind="sponsor" />
</div>
</template>
</JsonModal>
@ -63,31 +86,42 @@
<script>
import JsonModal from "./JsonModal.vue";
import FormRow from "./FormRow.vue";
import Sponsor, { VICTRON_DEVICE } from "../Savings/Sponsor.vue";
import Sponsor from "../Savings/Sponsor.vue";
import SponsorTokenExpires from "../Savings/SponsorTokenExpires.vue";
import store from "@/store";
import { docsPrefix } from "@/i18n";
import { cleanYaml } from "@/utils/cleanYaml";
export default {
name: "SponsorModal",
components: { FormRow, JsonModal, Sponsor, SponsorTokenExpires },
components: { JsonModal, Sponsor, SponsorTokenExpires },
props: {
error: Boolean,
},
emits: ["changed"],
data: () => ({
showForm: false,
editMode: false,
}),
computed: {
sponsor() {
return store?.state?.sponsor;
},
hasToken() {
const name = this.sponsor?.name || "";
return name !== "" && name !== VICTRON_DEVICE;
token() {
return this.sponsor?.token;
},
sponsorTokenLabel() {
return this.hasToken
? this.$t("config.sponsor.changeToken")
: this.$t("config.sponsor.addToken");
fromYaml() {
return this.sponsor?.fromYaml;
},
name() {
return this.sponsor?.name || "";
},
showTokenForm() {
return this.editMode || !this.token;
},
notUiEditable() {
return !!this.name && this.fromYaml;
},
hasUiToken() {
return this.token && !this.fromYaml;
},
trialTokenLink() {
return `${docsPrefix()}/docs/sponsorship#trial`;

View file

@ -78,6 +78,7 @@ export default defineComponent({
name: String,
expiresAt: String,
expiresSoon: Boolean,
fromYaml: Boolean,
},
computed: {
isTrial() {

View file

@ -29,6 +29,7 @@ export default defineComponent({
expiresSoon: Boolean,
expiresAt: String,
name: String,
fromYaml: Boolean,
},
computed: {
inXDays() {

View file

@ -58,5 +58,6 @@ TokenExpires.args = {
name: "Sponsor",
expiresAt: new Date().toISOString(),
expiresSoon: true,
fromYaml: false,
},
};

View file

@ -248,6 +248,8 @@ export interface Sponsor {
name: string;
expiresAt: string;
expiresSoon: boolean;
token?: string;
fromYaml: boolean;
}
export interface Notification {

View file

@ -7,7 +7,10 @@
<ExperimentalBanner v-else-if="$hiddenFeatures()" />
<h2 class="my-4 mt-5">{{ $t("config.section.general") }}</h2>
<GeneralConfig @site-changed="siteChanged" />
<GeneralConfig
:sponsor-error="hasClassError('sponsorship')"
@site-changed="siteChanged"
/>
<div v-if="$hiddenFeatures()">
<h2 class="my-4">{{ $t("config.section.loadpoints") }} 🧪</h2>
@ -367,7 +370,7 @@
<MqttModal @changed="loadDirty" />
<NetworkModal @changed="loadDirty" />
<ControlModal @changed="loadDirty" />
<SponsorModal @changed="loadDirty" />
<SponsorModal :error="hasClassError('sponsorship')" @changed="loadDirty" />
<HemsModal @changed="yamlChanged" />
<MessagingModal @changed="yamlChanged" />
<TariffsModal @changed="yamlChanged" />

View file

@ -20,8 +20,8 @@ func NewFromConfig(ctx context.Context, typ string, other map[string]interface{}
v, err := factory(ctx, other)
if err != nil {
err = fmt.Errorf("cannot create charger type '%s': %w", typ, err)
return nil, fmt.Errorf("cannot create charger type '%s': %w", typ, err)
}
return v, err
return v, nil
}

View file

@ -491,6 +491,7 @@ func configureSponsorship(token string) (err error) {
if token, err = settings.String(keys.SponsorToken); err != nil {
return err
}
sponsor.SetFromYaml(false) // from database
}
// TODO migrate settings

View file

@ -429,14 +429,18 @@
"addToken": "Token eingeben",
"changeToken": "Token ändern",
"description": "Das Sponsoring-Modell hilft uns, das Projekt zu pflegen und nachhaltig neue und spannende Funktionen zu entwickeln. Als Sponsor erhältst du Zugriff auf alle Wallbox-Implementierungen.",
"descriptionToken": "Du erhältst das Token von {url}. Wir bieten auch ein Testtoken für Tests an.",
"descriptionToken": "Als Sponsor erhältst du dein Token auf {url}. Zum Ausprobieren gibts ein ",
"enterYourToken": "Token eingeben",
"error": "Das Sponsortoken ist ungültig.",
"invalid": "ungültig",
"labelToken": "Sponsortoken",
"title": "Sponsoring",
"tokenRequired": "Du musst ein Sponsortoken konfigurieren, bevor du dieses Gerät anlegen kannst.",
"tokenRequiredLearnMore": "Mehr erfahren.",
"tokenRequiredShort": "Kein Sponsortoken konfiguriert.",
"trialToken": "Testtoken"
"trialToken": "Testtoken",
"viaYaml": "über evcc.yaml",
"yourToken": "Dein Token"
},
"system": {
"backupRestore": {

View file

@ -429,14 +429,18 @@
"addToken": "Enter token",
"changeToken": "Change token",
"description": "The sponsoring model helps us to maintain the project and sustainably build new and exciting features. As a sponsor you get access to all charger implementations.",
"descriptionToken": "You get the token from {url}. We also offer a trial token for testing.",
"descriptionToken": "Sponsors find their token on {url}. For getting-started, we offer a ",
"enterYourToken": "Enter your token",
"error": "The sponsor token is not valid.",
"invalid": "invalid",
"labelToken": "Sponsor token",
"title": "Sponsorship",
"tokenRequired": "You must configure a sponsor token before you can create this device.",
"tokenRequiredLearnMore": "Learn more.",
"tokenRequiredShort": "No sponsor token configured.",
"trialToken": "Trial token"
"trialToken": "Trial token",
"viaYaml": "via evcc.yaml",
"yourToken": "Your token"
},
"system": {
"backupRestore": {

View file

@ -24,6 +24,7 @@ func updateSponsortokenHandler(w http.ResponseWriter, r *http.Request) {
jsonError(w, http.StatusBadRequest, err)
return
}
sponsor.SetFromYaml(false)
}
// TODO find better place

View file

@ -25,8 +25,8 @@ func NewFromConfig(ctx context.Context, typ string, other map[string]interface{}
v, err := factory(ctx, other)
if err != nil {
err = fmt.Errorf("cannot create tariff type '%s': %w", typ, err)
return nil, fmt.Errorf("cannot create tariff type '%s': %w", typ, err)
}
return v, err
return v, nil
}

17
tests/sponsor.evcc.yaml Normal file
View file

@ -0,0 +1,17 @@
# expired sponsor token
sponsortoken: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJldmNjLmlvIiwic3ViIjoidHJpYWwiLCJleHAiOjE3NTQ5OTI4MDAsImlhdCI6MTc1MzY5NjgwMCwic3BlIjp0cnVlLCJzcmMiOiJtYSJ9.XKa5DHT-icCM9awcX4eS8feW0J_KIjsx2IxjcRRQOcQ
site:
title: Sponsor Test
loadpoints:
- title: Carport
charger: easee_charger
chargers:
- name: easee_charger
type: template
template: easee
user: test@example.org
password: none
charger: EH123456

80
tests/sponsor.spec.ts Normal file
View file

@ -0,0 +1,80 @@
import { test, expect } from "@playwright/test";
import { start, stop, baseUrl } from "./evcc";
import { enableExperimental } from "./utils";
test.use({ baseURL: baseUrl() });
const shortToken = (t: string) => t.substring(0, 6) + "......." + t.substring(t.length - 6);
const EXPIRED_TOKEN =
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJldmNjLmlvIiwic3ViIjoidHJpYWwiLCJleHAiOjE3NTQ5OTI4MDAsImlhdCI6MTc1MzY5NjgwMCwic3BlIjp0cnVlLCJzcmMiOiJtYSJ9.XKa5DHT-icCM9awcX4eS8feW0J_KIjsx2IxjcRRQOcQ";
const SHORT_TOKEN = shortToken(EXPIRED_TOKEN);
test.afterEach(async () => {
await stop();
});
test.describe("sponsor token", () => {
test("token from YAML config", async ({ page }) => {
await start("sponsor.evcc.yaml");
await page.goto("/#/config");
await enableExperimental(page);
// Check fatal error
await expect(page.getByTestId("fatal-error")).toContainText("sponsorship");
// Open sponsor modal
const sponsorEntry = page.getByTestId("generalconfig-sponsoring");
await expect(sponsorEntry).toContainText("invalid");
await sponsorEntry.getByRole("button", { name: "Edit" }).click();
const modal = page.getByTestId("sponsor-modal");
const tokenInput = modal.getByRole("textbox", { name: "Your token" });
await expect(tokenInput).toHaveValue(SHORT_TOKEN);
await expect(tokenInput).toHaveClass(/is-invalid/);
await expect(modal.getByText("via evcc.yaml")).toBeVisible();
await expect(modal.getByRole("button", { name: "Remove" })).not.toBeVisible();
});
test("token from database config", async ({ page }) => {
await start(undefined, "sponsor.sql");
await page.goto("/#/config");
await enableExperimental(page);
// Open sponsor modal
await page
.getByTestId("generalconfig-sponsoring")
.getByRole("button", { name: "Edit" })
.click();
const modal = page.getByTestId("sponsor-modal");
await expect(modal.getByText("via evcc.yaml")).not.toBeVisible();
// Click change button to reveal textarea
await modal.getByRole("button", { name: "Change token" }).click();
await expect(modal.getByRole("textbox", { name: "Enter your token" })).toBeVisible();
await expect(modal.getByRole("button", { name: "Remove" })).toBeVisible();
});
test("insert token in new installation", async ({ page }) => {
await start();
await page.goto("/#/config");
await enableExperimental(page);
// Open sponsor modal and enter token
await page
.getByTestId("generalconfig-sponsoring")
.getByRole("button", { name: "Edit" })
.click();
const modal = page.getByTestId("sponsor-modal");
const textarea = modal.getByRole("textbox", { name: "Enter your token" });
await textarea.fill(EXPIRED_TOKEN);
// Try to save to trigger validation
await modal.getByRole("button", { name: "Save" }).click();
await expect(modal).toContainText("token is expired");
});
});

26
tests/sponsor.sql Normal file
View file

@ -0,0 +1,26 @@
BEGIN;
CREATE TABLE `settings` (
`key` text
, `value` text
, PRIMARY KEY(`key`)
);
CREATE TABLE `configs` (
`id` integer PRIMARY KEY AUTOINCREMENT
, `class` integer
, `type` text
, `title` text
, `icon` text
, `product` text
, `value` text
);
-- expired sponsor token
INSERT INTO settings("key", value) VALUES('sponsorToken', 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJldmNjLmlvIiwic3ViIjoidHJpYWwiLCJleHAiOjE3NTQ5OTI4MDAsImlhdCI6MTc1MzY5NjgwMCwic3BlIjp0cnVlLCJzcmMiOiJtYSJ9.XKa5DHT-icCM9awcX4eS8feW0J_KIjsx2IxjcRRQOcQ');
-- loadpoint with charger that requires sponsorship
INSERT INTO configs(id, class, type, title, icon, product, value) VALUES(3, 1, 'template', '', '', 'Easee Home', '{"charger":"EH123456","password":"none","template":"easee","timeout":"20s","user":"test@example.org"}');
INSERT INTO configs(id, class, type, title, icon, product, value) VALUES(4, 5, '', '', '', '', '{"charger":"db:3","circuit":"","meter":"","phasesConfigured":0,"soc":{"poll":{"mode":"charging","interval":3600000000000},"estimate":true},"thresholds":{"enable":{"delay":60000000000,"threshold":0},"disable":{"delay":180000000000,"threshold":0}},"title":"Carport","vehicle":""}');
COMMIT;

View file

@ -15,6 +15,7 @@ import (
var (
mu sync.RWMutex
Subject, Token string
fromYaml bool = true
ExpiresAt time.Time
)
@ -35,6 +36,13 @@ func IsAuthorizedForApi() bool {
return IsAuthorized() && Subject != unavailable && Token != ""
}
// SetFromYaml sets whether the token comes from YAML config or database
func SetFromYaml(val bool) {
mu.Lock()
defer mu.Unlock()
fromYaml = val
}
// check and set sponsorship token
func ConfigureSponsorship(token string) error {
mu.Lock()
@ -52,6 +60,8 @@ func ConfigureSponsorship(token string) error {
}
}
Token = token
conn, err := cloud.Connection()
if err != nil {
return err
@ -66,7 +76,6 @@ func ConfigureSponsorship(token string) error {
if err == nil && res.Authorized {
Subject = res.Subject
ExpiresAt = res.ExpiresAt.AsTime()
Token = token
}
if err != nil {
@ -81,14 +90,27 @@ func ConfigureSponsorship(token string) error {
return err
}
// redactToken returns a redacted version of the token showing only start and end characters
func redactToken(token string) string {
if len(token) <= 12 {
return ""
}
return token[:6] + "......." + token[len(token)-6:]
}
type sponsorStatus struct {
Name string `json:"name"`
ExpiresAt time.Time `json:"expiresAt,omitempty"`
ExpiresSoon bool `json:"expiresSoon,omitempty"`
Token string `json:"token,omitempty"`
FromYaml bool `json:"fromYaml"`
}
// Status returns the sponsorship status
func Status() sponsorStatus {
mu.RLock()
defer mu.RUnlock()
var expiresSoon bool
if d := time.Until(ExpiresAt); d < 30*24*time.Hour && d > 0 {
expiresSoon = true
@ -98,5 +120,7 @@ func Status() sponsorStatus {
Name: Subject,
ExpiresAt: ExpiresAt,
ExpiresSoon: expiresSoon,
Token: redactToken(Token),
FromYaml: fromYaml,
}
}

View file

@ -46,8 +46,8 @@ func NewFromConfig(ctx context.Context, typ string, other map[string]interface{}
v, err := factory(ctx, cc.Other)
if err != nil {
err = fmt.Errorf("cannot create vehicle type '%s': %w", typ, err)
return nil, fmt.Errorf("cannot create vehicle type '%s': %w", typ, err)
}
return v, err
return v, nil
}