diff --git a/API Server/README.md b/API Server/README.md
index b367a0f..63e6cb3 100644
--- a/API Server/README.md
+++ b/API Server/README.md
@@ -35,7 +35,7 @@ internal/
├── pb/client.go # PocketBase superuser client (runtime-retargetable)
└── plugins/ # plugin system — see plugins/README.md
├── plugin.go manager.go external.go doc.go
- └── builtin/ # built-in connectors: toyota, ankersolix, greencell
+ └── builtin/ # built-in connectors: toyota, ankersolix, greencell, apprise
panel/ # Vue 3 + Tailwind panel source
scripts/ # Node/Python maintenance scripts
bin/api-server.exe # prebuilt binary the deployment runs
@@ -296,10 +296,11 @@ state and global config persist to PocketBase, in the `app_settings` singleton
the same place the per-org and per-user layers of the cascade live.
See **[`internal/plugins/README.md`](internal/plugins/README.md)** for the full
-guide. Three built-in connectors ship today — **Toyota Connected** (`toyota`,
+guide. Four built-in connectors ship today — **Toyota Connected** (`toyota`,
read-only MyToyota vehicle data), the **Anker Solix** V1 EV charger
-(`anker-solix`) and the **Greencell** HabuDen EV charger (`greencell`) — and any
-number of external HTTP plugins can be registered at runtime with no rebuild.
+(`anker-solix`), the **Greencell** HabuDen EV charger (`greencell`) and
+**Apprise** (`apprise`, notifications) — and any number of external HTTP plugins
+can be registered at runtime with no rebuild.
### Integrations & charging control
diff --git a/API Server/internal/api/dist/assets/index-CvY2-bIX.js b/API Server/internal/api/dist/assets/index-CvNOI6h4.js
similarity index 53%
rename from API Server/internal/api/dist/assets/index-CvY2-bIX.js
rename to API Server/internal/api/dist/assets/index-CvNOI6h4.js
index d347f7b..a94e9a9 100644
--- a/API Server/internal/api/dist/assets/index-CvY2-bIX.js
+++ b/API Server/internal/api/dist/assets/index-CvNOI6h4.js
@@ -14,4 +14,4 @@
* @vue/runtime-dom v3.5.39
* (c) 2018-present Yuxi (Evan) You and Vue contributors
* @license MIT
-**/let an;const Xn=typeof window<"u"&&window.trustedTypes;if(Xn)try{an=Xn.createPolicy("vue",{createHTML:e=>e})}catch{}const _o=an?e=>an.createHTML(e):e=>e,ka="http://www.w3.org/2000/svg",Ta="http://www.w3.org/1998/Math/MathML",tt=typeof document<"u"?document:null,Qn=tt&&tt.createElement("template"),Sa={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"?tt.createElementNS(ka,e):t==="mathml"?tt.createElementNS(Ta,e):s?tt.createElement(e,{is:s}):tt.createElement(e);return e==="select"&&n&&n.multiple!=null&&i.setAttribute("multiple",n.multiple),i},createText:e=>tt.createTextNode(e),createComment:e=>tt.createComment(e),setText:(e,t)=>{e.nodeValue=t},setElementText:(e,t)=>{e.textContent=t},parentNode:e=>e.parentNode,nextSibling:e=>e.nextSibling,querySelector:e=>tt.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{Qn.innerHTML=_o(n==="svg"?``:n==="mathml"?``:e);const a=Qn.content;if(n==="svg"||n==="mathml"){const l=a.firstChild;for(;l.firstChild;)a.appendChild(l.firstChild);a.removeChild(l)}t.insertBefore(a,s)}return[r?r.nextSibling:t.firstChild,s?s.previousSibling:t.lastChild]}},Ea=Symbol("_vtc");function Pa(e,t,s){const n=e[Ea];n&&(t=(t?[t,...n]:[...n]).join(" ")),t==null?e.removeAttribute("class"):s?e.setAttribute("class",t):e.className=t}const ei=Symbol("_vod"),Ca=Symbol("_vsh"),Aa=Symbol(""),$a=/(?:^|;)\s*display\s*:/;function Oa(e,t,s){const n=e.style,i=ue(s);let o=!1;if(s&&!i){if(t)if(ue(t))for(const r of t.split(";")){const a=r.slice(0,r.indexOf(":")).trim();s[a]==null&&Ft(n,a,"")}else for(const r in t)s[r]==null&&Ft(n,r,"");for(const r in s){r==="display"&&(o=!0);const a=s[r];a!=null?Ra(e,r,!ue(t)&&t?t[r]:void 0,a)||Ft(n,r,a):Ft(n,r,"")}}else if(i){if(t!==s){const r=n[Aa];r&&(s+=";"+r),n.cssText=s,o=$a.test(s)}}else t&&e.removeAttribute("style");ei in e&&(e[ei]=o?n.display:"",e[Ca]&&(n.display="none"))}const ti=/\s*!important$/;function Ft(e,t,s){if(L(s))s.forEach(n=>Ft(e,t,n));else if(s==null&&(s=""),t.startsWith("--"))e.setProperty(t,s);else{const n=Da(e,t);ti.test(s)?e.setProperty(ft(n),s.replace(ti,""),"important"):e[n]=s}}const si=["Webkit","Moz","ms"],qs={};function Da(e,t){const s=qs[t];if(s)return s;let n=je(t);if(n!=="filter"&&n in e)return qs[t]=n;n=_i(n);for(let i=0;iZs||(Ma.then(()=>Zs=0),Zs=Date.now());function Fa(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(),a=[n];for(let l=0;le.charCodeAt(0)===111&&e.charCodeAt(1)===110&&e.charCodeAt(2)>96&&e.charCodeAt(2)<123,Ba=(e,t,s,n,i,o)=>{const r=i==="svg";t==="class"?Pa(e,n,r):t==="style"?Oa(e,s,n):Ss(t)?Es(t)||Ia(e,t,s,n,o):(t[0]==="."?(t=t.slice(1),!0):t[0]==="^"?(t=t.slice(1),!1):Ha(e,t,n,r))?(oi(e,t,n),!e.tagName.includes("-")&&(t==="value"||t==="checked"||t==="selected")&&ii(e,t,n,r,o,t!=="value")):e._isVueCE&&(Va(e,t)||e._def.__asyncLoader&&(/[A-Z]/.test(t)||!ue(n)))?oi(e,je(t),n,o,t):(t==="true-value"?e._trueValue=n:t==="false-value"&&(e._falseValue=n),ii(e,t,n,r))};function Ha(e,t,s,n){if(n)return!!(t==="innerHTML"||t==="textContent"||t in e&&ai(t)&&W(s));if(t==="spellcheck"||t==="draggable"||t==="translate"||t==="autocorrect"||t==="sandbox"&&e.tagName==="IFRAME"||t==="form"||t==="list"&&e.tagName==="INPUT"||t==="type"&&e.tagName==="TEXTAREA")return!1;if(t==="width"||t==="height"){const i=e.tagName;if(i==="IMG"||i==="VIDEO"||i==="CANVAS"||i==="SOURCE")return!1}return ai(t)&&ue(s)?!1:t in e}function Va(e,t){const s=e._def.props;if(!s)return!1;const n=je(t);return Array.isArray(s)?s.some(i=>je(i)===n):Object.keys(s).some(i=>je(i)===n)}const pt=e=>{const t=e.props["onUpdate:modelValue"]||!1;return L(t)?s=>ps(t,s):t};function Ga(e){e.target.composing=!0}function li(e){const t=e.target;t.composing&&(t.composing=!1,t.dispatchEvent(new Event("input")))}const Ie=Symbol("_assign");function ci(e,t,s){return t&&(e=e.trim()),s&&(e=Cs(e)),e}const be={created(e,{modifiers:{lazy:t,trim:s,number:n}},i){e[Ie]=pt(i);const o=n||i.props&&i.props.type==="number";ot(e,t?"change":"input",r=>{r.target.composing||e[Ie](ci(e.value,s,o))}),(s||o)&&ot(e,"change",()=>{e.value=ci(e.value,s,o)}),t||(ot(e,"compositionstart",Ga),ot(e,"compositionend",li),ot(e,"change",li))},mounted(e,{value:t}){e.value=t??""},beforeUpdate(e,{value:t,oldValue:s,modifiers:{lazy:n,trim:i,number:o}},r){if(e[Ie]=pt(r),e.composing)return;const a=(o||e.type==="number")&&!/^0\d/.test(e.value)?Cs(e.value):e.value,l=t??"";if(a===l)return;const g=e.getRootNode();(g instanceof Document||g instanceof ShadowRoot)&&g.activeElement===e&&e.type!=="range"&&(n&&t===s||i&&e.value.trim()===l)||(e.value=l)}},Ka={deep:!0,created(e,t,s){e[Ie]=pt(s),ot(e,"change",()=>{const n=e._modelValue,i=Ot(e),o=e.checked,r=e[Ie];if(L(n)){const a=fn(n,i),l=a!==-1;if(o&&!l)r(n.concat(i));else if(!o&&l){const g=[...n];g.splice(a,1),r(g)}}else if(zt(n)){const a=new Set(n);o?a.add(i):a.delete(i),r(a)}else r(wo(e,o))})},mounted:ui,beforeUpdate(e,t,s){e[Ie]=pt(s),ui(e,t,s)}};function ui(e,{value:t,oldValue:s},n){e._modelValue=t;let i;if(L(t))i=fn(t,n.props.value)>-1;else if(zt(t))i=t.has(n.props.value);else{if(t===s)return;i=ut(t,wo(e,!0))}e.checked!==i&&(e.checked=i)}const Wa={created(e,{value:t},s){e.checked=ut(t,s.props.value),e[Ie]=pt(s),ot(e,"change",()=>{e[Ie](Ot(e))})},beforeUpdate(e,{value:t,oldValue:s},n){e[Ie]=pt(n),t!==s&&(e.checked=ut(t,n.props.value))}},ks={deep:!0,created(e,{value:t,modifiers:{number:s}},n){const i=zt(t);ot(e,"change",()=>{const o=Array.prototype.filter.call(e.options,r=>r.selected).map(r=>s?Cs(Ot(r)):Ot(r));e[Ie](e.multiple?i?new Set(o):o:o[0]),e._assigning=!0,Fi(()=>{e._assigning=!1})}),e[Ie]=pt(n)},mounted(e,{value:t}){di(e,t)},beforeUpdate(e,t,s){e[Ie]=pt(s)},updated(e,{value:t}){e._assigning||di(e,t)}};function di(e,t){const s=e.multiple,n=L(t);if(!(s&&!n&&!zt(t))){for(let i=0,o=e.options.length;iString(g)===String(a)):r.selected=fn(t,a)>-1}else r.selected=t.has(a);else if(ut(Ot(r),t)){e.selectedIndex!==i&&(e.selectedIndex=i);return}}!s&&e.selectedIndex!==-1&&(e.selectedIndex=-1)}}function Ot(e){return"_value"in e?e._value:e.value}function wo(e,t){const s=t?"_trueValue":"_falseValue";return s in e?e[s]:t}const qa={created(e,t,s){ds(e,t,s,null,"created")},mounted(e,t,s){ds(e,t,s,null,"mounted")},beforeUpdate(e,t,s,n){ds(e,t,s,n,"beforeUpdate")},updated(e,t,s,n){ds(e,t,s,n,"updated")}};function Za(e,t){switch(e){case"SELECT":return ks;case"TEXTAREA":return be;default:switch(t){case"checkbox":return Ka;case"radio":return Wa;default:return be}}}function ds(e,t,s,n,i){const r=Za(e.tagName,s.props&&s.props.type)[i];r&&r(e,t,s,n)}const Ja=["ctrl","shift","alt","meta"],Ya={stop:e=>e.stopPropagation(),prevent:e=>e.preventDefault(),self:e=>e.target!==e.currentTarget,ctrl:e=>!e.ctrlKey,shift:e=>!e.shiftKey,alt:e=>!e.altKey,meta:e=>!e.metaKey,left:e=>"button"in e&&e.button!==0,middle:e=>"button"in e&&e.button!==1,right:e=>"button"in e&&e.button!==2,exact:(e,t)=>Ja.some(s=>e[`${s}Key`]&&!t.includes(s))},Xa=(e,t)=>{if(!e)return e;const s=e._withMods||(e._withMods={}),n=t.join(".");return s[n]||(s[n]=((i,...o)=>{for(let r=0;r{const s=e._withKeys||(e._withKeys={}),n=t.join(".");return s[n]||(s[n]=(i=>{if(!("key"in i))return;const o=ft(i.key);if(t.some(r=>r===o||Qa[r]===o))return e(i)}))},el=_e({patchProp:Ba},Sa);let pi;function tl(){return pi||(pi=ia(el))}const sl=((...e)=>{const t=tl().createApp(...e),{mount:s}=t;return t.mount=n=>{const i=il(n);if(!i)return;const o=t._component;!W(o)&&!o.render&&!o.template&&(o.template=i.innerHTML),i.nodeType===1&&(i.textContent="");const r=s(i,!1,nl(i));return i instanceof Element&&(i.removeAttribute("v-cloak"),i.setAttribute("data-v-app","")),r},t});function nl(e){if(e instanceof SVGElement)return"svg";if(typeof MathMLElement=="function"&&e instanceof MathMLElement)return"mathml"}function il(e){return ue(e)?document.querySelector(e):e}const ko="dh-panel-theme";function ol(){var e;try{const t=localStorage.getItem(ko);if(t==="dark"||t==="light")return t}catch{}return(e=window.matchMedia)!=null&&e.call(window,"(prefers-color-scheme: dark)").matches?"dark":"light"}const Ct=H(ol());function To(e){Ct.value=e,document.documentElement.classList.toggle("dark",e==="dark");try{localStorage.setItem(ko,e)}catch{}}function fi(){To(Ct.value==="dark"?"light":"dark")}To(Ct.value);const rl={apiServer:"API server",switchToLight:"Switch to light theme",switchToDark:"Switch to dark theme",theme:"Theme",signOut:"Sign out",loading:"Loading…",standardUserNote:"Signed in as a standard user — management sections need an admin role",footer:"DriverVault — car maintenance & service tracker."},al={overview:"Overview",users:"Users",orgs:"Organizations",server:"API Server",pocketbase:"PocketBase",webapp:"Web App",plugins:"Plugins",api:"API"},ll={title:"Sign in",subtitle:"Superadmin console for the DriverVault API Server.",email:"Email",password:"Password",submit:"Sign in",submitting:"Signing in…",invalid:"Invalid email or password.",failed:"Could not sign in.",authNote:"Authenticates against PocketBase through this server"},cl={title:"Status",unreachable:"unreachable",checking:"Checking…",apiServer:"API Server",pocketBase:"PocketBase",webApp:"Web App",thisProcess:"this process"},ul={create:"Create",save:"Save",cancel:"Cancel",edit:"Edit",delete:"Delete",rename:"Rename",empty:"—",name:"Name",email:"Email",role:"Role",organization:"Organization"},dl={title:"Users",allOrgs:"All organizations",yourOrg:"Your organization",newUser:"New user",password:"Password",passwordUnchanged:"Password (blank = unchanged)",passwordPlaceholder:"min 8 characters",orgNone:"— none —",you:"you",empty:"No users.",confirmDelete:"Delete {email}? This cannot be undone."},pl={title:"Organizations",subtitle:"Tenants users belong to",subtitleOwn:"Your organization",subtitleNone:"Create one to manage your own team",newOrg:"New organization",namePlaceholder:"Acme Fleet",empty:"No organizations yet.",createHint:"You have no organization yet. Create one to become its admin.",colId:"ID",confirmDelete:'Delete the organization "{name}"?',confirmDeleteOwn:'Delete your organization "{name}"? You will be removed from it and become a regular user.'},fl={title:"API Server",subtitle:"How this server identifies itself to the clients that connect to it",name:"Server name",nameHint:"Shown by clients that can be pointed at more than one DriverVault, such as the Web App's server switcher. Reported by /api/health.",saveApply:"Save & apply",persistedEnv:"persisted to .env",savedNotice:"Saved. The new name applies to the next request."},hl={title:"PocketBase",subtitle:"Database connection used by every endpoint",connected:"connected",noSuperuser:"no superuser",unreachable:"unreachable",baseUrl:"Base URL",superuserEmail:"Superuser email",superuserPassword:"Superuser password",passwordUnchanged:"unchanged",passwordNotSet:"not set",saveApply:"Save & apply",testConnection:"Test connection",persistedEnv:"persisted to .env",testOk:"Connection OK — superuser authenticated.",testReachableNoAuth:"PocketBase is reachable, but the service account did not authenticate.",testUnreachable:"PocketBase is not reachable at that address.",savedNotice:"Saved. The server is now using this PocketBase."},gl={title:"Web App",subtitle:"Address probed by the status check, and who may call this API from a browser",reachable:"reachable",unreachable:"unreachable",baseUrl:"Base URL",allowedOrigins:"Allowed origins",originsHint:"Comma separated, or {star} for any. Native mobile apps are not subject to CORS.",saveApply:"Save & apply",testConnection:"Test connection",persistedEnv:"persisted to .env",testOk:"Web App is reachable.",testFailed:"Web App did not answer its health check at that address.",savedNotice:"Saved. New origins apply to the next request."},ml={title:"Plugins",subtitle:"Third-party service integrations",categories:{vehicles:"Car manufacturers",chargers:"EV chargers","apis-external":"Other APIs","drives-external":"Remote drives","drives-local":"Local drives"},registerExternal:"Register external",cancelRegister:"Cancel",name:"Name",baseUrl:"Base URL",provider:"Provider",register:"Register",empty:"No plugins yet. Register an external one above, or compile a built-in connector.",builtin:"builtin",enabled:"enabled",disabled:"disabled",health:"Health",configure:"Configure",close:"Close",noConfig:"This plugin takes no configuration.",notSet:"Not set — let organizations and users choose",capabilities:"Capabilities",saved:"Saved.",checking:"Checking…",save:"Save",saveEnable:"Save & enable",disable:"Disable",remove:"Remove",builtinNote:"Built-in plugins can be disabled but not removed",confirmRemove:'Remove the external plugin "{name}"? Its saved config is deleted.'},bl={colEndpoint:"Endpoint",colDescription:"Description",authNone:"No auth",authBearer:"Bearer token",authManager:"Admin / superadmin",authSuperadmin:"Superadmin",authCharger:"Charger credentials",groupPublic:"Public",groupIdentity:"Identity",groupCars:"Cars",groupService:"Service records",groupTechnical:"Technical checks",groupParts:"Parts",groupFuel:"Fuel",groupCharging:"Charging",groupMaintenance:"Maintenance",groupDocuments:"Documents",groupReminders:"Reminders",groupAttachments:"Attachments",groupProviders:"Vehicle providers",groupIntegrations:"Integrations",groupOcpp:"OCPP",groupAccount:"Account",groupManagement:"Management",groupSuperadmin:"Superadmin"},vl={app:rl,sections:al,login:ll,status:cl,common:ul,users:dl,orgs:pl,server:fl,pocketbase:hl,webapp:gl,plugins:ml,api:bl},yl={apiServer:"Serwer API",switchToLight:"Przełącz na motyw jasny",switchToDark:"Przełącz na motyw ciemny",theme:"Motyw",signOut:"Wyloguj się",loading:"Ładowanie…",standardUserNote:"Zalogowano jako zwykły użytkownik — sekcje zarządzania wymagają roli administratora",footer:"DriverVault — rejestr serwisu i konserwacji samochodu."},_l={overview:"Przegląd",users:"Użytkownicy",orgs:"Organizacje",server:"Serwer API",pocketbase:"PocketBase",webapp:"Aplikacja webowa",plugins:"Wtyczki",api:"API"},wl={title:"Zaloguj się",subtitle:"Konsola superadministratora serwera API DriverVault.",email:"E-mail",password:"Hasło",submit:"Zaloguj się",submitting:"Logowanie…",invalid:"Nieprawidłowy e-mail lub hasło.",failed:"Nie udało się zalogować.",authNote:"Uwierzytelnia w PocketBase za pośrednictwem tego serwera"},xl={title:"Status",unreachable:"niedostępny",checking:"Sprawdzanie…",apiServer:"Serwer API",pocketBase:"PocketBase",webApp:"Aplikacja webowa",thisProcess:"ten proces"},kl={create:"Utwórz",save:"Zapisz",cancel:"Anuluj",edit:"Edytuj",delete:"Usuń",rename:"Zmień nazwę",empty:"—",name:"Nazwa",email:"E-mail",role:"Rola",organization:"Organizacja"},Tl={title:"Użytkownicy",allOrgs:"Wszystkie organizacje",yourOrg:"Twoja organizacja",newUser:"Nowy użytkownik",password:"Hasło",passwordUnchanged:"Hasło (puste = bez zmian)",passwordPlaceholder:"min. 8 znaków",orgNone:"— brak —",you:"Ty",empty:"Brak użytkowników.",confirmDelete:"Usunąć {email}? Tej operacji nie można cofnąć."},Sl={title:"Organizacje",subtitle:"Podmioty, do których należą użytkownicy",subtitleOwn:"Twoja organizacja",subtitleNone:"Utwórz ją, aby zarządzać własnym zespołem",newOrg:"Nowa organizacja",namePlaceholder:"Acme Fleet",empty:"Brak organizacji.",createHint:"Nie masz jeszcze organizacji. Utwórz ją, aby zostać jej administratorem.",colId:"ID",confirmDelete:"Usunąć organizację „{name}”?",confirmDeleteOwn:"Usunąć Twoją organizację „{name}”? Zostaniesz z niej usunięty i staniesz się zwykłym użytkownikiem."},El={title:"Serwer API",subtitle:"Jak ten serwer przedstawia się klientom, które się z nim łączą",name:"Nazwa serwera",nameHint:"Widoczna w klientach, które można skierować na więcej niż jeden DriverVault, na przykład w przełączniku serwerów aplikacji webowej. Zwracana przez /api/health.",saveApply:"Zapisz i zastosuj",persistedEnv:"zapisano w .env",savedNotice:"Zapisano. Nowa nazwa obowiązuje od następnego żądania."},Pl={title:"PocketBase",subtitle:"Połączenie z bazą danych używane przez każdy punkt końcowy",connected:"połączono",noSuperuser:"brak superużytkownika",unreachable:"niedostępny",baseUrl:"Adres bazowy",superuserEmail:"E-mail superużytkownika",superuserPassword:"Hasło superużytkownika",passwordUnchanged:"bez zmian",passwordNotSet:"nie ustawiono",saveApply:"Zapisz i zastosuj",testConnection:"Testuj połączenie",persistedEnv:"zapisano w .env",testOk:"Połączenie OK — superużytkownik uwierzytelniony.",testReachableNoAuth:"PocketBase jest dostępny, ale konto usługowe nie zostało uwierzytelnione.",testUnreachable:"PocketBase jest niedostępny pod tym adresem.",savedNotice:"Zapisano. Serwer korzysta teraz z tego PocketBase."},Cl={title:"Aplikacja webowa",subtitle:"Adres sprawdzany podczas kontroli statusu oraz kto może wywoływać to API z przeglądarki",reachable:"dostępna",unreachable:"niedostępna",baseUrl:"Adres bazowy",allowedOrigins:"Dozwolone źródła",originsHint:"Oddzielone przecinkami lub {star} dla dowolnego. Natywne aplikacje mobilne nie podlegają CORS.",saveApply:"Zapisz i zastosuj",testConnection:"Testuj połączenie",persistedEnv:"zapisano w .env",testOk:"Aplikacja webowa jest dostępna.",testFailed:"Aplikacja webowa nie odpowiedziała na kontrolę stanu pod tym adresem.",savedNotice:"Zapisano. Nowe źródła obowiązują od następnego żądania."},Al={title:"Wtyczki",subtitle:"Integracje z usługami zewnętrznymi",categories:{vehicles:"Producenci samochodów",chargers:"Ładowarki EV","apis-external":"Pozostałe API","drives-external":"Dyski zdalne","drives-local":"Dyski lokalne"},registerExternal:"Zarejestruj zewnętrzną",cancelRegister:"Anuluj",name:"Nazwa",baseUrl:"Adres bazowy",provider:"Dostawca",register:"Zarejestruj",empty:"Brak wtyczek. Zarejestruj zewnętrzną powyżej lub skompiluj wbudowany łącznik.",builtin:"wbudowana",enabled:"włączona",disabled:"wyłączona",health:"Stan",configure:"Konfiguruj",close:"Zamknij",noConfig:"Ta wtyczka nie wymaga konfiguracji.",notSet:"Nie ustawiono — pozwól organizacjom i użytkownikom wybrać",capabilities:"Możliwości",saved:"Zapisano.",checking:"Sprawdzanie…",save:"Zapisz",saveEnable:"Zapisz i włącz",disable:"Wyłącz",remove:"Usuń",builtinNote:"Wtyczki wbudowane można wyłączyć, ale nie usunąć",confirmRemove:"Usunąć zewnętrzną wtyczkę „{name}”? Jej zapisana konfiguracja zostanie usunięta."},$l={colEndpoint:"Punkt końcowy",colDescription:"Opis",authNone:"Bez uwierzytelniania",authBearer:"Token Bearer",authManager:"Administrator / superadministrator",authSuperadmin:"Superadministrator",authCharger:"Dane logowania ładowarki",groupPublic:"Publiczne",groupIdentity:"Tożsamość",groupCars:"Samochody",groupService:"Wpisy serwisowe",groupTechnical:"Przeglądy techniczne",groupParts:"Części",groupFuel:"Paliwo",groupCharging:"Ładowanie",groupMaintenance:"Naprawy",groupDocuments:"Dokumenty",groupReminders:"Przypomnienia",groupAttachments:"Załączniki",groupProviders:"Dostawcy pojazdów",groupIntegrations:"Integracje",groupOcpp:"OCPP",groupAccount:"Konto",groupManagement:"Zarządzanie",groupSuperadmin:"Superadministrator"},Ol={app:yl,sections:_l,login:wl,status:xl,common:kl,users:Tl,orgs:Sl,server:El,pocketbase:Pl,webapp:Cl,plugins:Al,api:$l},Dl={apiServer:"API-server",switchToLight:"Skift til lyst tema",switchToDark:"Skift til mørkt tema",theme:"Tema",signOut:"Log ud",loading:"Indlæser…",standardUserNote:"Logget ind som almindelig bruger — administrationssektioner kræver en administratorrolle",footer:"DriverVault — bilservice- og vedligeholdelsesregister."},Rl={overview:"Oversigt",users:"Brugere",orgs:"Organisationer",server:"API-server",pocketbase:"PocketBase",webapp:"Webapp",plugins:"Plugins",api:"API"},zl={title:"Log ind",subtitle:"Superadmin-konsol til DriverVault API-serveren.",email:"E-mail",password:"Adgangskode",submit:"Log ind",submitting:"Logger ind…",invalid:"Ugyldig e-mail eller adgangskode.",failed:"Kunne ikke logge ind.",authNote:"Godkender mod PocketBase gennem denne server"},Il={title:"Status",unreachable:"utilgængelig",checking:"Tjekker…",apiServer:"API-server",pocketBase:"PocketBase",webApp:"Webapp",thisProcess:"denne proces"},jl={create:"Opret",save:"Gem",cancel:"Annuller",edit:"Rediger",delete:"Slet",rename:"Omdøb",empty:"—",name:"Navn",email:"E-mail",role:"Rolle",organization:"Organisation"},Ul={title:"Brugere",allOrgs:"Alle organisationer",yourOrg:"Din organisation",newUser:"Ny bruger",password:"Adgangskode",passwordUnchanged:"Adgangskode (tom = uændret)",passwordPlaceholder:"mindst 8 tegn",orgNone:"— ingen —",you:"dig",empty:"Ingen brugere.",confirmDelete:"Slet {email}? Dette kan ikke fortrydes."},Nl={title:"Organisationer",subtitle:"Enheder, som brugere tilhører",subtitleOwn:"Din organisation",subtitleNone:"Opret en for at administrere dit eget team",newOrg:"Ny organisation",namePlaceholder:"Acme Fleet",empty:"Ingen organisationer endnu.",createHint:"Du har endnu ingen organisation. Opret en for at blive dens administrator.",colId:"ID",confirmDelete:'Slet organisationen "{name}"?',confirmDeleteOwn:"Slet din organisation „{name}“? Du fjernes fra den og bliver en almindelig bruger."},Ml={title:"API-server",subtitle:"Hvordan denne server identificerer sig over for de klienter, der forbinder til den",name:"Servernavn",nameHint:"Vises af klienter, der kan pege på mere end én DriverVault, for eksempel webappens serverskifter. Returneres af /api/health.",saveApply:"Gem og anvend",persistedEnv:"gemt i .env",savedNotice:"Gemt. Det nye navn gælder fra næste anmodning."},Ll={title:"PocketBase",subtitle:"Databaseforbindelse brugt af hvert endpoint",connected:"forbundet",noSuperuser:"ingen superbruger",unreachable:"utilgængelig",baseUrl:"Basis-URL",superuserEmail:"Superbrugerens e-mail",superuserPassword:"Superbrugerens adgangskode",passwordUnchanged:"uændret",passwordNotSet:"ikke angivet",saveApply:"Gem og anvend",testConnection:"Test forbindelse",persistedEnv:"gemt i .env",testOk:"Forbindelse OK — superbruger godkendt.",testReachableNoAuth:"PocketBase er tilgængelig, men servicekontoen blev ikke godkendt.",testUnreachable:"PocketBase er ikke tilgængelig på den adresse.",savedNotice:"Gemt. Serveren bruger nu denne PocketBase."},Fl={title:"Webapp",subtitle:"Adressen, der tjekkes ved statuskontrol, og hvem der må kalde dette API fra en browser",reachable:"tilgængelig",unreachable:"utilgængelig",baseUrl:"Basis-URL",allowedOrigins:"Tilladte oprindelser",originsHint:"Kommasepareret, eller {star} for enhver. Native mobilapps er ikke underlagt CORS.",saveApply:"Gem og anvend",testConnection:"Test forbindelse",persistedEnv:"gemt i .env",testOk:"Webappen er tilgængelig.",testFailed:"Webappen svarede ikke på sit helbredstjek på den adresse.",savedNotice:"Gemt. Nye oprindelser gælder fra næste anmodning."},Bl={title:"Plugins",subtitle:"Integrationer med tredjepartstjenester",categories:{vehicles:"Bilproducenter",chargers:"EV-ladere","apis-external":"Øvrige API'er","drives-external":"Eksterne drev","drives-local":"Lokale drev"},registerExternal:"Registrér ekstern",cancelRegister:"Annuller",name:"Navn",baseUrl:"Basis-URL",provider:"Udbyder",register:"Registrér",empty:"Ingen plugins endnu. Registrér et eksternt ovenfor, eller kompilér et indbygget stik.",builtin:"indbygget",enabled:"aktiveret",disabled:"deaktiveret",health:"Helbred",configure:"Konfigurer",close:"Luk",noConfig:"Dette plugin kræver ingen konfiguration.",notSet:"Ikke angivet — lad organisationer og brugere vælge",capabilities:"Funktioner",saved:"Gemt.",checking:"Tjekker…",save:"Gem",saveEnable:"Gem og aktivér",disable:"Deaktiver",remove:"Fjern",builtinNote:"Indbyggede plugins kan deaktiveres, men ikke fjernes",confirmRemove:'Fjern det eksterne plugin "{name}"? Dets gemte konfiguration slettes.'},Hl={colEndpoint:"Endpoint",colDescription:"Beskrivelse",authNone:"Ingen godkendelse",authBearer:"Bearer-token",authManager:"Admin / superadmin",authSuperadmin:"Superadmin",authCharger:"Laderens loginoplysninger",groupPublic:"Offentlig",groupIdentity:"Identitet",groupCars:"Biler",groupService:"Serviceposter",groupTechnical:"Syn",groupParts:"Reservedele",groupFuel:"Brændstof",groupCharging:"Opladning",groupMaintenance:"Værksted",groupDocuments:"Dokumenter",groupReminders:"Påmindelser",groupAttachments:"Vedhæftede filer",groupProviders:"Køretøjsudbydere",groupIntegrations:"Integrationer",groupOcpp:"OCPP",groupAccount:"Konto",groupManagement:"Administration",groupSuperadmin:"Superadmin"},Vl={app:Dl,sections:Rl,login:zl,status:Il,common:jl,users:Ul,orgs:Nl,server:Ml,pocketbase:Ll,webapp:Fl,plugins:Bl,api:Hl},So="dh-panel-lang",Eo="en",Dt={en:vl,pl:Ol,da:Vl},Gl=Object.keys(Dt);function Kl(){try{const t=localStorage.getItem(So);if(t&&Dt[t])return t}catch{}const e=(navigator.language||"en").split("-")[0];return Dt[e]?e:Eo}const En=H(Kl());function Wl(e){if(Dt[e]){En.value=e;try{localStorage.setItem(So,e)}catch{}}}function hi(e,t){return t.split(".").reduce((s,n)=>s==null?void 0:s[n],e)}function ql(e,t){return t?e.replace(/\{(\w+)\}/g,(s,n)=>t[n]==null?s:String(t[n])):e}function Zl(e,t,s){let n="other";try{n=new Intl.PluralRules(s).select(t)}catch{n=t===1?"one":"other"}return e[n]??e.other??e.one}function f(e,t){const s=En.value;let n=hi(Dt[s],e);if(n==null&&(n=hi(Dt[Eo],e)),n==null)return e;if(typeof n=="object"){if((t==null?void 0:t.n)==null)return e;n=Zl(n,t.n,s)}return typeof n!="string"?e:ql(n,t)}function gi(e,t,s){const i=f(e,{...s,[t]:"\0"}),o=i.indexOf("\0");return o===-1?{before:i,after:""}:{before:i.slice(0,o),after:i.slice(o+1)}}const ln="dh-panel-token";function Jl(){try{return localStorage.getItem(ln)||""}catch{return""}}const Rt=H(Jl()),he=H(null),De=Oe(()=>{var e;return((e=he.value)==null?void 0:e.role)==="superadmin"}),gs=Oe(()=>{var e,t;return((e=he.value)==null?void 0:e.role)==="admin"||((t=he.value)==null?void 0:t.role)==="superadmin"});function Po(e){Rt.value=e;try{e?localStorage.setItem(ln,e):localStorage.removeItem(ln)}catch{}}class Yl extends Error{constructor(t,s,n){super(t),this.status=s,this.body=n}}function Xl(e,t){if(!e||typeof e!="object")return`HTTP ${t}`;if(e.error)return e.error;const s=Object.entries(e.data||{}).map(([n,i])=>`${n}: ${(i==null?void 0:i.message)||i}`).filter(Boolean);return s.length?s.join("; "):e.message||`HTTP ${t}`}async function ae(e,{method:t="GET",body:s,auth:n=!0}={}){const i={};s!==void 0&&(i["Content-Type"]="application/json"),n&&Rt.value&&(i.Authorization=Rt.value);const o=await fetch(e,{method:t,headers:i,body:s===void 0?void 0:JSON.stringify(s)}),r=await o.text();let a=null;try{a=r?JSON.parse(r):null}catch{a=null}if(!o.ok)throw o.status===401&&n&&Pn(),new Yl(Xl(a,o.status),o.status,a);return a}async function Ql(e,t){const s=await ae("/api/auth/login",{method:"POST",body:{email:e,password:t},auth:!1});return Po(s.token),await Ts(),he.value}async function Ts(){return he.value=await ae("/api/identity"),he.value}function Pn(){Po(""),he.value=null}async function ec(){if(!Rt.value)return null;try{return await Ts()}catch{return Pn(),null}}const tc={class:"inline-flex items-center gap-2.5 select-none"},sc={class:"h-full w-auto shrink-0",viewBox:"0 0 48 48",fill:"none","aria-hidden":"true"},nc={transform:"translate(7 0) skewX(-13)"},ic=["fill"],oc=["fill"],rc=["fill"],ac={key:0,class:"text-[1.35em] font-extrabold italic leading-none tracking-[-0.03em]"},lc={__name:"Logo",props:{iconOnly:{type:Boolean,default:!1},onDark:{type:Boolean,default:!1}},setup(e){return(t,s)=>(y(),T("span",tc,[(y(),T("svg",sc,[u("g",nc,[u("rect",{x:"9",y:"16",width:"6",height:"16",rx:"3",fill:e.onDark?"#60A5FA":"var(--brand-700)"},null,8,ic),u("rect",{x:"19",y:"12",width:"6",height:"24",rx:"3",fill:e.onDark?"#93C5FD":"var(--brand-500)"},null,8,oc),u("rect",{x:"29",y:"8",width:"6",height:"32",rx:"3",fill:e.onDark?"#ffffff":"var(--brand-400)"},null,8,rc)])])),e.iconOnly?F("",!0):(y(),T("span",ac,[u("span",{class:Ee(e.onDark?"text-white":"text-strong")},"Driver",2),u("span",{class:Ee(e.onDark?"text-brand-400":"text-brandtext")},"Vault",2)])),s[0]||(s[0]=u("span",{class:"sr-only"},"DriverVault",-1))]))}},cc={class:"mx-auto flex w-full max-w-sm flex-col gap-5 pt-24"},uc={class:"dh-card p-6"},dc={class:"text-lg font-bold tracking-[-0.02em] text-strong"},pc={class:"mt-1 mb-5 text-sm text-body"},fc={class:"dh-label",for:"login-email"},hc={class:"dh-label",for:"login-password"},gc={key:0,class:"rounded-control bg-danger-soft px-3 py-2 text-xs text-danger"},mc=["disabled"],bc={class:"eyebrow text-center"},vc={__name:"LoginView",emits:["authenticated"],setup(e,{emit:t}){const s=t,n=H(""),i=H(""),o=H(""),r=H(!1),a=Oe(()=>n.value.trim()!==""&&i.value!==""&&!r.value);async function l(){if(a.value){o.value="",r.value=!0;try{const g=await Ql(n.value.trim(),i.value);s("authenticated",g)}catch(g){o.value=g.status===400||g.status===404?f("login.invalid"):g.message||f("login.failed"),i.value=""}finally{r.value=!1}}}return(g,d)=>(y(),T("div",cc,[u("div",uc,[u("h1",dc,m(h(f)("login.title")),1),u("p",pc,m(h(f)("login.subtitle")),1),u("form",{class:"flex flex-col gap-4",onSubmit:Xa(l,["prevent"])},[u("div",null,[u("label",fc,m(h(f)("login.email")),1),pe(u("input",{id:"login-email","onUpdate:modelValue":d[0]||(d[0]=b=>n.value=b),class:"dh-input",type:"email",autocomplete:"username",autofocus:"",placeholder:"you@example.com"},null,512),[[be,n.value]])]),u("div",null,[u("label",hc,m(h(f)("login.password")),1),pe(u("input",{id:"login-password","onUpdate:modelValue":d[1]||(d[1]=b=>i.value=b),class:"dh-input",type:"password",autocomplete:"current-password",placeholder:"••••••••"},null,512),[[be,i.value]])]),o.value?(y(),T("p",gc,m(o.value),1)):F("",!0),u("button",{class:"dh-btn w-full",type:"submit",disabled:!a.value},m(r.value?h(f)("login.submitting"):h(f)("login.submit")),9,mc)],32)]),u("p",bc,m(h(f)("login.authNote")),1)]))}},yc={class:"dh-card overflow-hidden"},_c={class:"flex items-center justify-between border-b border-subtle px-5 py-4"},wc={class:"text-base font-bold tracking-[-0.02em] text-strong"},xc={key:0,class:"dh-pill bg-danger-soft text-danger"},kc={key:0,class:"px-5 py-4 text-sm text-danger"},Tc={key:1,class:"w-full text-left text-sm"},Sc={class:"px-5 py-3 font-medium text-strong"},Ec={class:"data px-5 py-3 text-xs text-muted"},Pc={class:"data px-5 py-3 text-right text-xs text-muted"},Cc={class:"px-5 py-3 text-right"},Ac={key:2,class:"px-5 py-4 text-sm text-muted"},$c={__name:"StatusCard",setup(e){const t=H(null),s=H("");let n=null;async function i(){try{t.value=await ae("/api/status",{auth:!1}),s.value=""}catch(a){t.value=null,s.value=a.message||f("status.unreachable")}}const o=Oe(()=>{var a,l,g;return[{key:"apiServer",label:f("status.apiServer"),h:(a=t.value)==null?void 0:a.apiServer},{key:"pocketBase",label:f("status.pocketBase"),h:(l=t.value)==null?void 0:l.pocketBase},{key:"webApp",label:f("status.webApp"),h:(g=t.value)==null?void 0:g.webApp}]});lt(()=>{i(),n=setInterval(i,1e4)}),kn(()=>clearInterval(n));const r=a=>a==="ok"?"bg-success-soft text-success":"bg-danger-soft text-danger";return(a,l)=>(y(),T("div",yc,[u("div",_c,[u("div",wc,m(h(f)("status.title")),1),s.value?(y(),T("span",xc,[l[0]||(l[0]=u("span",{class:"h-1.5 w-1.5 rounded-full bg-current"},null,-1)),$e(m(h(f)("status.unreachable")),1)])):F("",!0)]),s.value?(y(),T("div",kc,m(s.value),1)):t.value?(y(),T("table",Tc,[u("tbody",null,[(y(!0),T(re,null,Ce(o.value,g=>(y(),T("tr",{key:g.key,class:"border-t border-subtle first:border-t-0"},[u("td",Sc,m(g.label),1),u("td",Ec,m(g.h.url||h(f)("status.thisProcess")),1),u("td",Pc,m(g.h.latencyMs!=null?g.h.latencyMs+"ms":h(f)("common.empty")),1),u("td",Cc,[u("span",{class:Ee(["dh-pill",r(g.h.status)])},[l[1]||(l[1]=u("span",{class:"h-1.5 w-1.5 rounded-full bg-current"},null,-1)),$e(m(g.h.status),1)],2)])]))),128))])])):(y(),T("div",Ac,m(h(f)("status.checking")),1))]))}},Oc={class:"dh-card overflow-hidden"},Dc={class:"flex items-center justify-between border-b border-subtle px-5 py-4"},Rc={class:"text-base font-bold tracking-[-0.02em] text-strong"},zc={class:"mt-0.5 text-xs text-muted"},Ic={class:"flex flex-col gap-4 px-5 py-4"},jc={class:"dh-label",for:"pb-url"},Uc={class:"grid gap-4 sm:grid-cols-2"},Nc={class:"dh-label",for:"pb-email"},Mc={class:"dh-label",for:"pb-password"},Lc=["placeholder"],Fc={key:0,class:"data text-xs text-muted"},Bc={key:1,class:"rounded-control bg-info-soft px-3 py-2 text-xs text-info"},Hc={key:2,class:"rounded-control bg-danger-soft px-3 py-2 text-xs text-danger"},Vc={class:"flex items-center gap-2"},Gc=["disabled"],Kc=["disabled"],Wc={class:"eyebrow"},qc={__name:"PocketBaseCard",setup(e){const t=H(null),s=H({url:"",adminEmail:"",adminPassword:""}),n=H(null),i=H(""),o=H(""),r=H(!1);async function a(){try{t.value=await ae("/api/admin/pb-config"),s.value={url:t.value.url,adminEmail:t.value.adminEmail,adminPassword:""},n.value=t.value.probe}catch(d){i.value=d.message}}lt(a);async function l(){i.value="",o.value="",r.value=!0;try{n.value=await ae("/api/admin/pb-config/test",{method:"POST",body:s.value}),o.value=n.value.superuser?f("pocketbase.testOk"):n.value.reachable?f("pocketbase.testReachableNoAuth"):f("pocketbase.testUnreachable")}catch(d){i.value=d.message}finally{r.value=!1}}async function g(){i.value="",o.value="",r.value=!0;try{const d=await ae("/api/admin/pb-config",{method:"PUT",body:s.value});t.value=d.config,n.value=d.config.probe,s.value.adminPassword="",o.value=d.warning||f("pocketbase.savedNotice")}catch(d){i.value=d.message}finally{r.value=!1}}return(d,b)=>{var E,A;return y(),T("div",Oc,[u("div",Dc,[u("div",null,[u("div",Rc,m(h(f)("pocketbase.title")),1),u("p",zc,m(h(f)("pocketbase.subtitle")),1)]),n.value?(y(),T("span",{key:0,class:Ee(["dh-pill",n.value.superuser?"bg-success-soft text-success":n.value.reachable?"bg-warning-soft text-warning":"bg-danger-soft text-danger"])},[b[3]||(b[3]=u("span",{class:"h-1.5 w-1.5 rounded-full bg-current"},null,-1)),$e(" "+m(n.value.superuser?h(f)("pocketbase.connected"):n.value.reachable?h(f)("pocketbase.noSuperuser"):h(f)("pocketbase.unreachable")),1)],2)):F("",!0)]),u("div",Ic,[u("div",null,[u("label",jc,m(h(f)("pocketbase.baseUrl")),1),pe(u("input",{id:"pb-url","onUpdate:modelValue":b[0]||(b[0]=V=>s.value.url=V),class:"dh-input",placeholder:"http://10.2.1.10:8027"},null,512),[[be,s.value.url]])]),u("div",Uc,[u("div",null,[u("label",Nc,m(h(f)("pocketbase.superuserEmail")),1),pe(u("input",{id:"pb-email","onUpdate:modelValue":b[1]||(b[1]=V=>s.value.adminEmail=V),class:"dh-input",autocomplete:"off"},null,512),[[be,s.value.adminEmail]])]),u("div",null,[u("label",Mc,m(h(f)("pocketbase.superuserPassword")),1),pe(u("input",{id:"pb-password","onUpdate:modelValue":b[2]||(b[2]=V=>s.value.adminPassword=V),class:"dh-input",type:"password",autocomplete:"new-password",placeholder:(E=t.value)!=null&&E.adminConfigured?h(f)("pocketbase.passwordUnchanged"):h(f)("pocketbase.passwordNotSet")},null,8,Lc),[[be,s.value.adminPassword]])])]),(A=n.value)!=null&&A.detail?(y(),T("p",Fc,m(n.value.detail),1)):F("",!0),o.value?(y(),T("p",Bc,m(o.value),1)):F("",!0),i.value?(y(),T("p",Hc,m(i.value),1)):F("",!0),u("div",Vc,[u("button",{class:"dh-btn",disabled:r.value,onClick:g},m(h(f)("pocketbase.saveApply")),9,Gc),u("button",{class:"dh-btn-ghost",disabled:r.value,onClick:l},m(h(f)("pocketbase.testConnection")),9,Kc),b[4]||(b[4]=u("span",{class:"flex-1"},null,-1)),u("span",Wc,m(h(f)("pocketbase.persistedEnv")),1)])])])}}},Zc={class:"dh-card overflow-hidden"},Jc={class:"border-b border-subtle px-5 py-4"},Yc={class:"text-base font-bold tracking-[-0.02em] text-strong"},Xc={class:"mt-0.5 text-xs text-muted"},Qc={class:"flex flex-col gap-4 px-5 py-4"},eu={class:"dh-label",for:"server-name"},tu={class:"mt-1 text-xs text-muted"},su={key:0,class:"rounded-control bg-info-soft px-3 py-2 text-xs text-info"},nu={key:1,class:"rounded-control bg-danger-soft px-3 py-2 text-xs text-danger"},iu={class:"flex items-center gap-2"},ou=["disabled"],ru={class:"eyebrow"},au={__name:"ServerCard",setup(e){const t=H({name:""}),s=H(""),n=H(""),i=H(!1);async function o(){try{const a=await ae("/api/admin/server-config");t.value={name:a.name}}catch(a){s.value=a.message}}lt(o);async function r(){s.value="",n.value="",i.value=!0;try{const a=await ae("/api/admin/server-config",{method:"PUT",body:{name:t.value.name}});t.value={name:a.config.name},n.value=a.warning||f("server.savedNotice")}catch(a){s.value=a.message}finally{i.value=!1}}return(a,l)=>(y(),T("div",Zc,[u("div",Jc,[u("div",Yc,m(h(f)("server.title")),1),u("p",Xc,m(h(f)("server.subtitle")),1)]),u("div",Qc,[u("div",null,[u("label",eu,m(h(f)("server.name")),1),pe(u("input",{id:"server-name","onUpdate:modelValue":l[0]||(l[0]=g=>t.value.name=g),class:"dh-input",maxlength:"64",placeholder:"DriverVault API Server",onKeyup:xo(r,["enter"])},null,544),[[be,t.value.name]]),u("p",tu,m(h(f)("server.nameHint")),1)]),n.value?(y(),T("p",su,m(n.value),1)):F("",!0),s.value?(y(),T("p",nu,m(s.value),1)):F("",!0),u("div",iu,[u("button",{class:"dh-btn",disabled:i.value,onClick:r},m(h(f)("server.saveApply")),9,ou),l[1]||(l[1]=u("span",{class:"flex-1"},null,-1)),u("span",ru,m(h(f)("server.persistedEnv")),1)])])]))}},lu={class:"dh-card overflow-hidden"},cu={class:"flex items-center justify-between border-b border-subtle px-5 py-4"},uu={class:"text-base font-bold tracking-[-0.02em] text-strong"},du={class:"mt-0.5 text-xs text-muted"},pu={class:"flex flex-col gap-4 px-5 py-4"},fu={class:"dh-label",for:"web-url"},hu={class:"dh-label",for:"web-origins"},gu={class:"mt-1 text-xs text-muted"},mu={key:0,class:"data text-xs text-muted"},bu={key:1,class:"rounded-control bg-info-soft px-3 py-2 text-xs text-info"},vu={key:2,class:"rounded-control bg-danger-soft px-3 py-2 text-xs text-danger"},yu={class:"flex items-center gap-2"},_u=["disabled"],wu=["disabled"],xu={class:"eyebrow"},ku={__name:"WebAppCard",setup(e){const t=H(null),s=H({url:"",origins:""}),n=H(null),i=H(""),o=H(""),r=H(!1),a=E=>E.split(",").map(A=>A.trim()).filter(Boolean);function l(E){t.value=E,s.value={url:E.url,origins:(E.allowOrigins||[]).join(", ")},n.value=E.probe}async function g(){try{l(await ae("/api/admin/webapp-config"))}catch(E){i.value=E.message}}lt(g);async function d(){i.value="",o.value="",r.value=!0;try{n.value=await ae("/api/admin/webapp-config/test",{method:"POST",body:{url:s.value.url}}),o.value=n.value.status==="ok"?f("webapp.testOk"):f("webapp.testFailed")}catch(E){i.value=E.message}finally{r.value=!1}}async function b(){i.value="",o.value="",r.value=!0;try{const E=await ae("/api/admin/webapp-config",{method:"PUT",body:{url:s.value.url,allowOrigins:a(s.value.origins)}});l(E.config),o.value=E.warning||f("webapp.savedNotice")}catch(E){i.value=E.message}finally{r.value=!1}}return(E,A)=>{var V;return y(),T("div",lu,[u("div",cu,[u("div",null,[u("div",uu,m(h(f)("webapp.title")),1),u("p",du,m(h(f)("webapp.subtitle")),1)]),n.value?(y(),T("span",{key:0,class:Ee(["dh-pill",n.value.status==="ok"?"bg-success-soft text-success":"bg-danger-soft text-danger"])},[A[2]||(A[2]=u("span",{class:"h-1.5 w-1.5 rounded-full bg-current"},null,-1)),$e(" "+m(n.value.status==="ok"?h(f)("webapp.reachable"):h(f)("webapp.unreachable")),1)],2)):F("",!0)]),u("div",pu,[u("div",null,[u("label",fu,m(h(f)("webapp.baseUrl")),1),pe(u("input",{id:"web-url","onUpdate:modelValue":A[0]||(A[0]=x=>s.value.url=x),class:"dh-input",placeholder:"http://localhost:8090"},null,512),[[be,s.value.url]])]),u("div",null,[u("label",hu,m(h(f)("webapp.allowedOrigins")),1),pe(u("input",{id:"web-origins","onUpdate:modelValue":A[1]||(A[1]=x=>s.value.origins=x),class:"dh-input",placeholder:"http://localhost:8090, https://app.example.com"},null,512),[[be,s.value.origins]]),u("p",gu,[$e(m(h(gi)("webapp.originsHint","star").before),1),A[3]||(A[3]=u("span",{class:"data"},"*",-1)),$e(m(h(gi)("webapp.originsHint","star").after),1)])]),(V=n.value)!=null&&V.error?(y(),T("p",mu,m(n.value.error),1)):F("",!0),o.value?(y(),T("p",bu,m(o.value),1)):F("",!0),i.value?(y(),T("p",vu,m(i.value),1)):F("",!0),u("div",yu,[u("button",{class:"dh-btn",disabled:r.value,onClick:b},m(h(f)("webapp.saveApply")),9,_u),u("button",{class:"dh-btn-ghost",disabled:r.value,onClick:d},m(h(f)("webapp.testConnection")),9,wu),A[4]||(A[4]=u("span",{class:"flex-1"},null,-1)),u("span",xu,m(h(f)("webapp.persistedEnv")),1)])])])}}},Tu={class:"dh-card overflow-hidden"},Su={class:"flex items-center justify-between border-b border-subtle px-5 py-4"},Eu={class:"text-base font-bold tracking-[-0.02em] text-strong"},Pu={class:"mt-0.5 text-xs text-muted"},Cu={key:0,class:"border-b border-subtle bg-sunken px-5 py-4"},Au={class:"grid gap-3 sm:grid-cols-3"},$u={class:"dh-label"},Ou={class:"dh-label"},Du={class:"dh-label"},Ru=["disabled"],zu={key:1,class:"border-b border-subtle px-5 py-3 text-xs text-danger"},Iu={key:2,class:"px-5 py-6 text-center text-sm text-muted"},ju={key:3,class:"flex flex-wrap gap-1.5 border-b border-subtle px-5 py-3"},Uu=["onClick"],Nu={class:"dh-pill bg-sunken text-muted"},Mu={class:"flex items-center gap-3 px-5 py-3"},Lu=["onClick"],Fu={class:"font-semibold text-strong"},Bu={class:"dh-pill bg-sunken text-muted"},Hu={key:0,class:"text-xs text-muted"},Vu=["disabled","onClick"],Gu=["disabled","onClick"],Ku={key:0,class:"bg-sunken px-5 py-4"},Wu={key:0,class:"data mb-3 text-xs text-muted"},qu={key:1,class:"grid gap-3 sm:grid-cols-2"},Zu={class:"dh-label"},Ju={key:0,class:"text-danger"},Yu=["onUpdate:modelValue"],Xu={key:0,value:""},Qu=["value"],ed=["onUpdate:modelValue","type","placeholder"],td={key:2,class:"mt-1 text-xs text-muted"},sd={key:2,class:"text-xs text-muted"},nd={key:3,class:"mt-4"},id={class:"eyebrow mb-1.5"},od={class:"data flex flex-col gap-1 text-xs text-muted"},rd={class:"text-strong"},ad={key:0},ld={key:1},cd={key:4,class:"data mt-3 text-xs text-body"},ud={class:"mt-4 flex items-center gap-2"},dd=["disabled","onClick"],pd=["disabled","onClick"],fd=["disabled","onClick"],hd={key:5,class:"eyebrow mt-2"},gd={__name:"PluginsCard",setup(e){const t=H([]),s=H(""),n=H(!1),i=H(null),o=Yt({}),r=Yt({}),a=H(!1),l=H({name:"",baseURL:"",provider:""}),g=H(""),d=["vehicles","chargers","apis-external","drives-external","drives-local"],b=z=>d.includes(z.category)?z.category:"apis-external",E=Oe(()=>{var K;const z={};for(const P of t.value)(z[K=b(P)]||(z[K]=[])).push(P);return d.filter(P=>z[P]).map(P=>({id:P,label:f(`plugins.categories.${P}`),plugins:z[P]}))}),A=Oe(()=>E.value.find(z=>z.id===g.value)||E.value[0]),V=Oe(()=>{var z;return((z=A.value)==null?void 0:z.plugins)||[]});async function x(){try{const z=await ae("/api/admin/plugins");t.value=z.plugins||[],s.value=""}catch(z){s.value=z.message}}lt(x);function N(z){var P;if(i.value===z.name){i.value=null;return}const K={};for(const I of z.configFields||[])K[I.key]=((P=z.config)==null?void 0:P[I.key])??"";o[z.name]=K,i.value=z.name}async function R(z,K){n.value=!0,r[z.name]="";try{const P=await ae(`/api/admin/plugins/${encodeURIComponent(z.name)}`,{method:"PUT",body:{enabled:K,config:o[z.name]??{}}});r[z.name]=P.warning||f("plugins.saved"),await x()}catch(P){r[z.name]=P.message}finally{n.value=!1}}async function G(z){n.value=!0,r[z.name]=f("plugins.checking");try{const K=await ae(`/api/admin/plugins/${encodeURIComponent(z.name)}/health`,{method:"POST"});r[z.name]=`${K.health.status}${K.health.detail?" — "+K.health.detail:""}`,await x()}catch(K){r[z.name]=K.message}finally{n.value=!1}}async function Z(z){if(confirm(f("plugins.confirmRemove",{name:z.name}))){n.value=!0;try{await ae(`/api/admin/plugins/${encodeURIComponent(z.name)}`,{method:"DELETE"}),i.value===z.name&&(i.value=null),await x()}catch(K){r[z.name]=K.message}finally{n.value=!1}}}async function U(){n.value=!0,s.value="";try{await ae("/api/admin/plugins",{method:"POST",body:l.value}),l.value={name:"",baseURL:"",provider:""},a.value=!1,await x()}catch(z){s.value=z.message}finally{n.value=!1}}const le=z=>z==="ok"?"bg-success-soft text-success":z==="degraded"?"bg-warning-soft text-warning":"bg-danger-soft text-danger";return(z,K)=>(y(),T("div",Tu,[u("div",Su,[u("div",null,[u("div",Eu,m(h(f)("plugins.title")),1),u("p",Pu,m(h(f)("plugins.subtitle")),1)]),u("button",{class:"dh-btn-ghost",onClick:K[0]||(K[0]=P=>a.value=!a.value)},m(a.value?h(f)("plugins.cancelRegister"):h(f)("plugins.registerExternal")),1)]),a.value?(y(),T("div",Cu,[u("div",Au,[u("div",null,[u("label",$u,m(h(f)("plugins.name")),1),pe(u("input",{"onUpdate:modelValue":K[1]||(K[1]=P=>l.value.name=P),class:"dh-input",placeholder:"acme-parts"},null,512),[[be,l.value.name]])]),u("div",null,[u("label",Ou,m(h(f)("plugins.baseUrl")),1),pe(u("input",{"onUpdate:modelValue":K[2]||(K[2]=P=>l.value.baseURL=P),class:"dh-input",placeholder:"http://127.0.0.1:9100"},null,512),[[be,l.value.baseURL]])]),u("div",null,[u("label",Du,m(h(f)("plugins.provider")),1),pe(u("input",{"onUpdate:modelValue":K[3]||(K[3]=P=>l.value.provider=P),class:"dh-input",placeholder:"ACME Corp"},null,512),[[be,l.value.provider]])])]),u("button",{class:"dh-btn mt-3",disabled:n.value||!l.value.name||!l.value.baseURL,onClick:U},m(h(f)("plugins.register")),9,Ru)])):F("",!0),s.value?(y(),T("p",zu,m(s.value),1)):F("",!0),t.value.length?F("",!0):(y(),T("p",Iu,m(h(f)("plugins.empty")),1)),E.value.length>1?(y(),T("nav",ju,[(y(!0),T(re,null,Ce(E.value,P=>{var I;return y(),T("button",{key:P.id,class:Ee(["dh-btn-ghost",((I=A.value)==null?void 0:I.id)===P.id?"border-accent text-brandtext":""]),onClick:J=>g.value=P.id},[$e(m(P.label)+" ",1),u("span",Nu,m(P.plugins.length),1)],10,Uu)}),128))])):F("",!0),(y(!0),T(re,null,Ce(V.value,P=>(y(),T("div",{key:P.name,class:"border-t border-subtle first:border-t-0"},[u("div",Mu,[u("button",{class:"flex flex-1 items-center gap-3 text-left",onClick:I=>N(P)},[u("span",Fu,m(P.name),1),u("span",Bu,m(P.kind||h(f)("plugins.builtin")),1),P.provider?(y(),T("span",Hu,m(P.provider),1)):F("",!0),P.health?(y(),T("span",{key:1,class:Ee(["dh-pill",le(P.health.status)])},[K[4]||(K[4]=u("span",{class:"h-1.5 w-1.5 rounded-full bg-current"},null,-1)),$e(m(P.health.status),1)],2)):F("",!0)],8,Lu),u("span",{class:Ee(["dh-pill",P.enabled?"bg-success-soft text-success":"bg-sunken text-muted"])},m(P.enabled?h(f)("plugins.enabled"):h(f)("plugins.disabled")),3),u("button",{class:"dh-btn-ghost",disabled:n.value,onClick:I=>G(P)},m(h(f)("plugins.health")),9,Vu),u("button",{class:"dh-btn-ghost",disabled:n.value,onClick:I=>N(P)},m(i.value===P.name?h(f)("plugins.close"):h(f)("plugins.configure")),9,Gu)]),i.value===P.name?(y(),T("div",Ku,[P.baseURL?(y(),T("div",Wu,m(P.baseURL),1)):F("",!0),(P.configFields||[]).length?(y(),T("div",qu,[(y(!0),T(re,null,Ce(P.configFields,I=>(y(),T("div",{key:I.key},[u("label",Zu,[$e(m(I.label||I.key),1),I.required?(y(),T("span",Ju," *")):F("",!0)]),I.type==="select"?pe((y(),T("select",{key:0,"onUpdate:modelValue":J=>o[P.name][I.key]=J,class:"dh-select"},[I.required?F("",!0):(y(),T("option",Xu,m(h(f)("plugins.notSet")),1)),(y(!0),T(re,null,Ce(I.options||[],J=>(y(),T("option",{key:J.value,value:J.value},m(J.label||J.value),9,Qu))),128))],8,Yu)),[[ks,o[P.name][I.key]]]):pe((y(),T("input",{key:1,"onUpdate:modelValue":J=>o[P.name][I.key]=J,class:"dh-input",type:I.type==="password"?"password":I.type==="number"?"number":"text",placeholder:I.default||"",autocomplete:"off"},null,8,ed)),[[qa,o[P.name][I.key]]]),I.help?(y(),T("p",td,m(I.help),1)):F("",!0)]))),128))])):(y(),T("p",sd,m(h(f)("plugins.noConfig")),1)),(P.capabilities||[]).length?(y(),T("div",nd,[u("div",id,m(h(f)("plugins.capabilities")),1),u("ul",od,[(y(!0),T(re,null,Ce(P.capabilities,I=>(y(),T("li",{key:I.id},[u("span",rd,m(I.id),1),I.method||I.endpoint?(y(),T("span",ad," — "+m(I.method)+" "+m(I.endpoint),1)):F("",!0),I.description?(y(),T("span",ld," · "+m(I.description),1)):F("",!0)]))),128))])])):F("",!0),r[P.name]?(y(),T("p",cd,m(r[P.name]),1)):F("",!0),u("div",ud,[u("button",{class:"dh-btn",disabled:n.value,onClick:I=>R(P,!0)},m(P.enabled?h(f)("plugins.save"):h(f)("plugins.saveEnable")),9,dd),P.enabled?(y(),T("button",{key:0,class:"dh-btn-ghost",disabled:n.value,onClick:I=>R(P,!1)},m(h(f)("plugins.disable")),9,pd)):F("",!0),K[5]||(K[5]=u("span",{class:"flex-1"},null,-1)),P.kind==="external"?(y(),T("button",{key:1,class:"dh-btn-danger",disabled:n.value,onClick:I=>Z(P)},m(h(f)("plugins.remove")),9,fd)):F("",!0)]),P.kind!=="external"?(y(),T("p",hd,m(h(f)("plugins.builtinNote")),1)):F("",!0)])):F("",!0)]))),128))]))}},md={class:"dh-card overflow-hidden"},bd={class:"flex items-center justify-between border-b border-subtle px-5 py-4"},vd={class:"text-base font-bold tracking-[-0.02em] text-strong"},yd={class:"mt-0.5 text-xs text-muted"},_d={key:0,class:"border-b border-subtle px-5 py-3 text-xs text-danger"},wd={key:1,class:"border-b border-subtle bg-sunken px-5 py-4"},xd={class:"grid gap-3 sm:grid-cols-2"},kd={class:"dh-label"},Td={class:"dh-label"},Sd={class:"dh-label"},Ed=["placeholder"],Pd={class:"dh-label"},Cd=["value"],Ad={key:0},$d={class:"dh-label"},Od={value:""},Dd=["value"],Rd={class:"mt-3 flex items-center gap-2"},zd=["disabled"],Id=["disabled"],jd={key:2,class:"px-5 py-6 text-center text-sm text-muted"},Ud={key:3,class:"w-full text-left text-sm"},Nd={class:"[&>th]:eyebrow [&>th]:px-5 [&>th]:py-2.5 [&>th]:font-medium"},Md={class:"data px-5 py-2.5 text-xs text-strong"},Ld={key:0,class:"eyebrow ml-1"},Fd={class:"px-5 py-2.5 text-body"},Bd={class:"px-5 py-2.5 text-body"},Hd={class:"px-5 py-2.5"},Vd={class:"px-5 py-2.5 text-right whitespace-nowrap"},Gd=["onClick"],Kd=["disabled","onClick"],Wd={__name:"UsersCard",setup(e){const t=H([]),s=H([]),n=H(""),i=H(!1),o=H(null),r=H({}),a=Oe(()=>De.value?["user","admin","superadmin"]:["user","admin"]);async function l(){try{const[x,N]=await Promise.all([ae("/api/users"),ae("/api/orgs")]);t.value=x.users||[],s.value=N.organizations||[],n.value=""}catch(x){n.value=x.message}}lt(l);function g(){var x;o.value="new",r.value={email:"",name:"",password:"",role:"user",organization:De.value?"":((x=he.value)==null?void 0:x.organization)||""}}function d(x){o.value=x.id,r.value={email:x.email,name:x.name||"",password:"",role:x.role,organization:x.organization||""}}function b(){o.value=null,n.value=""}async function E(){i.value=!0,n.value="";try{if(o.value==="new")await ae("/api/users",{method:"POST",body:r.value});else{const x={...r.value};x.password||delete x.password,await ae(`/api/users/${o.value}`,{method:"PATCH",body:x})}o.value=null,await l()}catch(x){n.value=x.message}finally{i.value=!1}}async function A(x){if(confirm(f("users.confirmDelete",{email:x.email}))){i.value=!0,n.value="";try{await ae(`/api/users/${x.id}`,{method:"DELETE"}),await l()}catch(N){n.value=N.message}finally{i.value=!1}}}const V=x=>x==="superadmin"?"bg-info-soft text-info":x==="admin"?"bg-warning-soft text-warning":"bg-sunken text-muted";return(x,N)=>(y(),T("div",md,[u("div",bd,[u("div",null,[u("div",vd,m(h(f)("users.title")),1),u("p",yd,m(h(De)?h(f)("users.allOrgs"):h(f)("users.yourOrg")),1)]),u("button",{class:"dh-btn",onClick:g},m(h(f)("users.newUser")),1)]),n.value?(y(),T("p",_d,m(n.value),1)):F("",!0),o.value?(y(),T("div",wd,[u("div",xd,[u("div",null,[u("label",kd,m(h(f)("common.email")),1),pe(u("input",{"onUpdate:modelValue":N[0]||(N[0]=R=>r.value.email=R),class:"dh-input",type:"email",autocomplete:"off"},null,512),[[be,r.value.email]])]),u("div",null,[u("label",Td,m(h(f)("common.name")),1),pe(u("input",{"onUpdate:modelValue":N[1]||(N[1]=R=>r.value.name=R),class:"dh-input",autocomplete:"off"},null,512),[[be,r.value.name]])]),u("div",null,[u("label",Sd,m(o.value==="new"?h(f)("users.password"):h(f)("users.passwordUnchanged")),1),pe(u("input",{"onUpdate:modelValue":N[2]||(N[2]=R=>r.value.password=R),class:"dh-input",type:"password",autocomplete:"new-password",placeholder:h(f)("users.passwordPlaceholder")},null,8,Ed),[[be,r.value.password]])]),u("div",null,[u("label",Pd,m(h(f)("common.role")),1),pe(u("select",{"onUpdate:modelValue":N[3]||(N[3]=R=>r.value.role=R),class:"dh-select"},[(y(!0),T(re,null,Ce(a.value,R=>(y(),T("option",{key:R,value:R},m(R),9,Cd))),128))],512),[[ks,r.value.role]])]),h(De)?(y(),T("div",Ad,[u("label",$d,m(h(f)("common.organization")),1),pe(u("select",{"onUpdate:modelValue":N[4]||(N[4]=R=>r.value.organization=R),class:"dh-select"},[u("option",Od,m(h(f)("users.orgNone")),1),(y(!0),T(re,null,Ce(s.value,R=>(y(),T("option",{key:R.id,value:R.id},m(R.name),9,Dd))),128))],512),[[ks,r.value.organization]])])):F("",!0)]),u("div",Rd,[u("button",{class:"dh-btn",disabled:i.value,onClick:E},m(o.value==="new"?h(f)("common.create"):h(f)("common.save")),9,zd),u("button",{class:"dh-btn-ghost",disabled:i.value,onClick:b},m(h(f)("common.cancel")),9,Id)])])):F("",!0),t.value.length?(y(),T("table",Ud,[u("thead",null,[u("tr",Nd,[u("th",null,m(h(f)("common.email")),1),u("th",null,m(h(f)("common.name")),1),u("th",null,m(h(f)("common.organization")),1),u("th",null,m(h(f)("common.role")),1),N[5]||(N[5]=u("th",null,null,-1))])]),u("tbody",null,[(y(!0),T(re,null,Ce(t.value,R=>{var G,Z;return y(),T("tr",{key:R.id,class:"border-t border-subtle transition-colors hover:bg-sunken"},[u("td",Md,[$e(m(R.email)+" ",1),R.id===((G=h(he))==null?void 0:G.id)?(y(),T("span",Ld,m(h(f)("users.you")),1)):F("",!0)]),u("td",Fd,m(R.name||h(f)("common.empty")),1),u("td",Bd,m(R.organizationName||h(f)("common.empty")),1),u("td",Hd,[u("span",{class:Ee(["dh-pill",V(R.role)])},m(R.role),3)]),u("td",Vd,[u("button",{class:"dh-btn-ghost",onClick:U=>d(R)},m(h(f)("common.edit")),9,Gd),R.id!==((Z=h(he))==null?void 0:Z.id)?(y(),T("button",{key:0,class:"dh-btn-danger ml-1.5",disabled:i.value,onClick:U=>A(R)},m(h(f)("common.delete")),9,Kd)):F("",!0)])])}),128))])])):(y(),T("p",jd,m(h(f)("users.empty")),1))]))}},qd={class:"dh-card overflow-hidden"},Zd={class:"flex items-center justify-between border-b border-subtle px-5 py-4"},Jd={class:"text-base font-bold tracking-[-0.02em] text-strong"},Yd={class:"mt-0.5 text-xs text-muted"},Xd={key:0,class:"border-b border-subtle px-5 py-3 text-xs text-danger"},Qd={key:1,class:"border-b border-subtle bg-sunken px-5 py-4"},ep={class:"dh-label"},tp=["placeholder"],sp={class:"mt-3 flex items-center gap-2"},np=["disabled"],ip=["disabled"],op={key:2,class:"px-5 py-6 text-center text-sm text-muted"},rp={key:3,class:"w-full text-left text-sm"},ap={class:"[&>th]:eyebrow [&>th]:px-5 [&>th]:py-2.5 [&>th]:font-medium"},lp={class:"px-5 py-2.5 font-medium text-strong"},cp={class:"data px-5 py-2.5 text-xs text-muted"},up={class:"px-5 py-2.5 text-right whitespace-nowrap"},dp=["onClick"],pp=["disabled","onClick"],fp={__name:"OrgsCard",setup(e){const t=H([]),s=H(""),n=H(!1),i=H(null),o=H(""),r=Oe(()=>{var x;return((x=he.value)==null?void 0:x.organization)||""}),a=Oe(()=>De.value||!r.value);function l(x){return De.value||x.id===r.value}async function g(){if(!gs.value){t.value=[];return}try{const x=await ae("/api/orgs");t.value=x.organizations||[],s.value=""}catch(x){s.value=x.message}}lt(g);function d(){i.value="new",o.value=""}function b(x){i.value=x.id,o.value=x.name}function E(){i.value=null,s.value=""}async function A(){n.value=!0,s.value="";try{i.value==="new"?(await ae("/api/orgs",{method:"POST",body:{name:o.value}}),De.value||await Ts()):await ae(`/api/orgs/${i.value}`,{method:"PATCH",body:{name:o.value}}),i.value=null,await g()}catch(x){s.value=x.message}finally{n.value=!1}}async function V(x){const N=!De.value&&x.id===r.value;if(confirm(f(N?"orgs.confirmDeleteOwn":"orgs.confirmDelete",{name:x.name}))){n.value=!0,s.value="";try{await ae(`/api/orgs/${x.id}`,{method:"DELETE"}),N&&await Ts(),await g()}catch(G){s.value=G.message}finally{n.value=!1}}}return(x,N)=>(y(),T("div",qd,[u("div",Zd,[u("div",null,[u("div",Jd,m(h(De)?h(f)("orgs.title"):h(f)("common.organization")),1),u("p",Yd,m(h(De)?h(f)("orgs.subtitle"):r.value?h(f)("orgs.subtitleOwn"):h(f)("orgs.subtitleNone")),1)]),a.value?(y(),T("button",{key:0,class:"dh-btn",onClick:d},m(h(f)("orgs.newOrg")),1)):F("",!0)]),s.value?(y(),T("p",Xd,m(s.value),1)):F("",!0),i.value?(y(),T("div",Qd,[u("label",ep,m(h(f)("common.name")),1),pe(u("input",{"onUpdate:modelValue":N[0]||(N[0]=R=>o.value=R),class:"dh-input",placeholder:h(f)("orgs.namePlaceholder"),onKeyup:xo(A,["enter"])},null,40,tp),[[be,o.value]]),u("div",sp,[u("button",{class:"dh-btn",disabled:n.value||!o.value.trim(),onClick:A},m(i.value==="new"?h(f)("common.create"):h(f)("common.save")),9,np),u("button",{class:"dh-btn-ghost",disabled:n.value,onClick:E},m(h(f)("common.cancel")),9,ip)])])):F("",!0),t.value.length?(y(),T("table",rp,[u("thead",null,[u("tr",ap,[u("th",null,m(h(f)("common.name")),1),u("th",null,m(h(f)("orgs.colId")),1),N[1]||(N[1]=u("th",null,null,-1))])]),u("tbody",null,[(y(!0),T(re,null,Ce(t.value,R=>(y(),T("tr",{key:R.id,class:"border-t border-subtle transition-colors hover:bg-sunken"},[u("td",lp,m(R.name),1),u("td",cp,m(R.id),1),u("td",up,[l(R)?(y(),T(re,{key:0},[u("button",{class:"dh-btn-ghost",onClick:G=>b(R)},m(h(f)("common.rename")),9,dp),u("button",{class:"dh-btn-danger ml-1.5",disabled:n.value,onClick:G=>V(R)},m(h(f)("common.delete")),9,pp)],64)):F("",!0)])]))),128))])])):(y(),T("p",op,m(a.value&&!h(De)?h(f)("orgs.createHint"):h(f)("orgs.empty")),1))]))}},hp={class:"dh-card overflow-hidden"},gp={class:"border-b border-subtle px-5 py-4"},mp={class:"flex items-center justify-between"},bp={class:"text-base font-bold tracking-[-0.02em] text-strong"},vp={class:"eyebrow"},yp={key:0,class:"mt-1.5 text-xs text-muted"},_p={class:"overflow-x-auto"},wp={class:"w-full text-left text-sm"},xp={class:"[&>th]:eyebrow [&>th]:px-5 [&>th]:py-2.5 [&>th]:font-medium"},kp={class:"data border-t border-subtle px-5 py-2.5 text-xs whitespace-nowrap"},Tp={class:"text-strong"},Sp={class:"border-t border-subtle px-5 py-2.5 text-body"},fe={__name:"EndpointTable",props:{title:String,auth:String,endpoints:Array,note:String},setup(e){const t={GET:"text-success",POST:"text-brandtext",PUT:"text-info",PATCH:"text-warning",DELETE:"text-danger"};return(s,n)=>(y(),T("div",hp,[u("div",gp,[u("div",mp,[u("div",bp,m(e.title),1),u("span",vp,m(e.auth),1)]),e.note?(y(),T("p",yp,m(e.note),1)):F("",!0)]),u("div",_p,[u("table",wp,[u("thead",null,[u("tr",xp,[u("th",null,m(h(f)("api.colEndpoint")),1),u("th",null,m(h(f)("api.colDescription")),1)])]),u("tbody",null,[(y(!0),T(re,null,Ce(e.endpoints,i=>(y(),T("tr",{key:i.method+i.path,class:"transition-colors hover:bg-sunken"},[u("td",kp,[u("span",{class:Ee(["font-semibold",t[i.method]])},m(i.method),3),u("span",Tp,m(i.path),1)]),u("td",Sp,m(i.desc),1)]))),128))])])])]))}},Ep={class:"mx-auto flex max-w-4xl flex-col gap-6 px-6 pt-12 pb-16"},Pp={class:"flex items-center gap-3"},Cp={class:"eyebrow mt-1.5"},Ap={key:0,class:"data hidden text-xs text-muted sm:inline"},$p={key:0},Op={key:1,class:"dh-pill bg-info-soft text-info"},Dp=["value","aria-label"],Rp=["value"],zp=["title"],Ip={key:0,xmlns:"http://www.w3.org/2000/svg",viewBox:"0 0 24 24",fill:"none",stroke:"currentColor","stroke-width":"1.75",class:"h-4 w-4"},jp={key:1,xmlns:"http://www.w3.org/2000/svg",viewBox:"0 0 24 24",fill:"none",stroke:"currentColor","stroke-width":"1.75",class:"h-4 w-4"},Up={key:0,class:"eyebrow py-16 text-center"},Np={class:"flex flex-wrap gap-1.5"},Mp=["onClick"],Lp={key:8,class:"eyebrow text-center"},Fp={class:"eyebrow text-center"},Bp="{records} is any of service-records, technical-checks, maintenance, fuel-entries, charging-sessions, car-documents, parts.",Hp={__name:"App",setup(e){const t=H(!0),s=H("overview");lt(async()=>{await ec(),t.value=!1});const n=Oe(()=>{const P=[{id:"overview",label:f("sections.overview")}];return gs.value&&P.push({id:"users",label:f("sections.users")}),(gs.value||he.value)&&P.push({id:"orgs",label:f("sections.orgs")}),De.value&&P.push({id:"server",label:f("sections.server")},{id:"pocketbase",label:f("sections.pocketbase")},{id:"webapp",label:f("sections.webapp")},{id:"plugins",label:f("sections.plugins")}),P.push({id:"api",label:f("sections.api")}),P}),i=Oe(()=>Gl.map(P=>{let I=P;try{I=new Intl.DisplayNames([P],{type:"language"}).of(P)||P}catch{}return{code:P,label:I.charAt(0).toUpperCase()+I.slice(1)}}));function o(){Pn(),s.value="overview"}const r=[{method:"GET",path:"/api/health",desc:"Liveness probe (no auth)"},{method:"GET",path:"/healthz",desc:"The same probe under the conventional container path"},{method:"GET",path:"/api/status",desc:"Health of PocketBase + Web App"},{method:"POST",path:"/api/auth/login",desc:"Exchange email + password for a PocketBase token"},{method:"GET",path:"/api/auth/validate",desc:"Check whether a token is still valid"}],a=[{method:"GET",path:"/api/auth/me",desc:"Identity of the bearer token"},{method:"GET",path:"/api/identity",desc:"Identity incl. role + organization"}],l=[{method:"GET",path:"/api/cars",desc:"List owned + shared cars"},{method:"POST",path:"/api/cars",desc:"Create a car"},{method:"GET",path:"/api/cars/{id}",desc:"Fetch one car"},{method:"PATCH",path:"/api/cars/{id}",desc:"Update a car"},{method:"PUT",path:"/api/cars/{id}/view",desc:"Save the car's layout — hidden tabs/fields/columns and their order"},{method:"DELETE",path:"/api/cars/{id}",desc:"Delete a car (owner only)"},{method:"GET",path:"/api/cars/{id}/service-records",desc:"A car's service history"},{method:"GET",path:"/api/cars/{id}/technical-checks",desc:"A car's roadworthiness inspections"},{method:"GET",path:"/api/cars/{id}/parts",desc:"A car's parts catalog"},{method:"GET",path:"/api/cars/{id}/fuel-entries",desc:"A car's refuelings"},{method:"GET",path:"/api/cars/{id}/fuel-stats",desc:"Consumption + cost totals from the fuel log"},{method:"GET",path:"/api/cars/{id}/charging-sessions",desc:"A car's charging sessions"},{method:"GET",path:"/api/cars/{id}/charging-stats",desc:"Energy + cost totals from the charging log"},{method:"GET",path:"/api/cars/{id}/maintenance",desc:"A car's maintenance log"},{method:"GET",path:"/api/cars/{id}/documents",desc:"A car's documents"},{method:"GET",path:"/api/cars/{id}/reminders",desc:"A car's reminders"},{method:"GET",path:"/api/cars/{id}/provider",desc:"Live snapshot from the car's connected service"},{method:"POST",path:"/api/cars/{id}/provider",desc:"Link the car to a provider vehicle (an empty provider unlinks)"},{method:"POST",path:"/api/cars/{id}/provider/sync",desc:"Re-apply the provider's data to the car"},{method:"GET",path:"/api/cars/{id}/shares",desc:"Who a car is shared with (owner)"},{method:"POST",path:"/api/cars/{id}/shares",desc:"Share a car by email (owner)"},{method:"DELETE",path:"/api/cars/{id}/shares/{userId}",desc:"Revoke a share (owner)"}],g=[{method:"GET",path:"/api/service-records",desc:"List one car's service records (?car={id})"},{method:"POST",path:"/api/service-records",desc:"Log a service record"},{method:"GET",path:"/api/service-records/{id}",desc:"Fetch one record"},{method:"PATCH",path:"/api/service-records/{id}",desc:"Update a record"},{method:"DELETE",path:"/api/service-records/{id}",desc:"Delete a record"}],d=[{method:"GET",path:"/api/technical-checks",desc:"List one car's inspections (?car={id})"},{method:"POST",path:"/api/technical-checks",desc:"Log an inspection"},{method:"GET",path:"/api/technical-checks/{id}",desc:"Fetch one inspection"},{method:"PATCH",path:"/api/technical-checks/{id}",desc:"Update an inspection"},{method:"DELETE",path:"/api/technical-checks/{id}",desc:"Delete an inspection"}],b=[{method:"GET",path:"/api/parts",desc:"List one car's parts (?car={id})"},{method:"POST",path:"/api/parts",desc:"Add a part"},{method:"GET",path:"/api/parts/{id}",desc:"Fetch one part"},{method:"PATCH",path:"/api/parts/{id}",desc:"Update a part"},{method:"DELETE",path:"/api/parts/{id}",desc:"Delete a part"}],E=[{method:"GET",path:"/api/fuel-entries",desc:"List one car's refuelings (?car={id})"},{method:"POST",path:"/api/fuel-entries",desc:"Log a refueling"},{method:"GET",path:"/api/fuel-entries/{id}",desc:"Fetch one refueling"},{method:"PATCH",path:"/api/fuel-entries/{id}",desc:"Update a refueling"},{method:"DELETE",path:"/api/fuel-entries/{id}",desc:"Delete a refueling"}],A=[{method:"GET",path:"/api/charging-sessions",desc:"List one car's charging sessions (?car={id})"},{method:"POST",path:"/api/charging-sessions",desc:"Log a charging session"},{method:"GET",path:"/api/charging-sessions/{id}",desc:"Fetch one session"},{method:"PATCH",path:"/api/charging-sessions/{id}",desc:"Update a session"},{method:"DELETE",path:"/api/charging-sessions/{id}",desc:"Delete a session"}],V=[{method:"GET",path:"/api/maintenance",desc:"List one car's maintenance jobs (?car={id})"},{method:"POST",path:"/api/maintenance",desc:"Log a maintenance job"},{method:"GET",path:"/api/maintenance/{id}",desc:"Fetch one job"},{method:"PATCH",path:"/api/maintenance/{id}",desc:"Update a job"},{method:"DELETE",path:"/api/maintenance/{id}",desc:"Delete a job"}],x=[{method:"GET",path:"/api/car-documents",desc:"List one car's documents (?car={id})"},{method:"POST",path:"/api/car-documents",desc:"Add a document"},{method:"GET",path:"/api/car-documents/{id}",desc:"Fetch one document"},{method:"PATCH",path:"/api/car-documents/{id}",desc:"Update a document"},{method:"DELETE",path:"/api/car-documents/{id}",desc:"Delete a document"}],N=[{method:"GET",path:"/api/reminders",desc:"List one car's reminders (?car={id})"},{method:"POST",path:"/api/reminders",desc:"Create a reminder"},{method:"GET",path:"/api/reminders/{id}",desc:"Fetch one reminder"},{method:"PATCH",path:"/api/reminders/{id}",desc:"Update a reminder"},{method:"DELETE",path:"/api/reminders/{id}",desc:"Delete a reminder"},{method:"POST",path:"/api/reminders/{id}/complete",desc:"Mark done — a recurring reminder rolls forward from now"}],R=[{method:"POST",path:"/api/{records}/{id}/file",desc:"Attach a file, replacing any previous one (PDF or image, max 10 MB)"},{method:"GET",path:"/api/{records}/{id}/file",desc:"Download the attachment (car access re-checked on every request)"},{method:"DELETE",path:"/api/{records}/{id}/file",desc:"Detach and delete the file"}],G=[{method:"GET",path:"/api/vehicle-providers",desc:"Registered providers and whether the caller can use each one"},{method:"GET",path:"/api/vehicle-providers/{provider}/vehicles",desc:"Vehicles on the caller's provider account, as importable cars"},{method:"POST",path:"/api/vehicle-providers/{provider}/import",desc:"Create a car from a provider vehicle (409 if already imported)"}],Z=[{method:"GET",path:"/api/integrations/toyota",desc:"Resolved Toyota Connected settings (secrets masked)"},{method:"PUT",path:"/api/integrations/toyota",desc:"Save the caller's own layer (user or org scope)"},{method:"POST",path:"/api/integrations/toyota/health",desc:"Live probe with the resolved credentials"},{method:"GET",path:"/api/integrations/toyota/vehicles",desc:"Raw MyToyota vehicle payload"},{method:"GET",path:"/api/integrations/anker-solix",desc:"Resolved Anker Solix settings (secrets masked)"},{method:"PUT",path:"/api/integrations/anker-solix",desc:"Save the caller's own layer (user or org scope)"},{method:"POST",path:"/api/integrations/anker-solix/health",desc:"Live probe with the resolved credentials"},{method:"GET",path:"/api/integrations/anker-solix/chargers",desc:"EV chargers on the linked Anker account"},{method:"GET",path:"/api/integrations/anker-solix/chargers/{sn}/control",desc:"Control mode, token state and live CSMS session for one charger"},{method:"POST",path:"/api/integrations/anker-solix/chargers/{sn}/control/token",desc:"(Re)issue the charger's control token"},{method:"DELETE",path:"/api/integrations/anker-solix/chargers/{sn}/control/token",desc:"Revoke the control token"},{method:"POST",path:"/api/integrations/anker-solix/chargers/{sn}/{action}",desc:"One OCPP command: start, stop, limit, clear-limit, availability, reset, unlock, trigger, config"},{method:"GET",path:"/api/integrations/greencell",desc:"Resolved Greencell settings (secrets masked)"},{method:"PUT",path:"/api/integrations/greencell",desc:"Save the caller's own layer (user or org scope)"},{method:"POST",path:"/api/integrations/greencell/health",desc:"Live probe: connect to the broker and broadcast for devices"},{method:"GET",path:"/api/integrations/greencell/chargers",desc:"Greencell chargers answering on the configured MQTT broker"},{method:"GET",path:"/api/integrations/greencell/chargers/{sn}/state",desc:"Live EVSE state, access level, power and per-phase current/voltage"}],U=[{method:"GET",path:"/ocpp/{serial}",desc:"WebSocket the charger dials out to; OCPP Basic auth with the serial + its control token"}],le=[{method:"GET",path:"/api/me",desc:"Current user profile"},{method:"PATCH",path:"/api/me",desc:"Update profile"},{method:"POST",path:"/api/me/password",desc:"Change password"},{method:"POST",path:"/api/me/avatar",desc:"Upload avatar"},{method:"GET",path:"/api/me/avatar",desc:"Fetch avatar"},{method:"DELETE",path:"/api/me/avatar",desc:"Remove avatar"},{method:"POST",path:"/api/me/verify/request",desc:"Request email verification"},{method:"GET",path:"/api/me/export",desc:"Export your data"},{method:"POST",path:"/api/me/import",desc:"Import data"},{method:"POST",path:"/api/me/delete",desc:"Request account deletion"},{method:"POST",path:"/api/me/delete/cancel",desc:"Cancel deletion request"},{method:"DELETE",path:"/api/me",desc:"Finalize account deletion"}],z=[{method:"GET",path:"/api/users",desc:"List users (scoped by role)"},{method:"POST",path:"/api/users",desc:"Create a user"},{method:"PATCH",path:"/api/users/{id}",desc:"Update email / name / role / org / password"},{method:"DELETE",path:"/api/users/{id}",desc:"Delete a user"},{method:"GET",path:"/api/orgs",desc:"List organizations"},{method:"POST",path:"/api/orgs",desc:"Create an organization (any user without one; creator becomes admin)"},{method:"PATCH",path:"/api/orgs/{id}",desc:"Rename an organization (own org; any as superadmin)"},{method:"DELETE",path:"/api/orgs/{id}",desc:"Delete an organization (own org; any as superadmin)"}],K=[{method:"GET",path:"/api/admin/pb-config",desc:"PocketBase connection + live probe"},{method:"POST",path:"/api/admin/pb-config/test",desc:"Probe a candidate connection"},{method:"PUT",path:"/api/admin/pb-config",desc:"Apply + persist a connection"},{method:"GET",path:"/api/admin/server-config",desc:"This server's display name"},{method:"PUT",path:"/api/admin/server-config",desc:"Rename this server (applied + persisted)"},{method:"GET",path:"/api/admin/webapp-config",desc:"Web App URL + CORS origins, with a live probe"},{method:"POST",path:"/api/admin/webapp-config/test",desc:"Probe a candidate Web App address"},{method:"PUT",path:"/api/admin/webapp-config",desc:"Apply + persist Web App settings"},{method:"GET",path:"/api/admin/plugins",desc:"List plugins (secrets masked)"},{method:"POST",path:"/api/admin/plugins",desc:"Register an external plugin"},{method:"GET",path:"/api/admin/plugins/{name}",desc:"Fetch one plugin"},{method:"PUT",path:"/api/admin/plugins/{name}",desc:"Enable/disable + configure"},{method:"DELETE",path:"/api/admin/plugins/{name}",desc:"Remove an external plugin"},{method:"POST",path:"/api/admin/plugins/{name}/health",desc:"Run a health check now"}];return(P,I)=>(y(),T("div",Ep,[u("div",Pp,[ee(lc,{"on-dark":h(Ct)==="dark",class:"h-7"},null,8,["on-dark"]),u("span",Cp,m(h(f)("app.apiServer")),1),I[4]||(I[4]=u("div",{class:"flex-1"},null,-1)),h(he)?(y(),T("span",Ap,[$e(m(h(he).email),1),h(he).organizationName?(y(),T("span",$p," · "+m(h(he).organizationName),1)):F("",!0)])):F("",!0),h(he)?(y(),T("span",Op,m(h(he).role),1)):F("",!0),u("select",{class:"dh-select !w-auto !py-1 !text-xs",value:h(En),"aria-label":h(f)("sections.overview"),onChange:I[0]||(I[0]=J=>h(Wl)(J.target.value))},[(y(!0),T(re,null,Ce(i.value,J=>(y(),T("option",{key:J.code,value:J.code},m(J.label),9,Rp))),128))],40,Dp),u("button",{class:"dh-btn-ghost",title:h(Ct)==="dark"?h(f)("app.switchToLight"):h(f)("app.switchToDark"),onClick:I[1]||(I[1]=(...J)=>h(fi)&&h(fi)(...J))},[h(Ct)==="dark"?(y(),T("svg",Ip,[...I[2]||(I[2]=[u("circle",{cx:"12",cy:"12",r:"4"},null,-1),u("path",{"stroke-linecap":"round",d:"M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4"},null,-1)])])):(y(),T("svg",jp,[...I[3]||(I[3]=[u("path",{"stroke-linecap":"round","stroke-linejoin":"round",d:"M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8Z"},null,-1)])])),$e(" "+m(h(f)("app.theme")),1)],8,zp),h(Rt)?(y(),T("button",{key:2,class:"dh-btn-ghost",onClick:o},m(h(f)("app.signOut")),1)):F("",!0)]),t.value?(y(),T("p",Up,m(h(f)("app.loading")),1)):h(Rt)?(y(),T(re,{key:2},[u("nav",Np,[(y(!0),T(re,null,Ce(n.value,J=>(y(),T("button",{key:J.id,class:Ee(["dh-btn-ghost",s.value===J.id?"border-accent text-brandtext":""]),onClick:wt=>s.value=J.id},m(J.label),11,Mp))),128))]),s.value==="overview"?(y(),et($c,{key:0})):s.value==="users"?(y(),et(Wd,{key:1})):s.value==="orgs"?(y(),et(fp,{key:2})):s.value==="server"?(y(),et(au,{key:3})):s.value==="pocketbase"?(y(),et(qc,{key:4})):s.value==="webapp"?(y(),et(ku,{key:5})):s.value==="plugins"?(y(),et(gd,{key:6})):s.value==="api"?(y(),T(re,{key:7},[ee(fe,{title:h(f)("api.groupPublic"),auth:h(f)("api.authNone"),endpoints:r},null,8,["title","auth"]),ee(fe,{title:h(f)("api.groupIdentity"),auth:h(f)("api.authBearer"),endpoints:a},null,8,["title","auth"]),ee(fe,{title:h(f)("api.groupCars"),auth:h(f)("api.authBearer"),endpoints:l},null,8,["title","auth"]),ee(fe,{title:h(f)("api.groupService"),auth:h(f)("api.authBearer"),endpoints:g},null,8,["title","auth"]),ee(fe,{title:h(f)("api.groupTechnical"),auth:h(f)("api.authBearer"),endpoints:d},null,8,["title","auth"]),ee(fe,{title:h(f)("api.groupParts"),auth:h(f)("api.authBearer"),endpoints:b},null,8,["title","auth"]),ee(fe,{title:h(f)("api.groupFuel"),auth:h(f)("api.authBearer"),endpoints:E},null,8,["title","auth"]),ee(fe,{title:h(f)("api.groupCharging"),auth:h(f)("api.authBearer"),endpoints:A},null,8,["title","auth"]),ee(fe,{title:h(f)("api.groupMaintenance"),auth:h(f)("api.authBearer"),endpoints:V},null,8,["title","auth"]),ee(fe,{title:h(f)("api.groupDocuments"),auth:h(f)("api.authBearer"),endpoints:x},null,8,["title","auth"]),ee(fe,{title:h(f)("api.groupReminders"),auth:h(f)("api.authBearer"),endpoints:N},null,8,["title","auth"]),ee(fe,{title:h(f)("api.groupAttachments"),auth:h(f)("api.authBearer"),endpoints:R,note:Bp},null,8,["title","auth"]),ee(fe,{title:h(f)("api.groupProviders"),auth:h(f)("api.authBearer"),endpoints:G},null,8,["title","auth"]),ee(fe,{title:h(f)("api.groupIntegrations"),auth:h(f)("api.authBearer"),endpoints:Z},null,8,["title","auth"]),ee(fe,{title:h(f)("api.groupOcpp"),auth:h(f)("api.authCharger"),endpoints:U},null,8,["title","auth"]),ee(fe,{title:h(f)("api.groupAccount"),auth:h(f)("api.authBearer"),endpoints:le},null,8,["title","auth"]),ee(fe,{title:h(f)("api.groupManagement"),auth:h(f)("api.authManager"),endpoints:z},null,8,["title","auth"]),ee(fe,{title:h(f)("api.groupSuperadmin"),auth:h(f)("api.authSuperadmin"),endpoints:K},null,8,["title","auth"])],64)):F("",!0),!h(gs)&&s.value==="overview"?(y(),T("p",Lp,m(h(f)("app.standardUserNote")),1)):F("",!0)],64)):(y(),et(vc,{key:1})),u("p",Fp,m(h(f)("app.footer")),1)]))}};sl(Hp).mount("#app");
+**/let an;const Xn=typeof window<"u"&&window.trustedTypes;if(Xn)try{an=Xn.createPolicy("vue",{createHTML:e=>e})}catch{}const _o=an?e=>an.createHTML(e):e=>e,ka="http://www.w3.org/2000/svg",Ta="http://www.w3.org/1998/Math/MathML",tt=typeof document<"u"?document:null,Qn=tt&&tt.createElement("template"),Sa={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"?tt.createElementNS(ka,e):t==="mathml"?tt.createElementNS(Ta,e):s?tt.createElement(e,{is:s}):tt.createElement(e);return e==="select"&&n&&n.multiple!=null&&i.setAttribute("multiple",n.multiple),i},createText:e=>tt.createTextNode(e),createComment:e=>tt.createComment(e),setText:(e,t)=>{e.nodeValue=t},setElementText:(e,t)=>{e.textContent=t},parentNode:e=>e.parentNode,nextSibling:e=>e.nextSibling,querySelector:e=>tt.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{Qn.innerHTML=_o(n==="svg"?``:n==="mathml"?``:e);const a=Qn.content;if(n==="svg"||n==="mathml"){const l=a.firstChild;for(;l.firstChild;)a.appendChild(l.firstChild);a.removeChild(l)}t.insertBefore(a,s)}return[r?r.nextSibling:t.firstChild,s?s.previousSibling:t.lastChild]}},Ea=Symbol("_vtc");function Pa(e,t,s){const n=e[Ea];n&&(t=(t?[t,...n]:[...n]).join(" ")),t==null?e.removeAttribute("class"):s?e.setAttribute("class",t):e.className=t}const ei=Symbol("_vod"),Ca=Symbol("_vsh"),Aa=Symbol(""),$a=/(?:^|;)\s*display\s*:/;function Oa(e,t,s){const n=e.style,i=ue(s);let o=!1;if(s&&!i){if(t)if(ue(t))for(const r of t.split(";")){const a=r.slice(0,r.indexOf(":")).trim();s[a]==null&&Ft(n,a,"")}else for(const r in t)s[r]==null&&Ft(n,r,"");for(const r in s){r==="display"&&(o=!0);const a=s[r];a!=null?Ra(e,r,!ue(t)&&t?t[r]:void 0,a)||Ft(n,r,a):Ft(n,r,"")}}else if(i){if(t!==s){const r=n[Aa];r&&(s+=";"+r),n.cssText=s,o=$a.test(s)}}else t&&e.removeAttribute("style");ei in e&&(e[ei]=o?n.display:"",e[Ca]&&(n.display="none"))}const ti=/\s*!important$/;function Ft(e,t,s){if(L(s))s.forEach(n=>Ft(e,t,n));else if(s==null&&(s=""),t.startsWith("--"))e.setProperty(t,s);else{const n=Da(e,t);ti.test(s)?e.setProperty(ft(n),s.replace(ti,""),"important"):e[n]=s}}const si=["Webkit","Moz","ms"],qs={};function Da(e,t){const s=qs[t];if(s)return s;let n=je(t);if(n!=="filter"&&n in e)return qs[t]=n;n=_i(n);for(let i=0;iZs||(Ma.then(()=>Zs=0),Zs=Date.now());function Fa(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(),a=[n];for(let l=0;le.charCodeAt(0)===111&&e.charCodeAt(1)===110&&e.charCodeAt(2)>96&&e.charCodeAt(2)<123,Ba=(e,t,s,n,i,o)=>{const r=i==="svg";t==="class"?Pa(e,n,r):t==="style"?Oa(e,s,n):Ss(t)?Es(t)||Ia(e,t,s,n,o):(t[0]==="."?(t=t.slice(1),!0):t[0]==="^"?(t=t.slice(1),!1):Ha(e,t,n,r))?(oi(e,t,n),!e.tagName.includes("-")&&(t==="value"||t==="checked"||t==="selected")&&ii(e,t,n,r,o,t!=="value")):e._isVueCE&&(Va(e,t)||e._def.__asyncLoader&&(/[A-Z]/.test(t)||!ue(n)))?oi(e,je(t),n,o,t):(t==="true-value"?e._trueValue=n:t==="false-value"&&(e._falseValue=n),ii(e,t,n,r))};function Ha(e,t,s,n){if(n)return!!(t==="innerHTML"||t==="textContent"||t in e&&ai(t)&&W(s));if(t==="spellcheck"||t==="draggable"||t==="translate"||t==="autocorrect"||t==="sandbox"&&e.tagName==="IFRAME"||t==="form"||t==="list"&&e.tagName==="INPUT"||t==="type"&&e.tagName==="TEXTAREA")return!1;if(t==="width"||t==="height"){const i=e.tagName;if(i==="IMG"||i==="VIDEO"||i==="CANVAS"||i==="SOURCE")return!1}return ai(t)&&ue(s)?!1:t in e}function Va(e,t){const s=e._def.props;if(!s)return!1;const n=je(t);return Array.isArray(s)?s.some(i=>je(i)===n):Object.keys(s).some(i=>je(i)===n)}const pt=e=>{const t=e.props["onUpdate:modelValue"]||!1;return L(t)?s=>ps(t,s):t};function Ga(e){e.target.composing=!0}function li(e){const t=e.target;t.composing&&(t.composing=!1,t.dispatchEvent(new Event("input")))}const Ie=Symbol("_assign");function ci(e,t,s){return t&&(e=e.trim()),s&&(e=Cs(e)),e}const be={created(e,{modifiers:{lazy:t,trim:s,number:n}},i){e[Ie]=pt(i);const o=n||i.props&&i.props.type==="number";ot(e,t?"change":"input",r=>{r.target.composing||e[Ie](ci(e.value,s,o))}),(s||o)&&ot(e,"change",()=>{e.value=ci(e.value,s,o)}),t||(ot(e,"compositionstart",Ga),ot(e,"compositionend",li),ot(e,"change",li))},mounted(e,{value:t}){e.value=t??""},beforeUpdate(e,{value:t,oldValue:s,modifiers:{lazy:n,trim:i,number:o}},r){if(e[Ie]=pt(r),e.composing)return;const a=(o||e.type==="number")&&!/^0\d/.test(e.value)?Cs(e.value):e.value,l=t??"";if(a===l)return;const g=e.getRootNode();(g instanceof Document||g instanceof ShadowRoot)&&g.activeElement===e&&e.type!=="range"&&(n&&t===s||i&&e.value.trim()===l)||(e.value=l)}},Ka={deep:!0,created(e,t,s){e[Ie]=pt(s),ot(e,"change",()=>{const n=e._modelValue,i=Ot(e),o=e.checked,r=e[Ie];if(L(n)){const a=fn(n,i),l=a!==-1;if(o&&!l)r(n.concat(i));else if(!o&&l){const g=[...n];g.splice(a,1),r(g)}}else if(zt(n)){const a=new Set(n);o?a.add(i):a.delete(i),r(a)}else r(wo(e,o))})},mounted:ui,beforeUpdate(e,t,s){e[Ie]=pt(s),ui(e,t,s)}};function ui(e,{value:t,oldValue:s},n){e._modelValue=t;let i;if(L(t))i=fn(t,n.props.value)>-1;else if(zt(t))i=t.has(n.props.value);else{if(t===s)return;i=ut(t,wo(e,!0))}e.checked!==i&&(e.checked=i)}const Wa={created(e,{value:t},s){e.checked=ut(t,s.props.value),e[Ie]=pt(s),ot(e,"change",()=>{e[Ie](Ot(e))})},beforeUpdate(e,{value:t,oldValue:s},n){e[Ie]=pt(n),t!==s&&(e.checked=ut(t,n.props.value))}},ks={deep:!0,created(e,{value:t,modifiers:{number:s}},n){const i=zt(t);ot(e,"change",()=>{const o=Array.prototype.filter.call(e.options,r=>r.selected).map(r=>s?Cs(Ot(r)):Ot(r));e[Ie](e.multiple?i?new Set(o):o:o[0]),e._assigning=!0,Fi(()=>{e._assigning=!1})}),e[Ie]=pt(n)},mounted(e,{value:t}){di(e,t)},beforeUpdate(e,t,s){e[Ie]=pt(s)},updated(e,{value:t}){e._assigning||di(e,t)}};function di(e,t){const s=e.multiple,n=L(t);if(!(s&&!n&&!zt(t))){for(let i=0,o=e.options.length;iString(g)===String(a)):r.selected=fn(t,a)>-1}else r.selected=t.has(a);else if(ut(Ot(r),t)){e.selectedIndex!==i&&(e.selectedIndex=i);return}}!s&&e.selectedIndex!==-1&&(e.selectedIndex=-1)}}function Ot(e){return"_value"in e?e._value:e.value}function wo(e,t){const s=t?"_trueValue":"_falseValue";return s in e?e[s]:t}const qa={created(e,t,s){ds(e,t,s,null,"created")},mounted(e,t,s){ds(e,t,s,null,"mounted")},beforeUpdate(e,t,s,n){ds(e,t,s,n,"beforeUpdate")},updated(e,t,s,n){ds(e,t,s,n,"updated")}};function Za(e,t){switch(e){case"SELECT":return ks;case"TEXTAREA":return be;default:switch(t){case"checkbox":return Ka;case"radio":return Wa;default:return be}}}function ds(e,t,s,n,i){const r=Za(e.tagName,s.props&&s.props.type)[i];r&&r(e,t,s,n)}const Ja=["ctrl","shift","alt","meta"],Ya={stop:e=>e.stopPropagation(),prevent:e=>e.preventDefault(),self:e=>e.target!==e.currentTarget,ctrl:e=>!e.ctrlKey,shift:e=>!e.shiftKey,alt:e=>!e.altKey,meta:e=>!e.metaKey,left:e=>"button"in e&&e.button!==0,middle:e=>"button"in e&&e.button!==1,right:e=>"button"in e&&e.button!==2,exact:(e,t)=>Ja.some(s=>e[`${s}Key`]&&!t.includes(s))},Xa=(e,t)=>{if(!e)return e;const s=e._withMods||(e._withMods={}),n=t.join(".");return s[n]||(s[n]=((i,...o)=>{for(let r=0;r{const s=e._withKeys||(e._withKeys={}),n=t.join(".");return s[n]||(s[n]=(i=>{if(!("key"in i))return;const o=ft(i.key);if(t.some(r=>r===o||Qa[r]===o))return e(i)}))},el=_e({patchProp:Ba},Sa);let pi;function tl(){return pi||(pi=ia(el))}const sl=((...e)=>{const t=tl().createApp(...e),{mount:s}=t;return t.mount=n=>{const i=il(n);if(!i)return;const o=t._component;!W(o)&&!o.render&&!o.template&&(o.template=i.innerHTML),i.nodeType===1&&(i.textContent="");const r=s(i,!1,nl(i));return i instanceof Element&&(i.removeAttribute("v-cloak"),i.setAttribute("data-v-app","")),r},t});function nl(e){if(e instanceof SVGElement)return"svg";if(typeof MathMLElement=="function"&&e instanceof MathMLElement)return"mathml"}function il(e){return ue(e)?document.querySelector(e):e}const ko="dh-panel-theme";function ol(){var e;try{const t=localStorage.getItem(ko);if(t==="dark"||t==="light")return t}catch{}return(e=window.matchMedia)!=null&&e.call(window,"(prefers-color-scheme: dark)").matches?"dark":"light"}const Ct=H(ol());function To(e){Ct.value=e,document.documentElement.classList.toggle("dark",e==="dark");try{localStorage.setItem(ko,e)}catch{}}function fi(){To(Ct.value==="dark"?"light":"dark")}To(Ct.value);const rl={apiServer:"API server",switchToLight:"Switch to light theme",switchToDark:"Switch to dark theme",theme:"Theme",signOut:"Sign out",loading:"Loading…",standardUserNote:"Signed in as a standard user — management sections need an admin role",footer:"DriverVault — car maintenance & service tracker."},al={overview:"Overview",users:"Users",orgs:"Organizations",server:"API Server",pocketbase:"PocketBase",webapp:"Web App",plugins:"Plugins",api:"API"},ll={title:"Sign in",subtitle:"Superadmin console for the DriverVault API Server.",email:"Email",password:"Password",submit:"Sign in",submitting:"Signing in…",invalid:"Invalid email or password.",failed:"Could not sign in.",authNote:"Authenticates against PocketBase through this server"},cl={title:"Status",unreachable:"unreachable",checking:"Checking…",apiServer:"API Server",pocketBase:"PocketBase",webApp:"Web App",thisProcess:"this process"},ul={create:"Create",save:"Save",cancel:"Cancel",edit:"Edit",delete:"Delete",rename:"Rename",empty:"—",name:"Name",email:"Email",role:"Role",organization:"Organization"},dl={title:"Users",allOrgs:"All organizations",yourOrg:"Your organization",newUser:"New user",password:"Password",passwordUnchanged:"Password (blank = unchanged)",passwordPlaceholder:"min 8 characters",orgNone:"— none —",you:"you",empty:"No users.",confirmDelete:"Delete {email}? This cannot be undone."},pl={title:"Organizations",subtitle:"Tenants users belong to",subtitleOwn:"Your organization",subtitleNone:"Create one to manage your own team",newOrg:"New organization",namePlaceholder:"Acme Fleet",empty:"No organizations yet.",createHint:"You have no organization yet. Create one to become its admin.",colId:"ID",confirmDelete:'Delete the organization "{name}"?',confirmDeleteOwn:'Delete your organization "{name}"? You will be removed from it and become a regular user.'},fl={title:"API Server",subtitle:"How this server identifies itself to the clients that connect to it",name:"Server name",nameHint:"Shown by clients that can be pointed at more than one DriverVault, such as the Web App's server switcher. Reported by /api/health.",saveApply:"Save & apply",persistedEnv:"persisted to .env",savedNotice:"Saved. The new name applies to the next request."},hl={title:"PocketBase",subtitle:"Database connection used by every endpoint",connected:"connected",noSuperuser:"no superuser",unreachable:"unreachable",baseUrl:"Base URL",superuserEmail:"Superuser email",superuserPassword:"Superuser password",passwordUnchanged:"unchanged",passwordNotSet:"not set",saveApply:"Save & apply",testConnection:"Test connection",persistedEnv:"persisted to .env",testOk:"Connection OK — superuser authenticated.",testReachableNoAuth:"PocketBase is reachable, but the service account did not authenticate.",testUnreachable:"PocketBase is not reachable at that address.",savedNotice:"Saved. The server is now using this PocketBase."},gl={title:"Web App",subtitle:"Address probed by the status check, and who may call this API from a browser",reachable:"reachable",unreachable:"unreachable",baseUrl:"Base URL",allowedOrigins:"Allowed origins",originsHint:"Comma separated, or {star} for any. Native mobile apps are not subject to CORS.",saveApply:"Save & apply",testConnection:"Test connection",persistedEnv:"persisted to .env",testOk:"Web App is reachable.",testFailed:"Web App did not answer its health check at that address.",savedNotice:"Saved. New origins apply to the next request."},ml={title:"Plugins",subtitle:"Third-party service integrations",categories:{vehicles:"Car manufacturers",chargers:"EV chargers",notifications:"Notifications","apis-external":"Other APIs","drives-external":"Remote drives","drives-local":"Local drives"},registerExternal:"Register external",cancelRegister:"Cancel",name:"Name",baseUrl:"Base URL",provider:"Provider",register:"Register",empty:"No plugins yet. Register an external one above, or compile a built-in connector.",builtin:"builtin",enabled:"enabled",disabled:"disabled",health:"Health",configure:"Configure",close:"Close",noConfig:"This plugin takes no configuration.",notSet:"Not set — let organizations and users choose",capabilities:"Capabilities",saved:"Saved.",checking:"Checking…",save:"Save",saveEnable:"Save & enable",disable:"Disable",remove:"Remove",builtinNote:"Built-in plugins can be disabled but not removed",confirmRemove:'Remove the external plugin "{name}"? Its saved config is deleted.'},bl={colEndpoint:"Endpoint",colDescription:"Description",authNone:"No auth",authBearer:"Bearer token",authManager:"Admin / superadmin",authSuperadmin:"Superadmin",authCharger:"Charger credentials",groupPublic:"Public",groupIdentity:"Identity",groupCars:"Cars",groupService:"Service records",groupTechnical:"Technical checks",groupParts:"Parts",groupFuel:"Fuel",groupCharging:"Charging",groupMaintenance:"Maintenance",groupDocuments:"Documents",groupReminders:"Reminders",groupAttachments:"Attachments",groupProviders:"Vehicle providers",groupIntegrations:"Integrations",groupOcpp:"OCPP",groupAccount:"Account",groupManagement:"Management",groupSuperadmin:"Superadmin"},vl={app:rl,sections:al,login:ll,status:cl,common:ul,users:dl,orgs:pl,server:fl,pocketbase:hl,webapp:gl,plugins:ml,api:bl},yl={apiServer:"Serwer API",switchToLight:"Przełącz na motyw jasny",switchToDark:"Przełącz na motyw ciemny",theme:"Motyw",signOut:"Wyloguj się",loading:"Ładowanie…",standardUserNote:"Zalogowano jako zwykły użytkownik — sekcje zarządzania wymagają roli administratora",footer:"DriverVault — rejestr serwisu i konserwacji samochodu."},_l={overview:"Przegląd",users:"Użytkownicy",orgs:"Organizacje",server:"Serwer API",pocketbase:"PocketBase",webapp:"Aplikacja webowa",plugins:"Wtyczki",api:"API"},wl={title:"Zaloguj się",subtitle:"Konsola superadministratora serwera API DriverVault.",email:"E-mail",password:"Hasło",submit:"Zaloguj się",submitting:"Logowanie…",invalid:"Nieprawidłowy e-mail lub hasło.",failed:"Nie udało się zalogować.",authNote:"Uwierzytelnia w PocketBase za pośrednictwem tego serwera"},xl={title:"Status",unreachable:"niedostępny",checking:"Sprawdzanie…",apiServer:"Serwer API",pocketBase:"PocketBase",webApp:"Aplikacja webowa",thisProcess:"ten proces"},kl={create:"Utwórz",save:"Zapisz",cancel:"Anuluj",edit:"Edytuj",delete:"Usuń",rename:"Zmień nazwę",empty:"—",name:"Nazwa",email:"E-mail",role:"Rola",organization:"Organizacja"},Tl={title:"Użytkownicy",allOrgs:"Wszystkie organizacje",yourOrg:"Twoja organizacja",newUser:"Nowy użytkownik",password:"Hasło",passwordUnchanged:"Hasło (puste = bez zmian)",passwordPlaceholder:"min. 8 znaków",orgNone:"— brak —",you:"Ty",empty:"Brak użytkowników.",confirmDelete:"Usunąć {email}? Tej operacji nie można cofnąć."},Sl={title:"Organizacje",subtitle:"Podmioty, do których należą użytkownicy",subtitleOwn:"Twoja organizacja",subtitleNone:"Utwórz ją, aby zarządzać własnym zespołem",newOrg:"Nowa organizacja",namePlaceholder:"Acme Fleet",empty:"Brak organizacji.",createHint:"Nie masz jeszcze organizacji. Utwórz ją, aby zostać jej administratorem.",colId:"ID",confirmDelete:"Usunąć organizację „{name}”?",confirmDeleteOwn:"Usunąć Twoją organizację „{name}”? Zostaniesz z niej usunięty i staniesz się zwykłym użytkownikiem."},El={title:"Serwer API",subtitle:"Jak ten serwer przedstawia się klientom, które się z nim łączą",name:"Nazwa serwera",nameHint:"Widoczna w klientach, które można skierować na więcej niż jeden DriverVault, na przykład w przełączniku serwerów aplikacji webowej. Zwracana przez /api/health.",saveApply:"Zapisz i zastosuj",persistedEnv:"zapisano w .env",savedNotice:"Zapisano. Nowa nazwa obowiązuje od następnego żądania."},Pl={title:"PocketBase",subtitle:"Połączenie z bazą danych używane przez każdy punkt końcowy",connected:"połączono",noSuperuser:"brak superużytkownika",unreachable:"niedostępny",baseUrl:"Adres bazowy",superuserEmail:"E-mail superużytkownika",superuserPassword:"Hasło superużytkownika",passwordUnchanged:"bez zmian",passwordNotSet:"nie ustawiono",saveApply:"Zapisz i zastosuj",testConnection:"Testuj połączenie",persistedEnv:"zapisano w .env",testOk:"Połączenie OK — superużytkownik uwierzytelniony.",testReachableNoAuth:"PocketBase jest dostępny, ale konto usługowe nie zostało uwierzytelnione.",testUnreachable:"PocketBase jest niedostępny pod tym adresem.",savedNotice:"Zapisano. Serwer korzysta teraz z tego PocketBase."},Cl={title:"Aplikacja webowa",subtitle:"Adres sprawdzany podczas kontroli statusu oraz kto może wywoływać to API z przeglądarki",reachable:"dostępna",unreachable:"niedostępna",baseUrl:"Adres bazowy",allowedOrigins:"Dozwolone źródła",originsHint:"Oddzielone przecinkami lub {star} dla dowolnego. Natywne aplikacje mobilne nie podlegają CORS.",saveApply:"Zapisz i zastosuj",testConnection:"Testuj połączenie",persistedEnv:"zapisano w .env",testOk:"Aplikacja webowa jest dostępna.",testFailed:"Aplikacja webowa nie odpowiedziała na kontrolę stanu pod tym adresem.",savedNotice:"Zapisano. Nowe źródła obowiązują od następnego żądania."},Al={title:"Wtyczki",subtitle:"Integracje z usługami zewnętrznymi",categories:{vehicles:"Producenci samochodów",chargers:"Ładowarki EV",notifications:"Powiadomienia","apis-external":"Pozostałe API","drives-external":"Dyski zdalne","drives-local":"Dyski lokalne"},registerExternal:"Zarejestruj zewnętrzną",cancelRegister:"Anuluj",name:"Nazwa",baseUrl:"Adres bazowy",provider:"Dostawca",register:"Zarejestruj",empty:"Brak wtyczek. Zarejestruj zewnętrzną powyżej lub skompiluj wbudowany łącznik.",builtin:"wbudowana",enabled:"włączona",disabled:"wyłączona",health:"Stan",configure:"Konfiguruj",close:"Zamknij",noConfig:"Ta wtyczka nie wymaga konfiguracji.",notSet:"Nie ustawiono — pozwól organizacjom i użytkownikom wybrać",capabilities:"Możliwości",saved:"Zapisano.",checking:"Sprawdzanie…",save:"Zapisz",saveEnable:"Zapisz i włącz",disable:"Wyłącz",remove:"Usuń",builtinNote:"Wtyczki wbudowane można wyłączyć, ale nie usunąć",confirmRemove:"Usunąć zewnętrzną wtyczkę „{name}”? Jej zapisana konfiguracja zostanie usunięta."},$l={colEndpoint:"Punkt końcowy",colDescription:"Opis",authNone:"Bez uwierzytelniania",authBearer:"Token Bearer",authManager:"Administrator / superadministrator",authSuperadmin:"Superadministrator",authCharger:"Dane logowania ładowarki",groupPublic:"Publiczne",groupIdentity:"Tożsamość",groupCars:"Samochody",groupService:"Wpisy serwisowe",groupTechnical:"Przeglądy techniczne",groupParts:"Części",groupFuel:"Paliwo",groupCharging:"Ładowanie",groupMaintenance:"Naprawy",groupDocuments:"Dokumenty",groupReminders:"Przypomnienia",groupAttachments:"Załączniki",groupProviders:"Dostawcy pojazdów",groupIntegrations:"Integracje",groupOcpp:"OCPP",groupAccount:"Konto",groupManagement:"Zarządzanie",groupSuperadmin:"Superadministrator"},Ol={app:yl,sections:_l,login:wl,status:xl,common:kl,users:Tl,orgs:Sl,server:El,pocketbase:Pl,webapp:Cl,plugins:Al,api:$l},Dl={apiServer:"API-server",switchToLight:"Skift til lyst tema",switchToDark:"Skift til mørkt tema",theme:"Tema",signOut:"Log ud",loading:"Indlæser…",standardUserNote:"Logget ind som almindelig bruger — administrationssektioner kræver en administratorrolle",footer:"DriverVault — bilservice- og vedligeholdelsesregister."},Rl={overview:"Oversigt",users:"Brugere",orgs:"Organisationer",server:"API-server",pocketbase:"PocketBase",webapp:"Webapp",plugins:"Plugins",api:"API"},zl={title:"Log ind",subtitle:"Superadmin-konsol til DriverVault API-serveren.",email:"E-mail",password:"Adgangskode",submit:"Log ind",submitting:"Logger ind…",invalid:"Ugyldig e-mail eller adgangskode.",failed:"Kunne ikke logge ind.",authNote:"Godkender mod PocketBase gennem denne server"},Il={title:"Status",unreachable:"utilgængelig",checking:"Tjekker…",apiServer:"API-server",pocketBase:"PocketBase",webApp:"Webapp",thisProcess:"denne proces"},jl={create:"Opret",save:"Gem",cancel:"Annuller",edit:"Rediger",delete:"Slet",rename:"Omdøb",empty:"—",name:"Navn",email:"E-mail",role:"Rolle",organization:"Organisation"},Ul={title:"Brugere",allOrgs:"Alle organisationer",yourOrg:"Din organisation",newUser:"Ny bruger",password:"Adgangskode",passwordUnchanged:"Adgangskode (tom = uændret)",passwordPlaceholder:"mindst 8 tegn",orgNone:"— ingen —",you:"dig",empty:"Ingen brugere.",confirmDelete:"Slet {email}? Dette kan ikke fortrydes."},Nl={title:"Organisationer",subtitle:"Enheder, som brugere tilhører",subtitleOwn:"Din organisation",subtitleNone:"Opret en for at administrere dit eget team",newOrg:"Ny organisation",namePlaceholder:"Acme Fleet",empty:"Ingen organisationer endnu.",createHint:"Du har endnu ingen organisation. Opret en for at blive dens administrator.",colId:"ID",confirmDelete:'Slet organisationen "{name}"?',confirmDeleteOwn:"Slet din organisation „{name}“? Du fjernes fra den og bliver en almindelig bruger."},Ml={title:"API-server",subtitle:"Hvordan denne server identificerer sig over for de klienter, der forbinder til den",name:"Servernavn",nameHint:"Vises af klienter, der kan pege på mere end én DriverVault, for eksempel webappens serverskifter. Returneres af /api/health.",saveApply:"Gem og anvend",persistedEnv:"gemt i .env",savedNotice:"Gemt. Det nye navn gælder fra næste anmodning."},Ll={title:"PocketBase",subtitle:"Databaseforbindelse brugt af hvert endpoint",connected:"forbundet",noSuperuser:"ingen superbruger",unreachable:"utilgængelig",baseUrl:"Basis-URL",superuserEmail:"Superbrugerens e-mail",superuserPassword:"Superbrugerens adgangskode",passwordUnchanged:"uændret",passwordNotSet:"ikke angivet",saveApply:"Gem og anvend",testConnection:"Test forbindelse",persistedEnv:"gemt i .env",testOk:"Forbindelse OK — superbruger godkendt.",testReachableNoAuth:"PocketBase er tilgængelig, men servicekontoen blev ikke godkendt.",testUnreachable:"PocketBase er ikke tilgængelig på den adresse.",savedNotice:"Gemt. Serveren bruger nu denne PocketBase."},Fl={title:"Webapp",subtitle:"Adressen, der tjekkes ved statuskontrol, og hvem der må kalde dette API fra en browser",reachable:"tilgængelig",unreachable:"utilgængelig",baseUrl:"Basis-URL",allowedOrigins:"Tilladte oprindelser",originsHint:"Kommasepareret, eller {star} for enhver. Native mobilapps er ikke underlagt CORS.",saveApply:"Gem og anvend",testConnection:"Test forbindelse",persistedEnv:"gemt i .env",testOk:"Webappen er tilgængelig.",testFailed:"Webappen svarede ikke på sit helbredstjek på den adresse.",savedNotice:"Gemt. Nye oprindelser gælder fra næste anmodning."},Bl={title:"Plugins",subtitle:"Integrationer med tredjepartstjenester",categories:{vehicles:"Bilproducenter",chargers:"EV-ladere",notifications:"Notifikationer","apis-external":"Øvrige API'er","drives-external":"Eksterne drev","drives-local":"Lokale drev"},registerExternal:"Registrér ekstern",cancelRegister:"Annuller",name:"Navn",baseUrl:"Basis-URL",provider:"Udbyder",register:"Registrér",empty:"Ingen plugins endnu. Registrér et eksternt ovenfor, eller kompilér et indbygget stik.",builtin:"indbygget",enabled:"aktiveret",disabled:"deaktiveret",health:"Helbred",configure:"Konfigurer",close:"Luk",noConfig:"Dette plugin kræver ingen konfiguration.",notSet:"Ikke angivet — lad organisationer og brugere vælge",capabilities:"Funktioner",saved:"Gemt.",checking:"Tjekker…",save:"Gem",saveEnable:"Gem og aktivér",disable:"Deaktiver",remove:"Fjern",builtinNote:"Indbyggede plugins kan deaktiveres, men ikke fjernes",confirmRemove:'Fjern det eksterne plugin "{name}"? Dets gemte konfiguration slettes.'},Hl={colEndpoint:"Endpoint",colDescription:"Beskrivelse",authNone:"Ingen godkendelse",authBearer:"Bearer-token",authManager:"Admin / superadmin",authSuperadmin:"Superadmin",authCharger:"Laderens loginoplysninger",groupPublic:"Offentlig",groupIdentity:"Identitet",groupCars:"Biler",groupService:"Serviceposter",groupTechnical:"Syn",groupParts:"Reservedele",groupFuel:"Brændstof",groupCharging:"Opladning",groupMaintenance:"Værksted",groupDocuments:"Dokumenter",groupReminders:"Påmindelser",groupAttachments:"Vedhæftede filer",groupProviders:"Køretøjsudbydere",groupIntegrations:"Integrationer",groupOcpp:"OCPP",groupAccount:"Konto",groupManagement:"Administration",groupSuperadmin:"Superadmin"},Vl={app:Dl,sections:Rl,login:zl,status:Il,common:jl,users:Ul,orgs:Nl,server:Ml,pocketbase:Ll,webapp:Fl,plugins:Bl,api:Hl},So="dh-panel-lang",Eo="en",Dt={en:vl,pl:Ol,da:Vl},Gl=Object.keys(Dt);function Kl(){try{const t=localStorage.getItem(So);if(t&&Dt[t])return t}catch{}const e=(navigator.language||"en").split("-")[0];return Dt[e]?e:Eo}const En=H(Kl());function Wl(e){if(Dt[e]){En.value=e;try{localStorage.setItem(So,e)}catch{}}}function hi(e,t){return t.split(".").reduce((s,n)=>s==null?void 0:s[n],e)}function ql(e,t){return t?e.replace(/\{(\w+)\}/g,(s,n)=>t[n]==null?s:String(t[n])):e}function Zl(e,t,s){let n="other";try{n=new Intl.PluralRules(s).select(t)}catch{n=t===1?"one":"other"}return e[n]??e.other??e.one}function f(e,t){const s=En.value;let n=hi(Dt[s],e);if(n==null&&(n=hi(Dt[Eo],e)),n==null)return e;if(typeof n=="object"){if((t==null?void 0:t.n)==null)return e;n=Zl(n,t.n,s)}return typeof n!="string"?e:ql(n,t)}function gi(e,t,s){const i=f(e,{...s,[t]:"\0"}),o=i.indexOf("\0");return o===-1?{before:i,after:""}:{before:i.slice(0,o),after:i.slice(o+1)}}const ln="dh-panel-token";function Jl(){try{return localStorage.getItem(ln)||""}catch{return""}}const Rt=H(Jl()),he=H(null),De=Oe(()=>{var e;return((e=he.value)==null?void 0:e.role)==="superadmin"}),gs=Oe(()=>{var e,t;return((e=he.value)==null?void 0:e.role)==="admin"||((t=he.value)==null?void 0:t.role)==="superadmin"});function Po(e){Rt.value=e;try{e?localStorage.setItem(ln,e):localStorage.removeItem(ln)}catch{}}class Yl extends Error{constructor(t,s,n){super(t),this.status=s,this.body=n}}function Xl(e,t){if(!e||typeof e!="object")return`HTTP ${t}`;if(e.error)return e.error;const s=Object.entries(e.data||{}).map(([n,i])=>`${n}: ${(i==null?void 0:i.message)||i}`).filter(Boolean);return s.length?s.join("; "):e.message||`HTTP ${t}`}async function ae(e,{method:t="GET",body:s,auth:n=!0}={}){const i={};s!==void 0&&(i["Content-Type"]="application/json"),n&&Rt.value&&(i.Authorization=Rt.value);const o=await fetch(e,{method:t,headers:i,body:s===void 0?void 0:JSON.stringify(s)}),r=await o.text();let a=null;try{a=r?JSON.parse(r):null}catch{a=null}if(!o.ok)throw o.status===401&&n&&Pn(),new Yl(Xl(a,o.status),o.status,a);return a}async function Ql(e,t){const s=await ae("/api/auth/login",{method:"POST",body:{email:e,password:t},auth:!1});return Po(s.token),await Ts(),he.value}async function Ts(){return he.value=await ae("/api/identity"),he.value}function Pn(){Po(""),he.value=null}async function ec(){if(!Rt.value)return null;try{return await Ts()}catch{return Pn(),null}}const tc={class:"inline-flex items-center gap-2.5 select-none"},sc={class:"h-full w-auto shrink-0",viewBox:"0 0 48 48",fill:"none","aria-hidden":"true"},nc={transform:"translate(7 0) skewX(-13)"},ic=["fill"],oc=["fill"],rc=["fill"],ac={key:0,class:"text-[1.35em] font-extrabold italic leading-none tracking-[-0.03em]"},lc={__name:"Logo",props:{iconOnly:{type:Boolean,default:!1},onDark:{type:Boolean,default:!1}},setup(e){return(t,s)=>(y(),T("span",tc,[(y(),T("svg",sc,[u("g",nc,[u("rect",{x:"9",y:"16",width:"6",height:"16",rx:"3",fill:e.onDark?"#60A5FA":"var(--brand-700)"},null,8,ic),u("rect",{x:"19",y:"12",width:"6",height:"24",rx:"3",fill:e.onDark?"#93C5FD":"var(--brand-500)"},null,8,oc),u("rect",{x:"29",y:"8",width:"6",height:"32",rx:"3",fill:e.onDark?"#ffffff":"var(--brand-400)"},null,8,rc)])])),e.iconOnly?F("",!0):(y(),T("span",ac,[u("span",{class:Ee(e.onDark?"text-white":"text-strong")},"Driver",2),u("span",{class:Ee(e.onDark?"text-brand-400":"text-brandtext")},"Vault",2)])),s[0]||(s[0]=u("span",{class:"sr-only"},"DriverVault",-1))]))}},cc={class:"mx-auto flex w-full max-w-sm flex-col gap-5 pt-24"},uc={class:"dh-card p-6"},dc={class:"text-lg font-bold tracking-[-0.02em] text-strong"},pc={class:"mt-1 mb-5 text-sm text-body"},fc={class:"dh-label",for:"login-email"},hc={class:"dh-label",for:"login-password"},gc={key:0,class:"rounded-control bg-danger-soft px-3 py-2 text-xs text-danger"},mc=["disabled"],bc={class:"eyebrow text-center"},vc={__name:"LoginView",emits:["authenticated"],setup(e,{emit:t}){const s=t,n=H(""),i=H(""),o=H(""),r=H(!1),a=Oe(()=>n.value.trim()!==""&&i.value!==""&&!r.value);async function l(){if(a.value){o.value="",r.value=!0;try{const g=await Ql(n.value.trim(),i.value);s("authenticated",g)}catch(g){o.value=g.status===400||g.status===404?f("login.invalid"):g.message||f("login.failed"),i.value=""}finally{r.value=!1}}}return(g,d)=>(y(),T("div",cc,[u("div",uc,[u("h1",dc,m(h(f)("login.title")),1),u("p",pc,m(h(f)("login.subtitle")),1),u("form",{class:"flex flex-col gap-4",onSubmit:Xa(l,["prevent"])},[u("div",null,[u("label",fc,m(h(f)("login.email")),1),pe(u("input",{id:"login-email","onUpdate:modelValue":d[0]||(d[0]=b=>n.value=b),class:"dh-input",type:"email",autocomplete:"username",autofocus:"",placeholder:"you@example.com"},null,512),[[be,n.value]])]),u("div",null,[u("label",hc,m(h(f)("login.password")),1),pe(u("input",{id:"login-password","onUpdate:modelValue":d[1]||(d[1]=b=>i.value=b),class:"dh-input",type:"password",autocomplete:"current-password",placeholder:"••••••••"},null,512),[[be,i.value]])]),o.value?(y(),T("p",gc,m(o.value),1)):F("",!0),u("button",{class:"dh-btn w-full",type:"submit",disabled:!a.value},m(r.value?h(f)("login.submitting"):h(f)("login.submit")),9,mc)],32)]),u("p",bc,m(h(f)("login.authNote")),1)]))}},yc={class:"dh-card overflow-hidden"},_c={class:"flex items-center justify-between border-b border-subtle px-5 py-4"},wc={class:"text-base font-bold tracking-[-0.02em] text-strong"},xc={key:0,class:"dh-pill bg-danger-soft text-danger"},kc={key:0,class:"px-5 py-4 text-sm text-danger"},Tc={key:1,class:"w-full text-left text-sm"},Sc={class:"px-5 py-3 font-medium text-strong"},Ec={class:"data px-5 py-3 text-xs text-muted"},Pc={class:"data px-5 py-3 text-right text-xs text-muted"},Cc={class:"px-5 py-3 text-right"},Ac={key:2,class:"px-5 py-4 text-sm text-muted"},$c={__name:"StatusCard",setup(e){const t=H(null),s=H("");let n=null;async function i(){try{t.value=await ae("/api/status",{auth:!1}),s.value=""}catch(a){t.value=null,s.value=a.message||f("status.unreachable")}}const o=Oe(()=>{var a,l,g;return[{key:"apiServer",label:f("status.apiServer"),h:(a=t.value)==null?void 0:a.apiServer},{key:"pocketBase",label:f("status.pocketBase"),h:(l=t.value)==null?void 0:l.pocketBase},{key:"webApp",label:f("status.webApp"),h:(g=t.value)==null?void 0:g.webApp}]});lt(()=>{i(),n=setInterval(i,1e4)}),kn(()=>clearInterval(n));const r=a=>a==="ok"?"bg-success-soft text-success":"bg-danger-soft text-danger";return(a,l)=>(y(),T("div",yc,[u("div",_c,[u("div",wc,m(h(f)("status.title")),1),s.value?(y(),T("span",xc,[l[0]||(l[0]=u("span",{class:"h-1.5 w-1.5 rounded-full bg-current"},null,-1)),$e(m(h(f)("status.unreachable")),1)])):F("",!0)]),s.value?(y(),T("div",kc,m(s.value),1)):t.value?(y(),T("table",Tc,[u("tbody",null,[(y(!0),T(re,null,Ce(o.value,g=>(y(),T("tr",{key:g.key,class:"border-t border-subtle first:border-t-0"},[u("td",Sc,m(g.label),1),u("td",Ec,m(g.h.url||h(f)("status.thisProcess")),1),u("td",Pc,m(g.h.latencyMs!=null?g.h.latencyMs+"ms":h(f)("common.empty")),1),u("td",Cc,[u("span",{class:Ee(["dh-pill",r(g.h.status)])},[l[1]||(l[1]=u("span",{class:"h-1.5 w-1.5 rounded-full bg-current"},null,-1)),$e(m(g.h.status),1)],2)])]))),128))])])):(y(),T("div",Ac,m(h(f)("status.checking")),1))]))}},Oc={class:"dh-card overflow-hidden"},Dc={class:"flex items-center justify-between border-b border-subtle px-5 py-4"},Rc={class:"text-base font-bold tracking-[-0.02em] text-strong"},zc={class:"mt-0.5 text-xs text-muted"},Ic={class:"flex flex-col gap-4 px-5 py-4"},jc={class:"dh-label",for:"pb-url"},Uc={class:"grid gap-4 sm:grid-cols-2"},Nc={class:"dh-label",for:"pb-email"},Mc={class:"dh-label",for:"pb-password"},Lc=["placeholder"],Fc={key:0,class:"data text-xs text-muted"},Bc={key:1,class:"rounded-control bg-info-soft px-3 py-2 text-xs text-info"},Hc={key:2,class:"rounded-control bg-danger-soft px-3 py-2 text-xs text-danger"},Vc={class:"flex items-center gap-2"},Gc=["disabled"],Kc=["disabled"],Wc={class:"eyebrow"},qc={__name:"PocketBaseCard",setup(e){const t=H(null),s=H({url:"",adminEmail:"",adminPassword:""}),n=H(null),i=H(""),o=H(""),r=H(!1);async function a(){try{t.value=await ae("/api/admin/pb-config"),s.value={url:t.value.url,adminEmail:t.value.adminEmail,adminPassword:""},n.value=t.value.probe}catch(d){i.value=d.message}}lt(a);async function l(){i.value="",o.value="",r.value=!0;try{n.value=await ae("/api/admin/pb-config/test",{method:"POST",body:s.value}),o.value=n.value.superuser?f("pocketbase.testOk"):n.value.reachable?f("pocketbase.testReachableNoAuth"):f("pocketbase.testUnreachable")}catch(d){i.value=d.message}finally{r.value=!1}}async function g(){i.value="",o.value="",r.value=!0;try{const d=await ae("/api/admin/pb-config",{method:"PUT",body:s.value});t.value=d.config,n.value=d.config.probe,s.value.adminPassword="",o.value=d.warning||f("pocketbase.savedNotice")}catch(d){i.value=d.message}finally{r.value=!1}}return(d,b)=>{var E,A;return y(),T("div",Oc,[u("div",Dc,[u("div",null,[u("div",Rc,m(h(f)("pocketbase.title")),1),u("p",zc,m(h(f)("pocketbase.subtitle")),1)]),n.value?(y(),T("span",{key:0,class:Ee(["dh-pill",n.value.superuser?"bg-success-soft text-success":n.value.reachable?"bg-warning-soft text-warning":"bg-danger-soft text-danger"])},[b[3]||(b[3]=u("span",{class:"h-1.5 w-1.5 rounded-full bg-current"},null,-1)),$e(" "+m(n.value.superuser?h(f)("pocketbase.connected"):n.value.reachable?h(f)("pocketbase.noSuperuser"):h(f)("pocketbase.unreachable")),1)],2)):F("",!0)]),u("div",Ic,[u("div",null,[u("label",jc,m(h(f)("pocketbase.baseUrl")),1),pe(u("input",{id:"pb-url","onUpdate:modelValue":b[0]||(b[0]=V=>s.value.url=V),class:"dh-input",placeholder:"http://10.2.1.10:8027"},null,512),[[be,s.value.url]])]),u("div",Uc,[u("div",null,[u("label",Nc,m(h(f)("pocketbase.superuserEmail")),1),pe(u("input",{id:"pb-email","onUpdate:modelValue":b[1]||(b[1]=V=>s.value.adminEmail=V),class:"dh-input",autocomplete:"off"},null,512),[[be,s.value.adminEmail]])]),u("div",null,[u("label",Mc,m(h(f)("pocketbase.superuserPassword")),1),pe(u("input",{id:"pb-password","onUpdate:modelValue":b[2]||(b[2]=V=>s.value.adminPassword=V),class:"dh-input",type:"password",autocomplete:"new-password",placeholder:(E=t.value)!=null&&E.adminConfigured?h(f)("pocketbase.passwordUnchanged"):h(f)("pocketbase.passwordNotSet")},null,8,Lc),[[be,s.value.adminPassword]])])]),(A=n.value)!=null&&A.detail?(y(),T("p",Fc,m(n.value.detail),1)):F("",!0),o.value?(y(),T("p",Bc,m(o.value),1)):F("",!0),i.value?(y(),T("p",Hc,m(i.value),1)):F("",!0),u("div",Vc,[u("button",{class:"dh-btn",disabled:r.value,onClick:g},m(h(f)("pocketbase.saveApply")),9,Gc),u("button",{class:"dh-btn-ghost",disabled:r.value,onClick:l},m(h(f)("pocketbase.testConnection")),9,Kc),b[4]||(b[4]=u("span",{class:"flex-1"},null,-1)),u("span",Wc,m(h(f)("pocketbase.persistedEnv")),1)])])])}}},Zc={class:"dh-card overflow-hidden"},Jc={class:"border-b border-subtle px-5 py-4"},Yc={class:"text-base font-bold tracking-[-0.02em] text-strong"},Xc={class:"mt-0.5 text-xs text-muted"},Qc={class:"flex flex-col gap-4 px-5 py-4"},eu={class:"dh-label",for:"server-name"},tu={class:"mt-1 text-xs text-muted"},su={key:0,class:"rounded-control bg-info-soft px-3 py-2 text-xs text-info"},nu={key:1,class:"rounded-control bg-danger-soft px-3 py-2 text-xs text-danger"},iu={class:"flex items-center gap-2"},ou=["disabled"],ru={class:"eyebrow"},au={__name:"ServerCard",setup(e){const t=H({name:""}),s=H(""),n=H(""),i=H(!1);async function o(){try{const a=await ae("/api/admin/server-config");t.value={name:a.name}}catch(a){s.value=a.message}}lt(o);async function r(){s.value="",n.value="",i.value=!0;try{const a=await ae("/api/admin/server-config",{method:"PUT",body:{name:t.value.name}});t.value={name:a.config.name},n.value=a.warning||f("server.savedNotice")}catch(a){s.value=a.message}finally{i.value=!1}}return(a,l)=>(y(),T("div",Zc,[u("div",Jc,[u("div",Yc,m(h(f)("server.title")),1),u("p",Xc,m(h(f)("server.subtitle")),1)]),u("div",Qc,[u("div",null,[u("label",eu,m(h(f)("server.name")),1),pe(u("input",{id:"server-name","onUpdate:modelValue":l[0]||(l[0]=g=>t.value.name=g),class:"dh-input",maxlength:"64",placeholder:"DriverVault API Server",onKeyup:xo(r,["enter"])},null,544),[[be,t.value.name]]),u("p",tu,m(h(f)("server.nameHint")),1)]),n.value?(y(),T("p",su,m(n.value),1)):F("",!0),s.value?(y(),T("p",nu,m(s.value),1)):F("",!0),u("div",iu,[u("button",{class:"dh-btn",disabled:i.value,onClick:r},m(h(f)("server.saveApply")),9,ou),l[1]||(l[1]=u("span",{class:"flex-1"},null,-1)),u("span",ru,m(h(f)("server.persistedEnv")),1)])])]))}},lu={class:"dh-card overflow-hidden"},cu={class:"flex items-center justify-between border-b border-subtle px-5 py-4"},uu={class:"text-base font-bold tracking-[-0.02em] text-strong"},du={class:"mt-0.5 text-xs text-muted"},pu={class:"flex flex-col gap-4 px-5 py-4"},fu={class:"dh-label",for:"web-url"},hu={class:"dh-label",for:"web-origins"},gu={class:"mt-1 text-xs text-muted"},mu={key:0,class:"data text-xs text-muted"},bu={key:1,class:"rounded-control bg-info-soft px-3 py-2 text-xs text-info"},vu={key:2,class:"rounded-control bg-danger-soft px-3 py-2 text-xs text-danger"},yu={class:"flex items-center gap-2"},_u=["disabled"],wu=["disabled"],xu={class:"eyebrow"},ku={__name:"WebAppCard",setup(e){const t=H(null),s=H({url:"",origins:""}),n=H(null),i=H(""),o=H(""),r=H(!1),a=E=>E.split(",").map(A=>A.trim()).filter(Boolean);function l(E){t.value=E,s.value={url:E.url,origins:(E.allowOrigins||[]).join(", ")},n.value=E.probe}async function g(){try{l(await ae("/api/admin/webapp-config"))}catch(E){i.value=E.message}}lt(g);async function d(){i.value="",o.value="",r.value=!0;try{n.value=await ae("/api/admin/webapp-config/test",{method:"POST",body:{url:s.value.url}}),o.value=n.value.status==="ok"?f("webapp.testOk"):f("webapp.testFailed")}catch(E){i.value=E.message}finally{r.value=!1}}async function b(){i.value="",o.value="",r.value=!0;try{const E=await ae("/api/admin/webapp-config",{method:"PUT",body:{url:s.value.url,allowOrigins:a(s.value.origins)}});l(E.config),o.value=E.warning||f("webapp.savedNotice")}catch(E){i.value=E.message}finally{r.value=!1}}return(E,A)=>{var V;return y(),T("div",lu,[u("div",cu,[u("div",null,[u("div",uu,m(h(f)("webapp.title")),1),u("p",du,m(h(f)("webapp.subtitle")),1)]),n.value?(y(),T("span",{key:0,class:Ee(["dh-pill",n.value.status==="ok"?"bg-success-soft text-success":"bg-danger-soft text-danger"])},[A[2]||(A[2]=u("span",{class:"h-1.5 w-1.5 rounded-full bg-current"},null,-1)),$e(" "+m(n.value.status==="ok"?h(f)("webapp.reachable"):h(f)("webapp.unreachable")),1)],2)):F("",!0)]),u("div",pu,[u("div",null,[u("label",fu,m(h(f)("webapp.baseUrl")),1),pe(u("input",{id:"web-url","onUpdate:modelValue":A[0]||(A[0]=x=>s.value.url=x),class:"dh-input",placeholder:"http://localhost:8090"},null,512),[[be,s.value.url]])]),u("div",null,[u("label",hu,m(h(f)("webapp.allowedOrigins")),1),pe(u("input",{id:"web-origins","onUpdate:modelValue":A[1]||(A[1]=x=>s.value.origins=x),class:"dh-input",placeholder:"http://localhost:8090, https://app.example.com"},null,512),[[be,s.value.origins]]),u("p",gu,[$e(m(h(gi)("webapp.originsHint","star").before),1),A[3]||(A[3]=u("span",{class:"data"},"*",-1)),$e(m(h(gi)("webapp.originsHint","star").after),1)])]),(V=n.value)!=null&&V.error?(y(),T("p",mu,m(n.value.error),1)):F("",!0),o.value?(y(),T("p",bu,m(o.value),1)):F("",!0),i.value?(y(),T("p",vu,m(i.value),1)):F("",!0),u("div",yu,[u("button",{class:"dh-btn",disabled:r.value,onClick:b},m(h(f)("webapp.saveApply")),9,_u),u("button",{class:"dh-btn-ghost",disabled:r.value,onClick:d},m(h(f)("webapp.testConnection")),9,wu),A[4]||(A[4]=u("span",{class:"flex-1"},null,-1)),u("span",xu,m(h(f)("webapp.persistedEnv")),1)])])])}}},Tu={class:"dh-card overflow-hidden"},Su={class:"flex items-center justify-between border-b border-subtle px-5 py-4"},Eu={class:"text-base font-bold tracking-[-0.02em] text-strong"},Pu={class:"mt-0.5 text-xs text-muted"},Cu={key:0,class:"border-b border-subtle bg-sunken px-5 py-4"},Au={class:"grid gap-3 sm:grid-cols-3"},$u={class:"dh-label"},Ou={class:"dh-label"},Du={class:"dh-label"},Ru=["disabled"],zu={key:1,class:"border-b border-subtle px-5 py-3 text-xs text-danger"},Iu={key:2,class:"px-5 py-6 text-center text-sm text-muted"},ju={key:3,class:"flex flex-wrap gap-1.5 border-b border-subtle px-5 py-3"},Uu=["onClick"],Nu={class:"dh-pill bg-sunken text-muted"},Mu={class:"flex items-center gap-3 px-5 py-3"},Lu=["onClick"],Fu={class:"font-semibold text-strong"},Bu={class:"dh-pill bg-sunken text-muted"},Hu={key:0,class:"text-xs text-muted"},Vu=["disabled","onClick"],Gu=["disabled","onClick"],Ku={key:0,class:"bg-sunken px-5 py-4"},Wu={key:0,class:"data mb-3 text-xs text-muted"},qu={key:1,class:"grid gap-3 sm:grid-cols-2"},Zu={class:"dh-label"},Ju={key:0,class:"text-danger"},Yu=["onUpdate:modelValue"],Xu={key:0,value:""},Qu=["value"],ed=["onUpdate:modelValue","type","placeholder"],td={key:2,class:"mt-1 text-xs text-muted"},sd={key:2,class:"text-xs text-muted"},nd={key:3,class:"mt-4"},id={class:"eyebrow mb-1.5"},od={class:"data flex flex-col gap-1 text-xs text-muted"},rd={class:"text-strong"},ad={key:0},ld={key:1},cd={key:4,class:"data mt-3 text-xs text-body"},ud={class:"mt-4 flex items-center gap-2"},dd=["disabled","onClick"],pd=["disabled","onClick"],fd=["disabled","onClick"],hd={key:5,class:"eyebrow mt-2"},gd={__name:"PluginsCard",setup(e){const t=H([]),s=H(""),n=H(!1),i=H(null),o=Yt({}),r=Yt({}),a=H(!1),l=H({name:"",baseURL:"",provider:""}),g=H(""),d=["vehicles","chargers","notifications","apis-external","drives-external","drives-local"],b=z=>d.includes(z.category)?z.category:"apis-external",E=Oe(()=>{var K;const z={};for(const P of t.value)(z[K=b(P)]||(z[K]=[])).push(P);return d.filter(P=>z[P]).map(P=>({id:P,label:f(`plugins.categories.${P}`),plugins:z[P]}))}),A=Oe(()=>E.value.find(z=>z.id===g.value)||E.value[0]),V=Oe(()=>{var z;return((z=A.value)==null?void 0:z.plugins)||[]});async function x(){try{const z=await ae("/api/admin/plugins");t.value=z.plugins||[],s.value=""}catch(z){s.value=z.message}}lt(x);function N(z){var P;if(i.value===z.name){i.value=null;return}const K={};for(const I of z.configFields||[])K[I.key]=((P=z.config)==null?void 0:P[I.key])??"";o[z.name]=K,i.value=z.name}async function R(z,K){n.value=!0,r[z.name]="";try{const P=await ae(`/api/admin/plugins/${encodeURIComponent(z.name)}`,{method:"PUT",body:{enabled:K,config:o[z.name]??{}}});r[z.name]=P.warning||f("plugins.saved"),await x()}catch(P){r[z.name]=P.message}finally{n.value=!1}}async function G(z){n.value=!0,r[z.name]=f("plugins.checking");try{const K=await ae(`/api/admin/plugins/${encodeURIComponent(z.name)}/health`,{method:"POST"});r[z.name]=`${K.health.status}${K.health.detail?" — "+K.health.detail:""}`,await x()}catch(K){r[z.name]=K.message}finally{n.value=!1}}async function Z(z){if(confirm(f("plugins.confirmRemove",{name:z.name}))){n.value=!0;try{await ae(`/api/admin/plugins/${encodeURIComponent(z.name)}`,{method:"DELETE"}),i.value===z.name&&(i.value=null),await x()}catch(K){r[z.name]=K.message}finally{n.value=!1}}}async function U(){n.value=!0,s.value="";try{await ae("/api/admin/plugins",{method:"POST",body:l.value}),l.value={name:"",baseURL:"",provider:""},a.value=!1,await x()}catch(z){s.value=z.message}finally{n.value=!1}}const le=z=>z==="ok"?"bg-success-soft text-success":z==="degraded"?"bg-warning-soft text-warning":"bg-danger-soft text-danger";return(z,K)=>(y(),T("div",Tu,[u("div",Su,[u("div",null,[u("div",Eu,m(h(f)("plugins.title")),1),u("p",Pu,m(h(f)("plugins.subtitle")),1)]),u("button",{class:"dh-btn-ghost",onClick:K[0]||(K[0]=P=>a.value=!a.value)},m(a.value?h(f)("plugins.cancelRegister"):h(f)("plugins.registerExternal")),1)]),a.value?(y(),T("div",Cu,[u("div",Au,[u("div",null,[u("label",$u,m(h(f)("plugins.name")),1),pe(u("input",{"onUpdate:modelValue":K[1]||(K[1]=P=>l.value.name=P),class:"dh-input",placeholder:"acme-parts"},null,512),[[be,l.value.name]])]),u("div",null,[u("label",Ou,m(h(f)("plugins.baseUrl")),1),pe(u("input",{"onUpdate:modelValue":K[2]||(K[2]=P=>l.value.baseURL=P),class:"dh-input",placeholder:"http://127.0.0.1:9100"},null,512),[[be,l.value.baseURL]])]),u("div",null,[u("label",Du,m(h(f)("plugins.provider")),1),pe(u("input",{"onUpdate:modelValue":K[3]||(K[3]=P=>l.value.provider=P),class:"dh-input",placeholder:"ACME Corp"},null,512),[[be,l.value.provider]])])]),u("button",{class:"dh-btn mt-3",disabled:n.value||!l.value.name||!l.value.baseURL,onClick:U},m(h(f)("plugins.register")),9,Ru)])):F("",!0),s.value?(y(),T("p",zu,m(s.value),1)):F("",!0),t.value.length?F("",!0):(y(),T("p",Iu,m(h(f)("plugins.empty")),1)),E.value.length>1?(y(),T("nav",ju,[(y(!0),T(re,null,Ce(E.value,P=>{var I;return y(),T("button",{key:P.id,class:Ee(["dh-btn-ghost",((I=A.value)==null?void 0:I.id)===P.id?"border-accent text-brandtext":""]),onClick:J=>g.value=P.id},[$e(m(P.label)+" ",1),u("span",Nu,m(P.plugins.length),1)],10,Uu)}),128))])):F("",!0),(y(!0),T(re,null,Ce(V.value,P=>(y(),T("div",{key:P.name,class:"border-t border-subtle first:border-t-0"},[u("div",Mu,[u("button",{class:"flex flex-1 items-center gap-3 text-left",onClick:I=>N(P)},[u("span",Fu,m(P.name),1),u("span",Bu,m(P.kind||h(f)("plugins.builtin")),1),P.provider?(y(),T("span",Hu,m(P.provider),1)):F("",!0),P.health?(y(),T("span",{key:1,class:Ee(["dh-pill",le(P.health.status)])},[K[4]||(K[4]=u("span",{class:"h-1.5 w-1.5 rounded-full bg-current"},null,-1)),$e(m(P.health.status),1)],2)):F("",!0)],8,Lu),u("span",{class:Ee(["dh-pill",P.enabled?"bg-success-soft text-success":"bg-sunken text-muted"])},m(P.enabled?h(f)("plugins.enabled"):h(f)("plugins.disabled")),3),u("button",{class:"dh-btn-ghost",disabled:n.value,onClick:I=>G(P)},m(h(f)("plugins.health")),9,Vu),u("button",{class:"dh-btn-ghost",disabled:n.value,onClick:I=>N(P)},m(i.value===P.name?h(f)("plugins.close"):h(f)("plugins.configure")),9,Gu)]),i.value===P.name?(y(),T("div",Ku,[P.baseURL?(y(),T("div",Wu,m(P.baseURL),1)):F("",!0),(P.configFields||[]).length?(y(),T("div",qu,[(y(!0),T(re,null,Ce(P.configFields,I=>(y(),T("div",{key:I.key},[u("label",Zu,[$e(m(I.label||I.key),1),I.required?(y(),T("span",Ju," *")):F("",!0)]),I.type==="select"?pe((y(),T("select",{key:0,"onUpdate:modelValue":J=>o[P.name][I.key]=J,class:"dh-select"},[I.required?F("",!0):(y(),T("option",Xu,m(h(f)("plugins.notSet")),1)),(y(!0),T(re,null,Ce(I.options||[],J=>(y(),T("option",{key:J.value,value:J.value},m(J.label||J.value),9,Qu))),128))],8,Yu)),[[ks,o[P.name][I.key]]]):pe((y(),T("input",{key:1,"onUpdate:modelValue":J=>o[P.name][I.key]=J,class:"dh-input",type:I.type==="password"?"password":I.type==="number"?"number":"text",placeholder:I.default||"",autocomplete:"off"},null,8,ed)),[[qa,o[P.name][I.key]]]),I.help?(y(),T("p",td,m(I.help),1)):F("",!0)]))),128))])):(y(),T("p",sd,m(h(f)("plugins.noConfig")),1)),(P.capabilities||[]).length?(y(),T("div",nd,[u("div",id,m(h(f)("plugins.capabilities")),1),u("ul",od,[(y(!0),T(re,null,Ce(P.capabilities,I=>(y(),T("li",{key:I.id},[u("span",rd,m(I.id),1),I.method||I.endpoint?(y(),T("span",ad," — "+m(I.method)+" "+m(I.endpoint),1)):F("",!0),I.description?(y(),T("span",ld," · "+m(I.description),1)):F("",!0)]))),128))])])):F("",!0),r[P.name]?(y(),T("p",cd,m(r[P.name]),1)):F("",!0),u("div",ud,[u("button",{class:"dh-btn",disabled:n.value,onClick:I=>R(P,!0)},m(P.enabled?h(f)("plugins.save"):h(f)("plugins.saveEnable")),9,dd),P.enabled?(y(),T("button",{key:0,class:"dh-btn-ghost",disabled:n.value,onClick:I=>R(P,!1)},m(h(f)("plugins.disable")),9,pd)):F("",!0),K[5]||(K[5]=u("span",{class:"flex-1"},null,-1)),P.kind==="external"?(y(),T("button",{key:1,class:"dh-btn-danger",disabled:n.value,onClick:I=>Z(P)},m(h(f)("plugins.remove")),9,fd)):F("",!0)]),P.kind!=="external"?(y(),T("p",hd,m(h(f)("plugins.builtinNote")),1)):F("",!0)])):F("",!0)]))),128))]))}},md={class:"dh-card overflow-hidden"},bd={class:"flex items-center justify-between border-b border-subtle px-5 py-4"},vd={class:"text-base font-bold tracking-[-0.02em] text-strong"},yd={class:"mt-0.5 text-xs text-muted"},_d={key:0,class:"border-b border-subtle px-5 py-3 text-xs text-danger"},wd={key:1,class:"border-b border-subtle bg-sunken px-5 py-4"},xd={class:"grid gap-3 sm:grid-cols-2"},kd={class:"dh-label"},Td={class:"dh-label"},Sd={class:"dh-label"},Ed=["placeholder"],Pd={class:"dh-label"},Cd=["value"],Ad={key:0},$d={class:"dh-label"},Od={value:""},Dd=["value"],Rd={class:"mt-3 flex items-center gap-2"},zd=["disabled"],Id=["disabled"],jd={key:2,class:"px-5 py-6 text-center text-sm text-muted"},Ud={key:3,class:"w-full text-left text-sm"},Nd={class:"[&>th]:eyebrow [&>th]:px-5 [&>th]:py-2.5 [&>th]:font-medium"},Md={class:"data px-5 py-2.5 text-xs text-strong"},Ld={key:0,class:"eyebrow ml-1"},Fd={class:"px-5 py-2.5 text-body"},Bd={class:"px-5 py-2.5 text-body"},Hd={class:"px-5 py-2.5"},Vd={class:"px-5 py-2.5 text-right whitespace-nowrap"},Gd=["onClick"],Kd=["disabled","onClick"],Wd={__name:"UsersCard",setup(e){const t=H([]),s=H([]),n=H(""),i=H(!1),o=H(null),r=H({}),a=Oe(()=>De.value?["user","admin","superadmin"]:["user","admin"]);async function l(){try{const[x,N]=await Promise.all([ae("/api/users"),ae("/api/orgs")]);t.value=x.users||[],s.value=N.organizations||[],n.value=""}catch(x){n.value=x.message}}lt(l);function g(){var x;o.value="new",r.value={email:"",name:"",password:"",role:"user",organization:De.value?"":((x=he.value)==null?void 0:x.organization)||""}}function d(x){o.value=x.id,r.value={email:x.email,name:x.name||"",password:"",role:x.role,organization:x.organization||""}}function b(){o.value=null,n.value=""}async function E(){i.value=!0,n.value="";try{if(o.value==="new")await ae("/api/users",{method:"POST",body:r.value});else{const x={...r.value};x.password||delete x.password,await ae(`/api/users/${o.value}`,{method:"PATCH",body:x})}o.value=null,await l()}catch(x){n.value=x.message}finally{i.value=!1}}async function A(x){if(confirm(f("users.confirmDelete",{email:x.email}))){i.value=!0,n.value="";try{await ae(`/api/users/${x.id}`,{method:"DELETE"}),await l()}catch(N){n.value=N.message}finally{i.value=!1}}}const V=x=>x==="superadmin"?"bg-info-soft text-info":x==="admin"?"bg-warning-soft text-warning":"bg-sunken text-muted";return(x,N)=>(y(),T("div",md,[u("div",bd,[u("div",null,[u("div",vd,m(h(f)("users.title")),1),u("p",yd,m(h(De)?h(f)("users.allOrgs"):h(f)("users.yourOrg")),1)]),u("button",{class:"dh-btn",onClick:g},m(h(f)("users.newUser")),1)]),n.value?(y(),T("p",_d,m(n.value),1)):F("",!0),o.value?(y(),T("div",wd,[u("div",xd,[u("div",null,[u("label",kd,m(h(f)("common.email")),1),pe(u("input",{"onUpdate:modelValue":N[0]||(N[0]=R=>r.value.email=R),class:"dh-input",type:"email",autocomplete:"off"},null,512),[[be,r.value.email]])]),u("div",null,[u("label",Td,m(h(f)("common.name")),1),pe(u("input",{"onUpdate:modelValue":N[1]||(N[1]=R=>r.value.name=R),class:"dh-input",autocomplete:"off"},null,512),[[be,r.value.name]])]),u("div",null,[u("label",Sd,m(o.value==="new"?h(f)("users.password"):h(f)("users.passwordUnchanged")),1),pe(u("input",{"onUpdate:modelValue":N[2]||(N[2]=R=>r.value.password=R),class:"dh-input",type:"password",autocomplete:"new-password",placeholder:h(f)("users.passwordPlaceholder")},null,8,Ed),[[be,r.value.password]])]),u("div",null,[u("label",Pd,m(h(f)("common.role")),1),pe(u("select",{"onUpdate:modelValue":N[3]||(N[3]=R=>r.value.role=R),class:"dh-select"},[(y(!0),T(re,null,Ce(a.value,R=>(y(),T("option",{key:R,value:R},m(R),9,Cd))),128))],512),[[ks,r.value.role]])]),h(De)?(y(),T("div",Ad,[u("label",$d,m(h(f)("common.organization")),1),pe(u("select",{"onUpdate:modelValue":N[4]||(N[4]=R=>r.value.organization=R),class:"dh-select"},[u("option",Od,m(h(f)("users.orgNone")),1),(y(!0),T(re,null,Ce(s.value,R=>(y(),T("option",{key:R.id,value:R.id},m(R.name),9,Dd))),128))],512),[[ks,r.value.organization]])])):F("",!0)]),u("div",Rd,[u("button",{class:"dh-btn",disabled:i.value,onClick:E},m(o.value==="new"?h(f)("common.create"):h(f)("common.save")),9,zd),u("button",{class:"dh-btn-ghost",disabled:i.value,onClick:b},m(h(f)("common.cancel")),9,Id)])])):F("",!0),t.value.length?(y(),T("table",Ud,[u("thead",null,[u("tr",Nd,[u("th",null,m(h(f)("common.email")),1),u("th",null,m(h(f)("common.name")),1),u("th",null,m(h(f)("common.organization")),1),u("th",null,m(h(f)("common.role")),1),N[5]||(N[5]=u("th",null,null,-1))])]),u("tbody",null,[(y(!0),T(re,null,Ce(t.value,R=>{var G,Z;return y(),T("tr",{key:R.id,class:"border-t border-subtle transition-colors hover:bg-sunken"},[u("td",Md,[$e(m(R.email)+" ",1),R.id===((G=h(he))==null?void 0:G.id)?(y(),T("span",Ld,m(h(f)("users.you")),1)):F("",!0)]),u("td",Fd,m(R.name||h(f)("common.empty")),1),u("td",Bd,m(R.organizationName||h(f)("common.empty")),1),u("td",Hd,[u("span",{class:Ee(["dh-pill",V(R.role)])},m(R.role),3)]),u("td",Vd,[u("button",{class:"dh-btn-ghost",onClick:U=>d(R)},m(h(f)("common.edit")),9,Gd),R.id!==((Z=h(he))==null?void 0:Z.id)?(y(),T("button",{key:0,class:"dh-btn-danger ml-1.5",disabled:i.value,onClick:U=>A(R)},m(h(f)("common.delete")),9,Kd)):F("",!0)])])}),128))])])):(y(),T("p",jd,m(h(f)("users.empty")),1))]))}},qd={class:"dh-card overflow-hidden"},Zd={class:"flex items-center justify-between border-b border-subtle px-5 py-4"},Jd={class:"text-base font-bold tracking-[-0.02em] text-strong"},Yd={class:"mt-0.5 text-xs text-muted"},Xd={key:0,class:"border-b border-subtle px-5 py-3 text-xs text-danger"},Qd={key:1,class:"border-b border-subtle bg-sunken px-5 py-4"},ep={class:"dh-label"},tp=["placeholder"],sp={class:"mt-3 flex items-center gap-2"},np=["disabled"],ip=["disabled"],op={key:2,class:"px-5 py-6 text-center text-sm text-muted"},rp={key:3,class:"w-full text-left text-sm"},ap={class:"[&>th]:eyebrow [&>th]:px-5 [&>th]:py-2.5 [&>th]:font-medium"},lp={class:"px-5 py-2.5 font-medium text-strong"},cp={class:"data px-5 py-2.5 text-xs text-muted"},up={class:"px-5 py-2.5 text-right whitespace-nowrap"},dp=["onClick"],pp=["disabled","onClick"],fp={__name:"OrgsCard",setup(e){const t=H([]),s=H(""),n=H(!1),i=H(null),o=H(""),r=Oe(()=>{var x;return((x=he.value)==null?void 0:x.organization)||""}),a=Oe(()=>De.value||!r.value);function l(x){return De.value||x.id===r.value}async function g(){if(!gs.value){t.value=[];return}try{const x=await ae("/api/orgs");t.value=x.organizations||[],s.value=""}catch(x){s.value=x.message}}lt(g);function d(){i.value="new",o.value=""}function b(x){i.value=x.id,o.value=x.name}function E(){i.value=null,s.value=""}async function A(){n.value=!0,s.value="";try{i.value==="new"?(await ae("/api/orgs",{method:"POST",body:{name:o.value}}),De.value||await Ts()):await ae(`/api/orgs/${i.value}`,{method:"PATCH",body:{name:o.value}}),i.value=null,await g()}catch(x){s.value=x.message}finally{n.value=!1}}async function V(x){const N=!De.value&&x.id===r.value;if(confirm(f(N?"orgs.confirmDeleteOwn":"orgs.confirmDelete",{name:x.name}))){n.value=!0,s.value="";try{await ae(`/api/orgs/${x.id}`,{method:"DELETE"}),N&&await Ts(),await g()}catch(G){s.value=G.message}finally{n.value=!1}}}return(x,N)=>(y(),T("div",qd,[u("div",Zd,[u("div",null,[u("div",Jd,m(h(De)?h(f)("orgs.title"):h(f)("common.organization")),1),u("p",Yd,m(h(De)?h(f)("orgs.subtitle"):r.value?h(f)("orgs.subtitleOwn"):h(f)("orgs.subtitleNone")),1)]),a.value?(y(),T("button",{key:0,class:"dh-btn",onClick:d},m(h(f)("orgs.newOrg")),1)):F("",!0)]),s.value?(y(),T("p",Xd,m(s.value),1)):F("",!0),i.value?(y(),T("div",Qd,[u("label",ep,m(h(f)("common.name")),1),pe(u("input",{"onUpdate:modelValue":N[0]||(N[0]=R=>o.value=R),class:"dh-input",placeholder:h(f)("orgs.namePlaceholder"),onKeyup:xo(A,["enter"])},null,40,tp),[[be,o.value]]),u("div",sp,[u("button",{class:"dh-btn",disabled:n.value||!o.value.trim(),onClick:A},m(i.value==="new"?h(f)("common.create"):h(f)("common.save")),9,np),u("button",{class:"dh-btn-ghost",disabled:n.value,onClick:E},m(h(f)("common.cancel")),9,ip)])])):F("",!0),t.value.length?(y(),T("table",rp,[u("thead",null,[u("tr",ap,[u("th",null,m(h(f)("common.name")),1),u("th",null,m(h(f)("orgs.colId")),1),N[1]||(N[1]=u("th",null,null,-1))])]),u("tbody",null,[(y(!0),T(re,null,Ce(t.value,R=>(y(),T("tr",{key:R.id,class:"border-t border-subtle transition-colors hover:bg-sunken"},[u("td",lp,m(R.name),1),u("td",cp,m(R.id),1),u("td",up,[l(R)?(y(),T(re,{key:0},[u("button",{class:"dh-btn-ghost",onClick:G=>b(R)},m(h(f)("common.rename")),9,dp),u("button",{class:"dh-btn-danger ml-1.5",disabled:n.value,onClick:G=>V(R)},m(h(f)("common.delete")),9,pp)],64)):F("",!0)])]))),128))])])):(y(),T("p",op,m(a.value&&!h(De)?h(f)("orgs.createHint"):h(f)("orgs.empty")),1))]))}},hp={class:"dh-card overflow-hidden"},gp={class:"border-b border-subtle px-5 py-4"},mp={class:"flex items-center justify-between"},bp={class:"text-base font-bold tracking-[-0.02em] text-strong"},vp={class:"eyebrow"},yp={key:0,class:"mt-1.5 text-xs text-muted"},_p={class:"overflow-x-auto"},wp={class:"w-full text-left text-sm"},xp={class:"[&>th]:eyebrow [&>th]:px-5 [&>th]:py-2.5 [&>th]:font-medium"},kp={class:"data border-t border-subtle px-5 py-2.5 text-xs whitespace-nowrap"},Tp={class:"text-strong"},Sp={class:"border-t border-subtle px-5 py-2.5 text-body"},fe={__name:"EndpointTable",props:{title:String,auth:String,endpoints:Array,note:String},setup(e){const t={GET:"text-success",POST:"text-brandtext",PUT:"text-info",PATCH:"text-warning",DELETE:"text-danger"};return(s,n)=>(y(),T("div",hp,[u("div",gp,[u("div",mp,[u("div",bp,m(e.title),1),u("span",vp,m(e.auth),1)]),e.note?(y(),T("p",yp,m(e.note),1)):F("",!0)]),u("div",_p,[u("table",wp,[u("thead",null,[u("tr",xp,[u("th",null,m(h(f)("api.colEndpoint")),1),u("th",null,m(h(f)("api.colDescription")),1)])]),u("tbody",null,[(y(!0),T(re,null,Ce(e.endpoints,i=>(y(),T("tr",{key:i.method+i.path,class:"transition-colors hover:bg-sunken"},[u("td",kp,[u("span",{class:Ee(["font-semibold",t[i.method]])},m(i.method),3),u("span",Tp,m(i.path),1)]),u("td",Sp,m(i.desc),1)]))),128))])])])]))}},Ep={class:"mx-auto flex max-w-4xl flex-col gap-6 px-6 pt-12 pb-16"},Pp={class:"flex items-center gap-3"},Cp={class:"eyebrow mt-1.5"},Ap={key:0,class:"data hidden text-xs text-muted sm:inline"},$p={key:0},Op={key:1,class:"dh-pill bg-info-soft text-info"},Dp=["value","aria-label"],Rp=["value"],zp=["title"],Ip={key:0,xmlns:"http://www.w3.org/2000/svg",viewBox:"0 0 24 24",fill:"none",stroke:"currentColor","stroke-width":"1.75",class:"h-4 w-4"},jp={key:1,xmlns:"http://www.w3.org/2000/svg",viewBox:"0 0 24 24",fill:"none",stroke:"currentColor","stroke-width":"1.75",class:"h-4 w-4"},Up={key:0,class:"eyebrow py-16 text-center"},Np={class:"flex flex-wrap gap-1.5"},Mp=["onClick"],Lp={key:8,class:"eyebrow text-center"},Fp={class:"eyebrow text-center"},Bp="{records} is any of service-records, technical-checks, maintenance, fuel-entries, charging-sessions, car-documents, parts.",Hp={__name:"App",setup(e){const t=H(!0),s=H("overview");lt(async()=>{await ec(),t.value=!1});const n=Oe(()=>{const P=[{id:"overview",label:f("sections.overview")}];return gs.value&&P.push({id:"users",label:f("sections.users")}),(gs.value||he.value)&&P.push({id:"orgs",label:f("sections.orgs")}),De.value&&P.push({id:"server",label:f("sections.server")},{id:"pocketbase",label:f("sections.pocketbase")},{id:"webapp",label:f("sections.webapp")},{id:"plugins",label:f("sections.plugins")}),P.push({id:"api",label:f("sections.api")}),P}),i=Oe(()=>Gl.map(P=>{let I=P;try{I=new Intl.DisplayNames([P],{type:"language"}).of(P)||P}catch{}return{code:P,label:I.charAt(0).toUpperCase()+I.slice(1)}}));function o(){Pn(),s.value="overview"}const r=[{method:"GET",path:"/api/health",desc:"Liveness probe (no auth)"},{method:"GET",path:"/healthz",desc:"The same probe under the conventional container path"},{method:"GET",path:"/api/status",desc:"Health of PocketBase + Web App"},{method:"POST",path:"/api/auth/login",desc:"Exchange email + password for a PocketBase token"},{method:"GET",path:"/api/auth/validate",desc:"Check whether a token is still valid"}],a=[{method:"GET",path:"/api/auth/me",desc:"Identity of the bearer token"},{method:"GET",path:"/api/identity",desc:"Identity incl. role + organization"}],l=[{method:"GET",path:"/api/cars",desc:"List owned + shared cars"},{method:"POST",path:"/api/cars",desc:"Create a car"},{method:"GET",path:"/api/cars/{id}",desc:"Fetch one car"},{method:"PATCH",path:"/api/cars/{id}",desc:"Update a car"},{method:"PUT",path:"/api/cars/{id}/view",desc:"Save the car's layout — hidden tabs/fields/columns and their order"},{method:"DELETE",path:"/api/cars/{id}",desc:"Delete a car (owner only)"},{method:"GET",path:"/api/cars/{id}/service-records",desc:"A car's service history"},{method:"GET",path:"/api/cars/{id}/technical-checks",desc:"A car's roadworthiness inspections"},{method:"GET",path:"/api/cars/{id}/parts",desc:"A car's parts catalog"},{method:"GET",path:"/api/cars/{id}/fuel-entries",desc:"A car's refuelings"},{method:"GET",path:"/api/cars/{id}/fuel-stats",desc:"Consumption + cost totals from the fuel log"},{method:"GET",path:"/api/cars/{id}/charging-sessions",desc:"A car's charging sessions"},{method:"GET",path:"/api/cars/{id}/charging-stats",desc:"Energy + cost totals from the charging log"},{method:"GET",path:"/api/cars/{id}/maintenance",desc:"A car's maintenance log"},{method:"GET",path:"/api/cars/{id}/documents",desc:"A car's documents"},{method:"GET",path:"/api/cars/{id}/reminders",desc:"A car's reminders"},{method:"GET",path:"/api/cars/{id}/provider",desc:"Live snapshot from the car's connected service"},{method:"POST",path:"/api/cars/{id}/provider",desc:"Link the car to a provider vehicle (an empty provider unlinks)"},{method:"POST",path:"/api/cars/{id}/provider/sync",desc:"Re-apply the provider's data to the car"},{method:"GET",path:"/api/cars/{id}/shares",desc:"Who a car is shared with (owner)"},{method:"POST",path:"/api/cars/{id}/shares",desc:"Share a car by email (owner)"},{method:"DELETE",path:"/api/cars/{id}/shares/{userId}",desc:"Revoke a share (owner)"}],g=[{method:"GET",path:"/api/service-records",desc:"List one car's service records (?car={id})"},{method:"POST",path:"/api/service-records",desc:"Log a service record"},{method:"GET",path:"/api/service-records/{id}",desc:"Fetch one record"},{method:"PATCH",path:"/api/service-records/{id}",desc:"Update a record"},{method:"DELETE",path:"/api/service-records/{id}",desc:"Delete a record"}],d=[{method:"GET",path:"/api/technical-checks",desc:"List one car's inspections (?car={id})"},{method:"POST",path:"/api/technical-checks",desc:"Log an inspection"},{method:"GET",path:"/api/technical-checks/{id}",desc:"Fetch one inspection"},{method:"PATCH",path:"/api/technical-checks/{id}",desc:"Update an inspection"},{method:"DELETE",path:"/api/technical-checks/{id}",desc:"Delete an inspection"}],b=[{method:"GET",path:"/api/parts",desc:"List one car's parts (?car={id})"},{method:"POST",path:"/api/parts",desc:"Add a part"},{method:"GET",path:"/api/parts/{id}",desc:"Fetch one part"},{method:"PATCH",path:"/api/parts/{id}",desc:"Update a part"},{method:"DELETE",path:"/api/parts/{id}",desc:"Delete a part"}],E=[{method:"GET",path:"/api/fuel-entries",desc:"List one car's refuelings (?car={id})"},{method:"POST",path:"/api/fuel-entries",desc:"Log a refueling"},{method:"GET",path:"/api/fuel-entries/{id}",desc:"Fetch one refueling"},{method:"PATCH",path:"/api/fuel-entries/{id}",desc:"Update a refueling"},{method:"DELETE",path:"/api/fuel-entries/{id}",desc:"Delete a refueling"}],A=[{method:"GET",path:"/api/charging-sessions",desc:"List one car's charging sessions (?car={id})"},{method:"POST",path:"/api/charging-sessions",desc:"Log a charging session"},{method:"GET",path:"/api/charging-sessions/{id}",desc:"Fetch one session"},{method:"PATCH",path:"/api/charging-sessions/{id}",desc:"Update a session"},{method:"DELETE",path:"/api/charging-sessions/{id}",desc:"Delete a session"}],V=[{method:"GET",path:"/api/maintenance",desc:"List one car's maintenance jobs (?car={id})"},{method:"POST",path:"/api/maintenance",desc:"Log a maintenance job"},{method:"GET",path:"/api/maintenance/{id}",desc:"Fetch one job"},{method:"PATCH",path:"/api/maintenance/{id}",desc:"Update a job"},{method:"DELETE",path:"/api/maintenance/{id}",desc:"Delete a job"}],x=[{method:"GET",path:"/api/car-documents",desc:"List one car's documents (?car={id})"},{method:"POST",path:"/api/car-documents",desc:"Add a document"},{method:"GET",path:"/api/car-documents/{id}",desc:"Fetch one document"},{method:"PATCH",path:"/api/car-documents/{id}",desc:"Update a document"},{method:"DELETE",path:"/api/car-documents/{id}",desc:"Delete a document"}],N=[{method:"GET",path:"/api/reminders",desc:"List one car's reminders (?car={id})"},{method:"POST",path:"/api/reminders",desc:"Create a reminder"},{method:"GET",path:"/api/reminders/{id}",desc:"Fetch one reminder"},{method:"PATCH",path:"/api/reminders/{id}",desc:"Update a reminder"},{method:"DELETE",path:"/api/reminders/{id}",desc:"Delete a reminder"},{method:"POST",path:"/api/reminders/{id}/complete",desc:"Mark done — a recurring reminder rolls forward from now"}],R=[{method:"POST",path:"/api/{records}/{id}/file",desc:"Attach a file, replacing any previous one (PDF or image, max 10 MB)"},{method:"GET",path:"/api/{records}/{id}/file",desc:"Download the attachment (car access re-checked on every request)"},{method:"DELETE",path:"/api/{records}/{id}/file",desc:"Detach and delete the file"}],G=[{method:"GET",path:"/api/vehicle-providers",desc:"Registered providers and whether the caller can use each one"},{method:"GET",path:"/api/vehicle-providers/{provider}/vehicles",desc:"Vehicles on the caller's provider account, as importable cars"},{method:"POST",path:"/api/vehicle-providers/{provider}/import",desc:"Create a car from a provider vehicle (409 if already imported)"}],Z=[{method:"GET",path:"/api/integrations/toyota",desc:"Resolved Toyota Connected settings (secrets masked)"},{method:"PUT",path:"/api/integrations/toyota",desc:"Save the caller's own layer (user or org scope)"},{method:"POST",path:"/api/integrations/toyota/health",desc:"Live probe with the resolved credentials"},{method:"GET",path:"/api/integrations/toyota/vehicles",desc:"Raw MyToyota vehicle payload"},{method:"GET",path:"/api/integrations/anker-solix",desc:"Resolved Anker Solix settings (secrets masked)"},{method:"PUT",path:"/api/integrations/anker-solix",desc:"Save the caller's own layer (user or org scope)"},{method:"POST",path:"/api/integrations/anker-solix/health",desc:"Live probe with the resolved credentials"},{method:"GET",path:"/api/integrations/anker-solix/chargers",desc:"EV chargers on the linked Anker account"},{method:"GET",path:"/api/integrations/anker-solix/chargers/{sn}/control",desc:"Control mode, token state and live CSMS session for one charger"},{method:"POST",path:"/api/integrations/anker-solix/chargers/{sn}/control/token",desc:"(Re)issue the charger's control token"},{method:"DELETE",path:"/api/integrations/anker-solix/chargers/{sn}/control/token",desc:"Revoke the control token"},{method:"POST",path:"/api/integrations/anker-solix/chargers/{sn}/{action}",desc:"One OCPP command: start, stop, limit, clear-limit, availability, reset, unlock, trigger, config"},{method:"GET",path:"/api/integrations/greencell",desc:"Resolved Greencell settings (secrets masked)"},{method:"PUT",path:"/api/integrations/greencell",desc:"Save the caller's own layer (user or org scope)"},{method:"POST",path:"/api/integrations/greencell/health",desc:"Live probe: connect to the broker and broadcast for devices"},{method:"GET",path:"/api/integrations/greencell/chargers",desc:"Greencell chargers answering on the configured MQTT broker"},{method:"GET",path:"/api/integrations/greencell/chargers/{sn}/state",desc:"Live EVSE state, access level, power and per-phase current/voltage"}],U=[{method:"GET",path:"/ocpp/{serial}",desc:"WebSocket the charger dials out to; OCPP Basic auth with the serial + its control token"}],le=[{method:"GET",path:"/api/me",desc:"Current user profile"},{method:"PATCH",path:"/api/me",desc:"Update profile"},{method:"POST",path:"/api/me/password",desc:"Change password"},{method:"POST",path:"/api/me/avatar",desc:"Upload avatar"},{method:"GET",path:"/api/me/avatar",desc:"Fetch avatar"},{method:"DELETE",path:"/api/me/avatar",desc:"Remove avatar"},{method:"POST",path:"/api/me/verify/request",desc:"Request email verification"},{method:"GET",path:"/api/me/export",desc:"Export your data"},{method:"POST",path:"/api/me/import",desc:"Import data"},{method:"POST",path:"/api/me/delete",desc:"Request account deletion"},{method:"POST",path:"/api/me/delete/cancel",desc:"Cancel deletion request"},{method:"DELETE",path:"/api/me",desc:"Finalize account deletion"}],z=[{method:"GET",path:"/api/users",desc:"List users (scoped by role)"},{method:"POST",path:"/api/users",desc:"Create a user"},{method:"PATCH",path:"/api/users/{id}",desc:"Update email / name / role / org / password"},{method:"DELETE",path:"/api/users/{id}",desc:"Delete a user"},{method:"GET",path:"/api/orgs",desc:"List organizations"},{method:"POST",path:"/api/orgs",desc:"Create an organization (any user without one; creator becomes admin)"},{method:"PATCH",path:"/api/orgs/{id}",desc:"Rename an organization (own org; any as superadmin)"},{method:"DELETE",path:"/api/orgs/{id}",desc:"Delete an organization (own org; any as superadmin)"}],K=[{method:"GET",path:"/api/admin/pb-config",desc:"PocketBase connection + live probe"},{method:"POST",path:"/api/admin/pb-config/test",desc:"Probe a candidate connection"},{method:"PUT",path:"/api/admin/pb-config",desc:"Apply + persist a connection"},{method:"GET",path:"/api/admin/server-config",desc:"This server's display name"},{method:"PUT",path:"/api/admin/server-config",desc:"Rename this server (applied + persisted)"},{method:"GET",path:"/api/admin/webapp-config",desc:"Web App URL + CORS origins, with a live probe"},{method:"POST",path:"/api/admin/webapp-config/test",desc:"Probe a candidate Web App address"},{method:"PUT",path:"/api/admin/webapp-config",desc:"Apply + persist Web App settings"},{method:"GET",path:"/api/admin/plugins",desc:"List plugins (secrets masked)"},{method:"POST",path:"/api/admin/plugins",desc:"Register an external plugin"},{method:"GET",path:"/api/admin/plugins/{name}",desc:"Fetch one plugin"},{method:"PUT",path:"/api/admin/plugins/{name}",desc:"Enable/disable + configure"},{method:"DELETE",path:"/api/admin/plugins/{name}",desc:"Remove an external plugin"},{method:"POST",path:"/api/admin/plugins/{name}/health",desc:"Run a health check now"}];return(P,I)=>(y(),T("div",Ep,[u("div",Pp,[ee(lc,{"on-dark":h(Ct)==="dark",class:"h-7"},null,8,["on-dark"]),u("span",Cp,m(h(f)("app.apiServer")),1),I[4]||(I[4]=u("div",{class:"flex-1"},null,-1)),h(he)?(y(),T("span",Ap,[$e(m(h(he).email),1),h(he).organizationName?(y(),T("span",$p," · "+m(h(he).organizationName),1)):F("",!0)])):F("",!0),h(he)?(y(),T("span",Op,m(h(he).role),1)):F("",!0),u("select",{class:"dh-select !w-auto !py-1 !text-xs",value:h(En),"aria-label":h(f)("sections.overview"),onChange:I[0]||(I[0]=J=>h(Wl)(J.target.value))},[(y(!0),T(re,null,Ce(i.value,J=>(y(),T("option",{key:J.code,value:J.code},m(J.label),9,Rp))),128))],40,Dp),u("button",{class:"dh-btn-ghost",title:h(Ct)==="dark"?h(f)("app.switchToLight"):h(f)("app.switchToDark"),onClick:I[1]||(I[1]=(...J)=>h(fi)&&h(fi)(...J))},[h(Ct)==="dark"?(y(),T("svg",Ip,[...I[2]||(I[2]=[u("circle",{cx:"12",cy:"12",r:"4"},null,-1),u("path",{"stroke-linecap":"round",d:"M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4"},null,-1)])])):(y(),T("svg",jp,[...I[3]||(I[3]=[u("path",{"stroke-linecap":"round","stroke-linejoin":"round",d:"M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8Z"},null,-1)])])),$e(" "+m(h(f)("app.theme")),1)],8,zp),h(Rt)?(y(),T("button",{key:2,class:"dh-btn-ghost",onClick:o},m(h(f)("app.signOut")),1)):F("",!0)]),t.value?(y(),T("p",Up,m(h(f)("app.loading")),1)):h(Rt)?(y(),T(re,{key:2},[u("nav",Np,[(y(!0),T(re,null,Ce(n.value,J=>(y(),T("button",{key:J.id,class:Ee(["dh-btn-ghost",s.value===J.id?"border-accent text-brandtext":""]),onClick:wt=>s.value=J.id},m(J.label),11,Mp))),128))]),s.value==="overview"?(y(),et($c,{key:0})):s.value==="users"?(y(),et(Wd,{key:1})):s.value==="orgs"?(y(),et(fp,{key:2})):s.value==="server"?(y(),et(au,{key:3})):s.value==="pocketbase"?(y(),et(qc,{key:4})):s.value==="webapp"?(y(),et(ku,{key:5})):s.value==="plugins"?(y(),et(gd,{key:6})):s.value==="api"?(y(),T(re,{key:7},[ee(fe,{title:h(f)("api.groupPublic"),auth:h(f)("api.authNone"),endpoints:r},null,8,["title","auth"]),ee(fe,{title:h(f)("api.groupIdentity"),auth:h(f)("api.authBearer"),endpoints:a},null,8,["title","auth"]),ee(fe,{title:h(f)("api.groupCars"),auth:h(f)("api.authBearer"),endpoints:l},null,8,["title","auth"]),ee(fe,{title:h(f)("api.groupService"),auth:h(f)("api.authBearer"),endpoints:g},null,8,["title","auth"]),ee(fe,{title:h(f)("api.groupTechnical"),auth:h(f)("api.authBearer"),endpoints:d},null,8,["title","auth"]),ee(fe,{title:h(f)("api.groupParts"),auth:h(f)("api.authBearer"),endpoints:b},null,8,["title","auth"]),ee(fe,{title:h(f)("api.groupFuel"),auth:h(f)("api.authBearer"),endpoints:E},null,8,["title","auth"]),ee(fe,{title:h(f)("api.groupCharging"),auth:h(f)("api.authBearer"),endpoints:A},null,8,["title","auth"]),ee(fe,{title:h(f)("api.groupMaintenance"),auth:h(f)("api.authBearer"),endpoints:V},null,8,["title","auth"]),ee(fe,{title:h(f)("api.groupDocuments"),auth:h(f)("api.authBearer"),endpoints:x},null,8,["title","auth"]),ee(fe,{title:h(f)("api.groupReminders"),auth:h(f)("api.authBearer"),endpoints:N},null,8,["title","auth"]),ee(fe,{title:h(f)("api.groupAttachments"),auth:h(f)("api.authBearer"),endpoints:R,note:Bp},null,8,["title","auth"]),ee(fe,{title:h(f)("api.groupProviders"),auth:h(f)("api.authBearer"),endpoints:G},null,8,["title","auth"]),ee(fe,{title:h(f)("api.groupIntegrations"),auth:h(f)("api.authBearer"),endpoints:Z},null,8,["title","auth"]),ee(fe,{title:h(f)("api.groupOcpp"),auth:h(f)("api.authCharger"),endpoints:U},null,8,["title","auth"]),ee(fe,{title:h(f)("api.groupAccount"),auth:h(f)("api.authBearer"),endpoints:le},null,8,["title","auth"]),ee(fe,{title:h(f)("api.groupManagement"),auth:h(f)("api.authManager"),endpoints:z},null,8,["title","auth"]),ee(fe,{title:h(f)("api.groupSuperadmin"),auth:h(f)("api.authSuperadmin"),endpoints:K},null,8,["title","auth"])],64)):F("",!0),!h(gs)&&s.value==="overview"?(y(),T("p",Lp,m(h(f)("app.standardUserNote")),1)):F("",!0)],64)):(y(),et(vc,{key:1})),u("p",Fp,m(h(f)("app.footer")),1)]))}};sl(Hp).mount("#app");
diff --git a/API Server/internal/api/dist/index.html b/API Server/internal/api/dist/index.html
index 41899c7..87ce2ec 100644
--- a/API Server/internal/api/dist/index.html
+++ b/API Server/internal/api/dist/index.html
@@ -6,7 +6,7 @@
DriverVault · API Server
-
+
diff --git a/API Server/internal/plugins/README.md b/API Server/internal/plugins/README.md
index 8b9d48c..7ae24c3 100644
--- a/API Server/internal/plugins/README.md
+++ b/API Server/internal/plugins/README.md
@@ -51,6 +51,7 @@ Descriptor{
Provider: "ACME Corp", // human label
Version: "1.0.0",
Kind: plugins.KindBuiltin, // or KindExternal
+ Category: plugins.CategoryAPIsExternal, // which panel tab it lands under
Capabilities: []plugins.Capability{
{ID: "widgets.list", Method: "GET", Endpoint: "/widgets", Description: "List widgets."},
},
@@ -63,6 +64,14 @@ Descriptor{
}
```
+`Category` groups the plugin under a tab in the panel's Plugins card — one of
+`CategoryVehicles`, `CategoryChargers`, `CategoryNotifications`,
+`CategoryAPIsExternal`, `CategoryDrivesExternal` or `CategoryDrivesLocal`. An
+empty or unrecognised value is shown under *Other APIs*, so a plugin never
+disappears. Adding a **new** category means adding the constant in
+[`plugin.go`](plugin.go), the tab order in `panel/src/components/PluginsCard.vue`,
+and its label in all three `panel/src/i18n/*.json`.
+
`ConfigField.Type` is `"text"`, `"password"`, or `"number"` (form input hint).
Set **`Secret: true`** for credentials — the server never echoes them back in
clear; the panel shows a mask (`••••••••`), and on save a field left at the mask
@@ -167,10 +176,11 @@ go build -o bin/api-server.exe ./cmd/server
Restart the server. The plugin appears in the panel's **Plugins** card,
**disabled** by default.
-> DriverVault ships three built-in connectors today — `toyota` (Toyota Connected /
-> MyToyota, read-only vehicle data), `anker-solix` (Anker Solix V1 EV charger) and
+> DriverVault ships four built-in connectors today — `toyota` (Toyota Connected /
+> MyToyota, read-only vehicle data), `anker-solix` (Anker Solix V1 EV charger),
> `greencell` (Greencell HabuDen EV charger, read over the owner's MQTT broker
-> rather than a cloud API) — all blank-imported from `builtin/builtin.go`. The
+> rather than a cloud API) and `apprise` (notifications, through an apprise-api
+> gateway the operator runs) — all blank-imported from `builtin/builtin.go`. The
> **external** kind below needs no rebuild and is the easier place to start a new
> one.
@@ -322,13 +332,17 @@ The contract is shaped for these; see [`doc.go`](doc.go):
exist (`Manager.InvokeWith` / `InvokeBatchWith`, driven by the integration routes
and `internal/api/vehicleproviders.go`); what is missing is the generic route.
- **Resilience** — retry/backoff, circuit breaker, per-plugin latency/error metrics.
-- **Per-tenant credentials _for arbitrary plugins_** — the built-in connectors
- already have them, through the hand-written `/api/integrations/toyota`,
- `/api/integrations/anker-solix` and `/api/integrations/greencell` routes and
- their **superadmin → org admin → user** config cascade. Each is a near-copy of
- the last, which is the argument for the generic version: per-org/per-user
- config keyed off `ConfigFields`, so a newly registered plugin gets the same
- treatment without new endpoints.
+- **Per-tenant credentials _for arbitrary plugins_** — the three per-user
+ connectors already have them, through the hand-written
+ `/api/integrations/toyota`, `/api/integrations/anker-solix` and
+ `/api/integrations/greencell` routes and their **superadmin → org admin →
+ user** config cascade. Each is a near-copy of the last, which is the argument
+ for the generic version: per-org/per-user config keyed off `ConfigFields`, so a
+ newly registered plugin gets the same treatment without new endpoints.
+ `apprise` deliberately sits outside that cascade — the notification gateway is
+ infrastructure the operator runs, not an account a driver owns — so its config
+ is global only, and `baseUrl` is `Required` because nothing further down can
+ supply it.
- **Audit logging** of plugin access. (Charger *control* commands are already
audited to the `control_audit` collection; this is the wider plugin case.)
diff --git a/API Server/internal/plugins/builtin/apprise/apprise.go b/API Server/internal/plugins/builtin/apprise/apprise.go
new file mode 100644
index 0000000..b5a9132
--- /dev/null
+++ b/API Server/internal/plugins/builtin/apprise/apprise.go
@@ -0,0 +1,1002 @@
+// Package apprise is a built-in connector for Apprise — the notification
+// library that speaks 100+ services (email, Discord, Telegram, ntfy, Pushover,
+// Slack, Matrix, SMS gateways, desktop toasts, …) behind one URL grammar such as
+// mailto://, tgram:// or discord://.
+//
+// The library itself is Python; what this connector talks to is apprise-api
+// (github.com/caronc/apprise-api), the small Django service that wraps it in
+// HTTP and is normally run as a container beside DriverVault. So the plugin
+// carries no notification protocols of its own: it hands a message to an Apprise
+// endpoint the operator runs and lets Apprise fan it out.
+//
+// The endpoints used, all of them documented in that project's README:
+//
+// GET /status server health and which modes are enabled
+// GET /details every notification service this build supports
+// GET /json/urls/{key} the URLs and tags stored under a config key
+// POST /notify/{key} send using the URLs stored under that key (stateful)
+// POST /notify send using URLs supplied in the request (stateless)
+//
+// A JSON body and an Accept: application/json header go out on every call, which
+// is what makes apprise-api answer in JSON rather than HTML or plain text.
+//
+// There are two ways to address targets, and the choice is the configKey field:
+// - Stateful — the operator has stored a set of URLs on the Apprise server
+// under a key (POST /add/{key}, or the server's own web form) and refers to
+// them by that key, optionally narrowed by a tag expression. Recipients are
+// then edited on the Apprise side without touching DriverVault, and no
+// credentials for the downstream services are ever held here.
+// - Stateless — the URLs travel with each request, taken from the urls config
+// field. Simpler for a single destination, but those URLs embed tokens and
+// passwords, so the field is marked secret and stored the way every other
+// plugin secret is.
+//
+// Scope & limitations:
+// - Send and read. The config-writing endpoints (/add, /del) are deliberately
+// not implemented: the Apprise config is the operator's, DriverVault posts to
+// it rather than owning it, and a connector that can delete a notification
+// config is a wider blast radius than one that can only send through it.
+// - Attachments by URL only. apprise-api takes uploads as multipart; the
+// "attach" parameter here passes remote URLs for the Apprise server to fetch,
+// which needs APPRISE_ATTACH_SIZE to be non-zero on that server.
+// - Stored URLs are read back privacy-masked (privacy=1 is forced on
+// /json/urls), so a target listing shows mailto://user:****@host and
+// downstream credentials never enter a DriverVault response.
+// - No authentication is part of the Apprise API — the project says so by
+// design and expects network isolation instead. When the instance is
+// published through a reverse proxy that adds HTTP Basic auth, the
+// username/password fields below are sent; left blank, nothing is.
+package apprise
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "net/http"
+ "net/url"
+ "regexp"
+ "strconv"
+ "strings"
+ "sync"
+ "time"
+
+ "drivervault/apiserver/internal/plugins"
+)
+
+// API paths on the Apprise server.
+const (
+ pathStatus = "/status"
+ pathDetails = "/details"
+ pathJSONURLs = "/json/urls/"
+ pathNotify = "/notify"
+)
+
+// configKeyPattern is the key grammar apprise-api's routes accept
+// ([\w_-]{1,128}). Checking it here turns a typo into a clear error instead of a
+// 404 page from Django.
+var configKeyPattern = regexp.MustCompile(`^[\w-]{1,128}$`)
+
+// Notification types Apprise accepts, which colour the message on the services
+// that render one.
+const (
+ typeInfo = "info"
+ typeSuccess = "success"
+ typeWarning = "warning"
+ typeFailure = "failure"
+)
+
+var notifyTypes = map[string]bool{typeInfo: true, typeSuccess: true, typeWarning: true, typeFailure: true}
+
+// Body formats Apprise accepts. It converts between them per service, so a
+// markdown body still arrives readable on a service that only takes text.
+const (
+ formatText = "text"
+ formatMarkdown = "markdown"
+ formatHTML = "html"
+)
+
+var bodyFormats = map[string]bool{formatText: true, formatMarkdown: true, formatHTML: true}
+
+// tagAll is Apprise's reserved tag: it matches every URL in a config regardless
+// of the tags that URL carries.
+const tagAll = "all"
+
+// Request timeout bounds. A notification is only answered once every target
+// service has been tried, so a widely-fanned-out config legitimately takes
+// seconds.
+const (
+ defaultTimeout = 15 * time.Second
+ minTimeout = 1 * time.Second
+ maxTimeout = 120 * time.Second
+)
+
+// maxBody caps what is read from a response. /details is the large one — a few
+// hundred KiB of service templates on a full build.
+const maxBody = 8 << 20
+
+// Apprise status codes that mean something specific to us: 204 is an empty or
+// unknown config key, 424 is "at least one notification failed", and 417 is
+// /status reporting a problem with the server itself. Each deserves better than
+// a generic HTTP error.
+const (
+ statusNoContent = http.StatusNoContent // 204
+ statusFailedDependency = http.StatusFailedDependency // 424
+ statusExpectationFail = http.StatusExpectationFailed // 417
+)
+
+func init() {
+ plugins.Register("apprise", func() plugins.Plugin { return &Plugin{} })
+}
+
+// Plugin is the Apprise connector.
+type Plugin struct {
+ mu sync.Mutex // guards the config below; Invoke may run concurrently
+ baseURL string
+ configKey string
+ urls string
+ tag string
+ format string
+ username string
+ password string
+ client *http.Client
+}
+
+// Descriptor returns the plugin's static metadata for the admin panel.
+func (p *Plugin) Descriptor() plugins.Descriptor {
+ return plugins.Descriptor{
+ Name: "apprise",
+ Provider: "Apprise (apprise-api notification gateway)",
+ Version: "1.0.0",
+ Kind: plugins.KindBuiltin,
+ Category: plugins.CategoryNotifications,
+ AuthType: plugins.AuthBasic,
+ Capabilities: []plugins.Capability{
+ {ID: "notify", Method: "POST", Endpoint: pathNotify + "/{key}",
+ Description: "Send a notification through Apprise — to the URLs stored under the config key, or to URLs supplied with the call."},
+ {ID: "targets", Method: "GET", Endpoint: pathJSONURLs + "{key}",
+ Description: "List the notification targets stored under a config key, with their tags. URLs come back privacy-masked."},
+ {ID: "services", Method: "GET", Endpoint: pathDetails,
+ Description: "List the notification services this Apprise build supports, with the URL schemas each accepts."},
+ {ID: "status", Method: "GET", Endpoint: pathStatus,
+ Description: "Report the Apprise server's own health and which of its modes (stateful config, attachments) are enabled."},
+ },
+ ConfigFields: []plugins.ConfigField{
+ // Unlike the vehicle and charger connectors, this one has no per-user
+ // cascade to fill in a blank: the Apprise endpoint is infrastructure the
+ // operator runs, not an account a driver owns. So the address is
+ // genuinely required, and enabling without one fails immediately rather
+ // than at the first notification nobody sees.
+ {Key: "baseUrl", Label: "Apprise API base URL", Type: "text", Required: true,
+ Help: "Address of your apprise-api instance, for example http://apprise:8000 or https://apprise.example.com. A path is kept, so an instance published under /apprise works too."},
+ {Key: "configKey", Label: "Config key", Type: "text",
+ Help: "Key of a URL set stored on the Apprise server (its own default is \"apprise\"). Recipients are then managed there, and changing them needs no change here. Leave blank to send to the URLs below instead."},
+ {Key: "urls", Label: "Apprise URLs", Type: "password", Secret: true,
+ Help: "Used when no config key is set: one or more Apprise URLs, comma separated — for example mailto://user:pass@gmail.com, tgram://bottoken/chatid, ntfy://topic. Secret, because these carry the credentials of the services they address."},
+ {Key: "tag", Label: "Tag expression", Type: "text", Default: tagAll,
+ Help: "Which of the stored URLs to notify. \"" + tagAll + "\" is every one of them; \"a, b\" means tag a OR tag b, and \"a b\" means both. Applies to the config key only."},
+ {Key: "format", Label: "Message format", Type: "select", Default: formatText,
+ Help: "How a message body is written. Apprise converts it to whatever each service actually accepts.",
+ Options: []plugins.SelectOption{
+ {Value: formatText, Label: "Plain text"},
+ {Value: formatMarkdown, Label: "Markdown"},
+ {Value: formatHTML, Label: "HTML"},
+ }},
+ {Key: "username", Label: "HTTP username", Type: "text",
+ Help: "Only for an instance published behind a reverse proxy that adds HTTP Basic auth. The Apprise API itself requires no authentication — leave blank for a directly reachable one."},
+ {Key: "password", Label: "HTTP password", Type: "password", Secret: true,
+ Help: "Password for the Basic-auth username above."},
+ {Key: "timeout", Label: "Request timeout (seconds)", Type: "number", Default: "15",
+ Help: "How long to wait for the Apprise server. A notification is only answered once every target service has been tried, so raise this for a config that fans out widely."},
+ },
+ }
+}
+
+// Init applies resolved config. It performs no network I/O — every call opens
+// its own request.
+func (p *Plugin) Init(_ context.Context, config map[string]string) error {
+ base, err := normalizeBaseURL(config["baseUrl"])
+ if err != nil {
+ return err
+ }
+ key := strings.TrimSpace(config["configKey"])
+ if key != "" && !configKeyPattern.MatchString(key) {
+ return keyError(key)
+ }
+
+ p.mu.Lock()
+ defer p.mu.Unlock()
+ p.baseURL = base
+ p.configKey = key
+ p.urls = strings.TrimSpace(config["urls"])
+ p.tag = strings.TrimSpace(config["tag"])
+ if p.tag == "" {
+ p.tag = tagAll
+ }
+ p.format = normalizeFormat(config["format"], formatText)
+ p.username = strings.TrimSpace(config["username"])
+ p.password = config["password"]
+ p.client = &http.Client{Timeout: parseTimeout(config["timeout"])}
+ return nil
+}
+
+// HealthCheck probes the Apprise server and, when a config key is set, checks
+// that the key actually resolves to targets. A reachable server whose config
+// holds nothing to notify is degraded rather than down: the half we address
+// works, and the missing half is the operator's Apprise config.
+func (p *Plugin) HealthCheck(ctx context.Context) plugins.Health {
+ start := time.Now()
+ st, err := p.status(ctx)
+ if err != nil {
+ return plugins.Health{Status: plugins.StatusDown, LatencyMs: time.Since(start).Milliseconds(), Detail: shorten(err.Error())}
+ }
+ if !st.OK {
+ return plugins.Health{Status: plugins.StatusDegraded, LatencyMs: time.Since(start).Milliseconds(),
+ Detail: "Apprise reachable but reporting a problem with itself: " + strings.Join(st.Details, ", ")}
+ }
+
+ key, urls := p.target()
+ switch {
+ case key == "" && urls == "":
+ return plugins.Health{Status: plugins.StatusDegraded, LatencyMs: time.Since(start).Milliseconds(),
+ Detail: "Apprise reachable, but nothing to notify — set a config key, or the URLs to send to (unless this server supplies its own through APPRISE_STATELESS_URLS)"}
+ case key == "":
+ return plugins.Health{Status: plugins.StatusOK, LatencyMs: time.Since(start).Milliseconds(),
+ Detail: fmt.Sprintf("Apprise reachable; sending stateless to %d configured URL(s)", countURLs(urls))}
+ case !st.StatefulEnabled:
+ return plugins.Health{Status: plugins.StatusDegraded, LatencyMs: time.Since(start).Milliseconds(),
+ Detail: fmt.Sprintf("config key %q is set, but this Apprise server runs with stateful config disabled — it can only send URLs supplied per request", key)}
+ }
+
+ res, err := p.targets(ctx, key, tagAll)
+ if err != nil {
+ return plugins.Health{Status: plugins.StatusDegraded, LatencyMs: time.Since(start).Milliseconds(),
+ Detail: fmt.Sprintf("Apprise reachable, but reading config key %q failed: %s", key, shorten(err.Error()))}
+ }
+ if res.Count == 0 {
+ return plugins.Health{Status: plugins.StatusDegraded, LatencyMs: time.Since(start).Milliseconds(),
+ Detail: fmt.Sprintf("Apprise reachable, but config key %q holds no notification URLs", key)}
+ }
+ return plugins.Health{Status: plugins.StatusOK, LatencyMs: time.Since(start).Milliseconds(),
+ Detail: fmt.Sprintf("Apprise reachable; config key %q holds %d target(s)", key, res.Count)}
+}
+
+// Invoke runs a named capability.
+func (p *Plugin) Invoke(ctx context.Context, action string, params json.RawMessage) (json.RawMessage, error) {
+ switch action {
+ case "notify":
+ var np NotifyParams
+ if err := decodeParams(params, &np); err != nil {
+ return nil, err
+ }
+ res, err := p.notify(ctx, np)
+ if err != nil {
+ return nil, err
+ }
+ return json.Marshal(res)
+
+ case "targets":
+ var tp targetParams
+ if err := decodeParams(params, &tp); err != nil {
+ return nil, err
+ }
+ key := strings.TrimSpace(tp.Key)
+ if key == "" {
+ key, _ = p.target()
+ }
+ if key == "" {
+ return nil, errors.New("apprise: action \"targets\" needs a config key, either in the call or in the plugin config")
+ }
+ if !configKeyPattern.MatchString(key) {
+ return nil, keyError(key)
+ }
+ res, err := p.targets(ctx, key, strings.TrimSpace(tp.Tag))
+ if err != nil {
+ return nil, err
+ }
+ return json.Marshal(res)
+
+ case "services":
+ var sp servicesParams
+ if err := decodeParams(params, &sp); err != nil {
+ return nil, err
+ }
+ res, err := p.services(ctx, sp.All)
+ if err != nil {
+ return nil, err
+ }
+ return json.Marshal(res)
+
+ case "status":
+ st, err := p.status(ctx)
+ if err != nil {
+ return nil, err
+ }
+ return json.Marshal(st)
+
+ default:
+ return nil, fmt.Errorf("apprise: unknown action %q", action)
+ }
+}
+
+// Shutdown has nothing to release: no session outlives a call.
+func (p *Plugin) Shutdown(context.Context) error { return nil }
+
+// ---- notify ------------------------------------------------------------------
+
+// NotifyParams is what the "notify" action accepts. Everything but the body has
+// a configured default.
+type NotifyParams struct {
+ Body string `json:"body"`
+ Title string `json:"title"`
+ Type string `json:"type"` // info | success | warning | failure
+ Format string `json:"format"` // text | markdown | html
+ // Tag narrows which of a config key's URLs are notified; it means nothing to
+ // a call that carries its own URLs.
+ Tag string `json:"tag"`
+ // Key and URLs override the configured destination for one call.
+ Key string `json:"key"`
+ URLs string `json:"urls"`
+ // Attach is one or more URLs for the Apprise server to fetch and attach.
+ Attach stringList `json:"attach"`
+}
+
+// LogEntry is one line of the delivery log Apprise returns with a notification.
+type LogEntry struct {
+ Level string `json:"level"`
+ Time string `json:"time"`
+ Message string `json:"message"`
+}
+
+// NotifyResult is what a delivered notification reports back. It deliberately
+// carries no URLs: the log is there to say what happened, not to whom.
+type NotifyResult struct {
+ OK bool `json:"ok"`
+ Mode string `json:"mode"` // "stateful" (a config key) | "stateless" (URLs sent with the call)
+ Key string `json:"key,omitempty"`
+ Tag string `json:"tag,omitempty"`
+ // Targets is how many URLs a stateless call was given.
+ Targets int `json:"targets,omitempty"`
+ Type string `json:"type"`
+ Format string `json:"format"`
+ Log []LogEntry `json:"log,omitempty"`
+ SentAt time.Time `json:"sentAt"`
+}
+
+// notify sends one notification. Which endpoint it posts to is the only
+// structural difference between the stateful and stateless paths.
+func (p *Plugin) notify(ctx context.Context, np NotifyParams) (NotifyResult, error) {
+ body := strings.TrimSpace(np.Body)
+ if body == "" {
+ return NotifyResult{}, errors.New("apprise: action \"notify\" requires a body")
+ }
+ notifyType, err := normalizeType(np.Type)
+ if err != nil {
+ return NotifyResult{}, err
+ }
+
+ cfg := p.snapshot()
+ format := normalizeFormat(np.Format, cfg.format)
+
+ key := strings.TrimSpace(np.Key)
+ urls := strings.TrimSpace(np.URLs)
+ // A call that names either destination names it alone: honouring caller URLs
+ // while falling back to the configured key (or the reverse) would send the
+ // message somewhere the caller did not ask for.
+ if key == "" && urls == "" {
+ key, urls = cfg.configKey, cfg.urls
+ }
+ if key != "" && !configKeyPattern.MatchString(key) {
+ return NotifyResult{}, keyError(key)
+ }
+
+ payload := map[string]any{
+ "body": body,
+ "type": notifyType,
+ "format": format,
+ }
+ if title := strings.TrimSpace(np.Title); title != "" {
+ payload["title"] = title
+ }
+ if len(np.Attach) > 0 {
+ payload["attach"] = []string(np.Attach)
+ }
+
+ res := NotifyResult{Type: notifyType, Format: format}
+ path := pathNotify
+ if key != "" {
+ tag := strings.TrimSpace(np.Tag)
+ if tag == "" {
+ tag = cfg.tag
+ }
+ payload["tag"] = tag
+ path = pathNotify + "/" + key
+ res.Mode, res.Key, res.Tag = "stateful", key, tag
+ } else {
+ // An empty list is still sent: a server started with APPRISE_STATELESS_URLS
+ // supplies its own, and its 400 says what is missing more precisely than a
+ // guess made here would.
+ if urls != "" {
+ payload["urls"] = urls
+ }
+ res.Mode, res.Targets = "stateless", countURLs(urls)
+ }
+
+ status, respBody, err := p.do(ctx, http.MethodPost, path, nil, payload)
+ if err != nil {
+ return NotifyResult{}, err
+ }
+
+ var doc struct {
+ Error string `json:"error"`
+ Details json.RawMessage `json:"details"`
+ }
+ _ = json.Unmarshal(respBody, &doc)
+ entries := parseLog(doc.Details)
+
+ switch status {
+ case http.StatusOK:
+ res.OK = true
+ res.Log = entries
+ res.SentAt = time.Now().UTC()
+ return res, nil
+ case statusNoContent:
+ return NotifyResult{}, fmt.Errorf("apprise: nothing was notified — config key %q holds no configuration on this Apprise server", key)
+ case statusFailedDependency:
+ return NotifyResult{}, fmt.Errorf("apprise: %s%s", messageOr(doc.Error, "one or more notifications could not be sent"), logSuffix(entries))
+ default:
+ return NotifyResult{}, fmt.Errorf("apprise: notify failed (HTTP %d): %s%s", status,
+ messageOr(doc.Error, shorten(string(respBody))), logSuffix(entries))
+ }
+}
+
+// parseLog reads the delivery log, which arrives as an array of
+// ["LEVEL","timestamp","message"] triples. A shape we do not recognise is
+// dropped rather than guessed at — the status code and error carry the outcome.
+func parseLog(raw json.RawMessage) []LogEntry {
+ if len(raw) == 0 {
+ return nil
+ }
+ var rows [][]string
+ if err := json.Unmarshal(raw, &rows); err != nil {
+ return nil
+ }
+ out := make([]LogEntry, 0, len(rows))
+ for _, r := range rows {
+ if len(r) < 3 {
+ continue
+ }
+ out = append(out, LogEntry{Level: r[0], Time: r[1], Message: r[2]})
+ }
+ if len(out) == 0 {
+ return nil
+ }
+ return out
+}
+
+// logSuffix appends the first few problem lines to an error, which is where
+// Apprise says which service refused and why.
+func logSuffix(entries []LogEntry) string {
+ const limit = 3
+ msgs := make([]string, 0, limit)
+ for _, e := range entries {
+ switch strings.ToUpper(e.Level) {
+ case "DEBUG", "INFO":
+ continue
+ }
+ msgs = append(msgs, e.Message)
+ if len(msgs) == limit {
+ break
+ }
+ }
+ if len(msgs) == 0 {
+ return ""
+ }
+ return " — " + shorten(strings.Join(msgs, "; "))
+}
+
+// ---- targets -----------------------------------------------------------------
+
+type targetParams struct {
+ Key string `json:"key"`
+ Tag string `json:"tag"`
+}
+
+// Target is one notification URL stored under a config key.
+type Target struct {
+ ID string `json:"id"`
+ ServiceName string `json:"serviceName"`
+ Enabled bool `json:"enabled"`
+ // URL is privacy-masked by the Apprise server; the tokens and passwords in a
+ // stored URL never reach here.
+ URL string `json:"url"`
+ Tags []string `json:"tags"`
+}
+
+// TargetsResult lists what a config key would notify.
+type TargetsResult struct {
+ Key string `json:"key"`
+ Tag string `json:"tag"`
+ Tags []string `json:"tags"`
+ Count int `json:"count"`
+ Targets []Target `json:"targets"`
+}
+
+// targets reads the URLs stored under a config key. privacy=1 is not optional:
+// it is what keeps downstream credentials on the Apprise side of the wire.
+func (p *Plugin) targets(ctx context.Context, key, tag string) (TargetsResult, error) {
+ if tag == "" {
+ tag = tagAll
+ }
+ q := url.Values{"privacy": {"1"}, "tag": {tag}}
+ status, body, err := p.do(ctx, http.MethodGet, pathJSONURLs+key, q, nil)
+ if err != nil {
+ return TargetsResult{}, err
+ }
+ // 204 is the documented answer for a key that holds no configuration: an
+ // empty list, not a failure.
+ if status == statusNoContent {
+ return TargetsResult{Key: key, Tag: tag, Targets: []Target{}}, nil
+ }
+ if status != http.StatusOK {
+ return TargetsResult{}, fmt.Errorf("apprise: reading config key %q failed (HTTP %d): %s", key, status, errorOrBody(body))
+ }
+
+ var doc struct {
+ Tags []string `json:"tags"`
+ Error string `json:"error"`
+ URLs []struct {
+ ID string `json:"id"`
+ ServiceName string `json:"service_name"`
+ Enabled bool `json:"enabled"`
+ URL string `json:"url"`
+ Tags []string `json:"tags"`
+ } `json:"urls"`
+ }
+ if err := json.Unmarshal(body, &doc); err != nil {
+ return TargetsResult{}, fmt.Errorf("apprise: config key %q returned an unreadable response: %w", key, err)
+ }
+ if doc.Error != "" {
+ return TargetsResult{}, fmt.Errorf("apprise: reading config key %q: %s", key, shorten(doc.Error))
+ }
+
+ res := TargetsResult{Key: key, Tag: tag, Tags: doc.Tags, Targets: make([]Target, 0, len(doc.URLs))}
+ for _, u := range doc.URLs {
+ res.Targets = append(res.Targets, Target{
+ ID: u.ID, ServiceName: u.ServiceName, Enabled: u.Enabled, URL: u.URL, Tags: u.Tags,
+ })
+ }
+ res.Count = len(res.Targets)
+ return res, nil
+}
+
+// ---- services ----------------------------------------------------------------
+
+type servicesParams struct {
+ // All includes the services this Apprise build has disabled — usually because
+ // a Python dependency they need is not installed.
+ All bool `json:"all"`
+}
+
+// Service is one notification service the Apprise build supports.
+type Service struct {
+ Name string `json:"name"`
+ Category string `json:"category,omitempty"`
+ Protocols []string `json:"protocols,omitempty"`
+ SecureProtocols []string `json:"secureProtocols,omitempty"`
+ AttachmentSupport bool `json:"attachmentSupport"`
+ ServiceURL string `json:"serviceUrl,omitempty"`
+ SetupURL string `json:"setupUrl,omitempty"`
+ // Enabled is reported only when disabled services were asked for.
+ Enabled *bool `json:"enabled,omitempty"`
+}
+
+// ServicesResult is the catalogue of what this Apprise build can notify.
+type ServicesResult struct {
+ Version string `json:"version"`
+ Count int `json:"count"`
+ Services []Service `json:"services"`
+}
+
+// services lists the supported notification services. The upstream payload
+// carries a full configuration template per service — hundreds of KiB — so only
+// the identifying fields are kept.
+func (p *Plugin) services(ctx context.Context, all bool) (ServicesResult, error) {
+ var q url.Values
+ if all {
+ q = url.Values{"all": {"yes"}}
+ }
+ status, body, err := p.do(ctx, http.MethodGet, pathDetails, q, nil)
+ if err != nil {
+ return ServicesResult{}, err
+ }
+ if status != http.StatusOK {
+ return ServicesResult{}, fmt.Errorf("apprise: listing services failed (HTTP %d): %s", status, errorOrBody(body))
+ }
+
+ var doc struct {
+ Version string `json:"version"`
+ Schemas []struct {
+ ServiceName string `json:"service_name"`
+ ServiceURL string `json:"service_url"`
+ SetupURL string `json:"setup_url"`
+ Category string `json:"category"`
+ AttachmentSupport bool `json:"attachment_support"`
+ Protocols []string `json:"protocols"`
+ SecureProtocols []string `json:"secure_protocols"`
+ Enabled *bool `json:"enabled"`
+ } `json:"schemas"`
+ }
+ if err := json.Unmarshal(body, &doc); err != nil {
+ return ServicesResult{}, fmt.Errorf("apprise: the service catalogue was unreadable: %w", err)
+ }
+
+ res := ServicesResult{Version: doc.Version, Services: make([]Service, 0, len(doc.Schemas))}
+ for _, s := range doc.Schemas {
+ res.Services = append(res.Services, Service{
+ Name: s.ServiceName, Category: s.Category,
+ Protocols: s.Protocols, SecureProtocols: s.SecureProtocols,
+ AttachmentSupport: s.AttachmentSupport,
+ ServiceURL: s.ServiceURL, SetupURL: s.SetupURL,
+ Enabled: s.Enabled,
+ })
+ }
+ res.Count = len(res.Services)
+ return res, nil
+}
+
+// ---- status ------------------------------------------------------------------
+
+// Status is the Apprise server's own report on itself.
+type Status struct {
+ OK bool `json:"ok"`
+ // Details is Apprise's own code list: ["OK"], or markers such as
+ // CONFIG_PERMISSION_ISSUE / ATTACH_PERMISSION_ISSUE / STORE_PERMISSION_ISSUE.
+ Details []string `json:"details"`
+
+ StatefulEnabled bool `json:"statefulEnabled"`
+ ConfigLock bool `json:"configLock"`
+ AttachLock bool `json:"attachLock"`
+ MaxAttachments int `json:"maxAttachments"`
+ AttachSize int `json:"attachSize"`
+
+ PersistentStorage bool `json:"persistentStorage"`
+ CanWriteConfig bool `json:"canWriteConfig"`
+ CanWriteAttach bool `json:"canWriteAttach"`
+}
+
+// status probes /status. The endpoint answers 417 rather than 200 when Apprise
+// finds a problem with itself, and its body says which — so a non-200 there is
+// still a parsed answer, not a transport failure.
+func (p *Plugin) status(ctx context.Context) (Status, error) {
+ code, body, err := p.do(ctx, http.MethodGet, pathStatus, nil, nil)
+ if err != nil {
+ return Status{}, err
+ }
+ if code >= http.StatusInternalServerError {
+ return Status{}, fmt.Errorf("apprise: status check failed (HTTP %d): %s", code, errorOrBody(body))
+ }
+ if code != http.StatusOK && code != statusExpectationFail {
+ return Status{}, fmt.Errorf("apprise: %s did not answer as an Apprise API (HTTP %d): %s", pathStatus, code, errorOrBody(body))
+ }
+
+ var doc struct {
+ ConfigLock bool `json:"config_lock"`
+ AttachLock bool `json:"attach_lock"`
+ StatefulEnabled bool `json:"stateful_enabled"`
+ MaxAttachments int `json:"max_attachments"`
+ AttachSize int `json:"attach_size"`
+ Status struct {
+ PersistentStorage bool `json:"persistent_storage"`
+ CanWriteConfig bool `json:"can_write_config"`
+ CanWriteAttach bool `json:"can_write_attach"`
+ Details []string `json:"details"`
+ } `json:"status"`
+ }
+ if err := json.Unmarshal(body, &doc); err != nil {
+ // Releases before the JSON health body — and any proxy that strips our
+ // Accept header — answer in plain text: a comma-joined list of the same
+ // codes. Read it that way rather than calling a healthy server unreadable.
+ // Stateful mode is assumed on there, since the plain-text form does not say
+ // and a wrong "disabled" would be a warning about nothing.
+ details := splitDetails(string(body))
+ if len(details) == 0 {
+ return Status{}, fmt.Errorf("apprise: %s returned an unreadable response: %s", pathStatus, shorten(string(body)))
+ }
+ return Status{OK: hasOK(details), Details: details, StatefulEnabled: true}, nil
+ }
+
+ st := Status{
+ Details: doc.Status.Details,
+ StatefulEnabled: doc.StatefulEnabled,
+ ConfigLock: doc.ConfigLock,
+ AttachLock: doc.AttachLock,
+ MaxAttachments: doc.MaxAttachments,
+ AttachSize: doc.AttachSize,
+ PersistentStorage: doc.Status.PersistentStorage,
+ CanWriteConfig: doc.Status.CanWriteConfig,
+ CanWriteAttach: doc.Status.CanWriteAttach,
+ }
+ if st.Details == nil {
+ st.Details = []string{}
+ }
+ st.OK = hasOK(st.Details)
+ return st, nil
+}
+
+// hasOK reports whether Apprise's detail list is the all-clear.
+func hasOK(details []string) bool {
+ for _, d := range details {
+ if strings.EqualFold(strings.TrimSpace(d), "OK") {
+ return true
+ }
+ }
+ return false
+}
+
+// splitDetails reads the plain-text form of the detail list. The codes are
+// SCREAMING_SNAKE and nothing else, so anything with spaces or markup in it is
+// an error page and must not be mistaken for a health line.
+func splitDetails(s string) []string {
+ out := []string{}
+ for _, part := range strings.Split(s, ",") {
+ part = strings.TrimSpace(part)
+ if part == "" || strings.ContainsAny(part, " <>\n\t") {
+ return nil
+ }
+ out = append(out, part)
+ }
+ return out
+}
+
+// ---- HTTP --------------------------------------------------------------------
+
+// config is an immutable copy of the plugin's settings for one call.
+type config struct {
+ baseURL string
+ configKey string
+ urls string
+ tag string
+ format string
+ username string
+ password string
+ client *http.Client
+}
+
+// snapshot copies the config under the lock so a call is not affected by a
+// concurrent Init.
+func (p *Plugin) snapshot() config {
+ p.mu.Lock()
+ defer p.mu.Unlock()
+ c := config{
+ baseURL: p.baseURL, configKey: p.configKey, urls: p.urls,
+ tag: p.tag, format: p.format,
+ username: p.username, password: p.password,
+ client: p.client,
+ }
+ // An instance the manager never called Init on still answers with sane
+ // defaults rather than a nil-pointer panic.
+ if c.tag == "" {
+ c.tag = tagAll
+ }
+ if c.format == "" {
+ c.format = formatText
+ }
+ if c.client == nil {
+ c.client = &http.Client{Timeout: defaultTimeout}
+ }
+ return c
+}
+
+// target reports the configured destination: a config key, a URL list, or
+// neither.
+func (p *Plugin) target() (key, urls string) {
+ p.mu.Lock()
+ defer p.mu.Unlock()
+ return p.configKey, p.urls
+}
+
+// do issues one request to the Apprise server and returns its status and body.
+// The JSON content type and Accept header are what make apprise-api answer in
+// JSON on every endpoint; without them it renders HTML.
+func (p *Plugin) do(ctx context.Context, method, path string, query url.Values, payload any) (int, []byte, error) {
+ cfg := p.snapshot()
+ if cfg.baseURL == "" {
+ return 0, nil, errors.New("apprise: no Apprise API base URL configured — set the address of your apprise-api instance")
+ }
+
+ target := cfg.baseURL + path
+ if len(query) > 0 {
+ target += "?" + query.Encode()
+ }
+
+ var body io.Reader
+ if payload != nil {
+ buf, err := json.Marshal(payload)
+ if err != nil {
+ return 0, nil, fmt.Errorf("apprise: encoding the request: %w", err)
+ }
+ body = bytes.NewReader(buf)
+ }
+
+ req, err := http.NewRequestWithContext(ctx, method, target, body)
+ if err != nil {
+ return 0, nil, fmt.Errorf("apprise: %s %s: %w", method, path, err)
+ }
+ req.Header.Set("Accept", "application/json")
+ if payload != nil {
+ req.Header.Set("Content-Type", "application/json")
+ }
+ if cfg.username != "" || cfg.password != "" {
+ req.SetBasicAuth(cfg.username, cfg.password)
+ }
+
+ resp, err := cfg.client.Do(req)
+ if err != nil {
+ return 0, nil, fmt.Errorf("apprise: %s %s: %w", method, path, err)
+ }
+ defer drain(resp)
+ out, err := io.ReadAll(io.LimitReader(resp.Body, maxBody))
+ if err != nil {
+ return resp.StatusCode, nil, fmt.Errorf("apprise: reading the response to %s %s: %w", method, path, err)
+ }
+ return resp.StatusCode, out, nil
+}
+
+// drain closes a response body after discarding any remainder so the connection
+// can be reused.
+func drain(resp *http.Response) {
+ if resp != nil && resp.Body != nil {
+ _, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 1<<20))
+ _ = resp.Body.Close()
+ }
+}
+
+// ---- config helpers ----------------------------------------------------------
+
+// normalizeBaseURL validates the configured address and trims it to a base a
+// path can be appended to. A bare host is read as http, which is what an Apprise
+// container on an internal network almost always is.
+func normalizeBaseURL(raw string) (string, error) {
+ s := strings.TrimSpace(raw)
+ if s == "" {
+ return "", nil
+ }
+ if !strings.Contains(s, "://") {
+ s = "http://" + s
+ }
+ u, err := url.Parse(s)
+ if err != nil {
+ return "", fmt.Errorf("apprise: %q is not a usable base URL: %w", raw, err)
+ }
+ switch u.Scheme {
+ case "http", "https":
+ default:
+ return "", fmt.Errorf("apprise: base URL scheme %q is not supported — use http or https", u.Scheme)
+ }
+ if u.Host == "" {
+ return "", fmt.Errorf("apprise: base URL %q names no host", raw)
+ }
+ // Keep any path prefix (an instance published under /apprise), drop the query
+ // and fragment, and normalise away the trailing slash so paths concatenate.
+ return strings.TrimRight(u.Scheme+"://"+u.Host+u.Path, "/"), nil
+}
+
+// keyError is the one message every rejected config key gets, so a typo reads
+// the same wherever it is caught.
+func keyError(key string) error {
+ return fmt.Errorf("apprise: config key %q is not usable — Apprise accepts 1–128 letters, digits, underscores and dashes", key)
+}
+
+// normalizeType validates a notification type, defaulting to info.
+func normalizeType(v string) (string, error) {
+ t := strings.ToLower(strings.TrimSpace(v))
+ if t == "" {
+ return typeInfo, nil
+ }
+ if !notifyTypes[t] {
+ return "", fmt.Errorf("apprise: notification type %q is not one of info, success, warning, failure", v)
+ }
+ return t, nil
+}
+
+// normalizeFormat validates a body format, falling back to def.
+func normalizeFormat(v, def string) string {
+ f := strings.ToLower(strings.TrimSpace(v))
+ if bodyFormats[f] {
+ return f
+ }
+ return def
+}
+
+// parseTimeout reads the request timeout in seconds, clamped to a range that
+// keeps a caller's request honest at one end and a wide fan-out possible at the
+// other.
+func parseTimeout(v string) time.Duration {
+ n, err := strconv.Atoi(strings.TrimSpace(v))
+ if err != nil || n <= 0 {
+ return defaultTimeout
+ }
+ d := time.Duration(n) * time.Second
+ return max(minTimeout, min(d, maxTimeout))
+}
+
+// countURLs counts the entries in an Apprise URL list, which may be separated by
+// commas or whitespace.
+func countURLs(list string) int {
+ n := 0
+ for _, f := range strings.FieldsFunc(list, func(r rune) bool {
+ return r == ',' || r == ' ' || r == '\t' || r == '\n' || r == '\r'
+ }) {
+ if strings.TrimSpace(f) != "" {
+ n++
+ }
+ }
+ return n
+}
+
+// decodeParams reads an action's parameters, treating an absent body as the zero
+// value.
+func decodeParams(params json.RawMessage, into any) error {
+ if len(params) == 0 || string(params) == "null" {
+ return nil
+ }
+ if err := json.Unmarshal(params, into); err != nil {
+ return fmt.Errorf("apprise: invalid params: %w", err)
+ }
+ return nil
+}
+
+// stringList decodes either one string or an array of them, because apprise-api
+// accepts both spellings for attachments.
+type stringList []string
+
+func (s *stringList) UnmarshalJSON(b []byte) error {
+ var one string
+ if json.Unmarshal(b, &one) == nil {
+ if strings.TrimSpace(one) == "" {
+ *s = nil
+ return nil
+ }
+ *s = stringList{one}
+ return nil
+ }
+ var many []string
+ if err := json.Unmarshal(b, &many); err != nil {
+ return errors.New("attach must be a URL or a list of URLs")
+ }
+ *s = many
+ return nil
+}
+
+// errorOrBody prefers the upstream's own error message over its raw body.
+func errorOrBody(body []byte) string {
+ var doc struct {
+ Error string `json:"error"`
+ }
+ if json.Unmarshal(body, &doc) == nil && doc.Error != "" {
+ return shorten(doc.Error)
+ }
+ return shorten(string(body))
+}
+
+// messageOr returns msg, or fallback when msg is empty.
+func messageOr(msg, fallback string) string {
+ if m := strings.TrimSpace(msg); m != "" {
+ return shorten(m)
+ }
+ return fallback
+}
+
+// shorten trims long or multiline upstream messages for health details and
+// errors.
+func shorten(s string) string {
+ s = strings.TrimSpace(strings.ReplaceAll(s, "\n", " "))
+ const limit = 200
+ if len(s) > limit {
+ return s[:limit] + "…"
+ }
+ return s
+}
diff --git a/API Server/internal/plugins/builtin/apprise/apprise_test.go b/API Server/internal/plugins/builtin/apprise/apprise_test.go
new file mode 100644
index 0000000..91f8732
--- /dev/null
+++ b/API Server/internal/plugins/builtin/apprise/apprise_test.go
@@ -0,0 +1,822 @@
+package apprise
+
+import (
+ "context"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+ "time"
+
+ "drivervault/apiserver/internal/plugins"
+)
+
+// ---- descriptor & config -----------------------------------------------------
+
+func TestDescriptor(t *testing.T) {
+ d := (&Plugin{}).Descriptor()
+ if d.Name != "apprise" {
+ t.Fatalf("name = %q, want apprise", d.Name)
+ }
+ if d.Kind != plugins.KindBuiltin {
+ t.Fatalf("kind = %q, want builtin", d.Kind)
+ }
+ if d.Category != plugins.CategoryNotifications {
+ t.Fatalf("category = %q, want %q", d.Category, plugins.CategoryNotifications)
+ }
+
+ actions := map[string]bool{}
+ for _, c := range d.Capabilities {
+ actions[c.ID] = true
+ }
+ for _, want := range []string{"notify", "targets", "services", "status"} {
+ if !actions[want] {
+ t.Errorf("capability %q should be advertised", want)
+ }
+ }
+
+ fields := map[string]plugins.ConfigField{}
+ for _, f := range d.ConfigFields {
+ fields[f.Key] = f
+ }
+ for _, k := range []string{"baseUrl", "configKey", "urls", "tag", "format", "username", "password", "timeout"} {
+ if _, ok := fields[k]; !ok {
+ t.Errorf("config field %q should be present", k)
+ }
+ }
+ // There is no per-user cascade behind this connector: without an address it
+ // cannot work at all, so the panel must refuse to enable it blank.
+ if !fields["baseUrl"].Required {
+ t.Error("baseUrl must be required — nothing else can supply it")
+ }
+ // Apprise URLs embed the credentials of every service they address.
+ if !fields["urls"].Secret || !fields["password"].Secret {
+ t.Error("urls and password must both be marked secret")
+ }
+ if fields["configKey"].Required || fields["urls"].Required {
+ t.Error("the destination is either a key or URLs; neither may be required on its own")
+ }
+ if got := fields["format"].Type; got != "select" {
+ t.Errorf("format type = %q, want select", got)
+ }
+ if got := fields["tag"].Default; got != tagAll {
+ t.Errorf("tag default = %q, want %q", got, tagAll)
+ }
+}
+
+func TestNormalizeBaseURL(t *testing.T) {
+ cases := []struct {
+ in, want string
+ wantErr bool
+ }{
+ {in: "", want: ""},
+ {in: "http://apprise:8000", want: "http://apprise:8000"},
+ {in: " http://apprise:8000/ ", want: "http://apprise:8000"},
+ {in: "https://apprise.example.com", want: "https://apprise.example.com"},
+ // A bare host is the common shape on an internal network.
+ {in: "10.2.1.10:8000", want: "http://10.2.1.10:8000"},
+ // A reverse proxy may publish the API under a path prefix.
+ {in: "https://home.example.com/apprise/", want: "https://home.example.com/apprise"},
+ {in: "ftp://apprise", wantErr: true},
+ {in: "http://", wantErr: true},
+ }
+ for _, c := range cases {
+ got, err := normalizeBaseURL(c.in)
+ if c.wantErr {
+ if err == nil {
+ t.Errorf("normalizeBaseURL(%q) = %q, want an error", c.in, got)
+ }
+ continue
+ }
+ if err != nil {
+ t.Errorf("normalizeBaseURL(%q): %v", c.in, err)
+ continue
+ }
+ if got != c.want {
+ t.Errorf("normalizeBaseURL(%q) = %q, want %q", c.in, got, c.want)
+ }
+ }
+}
+
+func TestInitRejectsUnusableConfigKey(t *testing.T) {
+ p := &Plugin{}
+ err := p.Init(context.Background(), map[string]string{
+ "baseUrl": "http://apprise:8000", "configKey": "not a key",
+ })
+ if err == nil {
+ t.Fatal("a config key with a space should be rejected at Init")
+ }
+ if !strings.Contains(err.Error(), "not usable") {
+ t.Errorf("error should say what is wrong with the key, got: %v", err)
+ }
+}
+
+func TestParseTimeoutClamps(t *testing.T) {
+ cases := map[string]time.Duration{
+ "": defaultTimeout,
+ "abc": defaultTimeout,
+ "0": defaultTimeout,
+ "-5": defaultTimeout,
+ "30": 30 * time.Second,
+ "9000": maxTimeout,
+ }
+ for in, want := range cases {
+ if got := parseTimeout(in); got != want {
+ t.Errorf("parseTimeout(%q) = %v, want %v", in, got, want)
+ }
+ }
+}
+
+func TestCountURLs(t *testing.T) {
+ cases := map[string]int{
+ "": 0,
+ " ": 0,
+ "ntfy://topic": 1,
+ "mailto://a@b, ntfy://t": 2,
+ "a://one b://two c://three": 3,
+ }
+ for in, want := range cases {
+ if got := countURLs(in); got != want {
+ t.Errorf("countURLs(%q) = %d, want %d", in, got, want)
+ }
+ }
+}
+
+// ---- test server -------------------------------------------------------------
+
+// call records one request the fake Apprise server received.
+type call struct {
+ method string
+ path string
+ query string
+ accept string
+ ctype string
+ user string
+ pass string
+ hasAuth bool
+ payload map[string]any
+}
+
+// fakeApprise is an apprise-api stand-in: it records what it was asked and
+// answers from the handler the test installs.
+type fakeApprise struct {
+ t *testing.T
+ srv *httptest.Server
+ calls []call
+ handler func(w http.ResponseWriter, r *http.Request, c call)
+}
+
+func newFake(t *testing.T, handler func(w http.ResponseWriter, r *http.Request, c call)) *fakeApprise {
+ t.Helper()
+ f := &fakeApprise{t: t, handler: handler}
+ f.srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ c := call{
+ method: r.Method, path: r.URL.Path, query: r.URL.RawQuery,
+ accept: r.Header.Get("Accept"), ctype: r.Header.Get("Content-Type"),
+ }
+ c.user, c.pass, c.hasAuth = r.BasicAuth()
+ if r.Body != nil {
+ _ = json.NewDecoder(r.Body).Decode(&c.payload)
+ }
+ f.calls = append(f.calls, c)
+ f.handler(w, r, c)
+ }))
+ t.Cleanup(f.srv.Close)
+ return f
+}
+
+func (f *fakeApprise) last() call {
+ f.t.Helper()
+ if len(f.calls) == 0 {
+ f.t.Fatal("the plugin made no request")
+ }
+ return f.calls[len(f.calls)-1]
+}
+
+// writeJSON answers with a status and body, the way apprise-api does for a JSON
+// request.
+func writeJSON(w http.ResponseWriter, status int, body string) {
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(status)
+ _, _ = w.Write([]byte(body))
+}
+
+// statusOK is the /status body of a healthy default apprise-api deployment.
+const statusOK = `{"config_lock":false,"attach_lock":false,"stateful_enabled":true,
+ "max_attachments":6,"attach_size":200,
+ "status":{"persistent_storage":true,"can_write_config":true,"can_write_attach":true,"details":["OK"]}}`
+
+// newPlugin builds a plugin pointed at the fake server.
+func newPlugin(t *testing.T, f *fakeApprise, cfg map[string]string) *Plugin {
+ t.Helper()
+ if cfg == nil {
+ cfg = map[string]string{}
+ }
+ cfg["baseUrl"] = f.srv.URL
+ p := &Plugin{}
+ if err := p.Init(context.Background(), cfg); err != nil {
+ t.Fatalf("Init: %v", err)
+ }
+ return p
+}
+
+func invoke(t *testing.T, p *Plugin, action string, params string, into any) error {
+ t.Helper()
+ var raw json.RawMessage
+ if params != "" {
+ raw = json.RawMessage(params)
+ }
+ out, err := p.Invoke(context.Background(), action, raw)
+ if err != nil {
+ return err
+ }
+ if into != nil {
+ if err := json.Unmarshal(out, into); err != nil {
+ t.Fatalf("decoding the %s result: %v", action, err)
+ }
+ }
+ return nil
+}
+
+// ---- notify ------------------------------------------------------------------
+
+func TestNotifyStateful(t *testing.T) {
+ f := newFake(t, func(w http.ResponseWriter, r *http.Request, c call) {
+ writeJSON(w, http.StatusOK, `{"error":null,"details":[["INFO","2026-08-30 10:00:00,000","Sent Telegram notification."]]}`)
+ })
+ p := newPlugin(t, f, map[string]string{"configKey": "drivervault", "tag": "ops", "format": "markdown"})
+
+ var res NotifyResult
+ if err := invoke(t, p, "notify", `{"body":"Charge complete","title":"Kia EV6","type":"success"}`, &res); err != nil {
+ t.Fatalf("notify: %v", err)
+ }
+
+ c := f.last()
+ if c.method != http.MethodPost || c.path != "/notify/drivervault" {
+ t.Fatalf("request = %s %s, want POST /notify/drivervault", c.method, c.path)
+ }
+ // Both headers are what make apprise-api answer in JSON instead of HTML.
+ if !strings.Contains(c.accept, "application/json") || !strings.Contains(c.ctype, "application/json") {
+ t.Errorf("Accept = %q, Content-Type = %q; both should be application/json", c.accept, c.ctype)
+ }
+ if c.hasAuth {
+ t.Error("no Basic auth is configured, so none should be sent")
+ }
+ want := map[string]any{
+ "body": "Charge complete", "title": "Kia EV6",
+ "type": "success", "format": "markdown", "tag": "ops",
+ }
+ for k, v := range want {
+ if c.payload[k] != v {
+ t.Errorf("payload[%q] = %v, want %v", k, c.payload[k], v)
+ }
+ }
+ if _, sent := c.payload["urls"]; sent {
+ t.Error("a stateful call must not carry URLs")
+ }
+
+ if !res.OK || res.Mode != "stateful" || res.Key != "drivervault" || res.Tag != "ops" {
+ t.Fatalf("result = %+v", res)
+ }
+ if len(res.Log) != 1 || res.Log[0].Level != "INFO" || !strings.Contains(res.Log[0].Message, "Telegram") {
+ t.Errorf("delivery log = %+v", res.Log)
+ }
+ if res.SentAt.IsZero() {
+ t.Error("a delivered notification should be stamped")
+ }
+}
+
+func TestNotifyStatelessUsesConfiguredURLs(t *testing.T) {
+ f := newFake(t, func(w http.ResponseWriter, r *http.Request, c call) {
+ writeJSON(w, http.StatusOK, `{"error":null,"details":[]}`)
+ })
+ p := newPlugin(t, f, map[string]string{"urls": "ntfy://topic, mailto://user:pass@host"})
+
+ var res NotifyResult
+ if err := invoke(t, p, "notify", `{"body":"Service due"}`, &res); err != nil {
+ t.Fatalf("notify: %v", err)
+ }
+
+ c := f.last()
+ if c.path != "/notify" {
+ t.Fatalf("path = %q, want /notify (the stateless endpoint)", c.path)
+ }
+ if c.payload["urls"] != "ntfy://topic, mailto://user:pass@host" {
+ t.Errorf("urls = %v, want the configured list", c.payload["urls"])
+ }
+ if _, sent := c.payload["tag"]; sent {
+ t.Error("a tag means nothing to a stateless call and should not be sent")
+ }
+ // Defaults fill in for what the caller left out.
+ if c.payload["type"] != typeInfo || c.payload["format"] != formatText {
+ t.Errorf("defaults = type %v / format %v, want info / text", c.payload["type"], c.payload["format"])
+ }
+ if res.Mode != "stateless" || res.Targets != 2 {
+ t.Errorf("result = %+v, want stateless with 2 targets", res)
+ }
+ if res.Key != "" {
+ t.Errorf("a stateless result should name no key, got %q", res.Key)
+ }
+}
+
+// A call that names its own URLs must not be sent to the configured key as well
+// — the caller chose the destination.
+func TestNotifyCallerURLsOverrideConfiguredKey(t *testing.T) {
+ f := newFake(t, func(w http.ResponseWriter, r *http.Request, c call) {
+ writeJSON(w, http.StatusOK, `{"error":null,"details":[]}`)
+ })
+ p := newPlugin(t, f, map[string]string{"configKey": "drivervault"})
+
+ var res NotifyResult
+ if err := invoke(t, p, "notify", `{"body":"one-off","urls":"ntfy://alerts"}`, &res); err != nil {
+ t.Fatalf("notify: %v", err)
+ }
+ if c := f.last(); c.path != "/notify" || c.payload["urls"] != "ntfy://alerts" {
+ t.Fatalf("request = %s with urls %v, want the stateless endpoint with the caller's URL", c.path, c.payload["urls"])
+ }
+ if res.Mode != "stateless" {
+ t.Errorf("mode = %q, want stateless", res.Mode)
+ }
+}
+
+func TestNotifyKeyParamOverridesConfiguredKey(t *testing.T) {
+ f := newFake(t, func(w http.ResponseWriter, r *http.Request, c call) {
+ writeJSON(w, http.StatusOK, `{"error":null,"details":[]}`)
+ })
+ p := newPlugin(t, f, map[string]string{"configKey": "drivervault", "urls": "ntfy://ignored"})
+
+ if err := invoke(t, p, "notify", `{"body":"hi","key":"fleet","tag":"night"}`, nil); err != nil {
+ t.Fatalf("notify: %v", err)
+ }
+ c := f.last()
+ if c.path != "/notify/fleet" {
+ t.Fatalf("path = %q, want /notify/fleet", c.path)
+ }
+ if c.payload["tag"] != "night" {
+ t.Errorf("tag = %v, want the per-call night", c.payload["tag"])
+ }
+ if _, sent := c.payload["urls"]; sent {
+ t.Error("the configured URLs must not ride along with a keyed call")
+ }
+}
+
+func TestNotifyAttachAcceptsStringOrList(t *testing.T) {
+ f := newFake(t, func(w http.ResponseWriter, r *http.Request, c call) {
+ writeJSON(w, http.StatusOK, `{"error":null,"details":[]}`)
+ })
+ p := newPlugin(t, f, map[string]string{"urls": "ntfy://t"})
+
+ if err := invoke(t, p, "notify", `{"body":"b","attach":"https://example.com/a.png"}`, nil); err != nil {
+ t.Fatalf("notify with one attachment: %v", err)
+ }
+ if got := f.last().payload["attach"]; len(got.([]any)) != 1 {
+ t.Errorf("attach = %v, want a one-element list", got)
+ }
+
+ if err := invoke(t, p, "notify", `{"body":"b","attach":["https://example.com/a.png","https://example.com/b.pdf"]}`, nil); err != nil {
+ t.Fatalf("notify with two attachments: %v", err)
+ }
+ if got := f.last().payload["attach"]; len(got.([]any)) != 2 {
+ t.Errorf("attach = %v, want a two-element list", got)
+ }
+
+ // An empty string is no attachment at all, not an empty one.
+ if err := invoke(t, p, "notify", `{"body":"b","attach":""}`, nil); err != nil {
+ t.Fatalf("notify with an empty attachment: %v", err)
+ }
+ if _, sent := f.last().payload["attach"]; sent {
+ t.Error("an empty attach should not be sent")
+ }
+}
+
+func TestNotifyValidation(t *testing.T) {
+ f := newFake(t, func(w http.ResponseWriter, r *http.Request, c call) {
+ writeJSON(w, http.StatusOK, `{"error":null}`)
+ })
+ p := newPlugin(t, f, map[string]string{"urls": "ntfy://t"})
+
+ cases := map[string]string{
+ "empty body": `{"body":" "}`,
+ "bad type": `{"body":"b","type":"urgent"}`,
+ "bad key": `{"body":"b","key":"has space"}`,
+ "bad params": `{"body":`,
+ "bad attach": `{"body":"b","attach":{"url":"x"}}`,
+ }
+ for name, params := range cases {
+ if err := invoke(t, p, "notify", params, nil); err == nil {
+ t.Errorf("%s should have been rejected", name)
+ }
+ }
+ if len(f.calls) != 0 {
+ t.Errorf("nothing invalid should reach the server, got %d call(s)", len(f.calls))
+ }
+
+ // An unrecognised format is not worth failing a notification over: Apprise
+ // converts formats anyway, so it falls back to the configured one.
+ if err := invoke(t, p, "notify", `{"body":"b","format":"yaml"}`, nil); err != nil {
+ t.Fatalf("an odd format should fall back, not fail: %v", err)
+ }
+ if got := f.last().payload["format"]; got != formatText {
+ t.Errorf("format = %v, want the configured %q", got, formatText)
+ }
+}
+
+func TestNotifyReportsDeliveryFailure(t *testing.T) {
+ f := newFake(t, func(w http.ResponseWriter, r *http.Request, c call) {
+ writeJSON(w, statusFailedDependency, `{"error":"One or more notification could not be sent",
+ "details":[["WARNING","2026-08-30 10:00:00,000","Failed to send Discord notification: 401"]]}`)
+ })
+ p := newPlugin(t, f, map[string]string{"configKey": "drivervault"})
+
+ err := invoke(t, p, "notify", `{"body":"b"}`, nil)
+ if err == nil {
+ t.Fatal("a 424 means nothing was delivered and must surface as an error")
+ }
+ // The log line is the only place Apprise says which service refused.
+ if !strings.Contains(err.Error(), "Discord") || !strings.Contains(err.Error(), "401") {
+ t.Errorf("error should carry the delivery log, got: %v", err)
+ }
+}
+
+func TestNotifyUnknownKeyIsExplained(t *testing.T) {
+ f := newFake(t, func(w http.ResponseWriter, r *http.Request, c call) {
+ w.WriteHeader(http.StatusNoContent)
+ })
+ p := newPlugin(t, f, map[string]string{"configKey": "missing"})
+
+ err := invoke(t, p, "notify", `{"body":"b"}`, nil)
+ if err == nil {
+ t.Fatal("a 204 means the key holds nothing and must not read as success")
+ }
+ if !strings.Contains(err.Error(), "missing") {
+ t.Errorf("error should name the key, got: %v", err)
+ }
+}
+
+func TestBasicAuthIsSentWhenConfigured(t *testing.T) {
+ f := newFake(t, func(w http.ResponseWriter, r *http.Request, c call) {
+ writeJSON(w, http.StatusOK, `{"error":null}`)
+ })
+ p := newPlugin(t, f, map[string]string{"urls": "ntfy://t", "username": "ops", "password": "s3cret"})
+
+ if err := invoke(t, p, "notify", `{"body":"b"}`, nil); err != nil {
+ t.Fatalf("notify: %v", err)
+ }
+ c := f.last()
+ if !c.hasAuth || c.user != "ops" || c.pass != "s3cret" {
+ t.Errorf("Basic auth = %q/%q (present %v), want ops/s3cret", c.user, c.pass, c.hasAuth)
+ }
+}
+
+// ---- targets -----------------------------------------------------------------
+
+const targetsBody = `{"tags":["ops","night"],"urls":[
+ {"id":"a1","service_name":"Telegram","enabled":true,"url":"tgram://****/1234","tags":["ops"]},
+ {"id":"b2","service_name":"E-Mail","enabled":true,"url":"mailto://user:****@host","tags":["ops","night"]}]}`
+
+func TestTargetsForcesPrivacy(t *testing.T) {
+ f := newFake(t, func(w http.ResponseWriter, r *http.Request, c call) {
+ writeJSON(w, http.StatusOK, targetsBody)
+ })
+ p := newPlugin(t, f, map[string]string{"configKey": "drivervault"})
+
+ var res TargetsResult
+ if err := invoke(t, p, "targets", "", &res); err != nil {
+ t.Fatalf("targets: %v", err)
+ }
+
+ c := f.last()
+ if c.path != "/json/urls/drivervault" {
+ t.Fatalf("path = %q, want /json/urls/drivervault", c.path)
+ }
+ // privacy=1 is what keeps downstream tokens on the Apprise side.
+ if !strings.Contains(c.query, "privacy=1") {
+ t.Errorf("query = %q, want privacy=1", c.query)
+ }
+ if !strings.Contains(c.query, "tag="+tagAll) {
+ t.Errorf("query = %q, want the default tag=%s", c.query, tagAll)
+ }
+ if res.Count != 2 || len(res.Targets) != 2 {
+ t.Fatalf("count = %d, want 2", res.Count)
+ }
+ if res.Targets[0].ServiceName != "Telegram" || res.Targets[0].ID != "a1" {
+ t.Errorf("first target = %+v", res.Targets[0])
+ }
+ if len(res.Tags) != 2 {
+ t.Errorf("tags = %v, want both", res.Tags)
+ }
+}
+
+func TestTargetsEmptyKeyIsNotAFailure(t *testing.T) {
+ f := newFake(t, func(w http.ResponseWriter, r *http.Request, c call) {
+ w.WriteHeader(http.StatusNoContent)
+ })
+ p := newPlugin(t, f, map[string]string{"configKey": "fresh"})
+
+ var res TargetsResult
+ if err := invoke(t, p, "targets", "", &res); err != nil {
+ t.Fatalf("a key with no configuration is an empty list, not an error: %v", err)
+ }
+ if res.Count != 0 || res.Targets == nil {
+ t.Errorf("result = %+v, want an empty (non-null) target list", res)
+ }
+}
+
+func TestTargetsNeedsAKey(t *testing.T) {
+ f := newFake(t, func(w http.ResponseWriter, r *http.Request, c call) {
+ writeJSON(w, http.StatusOK, targetsBody)
+ })
+ p := newPlugin(t, f, map[string]string{"urls": "ntfy://t"})
+
+ if err := invoke(t, p, "targets", "", nil); err == nil {
+ t.Fatal("targets without a key anywhere should be refused")
+ }
+ if len(f.calls) != 0 {
+ t.Error("the refusal should not reach the server")
+ }
+
+ // A key given per call is enough.
+ if err := invoke(t, p, "targets", `{"key":"fleet","tag":"night"}`, nil); err != nil {
+ t.Fatalf("targets with a per-call key: %v", err)
+ }
+ if c := f.last(); c.path != "/json/urls/fleet" || !strings.Contains(c.query, "tag=night") {
+ t.Errorf("request = %s?%s, want the per-call key and tag", c.path, c.query)
+ }
+}
+
+// ---- services ----------------------------------------------------------------
+
+const detailsBody = `{"version":"1.9.4","asset":{},"schemas":[
+ {"service_name":"Telegram","service_url":"https://telegram.org/","setup_url":"https://github.com/caronc/apprise/wiki/Notify_telegram",
+ "category":"native","attachment_support":true,"protocols":null,"secure_protocols":["tgram"],
+ "details":{"templates":["{schema}://{bot_token}"]}},
+ {"service_name":"Ntfy","service_url":"https://ntfy.sh/","setup_url":"",
+ "category":"native","attachment_support":true,"protocols":["ntfy"],"secure_protocols":["ntfys"],
+ "details":{"templates":["{schema}://{topic}"]}}]}`
+
+func TestServices(t *testing.T) {
+ f := newFake(t, func(w http.ResponseWriter, r *http.Request, c call) {
+ writeJSON(w, http.StatusOK, detailsBody)
+ })
+ p := newPlugin(t, f, nil)
+
+ var res ServicesResult
+ if err := invoke(t, p, "services", "", &res); err != nil {
+ t.Fatalf("services: %v", err)
+ }
+ if c := f.last(); c.path != "/details" || c.query != "" {
+ t.Fatalf("request = %s?%s, want /details with no query", c.path, c.query)
+ }
+ if res.Version != "1.9.4" || res.Count != 2 {
+ t.Fatalf("result = version %q, count %d", res.Version, res.Count)
+ }
+ tg := res.Services[0]
+ if tg.Name != "Telegram" || !tg.AttachmentSupport || len(tg.SecureProtocols) != 1 {
+ t.Errorf("Telegram = %+v", tg)
+ }
+ if tg.Protocols != nil {
+ t.Errorf("a null protocol list should stay empty, got %v", tg.Protocols)
+ }
+ if tg.Enabled != nil {
+ t.Error("enabled is only reported when disabled services were asked for")
+ }
+
+ // The catalogue's per-service templates are dropped: they are hundreds of KiB
+ // and nothing here consumes them.
+ out, _ := json.Marshal(res)
+ if strings.Contains(string(out), "templates") {
+ t.Error("the raw service templates should not be passed through")
+ }
+
+ if err := invoke(t, p, "services", `{"all":true}`, nil); err != nil {
+ t.Fatalf("services all: %v", err)
+ }
+ if c := f.last(); !strings.Contains(c.query, "all=yes") {
+ t.Errorf("query = %q, want all=yes", c.query)
+ }
+}
+
+// ---- status ------------------------------------------------------------------
+
+func TestStatusJSON(t *testing.T) {
+ f := newFake(t, func(w http.ResponseWriter, r *http.Request, c call) {
+ writeJSON(w, http.StatusOK, statusOK)
+ })
+ p := newPlugin(t, f, nil)
+
+ var st Status
+ if err := invoke(t, p, "status", "", &st); err != nil {
+ t.Fatalf("status: %v", err)
+ }
+ if !st.OK || !st.StatefulEnabled || !st.CanWriteConfig || st.MaxAttachments != 6 {
+ t.Fatalf("status = %+v", st)
+ }
+ if len(st.Details) != 1 || st.Details[0] != "OK" {
+ t.Errorf("details = %v, want [OK]", st.Details)
+ }
+}
+
+// Apprise answers 417 — not 500 — when it finds a problem with itself, and the
+// body still says which. That is an answer, not a transport failure.
+func TestStatusProblemIsStillParsed(t *testing.T) {
+ f := newFake(t, func(w http.ResponseWriter, r *http.Request, c call) {
+ writeJSON(w, statusExpectationFail, `{"config_lock":false,"attach_lock":false,"stateful_enabled":true,
+ "max_attachments":6,"attach_size":200,
+ "status":{"persistent_storage":false,"can_write_config":false,"can_write_attach":true,
+ "details":["CONFIG_PERMISSION_ISSUE"]}}`)
+ })
+ p := newPlugin(t, f, nil)
+
+ var st Status
+ if err := invoke(t, p, "status", "", &st); err != nil {
+ t.Fatalf("a 417 should still parse: %v", err)
+ }
+ if st.OK {
+ t.Error("a permission issue is not OK")
+ }
+ if len(st.Details) != 1 || st.Details[0] != "CONFIG_PERMISSION_ISSUE" {
+ t.Errorf("details = %v", st.Details)
+ }
+}
+
+// A proxy that strips the Accept header, or an older release, answers /status in
+// plain text with the same codes.
+func TestStatusPlainTextFallback(t *testing.T) {
+ f := newFake(t, func(w http.ResponseWriter, r *http.Request, c call) {
+ w.Header().Set("Content-Type", "text/plain")
+ _, _ = w.Write([]byte("OK"))
+ })
+ p := newPlugin(t, f, nil)
+
+ var st Status
+ if err := invoke(t, p, "status", "", &st); err != nil {
+ t.Fatalf("plain-text status should be read, not rejected: %v", err)
+ }
+ if !st.OK || len(st.Details) != 1 || st.Details[0] != "OK" {
+ t.Errorf("status = %+v", st)
+ }
+}
+
+// An HTML error page from something that is not Apprise must not be mistaken for
+// a health line.
+func TestStatusRejectsNonAppriseBody(t *testing.T) {
+ f := newFake(t, func(w http.ResponseWriter, r *http.Request, c call) {
+ w.Header().Set("Content-Type", "text/html")
+ _, _ = w.Write([]byte("Not Apprise"))
+ })
+ p := newPlugin(t, f, nil)
+
+ if err := invoke(t, p, "status", "", nil); err == nil {
+ t.Fatal("an HTML page should not pass as a status")
+ }
+}
+
+// ---- health ------------------------------------------------------------------
+
+func TestHealthCheckOKWithConfigKey(t *testing.T) {
+ f := newFake(t, func(w http.ResponseWriter, r *http.Request, c call) {
+ if strings.HasPrefix(c.path, "/json/urls/") {
+ writeJSON(w, http.StatusOK, targetsBody)
+ return
+ }
+ writeJSON(w, http.StatusOK, statusOK)
+ })
+ p := newPlugin(t, f, map[string]string{"configKey": "drivervault"})
+
+ h := p.HealthCheck(context.Background())
+ if h.Status != plugins.StatusOK {
+ t.Fatalf("status = %q (%s), want ok", h.Status, h.Detail)
+ }
+ if !strings.Contains(h.Detail, "2 target") {
+ t.Errorf("detail should count the targets, got %q", h.Detail)
+ }
+}
+
+// A reachable server whose config holds nothing to notify is degraded, not down:
+// the half we address works, and the missing half is the operator's config.
+func TestHealthCheckDegradedWhenKeyIsEmpty(t *testing.T) {
+ f := newFake(t, func(w http.ResponseWriter, r *http.Request, c call) {
+ if strings.HasPrefix(c.path, "/json/urls/") {
+ w.WriteHeader(http.StatusNoContent)
+ return
+ }
+ writeJSON(w, http.StatusOK, statusOK)
+ })
+ p := newPlugin(t, f, map[string]string{"configKey": "drivervault"})
+
+ h := p.HealthCheck(context.Background())
+ if h.Status != plugins.StatusDegraded {
+ t.Fatalf("status = %q, want degraded", h.Status)
+ }
+ if !strings.Contains(h.Detail, "no notification URLs") {
+ t.Errorf("detail = %q", h.Detail)
+ }
+}
+
+func TestHealthCheckDegradedWithNoDestination(t *testing.T) {
+ f := newFake(t, func(w http.ResponseWriter, r *http.Request, c call) {
+ writeJSON(w, http.StatusOK, statusOK)
+ })
+ p := newPlugin(t, f, nil)
+
+ h := p.HealthCheck(context.Background())
+ if h.Status != plugins.StatusDegraded {
+ t.Fatalf("status = %q, want degraded", h.Status)
+ }
+ if !strings.Contains(h.Detail, "nothing to notify") {
+ t.Errorf("detail = %q", h.Detail)
+ }
+ if len(f.calls) != 1 {
+ t.Errorf("with nothing configured there is no key to read, so one call is enough; got %d", len(f.calls))
+ }
+}
+
+// A config key against a server that has stateful mode switched off can never
+// resolve, and saying so is more useful than an empty target list.
+func TestHealthCheckDegradedWhenStatefulDisabled(t *testing.T) {
+ f := newFake(t, func(w http.ResponseWriter, r *http.Request, c call) {
+ writeJSON(w, http.StatusOK, strings.Replace(statusOK, `"stateful_enabled":true`, `"stateful_enabled":false`, 1))
+ })
+ p := newPlugin(t, f, map[string]string{"configKey": "drivervault"})
+
+ h := p.HealthCheck(context.Background())
+ if h.Status != plugins.StatusDegraded {
+ t.Fatalf("status = %q, want degraded", h.Status)
+ }
+ if !strings.Contains(h.Detail, "stateful config disabled") {
+ t.Errorf("detail = %q", h.Detail)
+ }
+}
+
+func TestHealthCheckOKStateless(t *testing.T) {
+ f := newFake(t, func(w http.ResponseWriter, r *http.Request, c call) {
+ writeJSON(w, http.StatusOK, statusOK)
+ })
+ p := newPlugin(t, f, map[string]string{"urls": "ntfy://a, ntfy://b, ntfy://c"})
+
+ h := p.HealthCheck(context.Background())
+ if h.Status != plugins.StatusOK {
+ t.Fatalf("status = %q (%s), want ok", h.Status, h.Detail)
+ }
+ if !strings.Contains(h.Detail, "3 configured URL") {
+ t.Errorf("detail = %q", h.Detail)
+ }
+}
+
+func TestHealthCheckDownWhenUnreachable(t *testing.T) {
+ f := newFake(t, func(w http.ResponseWriter, r *http.Request, c call) {})
+ p := newPlugin(t, f, nil)
+ f.srv.Close() // the address stops answering
+
+ h := p.HealthCheck(context.Background())
+ if h.Status != plugins.StatusDown {
+ t.Fatalf("status = %q, want down", h.Status)
+ }
+}
+
+func TestHealthCheckWithoutBaseURLIsDown(t *testing.T) {
+ p := &Plugin{}
+ if err := p.Init(context.Background(), map[string]string{}); err != nil {
+ t.Fatalf("Init with no address should not fail, it should read as unhealthy: %v", err)
+ }
+ h := p.HealthCheck(context.Background())
+ if h.Status != plugins.StatusDown {
+ t.Fatalf("status = %q, want down", h.Status)
+ }
+ if !strings.Contains(h.Detail, "base URL") {
+ t.Errorf("detail should say what is missing, got %q", h.Detail)
+ }
+}
+
+// ---- contract ----------------------------------------------------------------
+
+func TestUnknownAction(t *testing.T) {
+ p := &Plugin{}
+ if _, err := p.Invoke(context.Background(), "explode", nil); err == nil {
+ t.Fatal("an unknown action should be refused")
+ }
+}
+
+func TestShutdownIsSafe(t *testing.T) {
+ if err := (&Plugin{}).Shutdown(context.Background()); err != nil {
+ t.Fatalf("Shutdown: %v", err)
+ }
+}
+
+// The manager builds an instance and may never reach Init before a probe; that
+// must not panic on a nil client.
+func TestUninitializedPluginDoesNotPanic(t *testing.T) {
+ p := &Plugin{}
+ if h := p.HealthCheck(context.Background()); h.Status != plugins.StatusDown {
+ t.Fatalf("status = %q, want down", h.Status)
+ }
+}
+
+func TestRegisteredWithTheRegistry(t *testing.T) {
+ // Register panics on a duplicate, so a second registration under the same
+ // name would already have failed at init. This pins the name itself.
+ if got := (&Plugin{}).Descriptor().Name; got != "apprise" {
+ t.Fatalf("registered name = %q", got)
+ }
+}
diff --git a/API Server/internal/plugins/builtin/builtin.go b/API Server/internal/plugins/builtin/builtin.go
index dd7611d..23be082 100644
--- a/API Server/internal/plugins/builtin/builtin.go
+++ b/API Server/internal/plugins/builtin/builtin.go
@@ -2,8 +2,8 @@
// register them with the plugin registry. Import this package once (from the api
// package) to make all built-in connectors available.
//
-// Built-in connectors that ship today: toyota, ankersolix and greencell
-// (imported below).
+// Built-in connectors that ship today: toyota, ankersolix, greencell and
+// apprise (imported below).
// Add another under internal/plugins/builtin// and blank-import it here, e.g.
//
// import _ "drivervault/apiserver/internal/plugins/builtin/acme"
@@ -13,6 +13,8 @@
package builtin
import (
+ // apprise — Apprise notification gateway, reached through apprise-api.
+ _ "drivervault/apiserver/internal/plugins/builtin/apprise"
// anker-solix — Anker Solix V1 Smart EV Charger read-only cloud data.
_ "drivervault/apiserver/internal/plugins/builtin/ankersolix"
// greencell — Greencell HabuDen EV charger, read over the owner's MQTT broker.
diff --git a/API Server/internal/plugins/builtin/builtin_test.go b/API Server/internal/plugins/builtin/builtin_test.go
new file mode 100644
index 0000000..867a125
--- /dev/null
+++ b/API Server/internal/plugins/builtin/builtin_test.go
@@ -0,0 +1,50 @@
+package builtin
+
+import (
+ "testing"
+
+ "drivervault/apiserver/internal/plugins"
+)
+
+// The blank imports above are the whole content of this package, and forgetting
+// one is a silent failure: the connector compiles, its tests pass, and it simply
+// never appears in the panel. This pins the list.
+func TestEveryBuiltinIsRegistered(t *testing.T) {
+ // List needs no store: it reports the registered factories, and the records
+ // it would merge in come from a Manager that has loaded.
+ views := plugins.NewManager(nil).List()
+
+ seen := map[string]plugins.View{}
+ for _, v := range views {
+ seen[v.Name] = v
+ }
+
+ want := map[string]string{
+ "anker-solix": plugins.CategoryChargers,
+ "apprise": plugins.CategoryNotifications,
+ "greencell": plugins.CategoryChargers,
+ "toyota": plugins.CategoryVehicles,
+ }
+ for name, category := range want {
+ v, ok := seen[name]
+ if !ok {
+ t.Errorf("%q is not registered — is it blank-imported in builtin.go?", name)
+ continue
+ }
+ if v.Kind != plugins.KindBuiltin {
+ t.Errorf("%q kind = %q, want builtin", name, v.Kind)
+ }
+ if v.Category != category {
+ t.Errorf("%q category = %q, want %q", name, v.Category, category)
+ }
+ if v.Provider == "" || v.Version == "" {
+ t.Errorf("%q should name a provider and a version, got %q / %q", name, v.Provider, v.Version)
+ }
+ if len(v.Capabilities) == 0 {
+ t.Errorf("%q advertises no capabilities", name)
+ }
+ }
+ if len(seen) != len(want) {
+ t.Errorf("registered builtins = %d, expected %d — update this test with the new connector", len(seen), len(want))
+ }
+}
diff --git a/API Server/internal/plugins/plugin.go b/API Server/internal/plugins/plugin.go
index 819a5f6..b7f23bf 100644
--- a/API Server/internal/plugins/plugin.go
+++ b/API Server/internal/plugins/plugin.go
@@ -94,6 +94,7 @@ func (c *Capability) UnmarshalJSON(b []byte) error {
const (
CategoryVehicles = "vehicles" // car manufacturers' connected-car services
CategoryChargers = "chargers" // EV chargers and charging hardware
+ CategoryNotifications = "notifications" // notification gateways and message delivery
CategoryAPIsExternal = "apis-external" // remote HTTP APIs (external plugins)
CategoryDrivesExternal = "drives-external" // remote file stores (FTP/SFTP)
CategoryDrivesLocal = "drives-local" // drives on the host machine
diff --git a/API Server/panel/src/components/PluginsCard.vue b/API Server/panel/src/components/PluginsCard.vue
index 5ee7c39..eef1bda 100644
--- a/API Server/panel/src/components/PluginsCard.vue
+++ b/API Server/panel/src/components/PluginsCard.vue
@@ -19,7 +19,7 @@ const tab = ref(""); // selected category tab; empty falls back to the first one
// Tab order, mirroring the Category* constants in internal/plugins/plugin.go. A
// plugin whose category is empty or unknown to this panel lands under the
// external-APIs tab rather than disappearing.
-const CATEGORIES = ["vehicles", "chargers", "apis-external", "drives-external", "drives-local"];
+const CATEGORIES = ["vehicles", "chargers", "notifications", "apis-external", "drives-external", "drives-local"];
const categoryOf = (p) => (CATEGORIES.includes(p.category) ? p.category : "apis-external");
// Only categories that actually have a plugin get a tab — a fresh install with
diff --git a/API Server/panel/src/i18n/da.json b/API Server/panel/src/i18n/da.json
index 8dc7246..ebaa526 100644
--- a/API Server/panel/src/i18n/da.json
+++ b/API Server/panel/src/i18n/da.json
@@ -137,6 +137,7 @@
"categories": {
"vehicles": "Bilproducenter",
"chargers": "EV-ladere",
+ "notifications": "Notifikationer",
"apis-external": "Øvrige API'er",
"drives-external": "Eksterne drev",
"drives-local": "Lokale drev"
diff --git a/API Server/panel/src/i18n/en.json b/API Server/panel/src/i18n/en.json
index 4079d2e..8a55734 100644
--- a/API Server/panel/src/i18n/en.json
+++ b/API Server/panel/src/i18n/en.json
@@ -137,6 +137,7 @@
"categories": {
"vehicles": "Car manufacturers",
"chargers": "EV chargers",
+ "notifications": "Notifications",
"apis-external": "Other APIs",
"drives-external": "Remote drives",
"drives-local": "Local drives"
diff --git a/API Server/panel/src/i18n/pl.json b/API Server/panel/src/i18n/pl.json
index ba4ad34..42e3b49 100644
--- a/API Server/panel/src/i18n/pl.json
+++ b/API Server/panel/src/i18n/pl.json
@@ -137,6 +137,7 @@
"categories": {
"vehicles": "Producenci samochodów",
"chargers": "Ładowarki EV",
+ "notifications": "Powiadomienia",
"apis-external": "Pozostałe API",
"drives-external": "Dyski zdalne",
"drives-local": "Dyski lokalne"
diff --git a/README.md b/README.md
index 863889d..f7e48e4 100644
--- a/README.md
+++ b/README.md
@@ -62,7 +62,10 @@ The Web and Phone apps are at feature parity.
- **Integrations** — per-user connectors under a superadmin → org-admin → user
cascade. Built-in today: **Toyota Connected** (read-only vehicle data), the
**Anker Solix** V1 EV charger and the **Greencell** HabuDen wallbox (read over
- the owner's own MQTT broker — no Greencell cloud is involved).
+ the owner's own MQTT broker — no Greencell cloud is involved). **Apprise**
+ joins them as a server-wide connector rather than a per-user one: it hands a
+ message to an Apprise gateway the operator runs, which fans it out to any of
+ the 100+ services Apprise speaks.
- **Cars from the manufacturer's own service** — import a car straight off a
connected account (MyToyota today), choosing what to pull in, and read everything
that service knows about it from a dedicated first tab on the car. Generic over
diff --git a/TRANSLATIONS.md b/TRANSLATIONS.md
index 5438cb3..cffa39e 100644
--- a/TRANSLATIONS.md
+++ b/TRANSLATIONS.md
@@ -87,7 +87,7 @@ No screen code changes are needed to add a language.
Both apps are complete in all three languages. The one place the wording is
deliberately not translated is proper nouns: protocol and product names (OCPP,
CSMS, MQTT, MQTTS, TLS, Toyota Connected, MyToyota, Anker Solix, Greencell,
-HabuDen, Lexus) read the same in every file, as do the units — and so does the
+HabuDen, Lexus, Apprise) read the same in every file, as do the units — and so does the
Greencell device command `QUERY`, which is a literal the charger listens for.
- **API Server panel** — UI chrome, cards, login, status, and the API section