Split Web App Settings into tabs; allow unset plugin Control mode

Web App: Settings is now two tabs — Personal settings (account,
appearance, profile, privacy, advanced/danger) and Integrations
(Toyota, Anker Solix). Panels use v-show so loaded state and in-flight
edits survive a tab switch. Adds settings.tabs.* labels (en/pl/da).

API Server panel: non-required select config fields now render a
leading "Not set" option, so a superadmin can leave the Anker Solix
Control mode (and Toyota Brand) unset at the global layer. Previously
every option was a real value, forcing a pick that won the cascade and
locked organizations and users out of choosing their own mode. The
server side already treated an empty value as "abstain"; this closes
the UI gap. Adds plugins.notSet label (en/pl/da) and rebuilds the
embedded panel dist.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
tajniak81andClaude Opus 4.8 committed 2026-07-19 00:02:37 +02:00
1 parent 318e9c1670
commit 8656aaaee3
10 files changed
+50 -3

No files matched your search

@@ -14,4 +14,4 @@
* @vue/runtime-dom v3.5.39 * @vue/runtime-dom v3.5.39
* (c) 2018-present Yuxi (Evan) You and Vue contributors * (c) 2018-present Yuxi (Evan) You and Vue contributors
* @license MIT * @license MIT
**/let on;const Jn=typeof window<"u"&&window.trustedTypes;if(Jn)try{on=Jn.createPolicy("vue",{createHTML:e=>e})}catch{}const _o=on?e=>on.createHTML(e):e=>e,xl="http://www.w3.org/2000/svg",kl="http://www.w3.org/1998/Math/MathML",qe=typeof document<"u"?document:null,Yn=qe&&qe.createElement("template"),Sl={insert:(e,t,s)=>{t.insertBefore(e,s||null)},remove:e=>{const t=e.parentNode;t&&t.removeChild(e)},createElement:(e,t,s,n)=>{const i=t==="svg"?qe.createElementNS(xl,e):t==="mathml"?qe.createElementNS(kl,e):s?qe.createElement(e,{is:s}):qe.createElement(e);return e==="select"&&n&&n.multiple!=null&&i.setAttribute("multiple",n.multiple),i},createText:e=>qe.createTextNode(e),createComment:e=>qe.createComment(e),setText:(e,t)=>{e.nodeValue=t},setElementText:(e,t)=>{e.textContent=t},parentNode:e=>e.parentNode,nextSibling:e=>e.nextSibling,querySelector:e=>qe.querySelector(e),setScopeId(e,t){e.setAttribute(t,"")},insertStaticContent(e,t,s,n,i,o){const r=s?s.previousSibling:t.lastChild;if(i&&(i===o||i.nextSibling))for(;t.insertBefore(i.cloneNode(!0),s),!(i===o||!(i=i.nextSibling)););else{Yn.innerHTML=_o(n==="svg"?`<svg>${e}</svg>`:n==="mathml"?`<math>${e}</math>`:e);const l=Yn.content;if(n==="svg"||n==="mathml"){const a=l.firstChild;for(;a.firstChild;)l.appendChild(a.firstChild);l.removeChild(a)}t.insertBefore(l,s)}return[r?r.nextSibling:t.firstChild,s?s.previousSibling:t.lastChild]}},Tl=Symbol("_vtc");function El(e,t,s){const n=e[Tl];n&&(t=(t?[t,...n]:[...n]).join(" ")),t==null?e.removeAttribute("class"):s?e.setAttribute("class",t):e.className=t}const Xn=Symbol("_vod"),Cl=Symbol("_vsh"),Pl=Symbol(""),$l=/(?:^|;)\s*display\s*:/;function Al(e,t,s){const n=e.style,i=ne(s);let o=!1;if(s&&!i){if(t)if(ne(t))for(const r of t.split(";")){const l=r.slice(0,r.indexOf(":")).trim();s[l]==null&&Lt(n,l,"")}else for(const r in t)s[r]==null&&Lt(n,r,"");for(const r in s){r==="display"&&(o=!0);const l=s[r];l!=null?Rl(e,r,!ne(t)&&t?t[r]:void 0,l)||Lt(n,r,l):Lt(n,r,"")}}else if(i){if(t!==s){const r=n[Pl];r&&(s+=";"+r),n.cssText=s,o=$l.test(s)}}else t&&e.removeAttribute("style");Xn in e&&(e[Xn]=o?n.display:"",e[Cl]&&(n.display="none"))}const Qn=/\s*!important$/;function Lt(e,t,s){if(L(s))s.forEach(n=>Lt(e,t,n));else if(s==null&&(s=""),t.startsWith("--"))e.setProperty(t,s);else{const n=Ol(e,t);Qn.test(s)?e.setProperty(ct(n),s.replace(Qn,""),"important"):e[n]=s}}const ei=["Webkit","Moz","ms"],Ws={};function Ol(e,t){const s=Ws[t];if(s)return s;let n=Ce(t);if(n!=="filter"&&n in e)return Ws[t]=n;n=_i(n);for(let i=0;i<ei.length;i++){const o=ei[i]+n;if(o in e)return Ws[t]=o}return t}function Rl(e,t,s,n){return e.tagName==="TEXTAREA"&&(t==="width"||t==="height")&&ne(n)&&s===n}const ti="http://www.w3.org/1999/xlink";function si(e,t,s,n,i,o=Do(t)){n&&t.startsWith("xlink:")?s==null?e.removeAttributeNS(ti,t.slice(6,t.length)):e.setAttributeNS(ti,t,s):s==null||o&&!wi(s)?e.removeAttribute(t):e.setAttribute(t,o?"":Ve(s)?String(s):s)}function ni(e,t,s,n,i){if(t==="innerHTML"||t==="textContent"){s!=null&&(e[t]=t==="innerHTML"?_o(s):s);return}const o=e.tagName;if(t==="value"&&o!=="PROGRESS"&&!o.includes("-")){const l=o==="OPTION"?e.getAttribute("value")||"":e.value,a=s==null?e.type==="checkbox"?"on":"":String(s);(l!==a||!("_value"in e))&&(e.value=a),s==null&&e.removeAttribute(t),e._value=s;return}let r=!1;if(s===""||s==null){const l=typeof e[t];l==="boolean"?s=wi(s):s==null&&l==="string"?(s="",r=!0):l==="number"&&(s=0,r=!0)}try{e[t]=s}catch{}r&&e.removeAttribute(i||t)}function Xe(e,t,s,n){e.addEventListener(t,s,n)}function Il(e,t,s,n){e.removeEventListener(t,s,n)}const ii=Symbol("_vei");function Ul(e,t,s,n,i=null){const o=e[ii]||(e[ii]={}),r=o[t];if(n&&r)r.value=n;else{const[l,a]=Nl(t);if(n){const p=o[t]=Ll(n,i);Xe(e,l,p,a)}else r&&(Il(e,l,r,a),o[t]=void 0)}}const jl=/(Once|Passive|Capture)$/,Ml=/^on:?(?:Once|Passive|Capture)$/;function Nl(e){let t,s;for(;(s=e.match(jl))&&!Ml.test(e);)t||(t={}),e=e.slice(0,e.length-s[1].length),t[s[1].toLowerCase()]=!0;return[e[2]===":"?e.slice(3):ct(e.slice(2)),t]}let Gs=0;const Dl=Promise.resolve(),zl=()=>Gs||(Dl.then(()=>Gs=0),Gs=Date.now());function Ll(e,t){const s=n=>{if(!n._vts)n._vts=Date.now();else if(n._vts<=s.attached)return;const i=s.value;if(L(i)){const o=n.stopImmediatePropagation;n.stopImmediatePropagation=()=>{o.call(n),n._stopped=!0};const r=i.slice(),l=[n];for(let a=0;a<r.length&&!n._stopped;a++){const p=r[a];p&&Oe(p,t,5,l)}}else Oe(i,t,5,[n])};return s.value=e,s.attached=zl(),s}const oi=e=>e.charCodeAt(0)===111&&e.charCodeAt(1)===110&&e.charCodeAt(2)>96&&e.charCodeAt(2)<123,Fl=(e,t,s,n,i,o)=>{const r=i==="svg";t==="class"?El(e,n,r):t==="style"?Al(e,s,n):ks(t)?Ss(t)||Ul(e,t,s,n,o):(t[0]==="."?(t=t.slice(1),!0):t[0]==="^"?(t=t.slice(1),!1):Bl(e,t,n,r))?(ni(e,t,n),!e.tagName.includes("-")&&(t==="value"||t==="checked"||t==="selected")&&si(e,t,n,r,o,t!=="value")):e._isVueCE&&(Hl(e,t)||e._def.__asyncLoader&&(/[A-Z]/.test(t)||!ne(n)))?ni(e,Ce(t),n,o,t):(t==="true-value"?e._trueValue=n:t==="false-value"&&(e._fals Line truncated **/let on;const Jn=typeof window<"u"&&window.trustedTypes;if(Jn)try{on=Jn.createPolicy("vue",{createHTML:e=>e})}catch{}const _o=on?e=>on.createHTML(e):e=>e,xl="http://www.w3.org/2000/svg",kl="http://www.w3.org/1998/Math/MathML",qe=typeof document<"u"?document:null,Yn=qe&&qe.createElement("template"),Sl={insert:(e,t,s)=>{t.insertBefore(e,s||null)},remove:e=>{const t=e.parentNode;t&&t.removeChild(e)},createElement:(e,t,s,n)=>{const i=t==="svg"?qe.createElementNS(xl,e):t==="mathml"?qe.createElementNS(kl,e):s?qe.createElement(e,{is:s}):qe.createElement(e);return e==="select"&&n&&n.multiple!=null&&i.setAttribute("multiple",n.multiple),i},createText:e=>qe.createTextNode(e),createComment:e=>qe.createComment(e),setText:(e,t)=>{e.nodeValue=t},setElementText:(e,t)=>{e.textContent=t},parentNode:e=>e.parentNode,nextSibling:e=>e.nextSibling,querySelector:e=>qe.querySelector(e),setScopeId(e,t){e.setAttribute(t,"")},insertStaticContent(e,t,s,n,i,o){const r=s?s.previousSibling:t.lastChild;if(i&&(i===o||i.nextSibling))for(;t.insertBefore(i.cloneNode(!0),s),!(i===o||!(i=i.nextSibling)););else{Yn.innerHTML=_o(n==="svg"?`<svg>${e}</svg>`:n==="mathml"?`<math>${e}</math>`:e);const l=Yn.content;if(n==="svg"||n==="mathml"){const a=l.firstChild;for(;a.firstChild;)l.appendChild(a.firstChild);l.removeChild(a)}t.insertBefore(l,s)}return[r?r.nextSibling:t.firstChild,s?s.previousSibling:t.lastChild]}},Tl=Symbol("_vtc");function El(e,t,s){const n=e[Tl];n&&(t=(t?[t,...n]:[...n]).join(" ")),t==null?e.removeAttribute("class"):s?e.setAttribute("class",t):e.className=t}const Xn=Symbol("_vod"),Cl=Symbol("_vsh"),Pl=Symbol(""),$l=/(?:^|;)\s*display\s*:/;function Al(e,t,s){const n=e.style,i=ne(s);let o=!1;if(s&&!i){if(t)if(ne(t))for(const r of t.split(";")){const l=r.slice(0,r.indexOf(":")).trim();s[l]==null&&Lt(n,l,"")}else for(const r in t)s[r]==null&&Lt(n,r,"");for(const r in s){r==="display"&&(o=!0);const l=s[r];l!=null?Rl(e,r,!ne(t)&&t?t[r]:void 0,l)||Lt(n,r,l):Lt(n,r,"")}}else if(i){if(t!==s){const r=n[Pl];r&&(s+=";"+r),n.cssText=s,o=$l.test(s)}}else t&&e.removeAttribute("style");Xn in e&&(e[Xn]=o?n.display:"",e[Cl]&&(n.display="none"))}const Qn=/\s*!important$/;function Lt(e,t,s){if(L(s))s.forEach(n=>Lt(e,t,n));else if(s==null&&(s=""),t.startsWith("--"))e.setProperty(t,s);else{const n=Ol(e,t);Qn.test(s)?e.setProperty(ct(n),s.replace(Qn,""),"important"):e[n]=s}}const ei=["Webkit","Moz","ms"],Ws={};function Ol(e,t){const s=Ws[t];if(s)return s;let n=Ce(t);if(n!=="filter"&&n in e)return Ws[t]=n;n=_i(n);for(let i=0;i<ei.length;i++){const o=ei[i]+n;if(o in e)return Ws[t]=o}return t}function Rl(e,t,s,n){return e.tagName==="TEXTAREA"&&(t==="width"||t==="height")&&ne(n)&&s===n}const ti="http://www.w3.org/1999/xlink";function si(e,t,s,n,i,o=Do(t)){n&&t.startsWith("xlink:")?s==null?e.removeAttributeNS(ti,t.slice(6,t.length)):e.setAttributeNS(ti,t,s):s==null||o&&!wi(s)?e.removeAttribute(t):e.setAttribute(t,o?"":Ve(s)?String(s):s)}function ni(e,t,s,n,i){if(t==="innerHTML"||t==="textContent"){s!=null&&(e[t]=t==="innerHTML"?_o(s):s);return}const o=e.tagName;if(t==="value"&&o!=="PROGRESS"&&!o.includes("-")){const l=o==="OPTION"?e.getAttribute("value")||"":e.value,a=s==null?e.type==="checkbox"?"on":"":String(s);(l!==a||!("_value"in e))&&(e.value=a),s==null&&e.removeAttribute(t),e._value=s;return}let r=!1;if(s===""||s==null){const l=typeof e[t];l==="boolean"?s=wi(s):s==null&&l==="string"?(s="",r=!0):l==="number"&&(s=0,r=!0)}try{e[t]=s}catch{}r&&e.removeAttribute(i||t)}function Xe(e,t,s,n){e.addEventListener(t,s,n)}function Il(e,t,s,n){e.removeEventListener(t,s,n)}const ii=Symbol("_vei");function Ul(e,t,s,n,i=null){const o=e[ii]||(e[ii]={}),r=o[t];if(n&&r)r.value=n;else{const[l,a]=Nl(t);if(n){const p=o[t]=Ll(n,i);Xe(e,l,p,a)}else r&&(Il(e,l,r,a),o[t]=void 0)}}const jl=/(Once|Passive|Capture)$/,Ml=/^on:?(?:Once|Passive|Capture)$/;function Nl(e){let t,s;for(;(s=e.match(jl))&&!Ml.test(e);)t||(t={}),e=e.slice(0,e.length-s[1].length),t[s[1].toLowerCase()]=!0;return[e[2]===":"?e.slice(3):ct(e.slice(2)),t]}let Gs=0;const Dl=Promise.resolve(),zl=()=>Gs||(Dl.then(()=>Gs=0),Gs=Date.now());function Ll(e,t){const s=n=>{if(!n._vts)n._vts=Date.now();else if(n._vts<=s.attached)return;const i=s.value;if(L(i)){const o=n.stopImmediatePropagation;n.stopImmediatePropagation=()=>{o.call(n),n._stopped=!0};const r=i.slice(),l=[n];for(let a=0;a<r.length&&!n._stopped;a++){const p=r[a];p&&Oe(p,t,5,l)}}else Oe(i,t,5,[n])};return s.value=e,s.attached=zl(),s}const oi=e=>e.charCodeAt(0)===111&&e.charCodeAt(1)===110&&e.charCodeAt(2)>96&&e.charCodeAt(2)<123,Fl=(e,t,s,n,i,o)=>{const r=i==="svg";t==="class"?El(e,n,r):t==="style"?Al(e,s,n):ks(t)?Ss(t)||Ul(e,t,s,n,o):(t[0]==="."?(t=t.slice(1),!0):t[0]==="^"?(t=t.slice(1),!1):Bl(e,t,n,r))?(ni(e,t,n),!e.tagName.includes("-")&&(t==="value"||t==="checked"||t==="selected")&&si(e,t,n,r,o,t!=="value")):e._isVueCE&&(Hl(e,t)||e._def.__asyncLoader&&(/[A-Z]/.test(t)||!ne(n)))?ni(e,Ce(t),n,o,t):(t==="true-value"?e._trueValue=n:t==="false-value"&&(e._fals Line truncated
+1 -1
View File
@@ -6,7 +6,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#2563eb" /> <meta name="theme-color" content="#2563eb" />
<title>DriverVault · API Server</title> <title>DriverVault · API Server</title>
<script type="module" crossorigin src="/assets/index-frPmZHwa.js"></script> <script type="module" crossorigin src="/assets/index-D-rTgBao.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-D3MeNcl7.css"> <link rel="stylesheet" crossorigin href="/assets/index-D3MeNcl7.css">
</head> </head>
<body> <body>
@@ -187,6 +187,9 @@ const healthClass = (s) =>
{{ f.label || f.key }}<span v-if="f.required" class="text-danger"> *</span> {{ f.label || f.key }}<span v-if="f.required" class="text-danger"> *</span>
</label> </label>
<select v-if="f.type === 'select'" v-model="drafts[p.name][f.key]" class="dh-select"> <select v-if="f.type === 'select'" v-model="drafts[p.name][f.key]" class="dh-select">
<!-- A non-required select can be left unset (empty), so the global
layer abstains and lower layers (org / user) may choose. -->
<option v-if="!f.required" value="">{{ t("plugins.notSet") }}</option>
<option v-for="o in f.options || []" :key="o.value" :value="o.value"> <option v-for="o in f.options || []" :key="o.value" :value="o.value">
{{ o.label || o.value }} {{ o.label || o.value }}
</option> </option>
+1
View File
@@ -133,6 +133,7 @@
"configure": "Konfigurer", "configure": "Konfigurer",
"close": "Luk", "close": "Luk",
"noConfig": "Dette plugin kræver ingen konfiguration.", "noConfig": "Dette plugin kræver ingen konfiguration.",
"notSet": "Ikke angivet — lad organisationer og brugere vælge",
"capabilities": "Funktioner", "capabilities": "Funktioner",
"saved": "Gemt.", "saved": "Gemt.",
"checking": "Tjekker…", "checking": "Tjekker…",
+1
View File
@@ -133,6 +133,7 @@
"configure": "Configure", "configure": "Configure",
"close": "Close", "close": "Close",
"noConfig": "This plugin takes no configuration.", "noConfig": "This plugin takes no configuration.",
"notSet": "Not set — let organizations and users choose",
"capabilities": "Capabilities", "capabilities": "Capabilities",
"saved": "Saved.", "saved": "Saved.",
"checking": "Checking…", "checking": "Checking…",
+1
View File
@@ -133,6 +133,7 @@
"configure": "Konfiguruj", "configure": "Konfiguruj",
"close": "Zamknij", "close": "Zamknij",
"noConfig": "Ta wtyczka nie wymaga konfiguracji.", "noConfig": "Ta wtyczka nie wymaga konfiguracji.",
"notSet": "Nie ustawiono — pozwól organizacjom i użytkownikom wybrać",
"capabilities": "Możliwości", "capabilities": "Możliwości",
"saved": "Zapisano.", "saved": "Zapisano.",
"checking": "Sprawdzanie…", "checking": "Sprawdzanie…",
+4
View File
@@ -134,6 +134,10 @@
"eyebrow": "Konto", "eyebrow": "Konto",
"title": "Indstillinger", "title": "Indstillinger",
"subtitle": "Administrer din konto, udseende og dine data.", "subtitle": "Administrer din konto, udseende og dine data.",
"tabs": {
"personal": "Personlige indstillinger",
"integrations": "Integrationer"
},
"account": { "account": {
"title": "Konto", "title": "Konto",
+4
View File
@@ -152,6 +152,10 @@
"eyebrow": "Account", "eyebrow": "Account",
"title": "Settings", "title": "Settings",
"subtitle": "Manage your account, appearance, and data.", "subtitle": "Manage your account, appearance, and data.",
"tabs": {
"personal": "Personal settings",
"integrations": "Integrations"
},
"account": { "account": {
"title": "Account", "title": "Account",
+4
View File
@@ -138,6 +138,10 @@
"eyebrow": "Konto", "eyebrow": "Konto",
"title": "Ustawienia", "title": "Ustawienia",
"subtitle": "Zarządzaj kontem, wyglądem i danymi.", "subtitle": "Zarządzaj kontem, wyglądem i danymi.",
"tabs": {
"personal": "Ustawienia osobiste",
"integrations": "Integracje"
},
"account": { "account": {
"title": "Konto", "title": "Konto",
+30 -1
View File
@@ -13,6 +13,11 @@ const loading = ref(true);
const loadError = ref(""); const loadError = ref("");
const profile = ref(null); const profile = ref(null);
// Settings is split into two tabs: personal account settings and external
// integrations. The panels stay mounted (v-show) so their loaded state and
// in-flight edits survive a tab switch.
const activeTab = ref("personal"); // "personal" | "integrations"
async function load() { async function load() {
loading.value = true; loading.value = true;
loadError.value = ""; loadError.value = "";
@@ -722,7 +727,24 @@ onBeforeUnmount(() => {
<p v-if="loadError" class="mb-4 rounded-control bg-danger-soft px-4 py-3 text-sm font-medium text-danger">{{ loadError }}</p> <p v-if="loadError" class="mb-4 rounded-control bg-danger-soft px-4 py-3 text-sm font-medium text-danger">{{ loadError }}</p>
<p v-if="loading" class="text-muted">{{ t("common.loading") }}</p> <p v-if="loading" class="text-muted">{{ t("common.loading") }}</p>
<div v-else-if="profile" class="space-y-6"> <div v-else-if="profile">
<!-- Tabs: personal settings vs. integrations -->
<div class="mb-6 flex gap-2 border-b border-subtle">
<button
v-for="tab in ['personal', 'integrations']"
:key="tab"
class="-mb-px border-b-2 px-1 pb-3 text-sm font-semibold transition-colors"
:class="activeTab === tab
? 'border-accent text-strong'
: 'border-transparent text-muted hover:text-body'"
@click="activeTab = tab"
>
{{ t(`settings.tabs.${tab}`) }}
</button>
</div>
<!-- Personal settings -->
<div v-show="activeTab === 'personal'" class="space-y-6">
<!-- Account --> <!-- Account -->
<section class="dh-card p-6"> <section class="dh-card p-6">
<h2 class="mb-4 text-lg font-bold tracking-[-0.02em] text-strong">{{ t("settings.account.title") }}</h2> <h2 class="mb-4 text-lg font-bold tracking-[-0.02em] text-strong">{{ t("settings.account.title") }}</h2>
@@ -891,7 +913,10 @@ onBeforeUnmount(() => {
</div> </div>
</section> </section>
</div>
<!-- Integrations --> <!-- Integrations -->
<div v-show="activeTab === 'integrations'" class="space-y-6">
<section v-if="toyota || anker" class="dh-card p-6"> <section v-if="toyota || anker" class="dh-card p-6">
<h2 class="text-lg font-bold tracking-[-0.02em] text-strong">{{ t("settings.integrations.title") }}</h2> <h2 class="text-lg font-bold tracking-[-0.02em] text-strong">{{ t("settings.integrations.title") }}</h2>
<p class="mb-4 mt-1 text-sm text-muted">{{ t("settings.integrations.subtitle") }}</p> <p class="mb-4 mt-1 text-sm text-muted">{{ t("settings.integrations.subtitle") }}</p>
@@ -1197,7 +1222,10 @@ onBeforeUnmount(() => {
<p v-if="ankerError" class="mt-2 text-sm text-danger">{{ ankerError }}</p> <p v-if="ankerError" class="mt-2 text-sm text-danger">{{ ankerError }}</p>
</div> </div>
</section> </section>
</div>
<!-- Personal settings (continued) -->
<div v-show="activeTab === 'personal'" class="space-y-6">
<!-- Privacy & Security --> <!-- Privacy & Security -->
<section class="dh-card p-6"> <section class="dh-card p-6">
<div class="mb-4 flex items-center justify-between"> <div class="mb-4 flex items-center justify-between">
@@ -1279,6 +1307,7 @@ onBeforeUnmount(() => {
<p v-if="deleteError" class="mt-3 text-sm font-medium text-danger">{{ deleteError }}</p> <p v-if="deleteError" class="mt-3 text-sm font-medium text-danger">{{ deleteError }}</p>
</section> </section>
</div>
</div> </div>
</div> </div>
</template> </template>