Add OpenWeather plugin with full Web App settings cascade

Builtin API Server connector for the OpenWeather API (current weather +
5-day forecast for a point, API-key auth, stdlib-only), surfaced to end
users through the same three-layer settings cascade as OpenSky/WebDAV.

- New builtin plugin (internal/plugins/builtin/openweather) with
  health probe, weather.current/forecast.5day capabilities, and tests.
- resolveOpenWeather cascade (global -> org -> user) with per-field
  independent resolution and API-key masking; GET/PUT/health endpoints.
- Web App BFF relays, api.js client, and a Settings card under
  APIs - External (scope switch, enable toggles, test connection).
- Resolver unit tests + rebuilt embedded frontend.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
tajniak81
2026-07-14 11:21:48 +02:00
co-authored by Claude Opus 4.8
parent 816db9b42f
commit 6231b43076
14 changed files with 1379 additions and 25 deletions
@@ -0,0 +1,423 @@
package api
import (
"context"
"encoding/json"
"net/http"
"net/url"
"strings"
)
// This file exposes the "openweather" plugin's settings to end users through the
// same three-layer cascade OpenSky uses (superadmin/global → organization →
// user); see integrations.go for the shared helpers (lockedFor, layerRank,
// maskPresent, callerFromRecord) and integrations_webdav.go for the endpoint shape.
//
// - global (L1): the plugin's config in plugins.json, set in the API Server panel.
// - org (L2): pluginSettings.openweather on the caller's organization record.
// - user (L3): pluginSettings.openweather on the caller's own user record.
//
// Unlike WebDAV (a connection group), every OpenWeather field resolves
// *independently* — top layer wins, a blank field falls through. There is a
// single secret (the API key) with no paired half, so no field group is needed.
//
// The API key is never returned to a lower-privileged client: the effective
// config is resolved server-side and only masked values leave the API. Live
// probes run server-side against the resolved config.
const openWeatherPlugin = "openweather"
// owFields are the plugin's ConfigField keys, in display order. Kept in sync with
// the plugin descriptor so the cascade covers every setting.
var owFields = []string{"apiKey", "units", "lat", "lon", "lang"}
// owSecretKeys are masked in every view and preserved on save when left at the mask.
var owSecretKeys = map[string]bool{"apiKey": true}
// owConfig is one layer's openweather settings. Values are strings to match the
// plugin's ConfigField keys 1:1 (they are handed straight to plugins.Init).
type owConfig struct {
APIKey string `json:"apiKey"`
Units string `json:"units"`
Lat string `json:"lat"`
Lon string `json:"lon"`
Lang string `json:"lang"`
}
// owStored is what we persist per user/org under pluginSettings.openweather.
type owStored struct {
Config owConfig `json:"config"`
// Enabled is the personal per-user opt-in (user layer). Default false.
Enabled bool `json:"enabled"`
// Disabled is the organization layer's off switch, stored inverted so that
// absent == enabled (mirrors OpenSky). Only meaningful on the org record.
Disabled bool `json:"disabled,omitempty"`
}
// owSettingsDoc is the openweather slice of the shared pluginSettings JSON.
type owSettingsDoc struct {
OpenWeather owStored `json:"openweather"`
}
// owResolution is the fully-resolved openweather state for one caller.
type owResolution struct {
eff owConfig // effective (unmasked) — used only server-side (probes)
userOwn owConfig // caller's personal (L3) values (unmasked)
orgOwn owConfig // organization (L2) values (unmasked)
source map[string]string // field -> layer name (global|org|user|unset)
isSuper bool // superadmin: manages the global layer in the panel
canOrg bool // caller may edit the organization layer (org admin)
available bool // global master switch (plugin enabled in the panel)
orgEnabled bool // org master switch (default true; gates the org's users)
enabled bool // caller's personal enable flag
}
// owConfigFromMap builds an owConfig from a flat string map (global plugin config).
func owConfigFromMap(m map[string]string) owConfig {
return owConfig{APIKey: m["apiKey"], Units: m["units"], Lat: m["lat"], Lon: m["lon"], Lang: m["lang"]}
}
// owGet returns a config field by the plugin's key name.
func owGet(c owConfig, key string) string {
switch key {
case "apiKey":
return c.APIKey
case "units":
return c.Units
case "lat":
return c.Lat
case "lon":
return c.Lon
case "lang":
return c.Lang
}
return ""
}
// owSet writes a config field by the plugin's key name.
func owSet(c *owConfig, key, v string) {
switch key {
case "apiKey":
c.APIKey = v
case "units":
c.Units = v
case "lat":
c.Lat = v
case "lon":
c.Lon = v
case "lang":
c.Lang = v
}
}
// resolveOpenWeather computes the cascade for a caller. userRaw is the caller's
// pluginSettings blob (from their auth-refresh record).
func (s *Server) resolveOpenWeather(ctx context.Context, who *callerIdentity, userRaw json.RawMessage) owResolution {
g, masterEnabled, _ := s.plugins.RawConfig(openWeatherPlugin)
gc := owConfigFromMap(g)
var oStored owStored
if who.OrgID != "" {
oStored, _ = s.orgOpenWeather(ctx, who.OrgID)
}
oc := oStored.Config
var uStored owStored
if len(userRaw) > 0 {
var d owSettingsDoc
_ = json.Unmarshal(userRaw, &d)
uStored = d.OpenWeather
}
uc := uStored.Config
res := owResolution{
source: map[string]string{},
userOwn: uc,
orgOwn: oc,
isSuper: who.isSuperadmin(),
// An org admin may edit the organization layer in addition to their own.
// Requires the service account (org writes go through it).
canOrg: who.isManager() && !who.isSuperadmin() && who.OrgID != "" && s.admin.configured(),
available: masterEnabled,
orgEnabled: !oStored.Disabled,
enabled: uStored.Enabled,
}
// Ordered layers, top (highest priority) first.
type layer struct {
name string
c owConfig
}
layers := []layer{{"global", gc}}
if who.OrgID != "" {
layers = append(layers, layer{"org", oc})
}
layers = append(layers, layer{"user", uc})
// Every field cascades independently: top wins, blanks fall through.
for _, key := range owFields {
src := "unset"
for _, l := range layers {
if v := strings.TrimSpace(owGet(l.c, key)); v != "" {
owSet(&res.eff, key, v)
src = l.name
break
}
}
res.source[key] = src
}
return res
}
// orgOpenWeather reads an organization's stored openweather settings (config + the
// org gate) and its raw pluginSettings blob via the service account. Best effort:
// zero values on any miss so callers proceed as if the org layer were empty.
func (s *Server) orgOpenWeather(ctx context.Context, orgID string) (owStored, json.RawMessage) {
if orgID == "" || !s.admin.configured() {
return owStored{}, nil
}
data, status, err := s.admin.do(ctx, http.MethodGet,
"/api/collections/organizations/records/"+url.PathEscape(orgID)+"?fields=pluginSettings", nil)
if err != nil || status != http.StatusOK {
return owStored{}, nil
}
var rec struct {
PluginSettings json.RawMessage `json:"pluginSettings"`
}
_ = json.Unmarshal(data, &rec)
var doc owSettingsDoc
if len(rec.PluginSettings) > 0 {
_ = json.Unmarshal(rec.PluginSettings, &doc)
}
return doc.OpenWeather, rec.PluginSettings
}
// mergeOpenWeather applies a mutation to the openweather entry of a pluginSettings
// blob, preserving any other plugin keys (e.g. opensky, webdav), and returns the
// new blob.
func mergeOpenWeather(existing json.RawMessage, apply func(*owStored)) json.RawMessage {
doc := map[string]json.RawMessage{}
if len(existing) > 0 {
_ = json.Unmarshal(existing, &doc)
}
if doc == nil {
doc = map[string]json.RawMessage{} // existing was JSON null
}
var ow owStored
if raw, ok := doc["openweather"]; ok {
_ = json.Unmarshal(raw, &ow)
}
apply(&ow)
b, _ := json.Marshal(ow)
doc["openweather"] = b
out, _ := json.Marshal(doc)
return out
}
// GET /api/integrations/openweather — resolved view for the caller.
func (s *Server) handleGetOpenWeather(w http.ResponseWriter, r *http.Request) {
who, userRaw, ok := s.integrationCaller(w, r)
if !ok {
return
}
res := s.resolveOpenWeather(r.Context(), who, userRaw)
writeJSON(w, http.StatusOK, s.openWeatherView(who, res))
}
// owScopeView builds the masked field set for one editable scope. editable is the
// layer the caller edits ("user" | "org" | "none"); a field is locked when its
// effective value is set above that layer.
func (s *Server) owScopeView(res owResolution, editable string) map[string]any {
own := res.userOwn
if editable == "org" {
own = res.orgOwn
}
fields := map[string]osFieldView{}
for _, key := range owFields {
src := res.source[key]
fv := osFieldView{Source: src, Locked: lockedFor(src, editable)}
if owSecretKeys[key] {
fv.Effective, fv.Own = maskPresent(owGet(res.eff, key)), maskPresent(owGet(own, key))
} else {
fv.Effective, fv.Own = owGet(res.eff, key), owGet(own, key)
}
fields[key] = fv
}
return map[string]any{"editableLayer": editable, "fields": fields}
}
// openWeatherView builds the masked, client-safe response body. It exposes a
// "user" scope for everyone plus, for org admins, an "org" scope.
func (s *Server) openWeatherView(who *callerIdentity, res owResolution) map[string]any {
out := map[string]any{
"available": res.available,
"orgEnabled": res.orgEnabled,
"enabled": res.enabled,
"role": who.Role,
"orgId": who.OrgID,
"canEditOrg": res.canOrg,
"isSuperadmin": res.isSuper,
}
if res.isSuper {
out["editableLayer"] = "none"
out["scopes"] = map[string]any{"user": s.owScopeView(res, "none")}
return out
}
scopes := map[string]any{"user": s.owScopeView(res, "user")}
if res.canOrg {
scopes["org"] = s.owScopeView(res, "org")
}
out["scopes"] = scopes
return out
}
// PUT /api/integrations/openweather — save the caller's editable layer. Body:
// {enabled?, scope?, config?}. Fields locked above the caller are ignored; the
// API key left at the mask is preserved.
func (s *Server) handlePutOpenWeather(w http.ResponseWriter, r *http.Request) {
token := r.Header.Get("Authorization")
var body struct {
Enabled *bool `json:"enabled"`
Scope string `json:"scope"`
Config map[string]string `json:"config"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, http.StatusBadRequest, "invalid json")
return
}
who, userRaw, ok := s.integrationCaller(w, r)
if !ok {
return
}
res := s.resolveOpenWeather(r.Context(), who, userRaw)
// Resolve which layer this write targets.
editable := "user"
switch {
case res.isSuper:
editable = "none"
case strings.EqualFold(strings.TrimSpace(body.Scope), "org"):
if !res.canOrg {
writeError(w, http.StatusForbidden, "only an organization admin can edit organization settings")
return
}
editable = "org"
}
// Overlay the fields the caller may change in this scope onto its own values.
newOwn := res.userOwn
if editable == "org" {
newOwn = res.orgOwn
}
for _, key := range owFields {
v, present := body.Config[key]
if !present || lockedFor(res.source[key], editable) {
continue
}
if owSecretKeys[key] && v == openSkySecretMask {
continue // keep current secret
}
owSet(&newOwn, key, strings.TrimSpace(v))
}
// Persist the organization layer (admins) via the service account.
if editable == "org" {
if who.OrgID == "" {
writeError(w, http.StatusForbidden, "your account is not attached to an organization")
return
}
if !s.admin.configured() {
writeError(w, http.StatusServiceUnavailable, "organization settings not configured on the server")
return
}
_, orgRaw := s.orgOpenWeather(r.Context(), who.OrgID)
newDoc := mergeOpenWeather(orgRaw, func(ow *owStored) {
ow.Config = newOwn
if body.Enabled != nil {
ow.Disabled = !*body.Enabled // org master switch, stored inverted
}
})
_, st, err := s.admin.do(r.Context(), http.MethodPatch,
"/api/collections/organizations/records/"+url.PathEscape(who.OrgID),
map[string]json.RawMessage{"pluginSettings": newDoc})
if err != nil {
writeJSON(w, http.StatusBadGateway, map[string]any{"error": "cannot reach PocketBase", "detail": err.Error()})
return
}
if st != http.StatusOK {
writeError(w, http.StatusBadGateway, "could not save organization settings")
return
}
}
// Persist the user record: the personal enable flag lives here (user/superadmin
// scope), and so does the personal config layer when this write targets user.
personalEnable := body.Enabled != nil && editable != "org"
if personalEnable || editable == "user" {
newDoc := mergeOpenWeather(userRaw, func(ow *owStored) {
if personalEnable {
ow.Enabled = *body.Enabled
}
if editable == "user" {
ow.Config = newOwn
}
})
if code, err := s.patchUserPluginSettings(r.Context(), token, who.ID, newDoc); err != nil {
writeJSON(w, http.StatusBadGateway, map[string]any{"error": "cannot reach PocketBase", "detail": err.Error()})
return
} else if code != http.StatusOK {
writeError(w, http.StatusBadGateway, "could not save user settings")
return
}
}
// Re-resolve and return the fresh view.
fresh, st, err := s.pbAuthRefresh(r.Context(), token)
if err != nil || st != http.StatusOK || fresh == nil {
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
return
}
res2 := s.resolveOpenWeather(r.Context(), who, fresh.Record["pluginSettings"])
writeJSON(w, http.StatusOK, s.openWeatherView(who, res2))
}
// POST /api/integrations/openweather/health — live probe using the caller's
// resolved config. Never returns the API key.
func (s *Server) handleOpenWeatherHealth(w http.ResponseWriter, r *http.Request) {
who, userRaw, ok := s.integrationCaller(w, r)
if !ok {
return
}
if !who.isSuperadmin() {
if _, _, ok := s.plugins.RawConfig(openWeatherPlugin); !ok {
writeError(w, http.StatusNotFound, "unknown plugin")
return
}
}
res := s.resolveOpenWeather(r.Context(), who, userRaw)
if !res.available {
writeJSON(w, http.StatusOK, map[string]any{"health": map[string]any{
"status": "down", "detail": "OpenWeather is disabled by the administrator"}})
return
}
if !res.orgEnabled {
writeJSON(w, http.StatusOK, map[string]any{"health": map[string]any{
"status": "down", "detail": "OpenWeather is disabled for your organization"}})
return
}
if strings.TrimSpace(res.eff.APIKey) == "" {
writeJSON(w, http.StatusOK, map[string]any{"health": map[string]any{
"status": "down", "detail": "No API key configured — add one to connect"}})
return
}
cfg := map[string]string{}
for _, k := range owFields {
cfg[k] = owGet(res.eff, k)
}
h, err := s.plugins.HealthCheckWith(r.Context(), openWeatherPlugin, cfg)
if err != nil {
writeJSON(w, http.StatusBadGateway, map[string]any{"error": err.Error()})
return
}
writeJSON(w, http.StatusOK, map[string]any{"health": h})
}
@@ -0,0 +1,145 @@
package api
import (
"context"
"encoding/json"
"testing"
"pilotvault/apiserver/internal/plugins"
)
// newOWServer builds a minimal Server whose plugin manager has openweather
// enabled with the given global config. admin is left nil (not configured), so
// the org layer is skipped and the cascade covers global + user only.
func newOWServer(t *testing.T, global map[string]string) *Server {
t.Helper()
mgr := plugins.NewManager(t.TempDir() + "/plugins.json")
if _, err := mgr.Upsert(context.Background(), openWeatherPlugin, true, global); err != nil {
t.Fatalf("enable openweather: %v", err)
}
return &Server{plugins: mgr}
}
// userRaw builds a pluginSettings blob with an openweather user layer.
func userRaw(t *testing.T, cfg owConfig, enabled bool) json.RawMessage {
t.Helper()
b, err := json.Marshal(owSettingsDoc{OpenWeather: owStored{Config: cfg, Enabled: enabled}})
if err != nil {
t.Fatal(err)
}
return b
}
func TestResolveOpenWeatherCascade(t *testing.T) {
// Global supplies the API key + units and leaves the location blank so the
// user layer fills it in. (A value set globally locks the lower layers — that
// is verified separately by the masked/locked apiKey below.)
s := newOWServer(t, map[string]string{"apiKey": "GLOBAL-KEY", "units": "metric"})
who := &callerIdentity{ID: "u1", Role: roleUser} // org-less
uRaw := userRaw(t, owConfig{Lat: "51.5", Lon: "-0.12"}, true)
res := s.resolveOpenWeather(context.Background(), who, uRaw)
if !res.available {
t.Error("available should be true when the plugin is enabled")
}
if !res.enabled {
t.Error("enabled should reflect the user's personal opt-in")
}
// API key + units come from global (user left them blank).
if res.eff.APIKey != "GLOBAL-KEY" || res.source["apiKey"] != "global" {
t.Errorf("apiKey = %q src %q, want GLOBAL-KEY/global", res.eff.APIKey, res.source["apiKey"])
}
if res.eff.Units != "metric" || res.source["units"] != "global" {
t.Errorf("units = %q src %q, want metric/global", res.eff.Units, res.source["units"])
}
// Location comes from the user layer (top-wins over the global default).
if res.eff.Lat != "51.5" || res.source["lat"] != "user" {
t.Errorf("lat = %q src %q, want 51.5/user", res.eff.Lat, res.source["lat"])
}
if res.eff.Lon != "-0.12" || res.source["lon"] != "user" {
t.Errorf("lon = %q src %q, want -0.12/user", res.eff.Lon, res.source["lon"])
}
}
// A field set globally locks the lower layers: even when the user supplies units,
// the global value stays in force and is sourced to "global".
func TestResolveOpenWeatherGlobalLocksUser(t *testing.T) {
s := newOWServer(t, map[string]string{"apiKey": "K", "units": "metric"})
who := &callerIdentity{ID: "u1", Role: roleUser}
uRaw := userRaw(t, owConfig{Units: "imperial"}, false)
res := s.resolveOpenWeather(context.Background(), who, uRaw)
if res.eff.Units != "metric" || res.source["units"] != "global" {
t.Errorf("units = %q src %q, want metric/global (global must lock the user's imperial)", res.eff.Units, res.source["units"])
}
}
// The view must never leak the concrete API key — only presence, masked.
func TestOpenWeatherViewMasksKey(t *testing.T) {
s := newOWServer(t, map[string]string{"apiKey": "GLOBAL-KEY", "units": "metric"})
who := &callerIdentity{ID: "u1", Role: roleUser}
res := s.resolveOpenWeather(context.Background(), who, nil)
view := s.openWeatherView(who, res)
scopes := view["scopes"].(map[string]any)
user := scopes["user"].(map[string]any)
fields := user["fields"].(map[string]osFieldView)
if got := fields["apiKey"].Effective; got != openSkySecretMask {
t.Errorf("apiKey effective = %q, want the mask (never the raw key)", got)
}
if fields["apiKey"].Source != "global" || !fields["apiKey"].Locked {
t.Errorf("apiKey field = %+v, want source global + locked for a plain user", fields["apiKey"])
}
// A non-secret field is shown in the clear.
if fields["units"].Effective != "metric" {
t.Errorf("units effective = %q, want metric", fields["units"].Effective)
}
}
// mergeOpenWeather must preserve sibling plugin keys (opensky/webdav) untouched.
func TestMergeOpenWeatherPreservesSiblings(t *testing.T) {
existing := json.RawMessage(`{"opensky":{"enabled":true},"webdav":{"config":{"baseURL":"https://x"}}}`)
out := mergeOpenWeather(existing, func(ow *owStored) {
ow.Config.APIKey = "K"
ow.Enabled = true
})
var doc map[string]json.RawMessage
if err := json.Unmarshal(out, &doc); err != nil {
t.Fatal(err)
}
if _, ok := doc["opensky"]; !ok {
t.Error("opensky key was dropped by mergeOpenWeather")
}
if _, ok := doc["webdav"]; !ok {
t.Error("webdav key was dropped by mergeOpenWeather")
}
var ow owStored
if err := json.Unmarshal(doc["openweather"], &ow); err != nil {
t.Fatal(err)
}
if ow.Config.APIKey != "K" || !ow.Enabled {
t.Errorf("openweather entry = %+v, want APIKey K + enabled", ow)
}
}
// A saved secret left at the mask on PUT must not be re-checked here, but the
// merge/keep logic lives in the handler; this guards the field/secret metadata
// the handler relies on stays consistent with the plugin descriptor.
func TestOpenWeatherFieldMetadata(t *testing.T) {
if !owSecretKeys["apiKey"] {
t.Error("apiKey must be a secret key")
}
// Every declared field must round-trip through owGet/owSet.
var c owConfig
for _, k := range owFields {
owSet(&c, k, "v-"+k)
}
for _, k := range owFields {
if owGet(c, k) != "v-"+k {
t.Errorf("owGet/owSet mismatch for %q", k)
}
}
}
+3
View File
@@ -112,6 +112,9 @@ func (s *Server) Handler() http.Handler {
mux.HandleFunc("GET /api/integrations/webdav", s.handleGetWebDav)
mux.HandleFunc("PUT /api/integrations/webdav", s.handlePutWebDav)
mux.HandleFunc("POST /api/integrations/webdav/health", s.handleWebDavHealth)
mux.HandleFunc("GET /api/integrations/openweather", s.handleGetOpenWeather)
mux.HandleFunc("PUT /api/integrations/openweather", s.handlePutOpenWeather)
mux.HandleFunc("POST /api/integrations/openweather/health", s.handleOpenWeatherHealth)
// User-management — gated on the caller being a manager (admin or superadmin).
// Admins are scoped to their own organization inside each handler.
@@ -7,5 +7,6 @@ import (
_ "pilotvault/apiserver/internal/plugins/builtin/filetransfer"
_ "pilotvault/apiserver/internal/plugins/builtin/localstorage"
_ "pilotvault/apiserver/internal/plugins/builtin/opensky"
_ "pilotvault/apiserver/internal/plugins/builtin/openweather"
_ "pilotvault/apiserver/internal/plugins/builtin/webdav"
)
@@ -0,0 +1,267 @@
// Package openweather is a built-in plugin connecting the OpenWeather API
// (current conditions and 5-day/3-hour forecast for a point location). Like the
// opensky connector it demonstrates a real third-party integration behind the
// plugin contract, here using simple API-key auth.
//
// Docs: https://openweathermap.org/current https://openweathermap.org/forecast5
package openweather
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"sync"
"time"
"pilotvault/apiserver/internal/plugins"
)
// apiBase is the OpenWeather REST root. It is copied into the plugin instance in
// Init so tests can point the connector at an httptest server.
const apiBase = "https://api.openweathermap.org"
// Default probe location — Warsaw, Poland. Used by the health probe and as the
// default point for weather.current / forecast.5day when a call supplies none.
const (
defaultLat = "52.2297"
defaultLon = "21.0122"
)
func init() {
plugins.Register("openweather", func() plugins.Plugin { return &Plugin{} })
}
// Plugin is the OpenWeather connector.
type Plugin struct {
mu sync.Mutex
apiKey string
units string
lang string
lat string
lon string
base string
client *http.Client
}
// errNoKey is returned when a probe/call has no resolved API key. OpenWeather has
// no anonymous tier, so a key is required for any live request.
var errNoKey = errors.New("no API key configured")
func (p *Plugin) Descriptor() plugins.Descriptor {
return plugins.Descriptor{
Name: "openweather",
Provider: "OpenWeather",
Version: "1.0.0",
Kind: plugins.KindBuiltin,
Category: plugins.CategoryAPIsExternal,
Capabilities: []plugins.Capability{
{ID: "weather.current", Method: "GET", Endpoint: "/data/2.5/weather?lat&lon",
Description: "Current weather for the configured (or a supplied) lat/lon."},
{ID: "forecast.5day", Method: "GET", Endpoint: "/data/2.5/forecast?lat&lon",
Description: "5-day / 3-hour forecast for the configured (or a supplied) lat/lon."},
},
AuthType: plugins.AuthAPIKey,
ConfigFields: []plugins.ConfigField{
{Key: "apiKey", Label: "API key", Type: "password", Secret: true,
Help: "Your OpenWeather API key (the appid query parameter). Required for any live request — OpenWeather has no anonymous tier. Leave blank to enable the plugin as a master switch and supply the key at another layer."},
{Key: "units", Label: "Units", Type: "select", Default: "metric",
Options: []plugins.SelectOption{
{Value: "standard", Label: "Standard — Kelvin, m/s"},
{Value: "metric", Label: "Metric — °C, m/s"},
{Value: "imperial", Label: "Imperial — °F, mph"},
},
Help: "Measurement system for temperatures and wind speed in responses."},
{Key: "lat", Label: "Default latitude", Type: "text", Default: defaultLat,
Help: "Latitude used by the health probe and by calls that supply no location (90…90)."},
{Key: "lon", Label: "Default longitude", Type: "text", Default: defaultLon,
Help: "Longitude used by the health probe and by calls that supply no location (180…180)."},
{Key: "lang", Label: "Language", Type: "text",
Help: "Optional ISO language code for human-readable weather descriptions (e.g. en, pl, de). Leave blank for the API default."},
},
}
}
func (p *Plugin) Init(_ context.Context, config map[string]string) error {
p.mu.Lock()
defer p.mu.Unlock()
p.apiKey = strings.TrimSpace(config["apiKey"])
p.units = strings.TrimSpace(config["units"])
if p.units == "" {
p.units = "metric"
}
p.lang = strings.TrimSpace(config["lang"])
p.lat = strings.TrimSpace(config["lat"])
if p.lat == "" {
p.lat = defaultLat
}
p.lon = strings.TrimSpace(config["lon"])
if p.lon == "" {
p.lon = defaultLon
}
p.base = apiBase
p.client = &http.Client{Timeout: 10 * time.Second}
return nil
}
// requestURL builds an absolute OpenWeather request URL for the given path,
// merging the caller's query with the configured units, language and API key.
// The API key is never included when unset (callers must guard on errNoKey).
func (p *Plugin) requestURL(path string, q url.Values) string {
p.mu.Lock()
base, key, units, lang := p.base, p.apiKey, p.units, p.lang
p.mu.Unlock()
if q == nil {
q = url.Values{}
}
if units != "" {
q.Set("units", units)
}
if lang != "" {
q.Set("lang", lang)
}
if key != "" {
q.Set("appid", key)
}
return strings.TrimRight(base, "/") + path + "?" + q.Encode()
}
// pointQuery returns a lat/lon query, preferring caller-supplied coordinates and
// falling back to the configured defaults.
func (p *Plugin) pointQuery(lat, lon string) url.Values {
p.mu.Lock()
dlat, dlon := p.lat, p.lon
p.mu.Unlock()
if strings.TrimSpace(lat) == "" {
lat = dlat
}
if strings.TrimSpace(lon) == "" {
lon = dlon
}
return url.Values{"lat": {strings.TrimSpace(lat)}, "lon": {strings.TrimSpace(lon)}}
}
// HealthCheck performs a live current-weather query at the configured location
// and classifies the outcome.
func (p *Plugin) HealthCheck(ctx context.Context) plugins.Health {
start := time.Now()
p.mu.Lock()
key := p.apiKey
p.mu.Unlock()
if key == "" {
return plugins.Health{Status: plugins.StatusDown, LatencyMs: time.Since(start).Milliseconds(),
Detail: errNoKey.Error()}
}
target := p.requestURL("/data/2.5/weather", p.pointQuery("", ""))
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, target, nil)
resp, err := p.client.Do(req)
lat := time.Since(start).Milliseconds()
if err != nil {
return plugins.Health{Status: plugins.StatusDown, LatencyMs: lat, Detail: err.Error()}
}
defer resp.Body.Close()
data, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
h := plugins.Health{LatencyMs: lat}
switch {
case resp.StatusCode >= 200 && resp.StatusCode < 300:
h.Status, h.Detail = plugins.StatusOK, "OpenWeather reachable"
if summary := currentSummary(data); summary != "" {
h.Detail += " · " + summary
}
case resp.StatusCode == http.StatusUnauthorized:
h.Status, h.Detail = plugins.StatusDegraded, "API key rejected (HTTP 401)"
case resp.StatusCode == http.StatusTooManyRequests:
h.Status, h.Detail = plugins.StatusDegraded, "rate limited (HTTP 429)"
default:
h.Status, h.Detail = plugins.StatusDown, "HTTP "+resp.Status
}
return h
}
// currentSummary renders a short "place: 12°C, clear sky" line from a current
// weather payload. Returns "" when the body can't be parsed.
func currentSummary(data []byte) string {
var out struct {
Name string `json:"name"`
Main struct {
Temp float64 `json:"temp"`
} `json:"main"`
Weather []struct {
Description string `json:"description"`
} `json:"weather"`
}
if json.Unmarshal(data, &out) != nil {
return ""
}
parts := []string{}
if out.Name != "" {
parts = append(parts, out.Name)
}
temp := strconv.FormatFloat(out.Main.Temp, 'f', -1, 64) + "°"
if len(out.Weather) > 0 && out.Weather[0].Description != "" {
temp += ", " + out.Weather[0].Description
}
if len(parts) == 0 {
return temp
}
return parts[0] + ": " + temp
}
// invokeParams is the optional per-call override accepted by Invoke actions.
type invokeParams struct {
Lat string `json:"lat"`
Lon string `json:"lon"`
}
// Invoke exposes weather.current / forecast.5day. Both accept an optional
// {lat,lon} override and otherwise use the configured default location.
func (p *Plugin) Invoke(ctx context.Context, action string, params json.RawMessage) (json.RawMessage, error) {
p.mu.Lock()
key := p.apiKey
p.mu.Unlock()
if key == "" {
return nil, errNoKey
}
var in invokeParams
if len(params) > 0 {
if err := json.Unmarshal(params, &in); err != nil {
return nil, fmt.Errorf("invalid params: %w", err)
}
}
var path string
switch action {
case "weather.current":
path = "/data/2.5/weather"
case "forecast.5day":
path = "/data/2.5/forecast"
default:
return nil, errors.New("unknown action: " + action)
}
target := p.requestURL(path, p.pointQuery(in.Lat, in.Lon))
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, target, nil)
resp, err := p.client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
data, _ := io.ReadAll(io.LimitReader(resp.Body, 8<<20))
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, fmt.Errorf("openweather %s: HTTP %s", action, resp.Status)
}
return data, nil
}
func (p *Plugin) Shutdown(context.Context) error { return nil }
@@ -0,0 +1,190 @@
package openweather
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"pilotvault/apiserver/internal/plugins"
)
func TestDescriptor(t *testing.T) {
p := &Plugin{}
d := p.Descriptor()
if d.Name != "openweather" {
t.Fatalf("name = %q, want openweather", d.Name)
}
if d.Kind != plugins.KindBuiltin {
t.Fatalf("kind = %q, want builtin", d.Kind)
}
if d.Category != plugins.CategoryAPIsExternal {
t.Fatalf("category = %q, want %q", d.Category, plugins.CategoryAPIsExternal)
}
if d.AuthType != plugins.AuthAPIKey {
t.Fatalf("authType = %q, want %q", d.AuthType, plugins.AuthAPIKey)
}
if len(d.Capabilities) == 0 {
t.Fatal("expected capabilities")
}
// The apiKey field must be flagged so the manager masks it, and no field may
// be Required (so the plugin can be enabled as an empty master switch).
for _, f := range d.ConfigFields {
if f.Key == "apiKey" && !f.Secret {
t.Errorf("config field %q must be Secret", f.Key)
}
if f.Required {
t.Errorf("config field %q must not be Required", f.Key)
}
}
}
func TestInitDefaults(t *testing.T) {
p := &Plugin{}
if err := p.Init(context.Background(), nil); err != nil {
t.Fatal(err)
}
if p.units != "metric" {
t.Errorf("default units = %q, want metric", p.units)
}
if p.lat != defaultLat || p.lon != defaultLon {
t.Errorf("default location = %q,%q, want %q,%q", p.lat, p.lon, defaultLat, defaultLon)
}
if p.client == nil {
t.Error("Init must build an http client")
}
}
func TestRequestURL(t *testing.T) {
p := &Plugin{}
_ = p.Init(context.Background(), map[string]string{
"apiKey": "secret", "units": "imperial", "lang": "pl",
})
got := p.requestURL("/data/2.5/weather", p.pointQuery("10", "20"))
for _, want := range []string{"lat=10", "lon=20", "units=imperial", "lang=pl", "appid=secret", "/data/2.5/weather?"} {
if !strings.Contains(got, want) {
t.Errorf("requestURL %q missing %q", got, want)
}
}
}
// TestRequestURLNoKey confirms the appid param is omitted when no key is set, so
// a key is never leaked as an empty value.
func TestRequestURLNoKey(t *testing.T) {
p := &Plugin{}
_ = p.Init(context.Background(), nil)
if got := p.requestURL("/data/2.5/weather", p.pointQuery("", "")); strings.Contains(got, "appid") {
t.Errorf("requestURL %q must not contain appid when key is unset", got)
}
}
// TestHealthCheckNoKey confirms a missing key is reported as down, not a panic.
func TestHealthCheckNoKey(t *testing.T) {
p := &Plugin{}
_ = p.Init(context.Background(), nil)
if h := p.HealthCheck(context.Background()); h.Status != plugins.StatusDown {
t.Errorf("status = %q, want down (detail=%q)", h.Status, h.Detail)
}
}
func TestRegistered(t *testing.T) {
m := plugins.NewManager(t.TempDir() + "/plugins.json")
if _, ok := m.Get("openweather"); !ok {
t.Fatal("openweather not registered in the plugin manager")
}
}
// fakeOW is a minimal OpenWeather stand-in: it validates the appid and echoes a
// small current-weather body, and returns 401 for a wrong key.
func newFakeOW(t *testing.T) *httptest.Server {
t.Helper()
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Query().Get("appid") != "good-key" {
w.WriteHeader(http.StatusUnauthorized)
return
}
switch r.URL.Path {
case "/data/2.5/weather":
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"name":"Warsaw","main":{"temp":12.5},"weather":[{"description":"clear sky"}]}`))
case "/data/2.5/forecast":
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"cnt":40,"list":[]}`))
default:
w.WriteHeader(http.StatusNotFound)
}
}))
}
func TestHealthCheckOK(t *testing.T) {
srv := newFakeOW(t)
defer srv.Close()
p := &Plugin{}
_ = p.Init(context.Background(), map[string]string{"apiKey": "good-key"})
p.base = srv.URL
h := p.HealthCheck(context.Background())
if h.Status != plugins.StatusOK {
t.Fatalf("status = %q, want ok (detail=%q)", h.Status, h.Detail)
}
if !strings.Contains(h.Detail, "Warsaw") || !strings.Contains(h.Detail, "clear sky") {
t.Errorf("detail = %q, want it to summarise the current conditions", h.Detail)
}
}
func TestHealthCheckAuthFailure(t *testing.T) {
srv := newFakeOW(t)
defer srv.Close()
p := &Plugin{}
_ = p.Init(context.Background(), map[string]string{"apiKey": "wrong"})
p.base = srv.URL
if h := p.HealthCheck(context.Background()); h.Status != plugins.StatusDegraded {
t.Errorf("status = %q, want degraded on 401 (detail=%q)", h.Status, h.Detail)
}
}
func TestInvoke(t *testing.T) {
srv := newFakeOW(t)
defer srv.Close()
p := &Plugin{}
_ = p.Init(context.Background(), map[string]string{"apiKey": "good-key"})
p.base = srv.URL
ctx := context.Background()
// current weather with an explicit location override
raw, err := p.Invoke(ctx, "weather.current", json.RawMessage(`{"lat":"51.5","lon":"-0.12"}`))
if err != nil {
t.Fatalf("weather.current: %v", err)
}
var cur struct {
Name string `json:"name"`
}
if err := json.Unmarshal(raw, &cur); err != nil || cur.Name != "Warsaw" {
t.Fatalf("weather.current body = %s (err=%v)", raw, err)
}
// 5-day forecast using the default location
if _, err := p.Invoke(ctx, "forecast.5day", nil); err != nil {
t.Fatalf("forecast.5day: %v", err)
}
// unknown action
if _, err := p.Invoke(ctx, "nope", nil); err == nil {
t.Error("expected error for unknown action")
}
}
// TestInvokeNoKey confirms Invoke refuses to call upstream without a key.
func TestInvokeNoKey(t *testing.T) {
p := &Plugin{}
_ = p.Init(context.Background(), nil)
if _, err := p.Invoke(context.Background(), "weather.current", nil); err == nil {
t.Error("expected errNoKey when no API key is configured")
}
}
+23
View File
@@ -321,6 +321,29 @@ func (a *App) handleWebDavHealth(w http.ResponseWriter, r *http.Request) {
a.doRelay(w, req)
}
// GET /bff/integrations/openweather → API Server /api/integrations/openweather
func (a *App) handleGetOpenWeather(w http.ResponseWriter, r *http.Request) {
req, _ := http.NewRequest(http.MethodGet, a.apiBaseFor(r)+"/api/integrations/openweather", nil)
req.Header.Set("Authorization", tokenOf(r))
a.doRelay(w, req)
}
// PUT /bff/integrations/openweather → API Server /api/integrations/openweather
func (a *App) handlePutOpenWeather(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
req, _ := http.NewRequest(http.MethodPut, a.apiBaseFor(r)+"/api/integrations/openweather", bytes.NewReader(body))
req.Header.Set("Authorization", tokenOf(r))
req.Header.Set("Content-Type", "application/json")
a.doRelay(w, req)
}
// POST /bff/integrations/openweather/health → API Server /api/integrations/openweather/health
func (a *App) handleOpenWeatherHealth(w http.ResponseWriter, r *http.Request) {
req, _ := http.NewRequest(http.MethodPost, a.apiBaseFor(r)+"/api/integrations/openweather/health", nil)
req.Header.Set("Authorization", tokenOf(r))
a.doRelay(w, req)
}
// GET /bff/users → API Server /api/users (admin only, enforced upstream)
func (a *App) handleListUsers(w http.ResponseWriter, r *http.Request) {
req, _ := http.NewRequest(http.MethodGet, a.apiBaseFor(r)+"/api/users", nil)
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -35,8 +35,8 @@
})()
</script>
<title>PilotVault — Control Panel</title>
<script type="module" crossorigin src="./assets/index-BfiNicZk.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-CcgvhCJr.css">
<script type="module" crossorigin src="./assets/index-BK_MuFN4.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-D7rbxd3q.css">
</head>
<body>
<div id="app"></div>
+3
View File
@@ -63,6 +63,9 @@ func main() {
mux.HandleFunc("GET /bff/integrations/webdav", app.requireAuth(app.handleGetWebDav))
mux.HandleFunc("PUT /bff/integrations/webdav", app.requireAuth(app.handlePutWebDav))
mux.HandleFunc("POST /bff/integrations/webdav/health", app.requireAuth(app.handleWebDavHealth))
mux.HandleFunc("GET /bff/integrations/openweather", app.requireAuth(app.handleGetOpenWeather))
mux.HandleFunc("PUT /bff/integrations/openweather", app.requireAuth(app.handlePutOpenWeather))
mux.HandleFunc("POST /bff/integrations/openweather/health", app.requireAuth(app.handleOpenWeatherHealth))
// User-management (role + org scoping enforced by the API Server)
mux.HandleFunc("GET /bff/users", app.requireAuth(app.handleListUsers))
mux.HandleFunc("POST /bff/users", app.requireAuth(app.handleCreateUser))
+29
View File
@@ -281,6 +281,35 @@ export async function testWebDav() {
return { ok: r.ok, status: r.status, body: await r.json().catch(() => ({})) }
}
/* ---------- Plugin integrations: OpenWeather ---------- */
// Resolved OpenWeather settings for the current user (cascade + masked API key).
export async function getOpenWeather() {
try {
const r = await fetch('/bff/integrations/openweather')
if (!r.ok) return { ok: false, status: r.status, body: await r.json().catch(() => ({})) }
return { ok: true, status: 200, body: await r.json() }
} catch {
return { ok: false, status: 0, body: {} }
}
}
// Save the caller's editable layer. payload: { scope?, enabled?, config? }.
export async function saveOpenWeather(payload) {
const r = await fetch('/bff/integrations/openweather', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
})
return { ok: r.ok, status: r.status, body: await r.json().catch(() => ({})) }
}
// Run a live current-weather probe against the caller's resolved config.
export async function testOpenWeather() {
const r = await fetch('/bff/integrations/openweather/health', { method: 'POST' })
return { ok: r.ok, status: r.status, body: await r.json().catch(() => ({})) }
}
/* ---------- Logbook: drones ---------- */
export async function getDrones() {
+272 -2
View File
@@ -14,6 +14,7 @@ import {
getFileTransfer, saveFileTransfer, testFileTransfer,
getLocalStorage, saveLocalStorage, testLocalStorage,
getWebDav, saveWebDav, testWebDav,
getOpenWeather, saveOpenWeather, testOpenWeather,
} from '../api.js'
const props = defineProps({
@@ -50,7 +51,7 @@ const SECTIONS = computed(() => {
const list = [
{ id: 'account', label: 'Account', icon: 'user', kw: 'name username email password verification login credentials role' },
{ id: 'appearance', label: 'Appearance', icon: 'sliders', kw: 'theme light dark system language region font size accessibility date time format motion' },
{ id: 'integrations', label: 'Integrations', icon: 'radio', kw: 'opensky flights adsb aircraft plugin oauth credentials bounding box plan connection ftp sftp ftps file transfer server host upload download local storage folder drive isolated private read only webdav nextcloud owncloud dav url https' },
{ id: 'integrations', label: 'Integrations', icon: 'radio', kw: 'opensky flights adsb aircraft plugin oauth credentials bounding box plan connection ftp sftp ftps file transfer server host upload download local storage folder drive isolated private read only webdav nextcloud owncloud dav url https openweather weather forecast temperature api key units' },
{ id: 'profile', label: 'Profile', icon: 'image', kw: 'avatar photo display name bio public' },
{ id: 'security', label: 'Privacy & Security', icon: 'shield', kw: 'two factor authentication 2fa sessions devices logout security privacy' },
]
@@ -79,7 +80,7 @@ function sectionMatches(s) {
const ROW_KW = {
account: ['full name', 'username', 'email address verification verify', 'password change current new'],
appearance: ['theme light dark system', 'language', 'region', 'font size accessibility', 'reduce motion', 'date format', 'time format clock'],
integrations: ['opensky live flights', 'enable plugin', 'oauth client id secret', 'plan credits', 'bounding box', 'test connection', 'file transfer ftp sftp ftps', 'server host port username password', 'private key passphrase', 'base path directory', 'local storage folder drive', 'private isolated folder', 'read only access mode', 'webdav nextcloud owncloud dav', 'server url username password tls', 'base path directory folder'],
integrations: ['opensky live flights', 'enable plugin', 'oauth client id secret', 'plan credits', 'bounding box', 'test connection', 'file transfer ftp sftp ftps', 'server host port username password', 'private key passphrase', 'base path directory', 'local storage folder drive', 'private isolated folder', 'read only access mode', 'webdav nextcloud owncloud dav', 'server url username password tls', 'base path directory folder', 'openweather weather forecast', 'api key units metric imperial', 'default latitude longitude language'],
profile: ['profile photo avatar', 'display name', 'bio about', 'show email public'],
security: ['two factor authentication', 'active sessions devices', 'sign out'],
team: ['add user create account', 'members list role admin remove delete', 'organization org assign'],
@@ -716,6 +717,150 @@ function wdHealthClass(status) {
return status === 'ok' ? badgeClass.success : status === 'degraded' ? badgeClass.warning : badgeClass.danger
}
/* ---------- integrations: OpenWeather ---------- */
// Same three-layer cascade as OpenSky (global → org → user); every field resolves
// independently (the API key is a lone secret, so no field group is needed).
const ow = reactive({
loaded: false,
available: false,
orgEnabled: true, // org master switch (admins toggle it; gates the org's users)
enabled: false, // personal opt-in
canEditOrg: false,
isSuperadmin: false,
scopes: {}, // { user: { editableLayer, fields }, org?: {...} }
})
const owScope = ref('user')
// All editable fields; the API key is prefilled with the mask and preserved on save.
const OW_KEYS = ['apiKey', 'units', 'lat', 'lon', 'lang']
const owForm = reactive(Object.fromEntries(OW_KEYS.map((k) => [k, ''])))
const owMsg = ref('')
const owSaving = ref(false)
const owTesting = ref(false)
const owHealth = ref(null) // { status, detail, latencyMs }
const owHealthTs = ref(null)
const OW_HEALTH_KEY = 'pv.openweather.health'
const OW_UNITS_OPTS = [
{ value: 'metric', label: 'Metric (°C)' },
{ value: 'imperial', label: 'Imperial (°F)' },
{ value: 'standard', label: 'Standard (K)' },
]
const owReadOnly = computed(() => ow.isSuperadmin)
const owScopeKey = computed(() => (ow.isSuperadmin ? 'user' : owScope.value))
const owScopeData = computed(() => ow.scopes[owScopeKey.value] || { editableLayer: 'user', fields: {} })
const owEditingOrg = computed(() => owScopeKey.value === 'org')
function owField(k) {
return owScopeData.value.fields[k] || { effective: '', own: '', source: 'unset', locked: false }
}
function owLocked(k) {
return owReadOnly.value || owField(k).locked
}
function owSourceLabel(k) {
const src = owField(k).source
if (src === 'global') return 'Set by administrator'
if (src === 'org') return 'Set by your organization'
return ''
}
function fillOwForm() {
for (const k of OW_KEYS) owForm[k] = owField(k).own || ''
if (!owForm.units) owForm.units = 'metric'
}
function applyOpenWeatherView(body) {
ow.available = !!body.available
ow.orgEnabled = body.orgEnabled !== false // default enabled
ow.enabled = !!body.enabled
ow.canEditOrg = !!body.canEditOrg
ow.isSuperadmin = !!body.isSuperadmin
ow.scopes = body.scopes || {}
if (owScope.value === 'org' && !ow.canEditOrg) owScope.value = 'user'
fillOwForm()
ow.loaded = true
}
watch(owScope, () => { owMsg.value = ''; fillOwForm() })
function owHealthAgo() {
if (!owHealthTs.value) return ''
const s = Math.max(0, Math.round((Date.now() - owHealthTs.value) / 1000))
if (s < 60) return 'just now'
const m = Math.round(s / 60)
if (m < 60) return `${m} min ago`
const h = Math.round(m / 60)
if (h < 24) return `${h} h ago`
return `${Math.round(h / 24)} d ago`
}
function persistOwHealth() {
try {
if (owHealth.value) localStorage.setItem(OW_HEALTH_KEY, JSON.stringify({ health: owHealth.value, ts: owHealthTs.value }))
} catch { /* storage unavailable — non-fatal */ }
}
function restoreOwHealth() {
try {
const raw = localStorage.getItem(OW_HEALTH_KEY)
if (!raw) return
const d = JSON.parse(raw)
if (d && d.health) { owHealth.value = d.health; owHealthTs.value = d.ts || null }
} catch { /* ignore corrupt cache */ }
}
async function loadOpenWeather() {
restoreOwHealth()
const { ok, body } = await getOpenWeather()
if (ok) applyOpenWeatherView(body)
}
async function toggleOpenWeather(v) {
const org = owEditingOrg.value
if (org) ow.orgEnabled = v
else ow.enabled = v
const { ok, body } = await saveOpenWeather(org ? { scope: 'org', enabled: v } : { scope: 'user', enabled: v })
if (ok) {
applyOpenWeatherView(body)
flash(org ? (v ? 'OpenWeather enabled for your organization.' : 'OpenWeather disabled for your organization.')
: (v ? 'OpenWeather enabled.' : 'OpenWeather disabled.'))
} else {
if (org) ow.orgEnabled = !v
else ow.enabled = !v
flash(body.error || 'Could not update.')
}
}
async function saveOpenWeatherSettings() {
owMsg.value = ''
owSaving.value = true
const config = {}
for (const k of OW_KEYS) {
if (!owLocked(k)) config[k] = owForm[k]
}
const payload = { scope: owScopeKey.value, config }
if (!owEditingOrg.value) payload.enabled = ow.enabled
const { ok, body } = await saveOpenWeather(payload)
owSaving.value = false
if (!ok) {
owMsg.value = body.error || 'Could not save settings.'
return
}
applyOpenWeatherView(body)
flash(owEditingOrg.value ? 'Organization OpenWeather settings saved.' : 'OpenWeather settings saved.')
}
async function testOpenWeatherConnection() {
owTesting.value = true
owHealth.value = null
const { ok, body } = await testOpenWeather()
owTesting.value = false
owHealth.value = ok && body.health ? body.health : { status: 'down', detail: body.error || 'Probe failed.' }
owHealthTs.value = Date.now()
persistOwHealth()
}
function owHealthClass(status) {
return status === 'ok' ? badgeClass.success : status === 'degraded' ? badgeClass.warning : badgeClass.danger
}
/* ---------- integrations: Local storage (host filesystem) ---------- */
// Same three-layer cascade as OpenSky (global → org → user), but the folder is not
// editable: it is derived from identity so each org-less user gets a private folder
@@ -1279,6 +1424,7 @@ onMounted(() => {
loadOpenSky()
loadFileTransfer()
loadWebDav()
loadOpenWeather()
loadLocalStorage()
})
onBeforeUnmount(() => {
@@ -1621,6 +1767,130 @@ onBeforeUnmount(() => {
</span>
</div>
</div>
<!-- ---- OpenWeather ---- -->
<div class="panel mb-5 p-5">
<!-- header -->
<div class="mb-4 flex items-start gap-3 border-b border-line pb-4">
<div class="grid h-10 w-10 place-items-center rounded-lg bg-accent-soft text-accent-soft-fg">
<Icon name="sun" :size="20" />
</div>
<div class="min-w-0">
<div class="text-sm font-semibold text-ink">OpenWeather</div>
<div class="mt-0.5 text-xs text-ink-muted">
Current conditions and forecast from the OpenWeather API. Configure the API key and default location your account uses.
</div>
</div>
</div>
<!-- unavailable notice -->
<div v-if="ow.loaded && !ow.available" class="mb-4 rounded-lg border border-line bg-surface-2 px-4 py-3 text-xs text-ink-secondary">
<Icon name="lock" :size="14" class="mr-1 inline" />
OpenWeather is currently disabled by your administrator. Contact them to enable it.
</div>
<!-- scope switch (admins manage personal + organization-wide settings) -->
<div v-if="ow.canEditOrg" class="mb-4 flex flex-col gap-2 border-b border-line pb-4 sm:flex-row sm:items-center sm:justify-between">
<div class="text-xs text-ink-muted">Manage your own settings, or organization-wide settings that apply to every user.</div>
<Segmented v-model="owScope" :options="OS_SCOPE_OPTS" />
</div>
<!-- enable toggle: org master switch (Organization) or personal (My settings) -->
<Row v-if="owEditingOrg" title="Enable OpenWeather (organization-wide)" desc="Turn it on or off for everyone in your organization." keywords="enable disable plugin openweather weather organization">
<Toggle :model-value="ow.orgEnabled" :disabled="!ow.available" @update:model-value="toggleOpenWeather" />
</Row>
<Row v-else title="Enable OpenWeather" desc="Turn the plugin on for your account in the Web App." keywords="enable disable plugin openweather weather">
<Toggle :model-value="ow.enabled" :disabled="!ow.available || !ow.orgEnabled" @update:model-value="toggleOpenWeather" />
</Row>
<!-- organization-disabled notice (personal scope) -->
<div v-if="!owEditingOrg && ow.available && !ow.orgEnabled" class="border-b border-line py-3 text-xs text-amber-fg">
<Icon name="lock" :size="13" class="mr-1 inline" />OpenWeather is turned off for your organization<span v-if="ow.canEditOrg"> — switch to <span class="font-semibold">Organization</span> to turn it back on</span>.
</div>
<!-- scope note -->
<div v-if="owEditingOrg" class="border-b border-line py-3 text-xs text-ink-muted">
<Icon name="users" :size="13" class="mr-1 inline" />These are organization-wide settings — they apply to everyone in
<span class="font-semibold text-ink-secondary">{{ organizationName || 'your organization' }}</span>. Leave the API key blank to let each user configure their own; a key set here overrides the user's.
</div>
<div v-else-if="owReadOnly" class="border-b border-line py-3 text-xs text-ink-muted">
As a superadmin you manage the global OpenWeather configuration in the API Server panel. The effective configuration is shown below.
</div>
<!-- API key -->
<Row title="API key" desc="Your OpenWeather API key (the appid parameter). Required — OpenWeather has no anonymous tier." keywords="api key appid secret credentials token openweather">
<template v-if="owLocked('apiKey')">
<span class="inline-flex items-center gap-2 font-mono text-sm text-ink">
{{ owField('apiKey').effective || '—' }}
<span v-if="owSourceLabel('apiKey')" class="inline-flex items-center gap-1 rounded-full bg-surface-2 px-2 py-0.5 text-[10px] font-semibold text-ink-secondary"><Icon name="lock" :size="10" />{{ owSourceLabel('apiKey') }}</span>
</span>
</template>
<input v-else v-model="owForm.apiKey" type="password" class="field w-64" placeholder="••••••••" />
</Row>
<!-- units -->
<Row title="Units" desc="Measurement system for temperatures and wind speed." keywords="units metric imperial standard celsius fahrenheit kelvin">
<template v-if="owLocked('units')">
<span class="inline-flex items-center gap-2 text-sm text-ink">
{{ (OW_UNITS_OPTS.find((o) => o.value === owField('units').effective) || {}).label || owField('units').effective || '—' }}
<span v-if="owSourceLabel('units')" class="inline-flex items-center gap-1 rounded-full bg-surface-2 px-2 py-0.5 text-[10px] font-semibold text-ink-secondary"><Icon name="lock" :size="10" />{{ owSourceLabel('units') }}</span>
</span>
</template>
<Segmented v-else v-model="owForm.units" :options="OW_UNITS_OPTS" />
</Row>
<!-- default latitude -->
<Row title="Default latitude" desc="Latitude used by the health probe and calls with no location (90…90)." keywords="latitude location coordinates default">
<template v-if="owLocked('lat')">
<span class="inline-flex items-center gap-2 font-mono text-sm text-ink">
{{ owField('lat').effective || '—' }}
<span v-if="owSourceLabel('lat')" class="inline-flex items-center gap-1 rounded-full bg-surface-2 px-2 py-0.5 text-[10px] font-semibold text-ink-secondary"><Icon name="lock" :size="10" />{{ owSourceLabel('lat') }}</span>
</span>
</template>
<input v-else v-model="owForm.lat" inputmode="decimal" class="field w-40 font-mono" placeholder="52.2297" />
</Row>
<!-- default longitude -->
<Row title="Default longitude" desc="Longitude used by the health probe and calls with no location (180…180)." keywords="longitude location coordinates default">
<template v-if="owLocked('lon')">
<span class="inline-flex items-center gap-2 font-mono text-sm text-ink">
{{ owField('lon').effective || '—' }}
<span v-if="owSourceLabel('lon')" class="inline-flex items-center gap-1 rounded-full bg-surface-2 px-2 py-0.5 text-[10px] font-semibold text-ink-secondary"><Icon name="lock" :size="10" />{{ owSourceLabel('lon') }}</span>
</span>
</template>
<input v-else v-model="owForm.lon" inputmode="decimal" class="field w-40 font-mono" placeholder="21.0122" />
</Row>
<!-- language (cascades independently) -->
<Row title="Language" desc="Optional ISO code for human-readable weather descriptions, e.g. en, pl, de." keywords="language locale description">
<template v-if="owLocked('lang')">
<span class="inline-flex items-center gap-2 font-mono text-sm text-ink">
{{ owField('lang').effective || '—' }}
<span v-if="owSourceLabel('lang')" class="inline-flex items-center gap-1 rounded-full bg-surface-2 px-2 py-0.5 text-[10px] font-semibold text-ink-secondary"><Icon name="lock" :size="10" />{{ owSourceLabel('lang') }}</span>
</span>
</template>
<input v-else v-model="owForm.lang" class="field w-24 font-mono" placeholder="en" />
</Row>
<!-- actions -->
<div class="mt-4 flex flex-wrap items-center gap-3">
<button v-if="!owReadOnly" class="btn-accent" :disabled="owSaving || !ow.available" @click="saveOpenWeatherSettings">
{{ owSaving ? 'Saving' : owEditingOrg ? 'Save organization settings' : 'Save settings' }}
</button>
<button v-if="!owEditingOrg" class="btn-ghost" :disabled="owTesting || !ow.available" @click="testOpenWeatherConnection">
{{ owTesting ? 'Testing' : 'Test connection' }}
</button>
<span v-if="owMsg" class="text-xs text-danger-fg">{{ owMsg }}</span>
<span v-if="owHealthTs && !owEditingOrg" class="text-[11px] text-ink-muted">Checked {{ owHealthAgo() }}</span>
<span
v-if="owHealth && !owEditingOrg"
class="inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-[11px] font-semibold"
:class="owHealthClass(owHealth.status)"
>
<span class="h-1.5 w-1.5 rounded-full bg-current"></span>{{ owHealth.detail || owHealth.status }}
</span>
</div>
</div>
</template>
<!-- ==== Drives External ==== -->