feat(setting): Implemented the settings administrative menu
This commit is contained in:
@@ -153,6 +153,43 @@ func RenewManifest(boxID string, seconds int64) (models.BoxManifest, error) {
|
|||||||
manifest.ExpiresAt = time.Now().UTC().Add(time.Duration(seconds) * time.Second)
|
manifest.ExpiresAt = time.Now().UTC().Add(time.Duration(seconds) * time.Second)
|
||||||
return manifest, writeManifestUnlocked(boxID, manifest)
|
return manifest, writeManifestUnlocked(boxID, manifest)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func ExpireBox(boxID string) (models.BoxManifest, error) {
|
||||||
|
manifestMu.Lock()
|
||||||
|
defer manifestMu.Unlock()
|
||||||
|
|
||||||
|
manifest, err := readManifestUnlocked(boxID)
|
||||||
|
if err != nil {
|
||||||
|
return manifest, err
|
||||||
|
}
|
||||||
|
manifest.ExpiresAt = time.Now().UTC().Add(-time.Second)
|
||||||
|
return manifest, writeManifestUnlocked(boxID, manifest)
|
||||||
|
}
|
||||||
|
|
||||||
|
func BumpBoxExpiry(boxID string, delta time.Duration) (models.BoxManifest, error) {
|
||||||
|
manifestMu.Lock()
|
||||||
|
defer manifestMu.Unlock()
|
||||||
|
|
||||||
|
manifest, err := readManifestUnlocked(boxID)
|
||||||
|
if err != nil {
|
||||||
|
return manifest, err
|
||||||
|
}
|
||||||
|
if delta <= 0 {
|
||||||
|
return manifest, fmt.Errorf("Invalid bump duration")
|
||||||
|
}
|
||||||
|
if manifest.OneTimeDownload {
|
||||||
|
return manifest, fmt.Errorf("One-time boxes cannot be extended")
|
||||||
|
}
|
||||||
|
|
||||||
|
base := manifest.ExpiresAt
|
||||||
|
now := time.Now().UTC()
|
||||||
|
if base.IsZero() || base.Before(now) {
|
||||||
|
base = now
|
||||||
|
}
|
||||||
|
manifest.ExpiresAt = base.Add(delta)
|
||||||
|
return manifest, writeManifestUnlocked(boxID, manifest)
|
||||||
|
}
|
||||||
|
|
||||||
func reconcileManifest(boxID string) (models.BoxManifest, error) {
|
func reconcileManifest(boxID string) (models.BoxManifest, error) {
|
||||||
manifestMu.Lock()
|
manifestMu.Lock()
|
||||||
defer manifestMu.Unlock()
|
defer manifestMu.Unlock()
|
||||||
|
|||||||
@@ -204,3 +204,57 @@ func TestBoxPasswordUsesBcryptAndVerifiesLegacy(t *testing.T) {
|
|||||||
t.Fatal("expected legacy password hash to verify")
|
t.Fatal("expected legacy password hash to verify")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestExpireBoxMarksManifestExpired(t *testing.T) {
|
||||||
|
restoreUploadRoot := UploadRoot()
|
||||||
|
defer SetUploadRoot(restoreUploadRoot)
|
||||||
|
SetUploadRoot(t.TempDir())
|
||||||
|
|
||||||
|
boxID := "0123456789abcdef0123456789abcdef"
|
||||||
|
if err := os.MkdirAll(BoxPath(boxID), 0755); err != nil {
|
||||||
|
t.Fatalf("MkdirAll returned error: %v", err)
|
||||||
|
}
|
||||||
|
manifest := models.BoxManifest{
|
||||||
|
CreatedAt: time.Now().UTC().Add(-time.Hour),
|
||||||
|
ExpiresAt: time.Now().UTC().Add(time.Hour),
|
||||||
|
}
|
||||||
|
if err := WriteManifest(boxID, manifest); err != nil {
|
||||||
|
t.Fatalf("WriteManifest returned error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
expired, err := ExpireBox(boxID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ExpireBox returned error: %v", err)
|
||||||
|
}
|
||||||
|
if !expired.ExpiresAt.Before(time.Now().UTC()) {
|
||||||
|
t.Fatalf("expected expired manifest time in past, got %s", expired.ExpiresAt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBumpBoxExpiryExtendsFutureExpiry(t *testing.T) {
|
||||||
|
restoreUploadRoot := UploadRoot()
|
||||||
|
defer SetUploadRoot(restoreUploadRoot)
|
||||||
|
SetUploadRoot(t.TempDir())
|
||||||
|
|
||||||
|
boxID := "fedcba9876543210fedcba9876543210"
|
||||||
|
if err := os.MkdirAll(BoxPath(boxID), 0755); err != nil {
|
||||||
|
t.Fatalf("MkdirAll returned error: %v", err)
|
||||||
|
}
|
||||||
|
base := time.Now().UTC().Add(time.Hour).Truncate(time.Second)
|
||||||
|
manifest := models.BoxManifest{
|
||||||
|
CreatedAt: time.Now().UTC().Add(-time.Hour),
|
||||||
|
ExpiresAt: base,
|
||||||
|
}
|
||||||
|
if err := WriteManifest(boxID, manifest); err != nil {
|
||||||
|
t.Fatalf("WriteManifest returned error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
bumped, err := BumpBoxExpiry(boxID, 24*time.Hour)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("BumpBoxExpiry returned error: %v", err)
|
||||||
|
}
|
||||||
|
expected := base.Add(24 * time.Hour)
|
||||||
|
if bumped.ExpiresAt.Before(expected.Add(-time.Second)) || bumped.ExpiresAt.After(expected.Add(time.Second)) {
|
||||||
|
t.Fatalf("expected bumped expiry near %s, got %s", expected, bumped.ExpiresAt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -145,8 +145,14 @@ func TestSettingsOverrideValidation(t *testing.T) {
|
|||||||
if err := cfg.ApplyOverride(SettingDefaultGuestExpirySecs, "-1"); err == nil {
|
if err := cfg.ApplyOverride(SettingDefaultGuestExpirySecs, "-1"); err == nil {
|
||||||
t.Fatal("expected negative expiry override to fail")
|
t.Fatal("expected negative expiry override to fail")
|
||||||
}
|
}
|
||||||
if err := cfg.ApplyOverride(SettingGlobalMaxFileSizeBytes, "1"); err == nil {
|
if err := cfg.ApplyOverride(SettingGlobalMaxFileSizeBytes, "1"); err != nil {
|
||||||
t.Fatal("expected hard limit override to fail")
|
t.Fatalf("expected global max file size override to succeed, got %v", err)
|
||||||
|
}
|
||||||
|
if cfg.GlobalMaxFileSizeBytes != 1 {
|
||||||
|
t.Fatalf("expected global max file size override to apply, got %d", cfg.GlobalMaxFileSizeBytes)
|
||||||
|
}
|
||||||
|
if err := cfg.ApplyOverride(SettingDataDir, "/tmp/elsewhere"); err == nil {
|
||||||
|
t.Fatal("expected data_dir override to remain locked")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,8 +12,8 @@ var Definitions = []SettingDefinition{
|
|||||||
{Key: SettingRenewOnDownloadEnabled, EnvName: "WARPBOX_RENEW_ON_DOWNLOAD_ENABLED", Label: "Renew on download enabled", Type: SettingTypeBool, Editable: true},
|
{Key: SettingRenewOnDownloadEnabled, EnvName: "WARPBOX_RENEW_ON_DOWNLOAD_ENABLED", Label: "Renew on download enabled", Type: SettingTypeBool, Editable: true},
|
||||||
{Key: SettingDefaultGuestExpirySecs, EnvName: "WARPBOX_DEFAULT_GUEST_EXPIRY_SECONDS", Label: "Default guest expiry seconds", Type: SettingTypeInt64, Editable: true, Minimum: 0},
|
{Key: SettingDefaultGuestExpirySecs, EnvName: "WARPBOX_DEFAULT_GUEST_EXPIRY_SECONDS", Label: "Default guest expiry seconds", Type: SettingTypeInt64, Editable: true, Minimum: 0},
|
||||||
{Key: SettingMaxGuestExpirySecs, EnvName: "WARPBOX_MAX_GUEST_EXPIRY_SECONDS", Label: "Max guest expiry seconds", Type: SettingTypeInt64, Editable: true, Minimum: 0},
|
{Key: SettingMaxGuestExpirySecs, EnvName: "WARPBOX_MAX_GUEST_EXPIRY_SECONDS", Label: "Max guest expiry seconds", Type: SettingTypeInt64, Editable: true, Minimum: 0},
|
||||||
{Key: SettingGlobalMaxFileSizeBytes, EnvName: "WARPBOX_GLOBAL_MAX_FILE_SIZE_BYTES", Label: "Global max file size bytes", Type: SettingTypeInt64, Editable: false, HardLimit: true, Minimum: 0},
|
{Key: SettingGlobalMaxFileSizeBytes, EnvName: "WARPBOX_GLOBAL_MAX_FILE_SIZE_BYTES", Label: "Global max file size bytes", Type: SettingTypeInt64, Editable: true, Minimum: 0},
|
||||||
{Key: SettingGlobalMaxBoxSizeBytes, EnvName: "WARPBOX_GLOBAL_MAX_BOX_SIZE_BYTES", Label: "Global max box size bytes", Type: SettingTypeInt64, Editable: false, HardLimit: true, Minimum: 0},
|
{Key: SettingGlobalMaxBoxSizeBytes, EnvName: "WARPBOX_GLOBAL_MAX_BOX_SIZE_BYTES", Label: "Global max box size bytes", Type: SettingTypeInt64, Editable: true, Minimum: 0},
|
||||||
{Key: SettingDefaultUserMaxFileBytes, EnvName: "WARPBOX_DEFAULT_USER_MAX_FILE_SIZE_BYTES", Label: "Default user max file size bytes", Type: SettingTypeInt64, Editable: true, Minimum: 0},
|
{Key: SettingDefaultUserMaxFileBytes, EnvName: "WARPBOX_DEFAULT_USER_MAX_FILE_SIZE_BYTES", Label: "Default user max file size bytes", Type: SettingTypeInt64, Editable: true, Minimum: 0},
|
||||||
{Key: SettingDefaultUserMaxBoxBytes, EnvName: "WARPBOX_DEFAULT_USER_MAX_BOX_SIZE_BYTES", Label: "Default user max box size bytes", Type: SettingTypeInt64, Editable: true, Minimum: 0},
|
{Key: SettingDefaultUserMaxBoxBytes, EnvName: "WARPBOX_DEFAULT_USER_MAX_BOX_SIZE_BYTES", Label: "Default user max box size bytes", Type: SettingTypeInt64, Editable: true, Minimum: 0},
|
||||||
{Key: SettingSessionTTLSeconds, EnvName: "WARPBOX_SESSION_TTL_SECONDS", Label: "Session TTL seconds", Type: SettingTypeInt64, Editable: true, Minimum: 60},
|
{Key: SettingSessionTTLSeconds, EnvName: "WARPBOX_SESSION_TTL_SECONDS", Label: "Session TTL seconds", Type: SettingTypeInt64, Editable: true, Minimum: 60},
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ func Load() (*Config, error) {
|
|||||||
ThumbnailIntervalSeconds: 30,
|
ThumbnailIntervalSeconds: 30,
|
||||||
sources: make(map[string]Source),
|
sources: make(map[string]Source),
|
||||||
values: make(map[string]string),
|
values: make(map[string]string),
|
||||||
|
defaults: make(map[string]string),
|
||||||
}
|
}
|
||||||
|
|
||||||
// Config precedence: defaults -> env -> overrides.
|
// Config precedence: defaults -> env -> overrides.
|
||||||
@@ -152,25 +153,32 @@ func (cfg *Config) EnsureDirectories() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
func (cfg *Config) captureDefaults() {
|
func (cfg *Config) captureDefaults() {
|
||||||
cfg.setValue(SettingDataDir, cfg.DataDir, SourceDefault)
|
cfg.captureDefaultValue(SettingDataDir, cfg.DataDir)
|
||||||
cfg.setValue(SettingGuestUploadsEnabled, formatBool(cfg.GuestUploadsEnabled), SourceDefault)
|
cfg.captureDefaultValue(SettingGuestUploadsEnabled, formatBool(cfg.GuestUploadsEnabled))
|
||||||
cfg.setValue(SettingAPIEnabled, formatBool(cfg.APIEnabled), SourceDefault)
|
cfg.captureDefaultValue(SettingAPIEnabled, formatBool(cfg.APIEnabled))
|
||||||
cfg.setValue(SettingZipDownloadsEnabled, formatBool(cfg.ZipDownloadsEnabled), SourceDefault)
|
cfg.captureDefaultValue(SettingZipDownloadsEnabled, formatBool(cfg.ZipDownloadsEnabled))
|
||||||
cfg.setValue(SettingOneTimeDownloadsEnabled, formatBool(cfg.OneTimeDownloadsEnabled), SourceDefault)
|
cfg.captureDefaultValue(SettingOneTimeDownloadsEnabled, formatBool(cfg.OneTimeDownloadsEnabled))
|
||||||
cfg.setValue(SettingOneTimeDownloadExpirySecs, strconv.FormatInt(cfg.OneTimeDownloadExpirySeconds, 10), SourceDefault)
|
cfg.captureDefaultValue(SettingOneTimeDownloadExpirySecs, strconv.FormatInt(cfg.OneTimeDownloadExpirySeconds, 10))
|
||||||
cfg.setValue(SettingOneTimeDownloadRetryFail, formatBool(cfg.OneTimeDownloadRetryOnFailure), SourceDefault)
|
cfg.captureDefaultValue(SettingOneTimeDownloadRetryFail, formatBool(cfg.OneTimeDownloadRetryOnFailure))
|
||||||
cfg.setValue(SettingRenewOnAccessEnabled, formatBool(cfg.RenewOnAccessEnabled), SourceDefault)
|
cfg.captureDefaultValue(SettingRenewOnAccessEnabled, formatBool(cfg.RenewOnAccessEnabled))
|
||||||
cfg.setValue(SettingRenewOnDownloadEnabled, formatBool(cfg.RenewOnDownloadEnabled), SourceDefault)
|
cfg.captureDefaultValue(SettingRenewOnDownloadEnabled, formatBool(cfg.RenewOnDownloadEnabled))
|
||||||
cfg.setValue(SettingDefaultGuestExpirySecs, strconv.FormatInt(cfg.DefaultGuestExpirySeconds, 10), SourceDefault)
|
cfg.captureDefaultValue(SettingDefaultGuestExpirySecs, strconv.FormatInt(cfg.DefaultGuestExpirySeconds, 10))
|
||||||
cfg.setValue(SettingMaxGuestExpirySecs, strconv.FormatInt(cfg.MaxGuestExpirySeconds, 10), SourceDefault)
|
cfg.captureDefaultValue(SettingMaxGuestExpirySecs, strconv.FormatInt(cfg.MaxGuestExpirySeconds, 10))
|
||||||
cfg.setValue(SettingGlobalMaxFileSizeBytes, strconv.FormatInt(cfg.GlobalMaxFileSizeBytes, 10), SourceDefault)
|
cfg.captureDefaultValue(SettingGlobalMaxFileSizeBytes, strconv.FormatInt(cfg.GlobalMaxFileSizeBytes, 10))
|
||||||
cfg.setValue(SettingGlobalMaxBoxSizeBytes, strconv.FormatInt(cfg.GlobalMaxBoxSizeBytes, 10), SourceDefault)
|
cfg.captureDefaultValue(SettingGlobalMaxBoxSizeBytes, strconv.FormatInt(cfg.GlobalMaxBoxSizeBytes, 10))
|
||||||
cfg.setValue(SettingDefaultUserMaxFileBytes, strconv.FormatInt(cfg.DefaultUserMaxFileSizeBytes, 10), SourceDefault)
|
cfg.captureDefaultValue(SettingDefaultUserMaxFileBytes, strconv.FormatInt(cfg.DefaultUserMaxFileSizeBytes, 10))
|
||||||
cfg.setValue(SettingDefaultUserMaxBoxBytes, strconv.FormatInt(cfg.DefaultUserMaxBoxSizeBytes, 10), SourceDefault)
|
cfg.captureDefaultValue(SettingDefaultUserMaxBoxBytes, strconv.FormatInt(cfg.DefaultUserMaxBoxSizeBytes, 10))
|
||||||
cfg.setValue(SettingSessionTTLSeconds, strconv.FormatInt(cfg.SessionTTLSeconds, 10), SourceDefault)
|
cfg.captureDefaultValue(SettingSessionTTLSeconds, strconv.FormatInt(cfg.SessionTTLSeconds, 10))
|
||||||
cfg.setValue(SettingBoxPollIntervalMS, strconv.Itoa(cfg.BoxPollIntervalMS), SourceDefault)
|
cfg.captureDefaultValue(SettingBoxPollIntervalMS, strconv.Itoa(cfg.BoxPollIntervalMS))
|
||||||
cfg.setValue(SettingThumbnailBatchSize, strconv.Itoa(cfg.ThumbnailBatchSize), SourceDefault)
|
cfg.captureDefaultValue(SettingThumbnailBatchSize, strconv.Itoa(cfg.ThumbnailBatchSize))
|
||||||
cfg.setValue(SettingThumbnailIntervalSeconds, strconv.Itoa(cfg.ThumbnailIntervalSeconds), SourceDefault)
|
cfg.captureDefaultValue(SettingThumbnailIntervalSeconds, strconv.Itoa(cfg.ThumbnailIntervalSeconds))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cfg *Config) captureDefaultValue(key string, value string) {
|
||||||
|
cfg.setValue(key, value, SourceDefault)
|
||||||
|
if cfg.defaults != nil {
|
||||||
|
cfg.defaults[key] = value
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (cfg *Config) applyStringEnv(key string, name string, target *string) error {
|
func (cfg *Config) applyStringEnv(key string, name string, target *string) error {
|
||||||
|
|||||||
@@ -97,4 +97,5 @@ type Config struct {
|
|||||||
|
|
||||||
sources map[string]Source
|
sources map[string]Source
|
||||||
values map[string]string
|
values map[string]string
|
||||||
|
defaults map[string]string
|
||||||
}
|
}
|
||||||
|
|||||||
68
lib/config/override_store.go
Normal file
68
lib/config/override_store.go
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const AdminSettingsOverrideFilename = "admin_settings_overrides.json"
|
||||||
|
|
||||||
|
type adminSettingsOverrideFile struct {
|
||||||
|
Format string `json:"format"`
|
||||||
|
SavedAt string `json:"saved_at"`
|
||||||
|
Overrides map[string]string `json:"overrides"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func ReadAdminSettingsOverrides(path string) (map[string]string, error) {
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return map[string]string{}, nil
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var payload adminSettingsOverrideFile
|
||||||
|
if err := json.Unmarshal(data, &payload); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if payload.Overrides == nil {
|
||||||
|
return map[string]string{}, nil
|
||||||
|
}
|
||||||
|
return payload.Overrides, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func WriteAdminSettingsOverrides(path string, overrides map[string]string) error {
|
||||||
|
if overrides == nil {
|
||||||
|
overrides = map[string]string{}
|
||||||
|
}
|
||||||
|
|
||||||
|
keys := make([]string, 0, len(overrides))
|
||||||
|
for key := range overrides {
|
||||||
|
keys = append(keys, key)
|
||||||
|
}
|
||||||
|
sort.Strings(keys)
|
||||||
|
|
||||||
|
normalized := make(map[string]string, len(overrides))
|
||||||
|
for _, key := range keys {
|
||||||
|
normalized[key] = overrides[key]
|
||||||
|
}
|
||||||
|
|
||||||
|
payload := adminSettingsOverrideFile{
|
||||||
|
Format: "warpbox.admin.settings.overrides.v1",
|
||||||
|
SavedAt: time.Now().UTC().Format(time.RFC3339),
|
||||||
|
Overrides: normalized,
|
||||||
|
}
|
||||||
|
data, err := json.MarshalIndent(payload, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return os.WriteFile(path, data, 0644)
|
||||||
|
}
|
||||||
@@ -76,6 +76,10 @@ func (cfg *Config) assignInt64(key string, value int64, source Source) {
|
|||||||
cfg.MaxGuestExpirySeconds = value
|
cfg.MaxGuestExpirySeconds = value
|
||||||
case SettingOneTimeDownloadExpirySecs:
|
case SettingOneTimeDownloadExpirySecs:
|
||||||
cfg.OneTimeDownloadExpirySeconds = value
|
cfg.OneTimeDownloadExpirySeconds = value
|
||||||
|
case SettingGlobalMaxFileSizeBytes:
|
||||||
|
cfg.GlobalMaxFileSizeBytes = value
|
||||||
|
case SettingGlobalMaxBoxSizeBytes:
|
||||||
|
cfg.GlobalMaxBoxSizeBytes = value
|
||||||
case SettingDefaultUserMaxFileBytes:
|
case SettingDefaultUserMaxFileBytes:
|
||||||
cfg.DefaultUserMaxFileSizeBytes = value
|
cfg.DefaultUserMaxFileSizeBytes = value
|
||||||
case SettingDefaultUserMaxBoxBytes:
|
case SettingDefaultUserMaxBoxBytes:
|
||||||
@@ -113,3 +117,10 @@ func (cfg *Config) sourceFor(key string) Source {
|
|||||||
}
|
}
|
||||||
return source
|
return source
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (cfg *Config) DefaultValue(key string) string {
|
||||||
|
if cfg.defaults == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return cfg.defaults[key]
|
||||||
|
}
|
||||||
|
|||||||
@@ -22,6 +22,13 @@ type Handlers struct {
|
|||||||
AdminLogout gin.HandlerFunc
|
AdminLogout gin.HandlerFunc
|
||||||
AdminDashboard gin.HandlerFunc
|
AdminDashboard gin.HandlerFunc
|
||||||
AdminAlerts gin.HandlerFunc
|
AdminAlerts gin.HandlerFunc
|
||||||
|
AdminBoxes gin.HandlerFunc
|
||||||
|
AdminBoxesAction gin.HandlerFunc
|
||||||
|
AdminSettings gin.HandlerFunc
|
||||||
|
AdminSettingsExport gin.HandlerFunc
|
||||||
|
AdminSettingsSave gin.HandlerFunc
|
||||||
|
AdminSettingsImport gin.HandlerFunc
|
||||||
|
AdminSettingsReset gin.HandlerFunc
|
||||||
AdminAuth gin.HandlerFunc
|
AdminAuth gin.HandlerFunc
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -52,4 +59,11 @@ func Register(router *gin.Engine, handlers Handlers) {
|
|||||||
protected := router.Group("/admin", handlers.AdminAuth)
|
protected := router.Group("/admin", handlers.AdminAuth)
|
||||||
protected.GET("/dashboard", handlers.AdminDashboard)
|
protected.GET("/dashboard", handlers.AdminDashboard)
|
||||||
protected.GET("/alerts", handlers.AdminAlerts)
|
protected.GET("/alerts", handlers.AdminAlerts)
|
||||||
|
protected.GET("/boxes", handlers.AdminBoxes)
|
||||||
|
protected.POST("/boxes/actions", handlers.AdminBoxesAction)
|
||||||
|
protected.GET("/settings", handlers.AdminSettings)
|
||||||
|
protected.GET("/settings/export", handlers.AdminSettingsExport)
|
||||||
|
protected.POST("/settings/save", handlers.AdminSettingsSave)
|
||||||
|
protected.POST("/settings/import", handlers.AdminSettingsImport)
|
||||||
|
protected.POST("/settings/reset", handlers.AdminSettingsReset)
|
||||||
}
|
}
|
||||||
|
|||||||
316
lib/server/admin_boxes.go
Normal file
316
lib/server/admin_boxes.go
Normal file
@@ -0,0 +1,316 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"warpbox/lib/boxstore"
|
||||||
|
)
|
||||||
|
|
||||||
|
type adminBoxesActionRequest struct {
|
||||||
|
Action string `json:"action"`
|
||||||
|
BoxIDs []string `json:"box_ids"`
|
||||||
|
DeltaSeconds int64 `json:"delta_seconds,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type adminBoxFileView struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
SizeLabel string `json:"size_label"`
|
||||||
|
MimeType string `json:"mime_type"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
StatusLabel string `json:"status_label"`
|
||||||
|
DownloadPath string `json:"download_path"`
|
||||||
|
ThumbnailURL string `json:"thumbnail_url"`
|
||||||
|
IsComplete bool `json:"is_complete"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type adminBoxView struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
StatusLabel string `json:"status_label"`
|
||||||
|
FileCount int `json:"file_count"`
|
||||||
|
CompleteFiles int `json:"complete_files"`
|
||||||
|
PendingFiles int `json:"pending_files"`
|
||||||
|
FailedFiles int `json:"failed_files"`
|
||||||
|
TotalSizeLabel string `json:"total_size_label"`
|
||||||
|
CreatedAtLabel string `json:"created_at_label"`
|
||||||
|
CreatedAtISO string `json:"created_at_iso"`
|
||||||
|
ExpiresAtLabel string `json:"expires_at_label"`
|
||||||
|
ExpiresAtISO string `json:"expires_at_iso"`
|
||||||
|
RetentionLabel string `json:"retention_label"`
|
||||||
|
PasswordProtected bool `json:"password_protected"`
|
||||||
|
OneTimeDownload bool `json:"one_time_download"`
|
||||||
|
ZipDisabled bool `json:"zip_disabled"`
|
||||||
|
ZipAvailable bool `json:"zip_available"`
|
||||||
|
Consumed bool `json:"consumed"`
|
||||||
|
HasManifest bool `json:"has_manifest"`
|
||||||
|
OpenURL string `json:"open_url"`
|
||||||
|
ZipURL string `json:"zip_url"`
|
||||||
|
Flags []string `json:"flags"`
|
||||||
|
Files []adminBoxFileView `json:"files"`
|
||||||
|
SearchText string `json:"search_text"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *App) handleAdminBoxes(ctx *gin.Context) {
|
||||||
|
if !app.adminLoginEnabled() {
|
||||||
|
ctx.Redirect(http.StatusSeeOther, "/")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
boxes, err := app.listAdminBoxes()
|
||||||
|
if err != nil {
|
||||||
|
ctx.String(http.StatusInternalServerError, "Could not load boxes")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.HTML(http.StatusOK, "admin/boxes.html", gin.H{
|
||||||
|
"AdminUsername": app.config.AdminUsername,
|
||||||
|
"AdminEmail": app.config.AdminEmail,
|
||||||
|
"ActivePage": "boxes",
|
||||||
|
"Boxes": boxes,
|
||||||
|
"ZipDownloadsOn": app.config.ZipDownloadsEnabled,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *App) handleAdminBoxesAction(ctx *gin.Context) {
|
||||||
|
var request adminBoxesActionRequest
|
||||||
|
if err := ctx.ShouldBindJSON(&request); err != nil {
|
||||||
|
ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid action payload"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(request.BoxIDs) == 0 {
|
||||||
|
ctx.JSON(http.StatusBadRequest, gin.H{"error": "Select one or more boxes first"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
switch request.Action {
|
||||||
|
case "delete", "expire", "bump":
|
||||||
|
default:
|
||||||
|
ctx.JSON(http.StatusBadRequest, gin.H{"error": "Unknown action"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if request.Action == "bump" && request.DeltaSeconds <= 0 {
|
||||||
|
ctx.JSON(http.StatusBadRequest, gin.H{"error": "Missing bump duration"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
processed := 0
|
||||||
|
warnings := make([]string, 0)
|
||||||
|
|
||||||
|
for _, boxID := range request.BoxIDs {
|
||||||
|
if !boxstore.ValidBoxID(boxID) {
|
||||||
|
warnings = append(warnings, fmt.Sprintf("%s: invalid box id", boxID))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
var err error
|
||||||
|
switch request.Action {
|
||||||
|
case "delete":
|
||||||
|
err = boxstore.DeleteBox(boxID)
|
||||||
|
case "expire":
|
||||||
|
_, err = boxstore.ExpireBox(boxID)
|
||||||
|
case "bump":
|
||||||
|
_, err = boxstore.BumpBoxExpiry(boxID, time.Duration(request.DeltaSeconds)*time.Second)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
warnings = append(warnings, fmt.Sprintf("%s: %v", boxID, err))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
processed++
|
||||||
|
}
|
||||||
|
|
||||||
|
boxes, err := app.listAdminBoxes()
|
||||||
|
if err != nil {
|
||||||
|
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "Action finished, but boxes could not be reloaded"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
status := http.StatusOK
|
||||||
|
if processed == 0 && len(warnings) > 0 {
|
||||||
|
status = http.StatusBadRequest
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.JSON(status, gin.H{
|
||||||
|
"ok": len(warnings) == 0,
|
||||||
|
"message": adminBoxesActionMessage(request.Action, processed, request.DeltaSeconds),
|
||||||
|
"warnings": warnings,
|
||||||
|
"boxes": boxes,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *App) listAdminBoxes() ([]adminBoxView, error) {
|
||||||
|
summaries, err := boxstore.ListBoxSummaries()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
boxes := make([]adminBoxView, 0, len(summaries))
|
||||||
|
for _, summary := range summaries {
|
||||||
|
boxView, err := app.buildAdminBoxView(summary.ID)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
boxes = append(boxes, boxView)
|
||||||
|
}
|
||||||
|
|
||||||
|
sort.Slice(boxes, func(i, j int) bool {
|
||||||
|
return boxes[i].CreatedAtISO > boxes[j].CreatedAtISO
|
||||||
|
})
|
||||||
|
return boxes, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *App) buildAdminBoxView(boxID string) (adminBoxView, error) {
|
||||||
|
summary, err := boxstore.BoxSummary(boxID)
|
||||||
|
if err != nil {
|
||||||
|
return adminBoxView{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
files, err := boxstore.ListFiles(boxID)
|
||||||
|
if err != nil {
|
||||||
|
return adminBoxView{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
manifest, manifestErr := boxstore.ReadManifest(boxID)
|
||||||
|
hasManifest := manifestErr == nil
|
||||||
|
|
||||||
|
boxView := adminBoxView{
|
||||||
|
ID: summary.ID,
|
||||||
|
FileCount: summary.FileCount,
|
||||||
|
TotalSizeLabel: summary.TotalSizeLabel,
|
||||||
|
CreatedAtLabel: adminTimeLabel(summary.CreatedAt),
|
||||||
|
CreatedAtISO: formatBrowserTime(summary.CreatedAt),
|
||||||
|
ExpiresAtLabel: "Not set",
|
||||||
|
ExpiresAtISO: formatBrowserTime(summary.ExpiresAt),
|
||||||
|
RetentionLabel: "Legacy / unmanaged",
|
||||||
|
PasswordProtected: summary.PasswordProtected,
|
||||||
|
OneTimeDownload: summary.OneTimeDownload,
|
||||||
|
HasManifest: hasManifest,
|
||||||
|
OpenURL: "/box/" + summary.ID,
|
||||||
|
Files: make([]adminBoxFileView, 0, len(files)),
|
||||||
|
}
|
||||||
|
|
||||||
|
if !summary.ExpiresAt.IsZero() {
|
||||||
|
boxView.ExpiresAtLabel = adminTimeLabel(summary.ExpiresAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
searchParts := []string{summary.ID, summary.TotalSizeLabel}
|
||||||
|
for _, file := range files {
|
||||||
|
if file.IsComplete {
|
||||||
|
boxView.CompleteFiles++
|
||||||
|
}
|
||||||
|
if file.Status == "failed" {
|
||||||
|
boxView.FailedFiles++
|
||||||
|
}
|
||||||
|
if !file.IsComplete && file.Status != "failed" {
|
||||||
|
boxView.PendingFiles++
|
||||||
|
}
|
||||||
|
|
||||||
|
boxView.Files = append(boxView.Files, adminBoxFileView{
|
||||||
|
Name: file.Name,
|
||||||
|
SizeLabel: file.SizeLabel,
|
||||||
|
MimeType: file.MimeType,
|
||||||
|
Status: file.Status,
|
||||||
|
StatusLabel: file.StatusLabel,
|
||||||
|
DownloadPath: file.DownloadPath,
|
||||||
|
ThumbnailURL: file.ThumbnailURL,
|
||||||
|
IsComplete: file.IsComplete,
|
||||||
|
})
|
||||||
|
searchParts = append(searchParts, file.Name, file.MimeType, file.StatusLabel)
|
||||||
|
}
|
||||||
|
|
||||||
|
if hasManifest {
|
||||||
|
boxView.RetentionLabel = manifest.RetentionLabel
|
||||||
|
boxView.ZipDisabled = manifest.DisableZip
|
||||||
|
boxView.Consumed = manifest.Consumed
|
||||||
|
} else {
|
||||||
|
boxView.ZipDisabled = false
|
||||||
|
}
|
||||||
|
|
||||||
|
boxView.ZipAvailable = app.config.ZipDownloadsEnabled && !boxView.ZipDisabled && !boxView.Consumed && boxView.FileCount > 0 && boxView.PendingFiles == 0
|
||||||
|
if boxView.ZipAvailable {
|
||||||
|
boxView.ZipURL = "/box/" + summary.ID + "/download"
|
||||||
|
}
|
||||||
|
|
||||||
|
boxView.Status, boxView.StatusLabel = deriveAdminBoxStatus(hasManifest, summary.Expired, boxView.PendingFiles, boxView.FailedFiles, boxView.Consumed)
|
||||||
|
boxView.Flags = deriveAdminBoxFlags(boxView)
|
||||||
|
searchParts = append(searchParts, boxView.StatusLabel, boxView.RetentionLabel)
|
||||||
|
boxView.SearchText = strings.ToLower(strings.Join(searchParts, " "))
|
||||||
|
|
||||||
|
return boxView, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func deriveAdminBoxStatus(hasManifest bool, expired bool, pendingFiles int, failedFiles int, consumed bool) (string, string) {
|
||||||
|
switch {
|
||||||
|
case !hasManifest:
|
||||||
|
return "legacy", "Legacy"
|
||||||
|
case consumed:
|
||||||
|
return "consumed", "Consumed"
|
||||||
|
case expired:
|
||||||
|
return "expired", "Expired"
|
||||||
|
case pendingFiles > 0:
|
||||||
|
return "uploading", "Uploading"
|
||||||
|
case failedFiles > 0:
|
||||||
|
return "attention", "Needs review"
|
||||||
|
default:
|
||||||
|
return "ready", "Ready"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func deriveAdminBoxFlags(box adminBoxView) []string {
|
||||||
|
flags := make([]string, 0, 5)
|
||||||
|
if box.PasswordProtected {
|
||||||
|
flags = append(flags, "protected")
|
||||||
|
}
|
||||||
|
if box.OneTimeDownload {
|
||||||
|
flags = append(flags, "one-time")
|
||||||
|
}
|
||||||
|
if box.ZipDisabled {
|
||||||
|
flags = append(flags, "zip off")
|
||||||
|
}
|
||||||
|
if !box.HasManifest {
|
||||||
|
flags = append(flags, "legacy")
|
||||||
|
}
|
||||||
|
if box.Consumed {
|
||||||
|
flags = append(flags, "consumed")
|
||||||
|
}
|
||||||
|
return flags
|
||||||
|
}
|
||||||
|
|
||||||
|
func adminTimeLabel(value time.Time) string {
|
||||||
|
if value.IsZero() {
|
||||||
|
return "Not set"
|
||||||
|
}
|
||||||
|
return value.UTC().Format("2006-01-02 15:04 UTC")
|
||||||
|
}
|
||||||
|
|
||||||
|
func adminBoxesActionMessage(action string, processed int, deltaSeconds int64) string {
|
||||||
|
switch action {
|
||||||
|
case "delete":
|
||||||
|
return fmt.Sprintf("Deleted %d box(es)", processed)
|
||||||
|
case "expire":
|
||||||
|
return fmt.Sprintf("Expired %d box(es)", processed)
|
||||||
|
case "bump":
|
||||||
|
return fmt.Sprintf("Extended %d box(es) by %s", processed, adminBoxesDeltaLabel(deltaSeconds))
|
||||||
|
default:
|
||||||
|
return "Action complete"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func adminBoxesDeltaLabel(deltaSeconds int64) string {
|
||||||
|
switch deltaSeconds {
|
||||||
|
case 24 * 60 * 60:
|
||||||
|
return "24h"
|
||||||
|
case 7 * 24 * 60 * 60:
|
||||||
|
return "7d"
|
||||||
|
default:
|
||||||
|
return (time.Duration(deltaSeconds) * time.Second).String()
|
||||||
|
}
|
||||||
|
}
|
||||||
461
lib/server/admin_settings.go
Normal file
461
lib/server/admin_settings.go
Normal file
@@ -0,0 +1,461 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"warpbox/lib/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
type adminSettingsCategoryView struct {
|
||||||
|
Key string
|
||||||
|
Label string
|
||||||
|
Icon string
|
||||||
|
Count int
|
||||||
|
Rows []adminSettingRowView
|
||||||
|
}
|
||||||
|
|
||||||
|
type adminSettingRowView struct {
|
||||||
|
Key string `json:"key"`
|
||||||
|
Label string `json:"label"`
|
||||||
|
EnvName string `json:"env_name"`
|
||||||
|
Category string `json:"category"`
|
||||||
|
CategoryLabel string `json:"category_label"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
Value string `json:"value"`
|
||||||
|
DefaultValue string `json:"default_value"`
|
||||||
|
Source string `json:"source"`
|
||||||
|
SourceBadge string `json:"source_badge"`
|
||||||
|
Editable bool `json:"editable"`
|
||||||
|
Locked bool `json:"locked"`
|
||||||
|
HardLimit bool `json:"hard_limit"`
|
||||||
|
Minimum int64 `json:"minimum"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type adminSettingsSaveRequest struct {
|
||||||
|
Values map[string]string `json:"values"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type adminSettingsImportRequest struct {
|
||||||
|
Settings map[string]string `json:"settings"`
|
||||||
|
EditableSettings map[string]string `json:"editable_settings"`
|
||||||
|
Values map[string]string `json:"values"`
|
||||||
|
Changes map[string]string `json:"changes"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type adminSettingsResetRequest struct {
|
||||||
|
Keys []string `json:"keys"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type adminSettingsExportResponse struct {
|
||||||
|
Format string `json:"format"`
|
||||||
|
ExportedAt string `json:"exported_at"`
|
||||||
|
Settings map[string]string `json:"settings"`
|
||||||
|
EditableSettings map[string]string `json:"editable_settings"`
|
||||||
|
Rows []adminSettingRowView `json:"rows"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *App) handleAdminSettings(ctx *gin.Context) {
|
||||||
|
rows, categories := app.buildAdminSettingsRows()
|
||||||
|
ctx.HTML(http.StatusOK, "admin/settings.html", gin.H{
|
||||||
|
"AdminUsername": app.config.AdminUsername,
|
||||||
|
"AdminEmail": app.config.AdminEmail,
|
||||||
|
"ActivePage": "settings",
|
||||||
|
"Rows": rows,
|
||||||
|
"Categories": categories,
|
||||||
|
"RowsJSON": rows,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *App) handleAdminSettingsExport(ctx *gin.Context) {
|
||||||
|
rows, _ := app.buildAdminSettingsRows()
|
||||||
|
ctx.JSON(http.StatusOK, app.buildSettingsExportPayload(rows))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *App) handleAdminSettingsSave(ctx *gin.Context) {
|
||||||
|
var request adminSettingsSaveRequest
|
||||||
|
if err := ctx.ShouldBindJSON(&request); err != nil {
|
||||||
|
ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid save payload"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, warnings, err := app.applySettingsOverrideSet(request.Values)
|
||||||
|
if err != nil {
|
||||||
|
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.JSON(http.StatusOK, gin.H{
|
||||||
|
"ok": true,
|
||||||
|
"message": fmt.Sprintf("Saved %d editable setting(s)", len(request.Values)),
|
||||||
|
"warnings": warnings,
|
||||||
|
"rows": rows,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *App) handleAdminSettingsImport(ctx *gin.Context) {
|
||||||
|
var request adminSettingsImportRequest
|
||||||
|
if err := ctx.ShouldBindJSON(&request); err != nil {
|
||||||
|
ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid import payload"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
values := request.Values
|
||||||
|
if len(values) == 0 {
|
||||||
|
values = request.Settings
|
||||||
|
}
|
||||||
|
if len(values) == 0 {
|
||||||
|
values = request.EditableSettings
|
||||||
|
}
|
||||||
|
if len(values) == 0 {
|
||||||
|
values = request.Changes
|
||||||
|
}
|
||||||
|
if len(values) == 0 {
|
||||||
|
ctx.JSON(http.StatusBadRequest, gin.H{"error": "No importable settings found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
editable := map[string]bool{}
|
||||||
|
for _, def := range config.EditableDefinitions() {
|
||||||
|
editable[def.Key] = true
|
||||||
|
}
|
||||||
|
filtered := make(map[string]string, len(values))
|
||||||
|
warnings := make([]string, 0)
|
||||||
|
for key, value := range values {
|
||||||
|
if editable[key] {
|
||||||
|
filtered[key] = value
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, found := config.Definition(key); found {
|
||||||
|
warnings = append(warnings, fmt.Sprintf("%s skipped: locked", key))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
warnings = append(warnings, fmt.Sprintf("%s skipped: unknown key", key))
|
||||||
|
}
|
||||||
|
currentOverrides, err := config.ReadAdminSettingsOverrides(app.settingsOverridesPath)
|
||||||
|
if err != nil {
|
||||||
|
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "Could not load current settings overrides"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for key, value := range currentOverrides {
|
||||||
|
if _, exists := filtered[key]; !exists {
|
||||||
|
filtered[key] = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, applyWarnings, err := app.applySettingsOverrideSet(filtered)
|
||||||
|
if err != nil {
|
||||||
|
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
warnings = append(warnings, applyWarnings...)
|
||||||
|
|
||||||
|
ctx.JSON(http.StatusOK, gin.H{
|
||||||
|
"ok": true,
|
||||||
|
"message": fmt.Sprintf("Imported %d setting value(s)", len(values)),
|
||||||
|
"warnings": warnings,
|
||||||
|
"rows": rows,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *App) handleAdminSettingsReset(ctx *gin.Context) {
|
||||||
|
var request adminSettingsResetRequest
|
||||||
|
_ = ctx.ShouldBindJSON(&request)
|
||||||
|
|
||||||
|
defs := config.EditableDefinitions()
|
||||||
|
overrideSet := make(map[string]string, len(defs))
|
||||||
|
targetKeys := map[string]bool{}
|
||||||
|
for _, key := range request.Keys {
|
||||||
|
targetKeys[key] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(targetKeys) == 0 {
|
||||||
|
for _, def := range defs {
|
||||||
|
overrideSet[def.Key] = app.config.DefaultValue(def.Key)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
currentOverrides, err := config.ReadAdminSettingsOverrides(app.settingsOverridesPath)
|
||||||
|
if err != nil {
|
||||||
|
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "Could not load settings overrides"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for key, value := range currentOverrides {
|
||||||
|
overrideSet[key] = value
|
||||||
|
}
|
||||||
|
for _, def := range defs {
|
||||||
|
if targetKeys[def.Key] {
|
||||||
|
overrideSet[def.Key] = app.config.DefaultValue(def.Key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, warnings, err := app.applySettingsOverrideSet(overrideSet)
|
||||||
|
if err != nil {
|
||||||
|
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.JSON(http.StatusOK, gin.H{
|
||||||
|
"ok": true,
|
||||||
|
"message": "Editable settings reset to application defaults",
|
||||||
|
"warnings": warnings,
|
||||||
|
"rows": rows,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *App) applySettingsOverrideSet(values map[string]string) ([]adminSettingRowView, []string, error) {
|
||||||
|
if !app.config.AllowAdminSettingsOverride {
|
||||||
|
return nil, nil, fmt.Errorf("runtime admin setting overrides are disabled by environment")
|
||||||
|
}
|
||||||
|
if values == nil {
|
||||||
|
values = map[string]string{}
|
||||||
|
}
|
||||||
|
|
||||||
|
overrideSet := make(map[string]string, len(values))
|
||||||
|
warnings := make([]string, 0)
|
||||||
|
editable := map[string]config.SettingDefinition{}
|
||||||
|
for _, def := range config.EditableDefinitions() {
|
||||||
|
editable[def.Key] = def
|
||||||
|
}
|
||||||
|
|
||||||
|
keys := make([]string, 0, len(values))
|
||||||
|
for key := range values {
|
||||||
|
keys = append(keys, key)
|
||||||
|
}
|
||||||
|
sort.Strings(keys)
|
||||||
|
|
||||||
|
for _, key := range keys {
|
||||||
|
value := strings.TrimSpace(values[key])
|
||||||
|
def, ok := editable[key]
|
||||||
|
if !ok {
|
||||||
|
if _, found := config.Definition(key); found {
|
||||||
|
return nil, nil, fmt.Errorf("setting %q is locked and cannot be changed", key)
|
||||||
|
}
|
||||||
|
warnings = append(warnings, fmt.Sprintf("%s skipped: unknown key", key))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if value == "" && def.Type != config.SettingTypeText {
|
||||||
|
return nil, nil, fmt.Errorf("setting %q cannot be blank", key)
|
||||||
|
}
|
||||||
|
overrideSet[key] = value
|
||||||
|
}
|
||||||
|
|
||||||
|
nextCfg, err := config.Load()
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
if err := nextCfg.ApplyOverrides(overrideSet); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
if err := config.WriteAdminSettingsOverrides(app.settingsOverridesPath, overrideSet); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
app.config = nextCfg
|
||||||
|
applyBoxstoreRuntimeConfig(app.config)
|
||||||
|
rows, _ := app.buildAdminSettingsRows()
|
||||||
|
return rows, warnings, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *App) buildSettingsExportPayload(rows []adminSettingRowView) adminSettingsExportResponse {
|
||||||
|
settings := make(map[string]string, len(rows))
|
||||||
|
editable := make(map[string]string)
|
||||||
|
for _, row := range rows {
|
||||||
|
settings[row.Key] = row.Value
|
||||||
|
if row.Editable && !row.Locked {
|
||||||
|
editable[row.Key] = row.Value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return adminSettingsExportResponse{
|
||||||
|
Format: "warpbox.settings.export.v1",
|
||||||
|
ExportedAt: time.Now().UTC().Format(time.RFC3339),
|
||||||
|
Settings: settings,
|
||||||
|
EditableSettings: editable,
|
||||||
|
Rows: rows,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *App) buildAdminSettingsRows() ([]adminSettingRowView, []adminSettingsCategoryView) {
|
||||||
|
cfgRows := app.config.SettingRows()
|
||||||
|
rows := make([]adminSettingRowView, 0, len(cfgRows)+5)
|
||||||
|
for _, row := range cfgRows {
|
||||||
|
rows = append(rows, app.makeDefinitionSettingRow(row))
|
||||||
|
}
|
||||||
|
rows = append(rows,
|
||||||
|
app.makeLockedSettingRow("admin_username", "Admin username", "WARPBOX_ADMIN_USERNAME", "accounts", "admin", app.config.AdminUsername, "Environment-controlled admin login name."),
|
||||||
|
app.makeLockedSettingRow("admin_email", "Admin email", "WARPBOX_ADMIN_EMAIL", "accounts", "admin", app.config.AdminEmail, "Administrative contact address used for future account and alert workflows."),
|
||||||
|
app.makeLockedSettingRow("admin_enabled", "Admin enabled mode", "WARPBOX_ADMIN_ENABLED", "accounts", "admin", string(app.config.AdminEnabled), "Controls whether administrative login is disabled, forced on, or auto-detected."),
|
||||||
|
app.makeLockedSettingRow("admin_cookie_secure", "Admin cookie secure", "WARPBOX_ADMIN_COOKIE_SECURE", "accounts", "bool", boolString(app.config.AdminCookieSecure), "Secure admin cookie flag. Locking this avoids accidental auth regressions."),
|
||||||
|
app.makeLockedSettingRow("allow_admin_settings_override", "Admin settings override allowed", "WARPBOX_ALLOW_ADMIN_SETTINGS_OVERRIDE", "accounts", "bool", boolString(app.config.AllowAdminSettingsOverride), "Master switch for runtime admin setting overrides."),
|
||||||
|
)
|
||||||
|
|
||||||
|
sort.Slice(rows, func(i, j int) bool {
|
||||||
|
if rows[i].Category == rows[j].Category {
|
||||||
|
return rows[i].Label < rows[j].Label
|
||||||
|
}
|
||||||
|
return settingsCategoryRank(rows[i].Category) < settingsCategoryRank(rows[j].Category)
|
||||||
|
})
|
||||||
|
|
||||||
|
categoryMeta := settingsCategoryMeta()
|
||||||
|
categories := make([]adminSettingsCategoryView, 0, len(categoryMeta)+1)
|
||||||
|
allCategory := adminSettingsCategoryView{Key: "all", Label: "All settings", Icon: "▤", Count: len(rows)}
|
||||||
|
categories = append(categories, allCategory)
|
||||||
|
|
||||||
|
grouped := map[string][]adminSettingRowView{}
|
||||||
|
for _, row := range rows {
|
||||||
|
grouped[row.Category] = append(grouped[row.Category], row)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, meta := range categoryMeta {
|
||||||
|
categories = append(categories, adminSettingsCategoryView{
|
||||||
|
Key: meta.Key,
|
||||||
|
Label: meta.Label,
|
||||||
|
Icon: meta.Icon,
|
||||||
|
Count: len(grouped[meta.Key]),
|
||||||
|
Rows: grouped[meta.Key],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return rows, categories
|
||||||
|
}
|
||||||
|
|
||||||
|
func boolString(value bool) string {
|
||||||
|
if value {
|
||||||
|
return "true"
|
||||||
|
}
|
||||||
|
return "false"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *App) makeDefinitionSettingRow(row config.SettingRow) adminSettingRowView {
|
||||||
|
def := row.Definition
|
||||||
|
locked := !def.Editable || def.HardLimit
|
||||||
|
source := string(row.Source)
|
||||||
|
sourceBadge := source
|
||||||
|
if locked {
|
||||||
|
sourceBadge = "hard env"
|
||||||
|
}
|
||||||
|
return adminSettingRowView{
|
||||||
|
Key: def.Key,
|
||||||
|
Label: def.Label,
|
||||||
|
EnvName: def.EnvName,
|
||||||
|
Category: settingsCategoryForKey(def.Key),
|
||||||
|
CategoryLabel: settingsCategoryLabel(settingsCategoryForKey(def.Key)),
|
||||||
|
Type: string(def.Type),
|
||||||
|
Value: row.Value,
|
||||||
|
DefaultValue: app.config.DefaultValue(def.Key),
|
||||||
|
Source: source,
|
||||||
|
SourceBadge: sourceBadge,
|
||||||
|
Editable: def.Editable && !def.HardLimit,
|
||||||
|
Locked: locked,
|
||||||
|
HardLimit: def.HardLimit,
|
||||||
|
Minimum: def.Minimum,
|
||||||
|
Description: settingsDescription(def.Key),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *App) makeLockedSettingRow(key string, label string, envName string, category string, rowType string, value string, description string) adminSettingRowView {
|
||||||
|
return adminSettingRowView{
|
||||||
|
Key: key,
|
||||||
|
Label: label,
|
||||||
|
EnvName: envName,
|
||||||
|
Category: category,
|
||||||
|
CategoryLabel: settingsCategoryLabel(category),
|
||||||
|
Type: rowType,
|
||||||
|
Value: value,
|
||||||
|
DefaultValue: "",
|
||||||
|
Source: "environment",
|
||||||
|
SourceBadge: "hard env",
|
||||||
|
Editable: false,
|
||||||
|
Locked: true,
|
||||||
|
HardLimit: true,
|
||||||
|
Description: description,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type settingsCategoryInfo struct {
|
||||||
|
Key string
|
||||||
|
Label string
|
||||||
|
Icon string
|
||||||
|
}
|
||||||
|
|
||||||
|
func settingsCategoryMeta() []settingsCategoryInfo {
|
||||||
|
return []settingsCategoryInfo{
|
||||||
|
{Key: "uploads", Label: "Uploads", Icon: "↥"},
|
||||||
|
{Key: "downloads", Label: "Downloads", Icon: "↧"},
|
||||||
|
{Key: "retention", Label: "Retention", Icon: "⌛"},
|
||||||
|
{Key: "accounts", Label: "Accounts", Icon: "☺"},
|
||||||
|
{Key: "api", Label: "API", Icon: "{ }"},
|
||||||
|
{Key: "storage", Label: "Storage", Icon: "▥"},
|
||||||
|
{Key: "workers", Label: "Workers", Icon: "⚙"},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func settingsCategoryLabel(key string) string {
|
||||||
|
for _, meta := range settingsCategoryMeta() {
|
||||||
|
if meta.Key == key {
|
||||||
|
return meta.Label
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "General"
|
||||||
|
}
|
||||||
|
|
||||||
|
func settingsCategoryRank(key string) int {
|
||||||
|
for index, meta := range settingsCategoryMeta() {
|
||||||
|
if meta.Key == key {
|
||||||
|
return index
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return len(settingsCategoryMeta()) + 1
|
||||||
|
}
|
||||||
|
|
||||||
|
func settingsCategoryForKey(key string) string {
|
||||||
|
switch key {
|
||||||
|
case config.SettingGuestUploadsEnabled, config.SettingDefaultUserMaxFileBytes, config.SettingDefaultUserMaxBoxBytes, config.SettingGlobalMaxFileSizeBytes, config.SettingGlobalMaxBoxSizeBytes:
|
||||||
|
return "uploads"
|
||||||
|
case config.SettingZipDownloadsEnabled, config.SettingOneTimeDownloadsEnabled, config.SettingOneTimeDownloadExpirySecs, config.SettingRenewOnDownloadEnabled:
|
||||||
|
return "downloads"
|
||||||
|
case config.SettingRenewOnAccessEnabled, config.SettingDefaultGuestExpirySecs, config.SettingMaxGuestExpirySecs, config.SettingOneTimeDownloadRetryFail:
|
||||||
|
return "retention"
|
||||||
|
case config.SettingSessionTTLSeconds:
|
||||||
|
return "accounts"
|
||||||
|
case config.SettingAPIEnabled:
|
||||||
|
return "api"
|
||||||
|
case config.SettingDataDir:
|
||||||
|
return "storage"
|
||||||
|
case config.SettingBoxPollIntervalMS, config.SettingThumbnailBatchSize, config.SettingThumbnailIntervalSeconds:
|
||||||
|
return "workers"
|
||||||
|
default:
|
||||||
|
return "accounts"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func settingsDescription(key string) string {
|
||||||
|
descriptions := map[string]string{
|
||||||
|
config.SettingGuestUploadsEnabled: "Allow unauthenticated guests to create boxes through the public upload flow.",
|
||||||
|
config.SettingAPIEnabled: "Enable API endpoints used by the browser upload and status workflows.",
|
||||||
|
config.SettingZipDownloadsEnabled: "Allow archive downloads for full boxes when ZIP is supported.",
|
||||||
|
config.SettingOneTimeDownloadsEnabled: "Enable one-time download retention mode for boxes.",
|
||||||
|
config.SettingOneTimeDownloadExpirySecs: "Expiry window, in seconds, for one-time download boxes after upload completion.",
|
||||||
|
config.SettingOneTimeDownloadRetryFail: "When enabled by environment, failed one-time ZIP writes leave the box retryable.",
|
||||||
|
config.SettingRenewOnAccessEnabled: "Extend retention when a box page is viewed.",
|
||||||
|
config.SettingRenewOnDownloadEnabled: "Extend retention when file or ZIP downloads happen.",
|
||||||
|
config.SettingDefaultGuestExpirySecs: "Default retention presented to guest uploads.",
|
||||||
|
config.SettingMaxGuestExpirySecs: "Maximum retention guests may request.",
|
||||||
|
config.SettingGlobalMaxFileSizeBytes: "Global single-file upload ceiling applied to future requests across the whole app.",
|
||||||
|
config.SettingGlobalMaxBoxSizeBytes: "Global total box size ceiling applied to future requests across the whole app.",
|
||||||
|
config.SettingDefaultUserMaxFileBytes: "Default per-user file size ceiling used by future account-aware flows.",
|
||||||
|
config.SettingDefaultUserMaxBoxBytes: "Default per-user box size ceiling used by future account-aware flows.",
|
||||||
|
config.SettingSessionTTLSeconds: "Lifetime for authenticated browser sessions, including admin session cookies.",
|
||||||
|
config.SettingBoxPollIntervalMS: "Browser polling cadence for box status refreshes.",
|
||||||
|
config.SettingThumbnailBatchSize: "How many thumbnail jobs the worker handles per batch.",
|
||||||
|
config.SettingThumbnailIntervalSeconds: "Delay between thumbnail worker passes.",
|
||||||
|
config.SettingDataDir: "Root data path. Locked because moving storage roots live is risky.",
|
||||||
|
}
|
||||||
|
return descriptions[key]
|
||||||
|
}
|
||||||
258
lib/server/admin_settings_test.go
Normal file
258
lib/server/admin_settings_test.go
Normal file
@@ -0,0 +1,258 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"warpbox/lib/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestAdminSettingsRequiresAuth(t *testing.T) {
|
||||||
|
app, router := setupAdminSettingsTest(t)
|
||||||
|
|
||||||
|
request := httptest.NewRequest(http.MethodGet, "/admin/settings", nil)
|
||||||
|
response := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(response, request)
|
||||||
|
|
||||||
|
if response.Code != http.StatusSeeOther {
|
||||||
|
t.Fatalf("expected redirect, got %d", response.Code)
|
||||||
|
}
|
||||||
|
if location := response.Header().Get("Location"); location != "/admin/login" {
|
||||||
|
t.Fatalf("expected login redirect, got %q", location)
|
||||||
|
}
|
||||||
|
if app == nil {
|
||||||
|
t.Fatal("expected app setup")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAdminSettingsPageRenders(t *testing.T) {
|
||||||
|
app, router := setupAdminSettingsTest(t)
|
||||||
|
|
||||||
|
request := httptest.NewRequest(http.MethodGet, "/admin/settings", nil)
|
||||||
|
request.AddCookie(authCookie(app))
|
||||||
|
response := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(response, request)
|
||||||
|
|
||||||
|
if response.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected 200, got %d", response.Code)
|
||||||
|
}
|
||||||
|
body := response.Body.String()
|
||||||
|
if !strings.Contains(body, "WarpBox Settings") {
|
||||||
|
t.Fatalf("expected settings page title, got %s", body)
|
||||||
|
}
|
||||||
|
if !strings.Contains(body, "WARPBOX_API_ENABLED") {
|
||||||
|
t.Fatalf("expected API env var in page body")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAdminSettingsExportIncludesCurrentValues(t *testing.T) {
|
||||||
|
app, router := setupAdminSettingsTest(t)
|
||||||
|
|
||||||
|
request := httptest.NewRequest(http.MethodGet, "/admin/settings/export", nil)
|
||||||
|
request.AddCookie(authCookie(app))
|
||||||
|
response := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(response, request)
|
||||||
|
|
||||||
|
if response.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected 200, got %d", response.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
var payload struct {
|
||||||
|
Format string `json:"format"`
|
||||||
|
Settings map[string]string `json:"settings"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(response.Body.Bytes(), &payload); err != nil {
|
||||||
|
t.Fatalf("json.Unmarshal returned error: %v", err)
|
||||||
|
}
|
||||||
|
if payload.Format != "warpbox.settings.export.v1" {
|
||||||
|
t.Fatalf("unexpected export format: %q", payload.Format)
|
||||||
|
}
|
||||||
|
if payload.Settings[config.SettingAPIEnabled] != "false" {
|
||||||
|
t.Fatalf("expected api_enabled to reflect environment false, got %q", payload.Settings[config.SettingAPIEnabled])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAdminSettingsSavePersistsEditableOverrides(t *testing.T) {
|
||||||
|
app, router := setupAdminSettingsTest(t)
|
||||||
|
|
||||||
|
request := httptest.NewRequest(http.MethodPost, "/admin/settings/save", strings.NewReader(`{"values":{"api_enabled":"true","box_poll_interval_ms":"6000"}}`))
|
||||||
|
request.Header.Set("Content-Type", "application/json")
|
||||||
|
request.AddCookie(authCookie(app))
|
||||||
|
response := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(response, request)
|
||||||
|
|
||||||
|
if response.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected 200, got %d: %s", response.Code, response.Body.String())
|
||||||
|
}
|
||||||
|
if !app.config.APIEnabled {
|
||||||
|
t.Fatal("expected APIEnabled override to be applied")
|
||||||
|
}
|
||||||
|
if app.config.BoxPollIntervalMS != 6000 {
|
||||||
|
t.Fatalf("expected poll interval override, got %d", app.config.BoxPollIntervalMS)
|
||||||
|
}
|
||||||
|
|
||||||
|
overrides, err := config.ReadAdminSettingsOverrides(app.settingsOverridesPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ReadAdminSettingsOverrides returned error: %v", err)
|
||||||
|
}
|
||||||
|
if overrides[config.SettingAPIEnabled] != "true" {
|
||||||
|
t.Fatalf("expected persisted API override, got %#v", overrides)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAdminSettingsSaveRejectsLockedSetting(t *testing.T) {
|
||||||
|
app, router := setupAdminSettingsTest(t)
|
||||||
|
|
||||||
|
request := httptest.NewRequest(http.MethodPost, "/admin/settings/save", strings.NewReader(`{"values":{"data_dir":"./other"}}`))
|
||||||
|
request.Header.Set("Content-Type", "application/json")
|
||||||
|
request.AddCookie(authCookie(app))
|
||||||
|
response := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(response, request)
|
||||||
|
|
||||||
|
if response.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("expected 400, got %d", response.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAdminSettingsImportSkipsLockedAndUnknownKeys(t *testing.T) {
|
||||||
|
app, router := setupAdminSettingsTest(t)
|
||||||
|
|
||||||
|
request := httptest.NewRequest(http.MethodPost, "/admin/settings/import", strings.NewReader(`{"settings":{"api_enabled":"true","data_dir":"./other","bogus":"x"}}`))
|
||||||
|
request.Header.Set("Content-Type", "application/json")
|
||||||
|
request.AddCookie(authCookie(app))
|
||||||
|
response := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(response, request)
|
||||||
|
|
||||||
|
if response.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected 200, got %d: %s", response.Code, response.Body.String())
|
||||||
|
}
|
||||||
|
if !app.config.APIEnabled {
|
||||||
|
t.Fatal("expected editable import value to apply")
|
||||||
|
}
|
||||||
|
|
||||||
|
var payload struct {
|
||||||
|
Warnings []string `json:"warnings"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(response.Body.Bytes(), &payload); err != nil {
|
||||||
|
t.Fatalf("json.Unmarshal returned error: %v", err)
|
||||||
|
}
|
||||||
|
if len(payload.Warnings) != 2 {
|
||||||
|
t.Fatalf("expected 2 warnings, got %#v", payload.Warnings)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAdminSettingsResetUsesBuiltInDefaults(t *testing.T) {
|
||||||
|
app, router := setupAdminSettingsTest(t)
|
||||||
|
|
||||||
|
request := httptest.NewRequest(http.MethodPost, "/admin/settings/reset", strings.NewReader(`{}`))
|
||||||
|
request.Header.Set("Content-Type", "application/json")
|
||||||
|
request.AddCookie(authCookie(app))
|
||||||
|
response := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(response, request)
|
||||||
|
|
||||||
|
if response.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected 200, got %d: %s", response.Code, response.Body.String())
|
||||||
|
}
|
||||||
|
if !app.config.APIEnabled {
|
||||||
|
t.Fatal("expected reset to built-in defaults to restore APIEnabled=true")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func setupAdminSettingsTest(t *testing.T) (*App, *gin.Engine) {
|
||||||
|
t.Helper()
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
cwd, err := os.Getwd()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Getwd returned error: %v", err)
|
||||||
|
}
|
||||||
|
root := filepath.Clean(filepath.Join(cwd, "..", ".."))
|
||||||
|
if err := os.Chdir(root); err != nil {
|
||||||
|
t.Fatalf("Chdir returned error: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() {
|
||||||
|
_ = os.Chdir(cwd)
|
||||||
|
})
|
||||||
|
clearAdminSettingsEnv(t)
|
||||||
|
t.Setenv("WARPBOX_DATA_DIR", t.TempDir())
|
||||||
|
t.Setenv("WARPBOX_ADMIN_PASSWORD", "secret")
|
||||||
|
t.Setenv("WARPBOX_ADMIN_ENABLED", "true")
|
||||||
|
t.Setenv("WARPBOX_API_ENABLED", "false")
|
||||||
|
|
||||||
|
cfg, err := config.Load()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Load returned error: %v", err)
|
||||||
|
}
|
||||||
|
if err := cfg.EnsureDirectories(); err != nil {
|
||||||
|
t.Fatalf("EnsureDirectories returned error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
app := &App{
|
||||||
|
config: cfg,
|
||||||
|
settingsOverridesPath: filepath.Join(cfg.DBDir, config.AdminSettingsOverrideFilename),
|
||||||
|
}
|
||||||
|
|
||||||
|
htmlTemplates, err := loadHTMLTemplates()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("loadHTMLTemplates returned error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
router := gin.New()
|
||||||
|
router.SetHTMLTemplate(htmlTemplates)
|
||||||
|
admin := router.Group("/admin")
|
||||||
|
admin.GET("/login", app.handleAdminLogin)
|
||||||
|
protected := router.Group("/admin", app.adminAuthMiddleware)
|
||||||
|
protected.GET("/settings", app.handleAdminSettings)
|
||||||
|
protected.GET("/settings/export", app.handleAdminSettingsExport)
|
||||||
|
protected.POST("/settings/save", app.handleAdminSettingsSave)
|
||||||
|
protected.POST("/settings/import", app.handleAdminSettingsImport)
|
||||||
|
protected.POST("/settings/reset", app.handleAdminSettingsReset)
|
||||||
|
return app, router
|
||||||
|
}
|
||||||
|
|
||||||
|
func authCookie(app *App) *http.Cookie {
|
||||||
|
return &http.Cookie{Name: adminSessionCookie, Value: app.adminSessionToken()}
|
||||||
|
}
|
||||||
|
|
||||||
|
func clearAdminSettingsEnv(t *testing.T) {
|
||||||
|
t.Helper()
|
||||||
|
for _, name := range []string{
|
||||||
|
"WARPBOX_DATA_DIR",
|
||||||
|
"WARPBOX_ADMIN_PASSWORD",
|
||||||
|
"WARPBOX_ADMIN_USERNAME",
|
||||||
|
"WARPBOX_ADMIN_EMAIL",
|
||||||
|
"WARPBOX_ADMIN_ENABLED",
|
||||||
|
"WARPBOX_ALLOW_ADMIN_SETTINGS_OVERRIDE",
|
||||||
|
"WARPBOX_ADMIN_COOKIE_SECURE",
|
||||||
|
"WARPBOX_GUEST_UPLOADS_ENABLED",
|
||||||
|
"WARPBOX_API_ENABLED",
|
||||||
|
"WARPBOX_ZIP_DOWNLOADS_ENABLED",
|
||||||
|
"WARPBOX_ONE_TIME_DOWNLOADS_ENABLED",
|
||||||
|
"WARPBOX_ONE_TIME_DOWNLOAD_EXPIRY_SECONDS",
|
||||||
|
"WARPBOX_ONE_TIME_DOWNLOAD_RETRY_ON_FAILURE",
|
||||||
|
"WARPBOX_RENEW_ON_ACCESS_ENABLED",
|
||||||
|
"WARPBOX_RENEW_ON_DOWNLOAD_ENABLED",
|
||||||
|
"WARPBOX_DEFAULT_GUEST_EXPIRY_SECONDS",
|
||||||
|
"WARPBOX_MAX_GUEST_EXPIRY_SECONDS",
|
||||||
|
"WARPBOX_GLOBAL_MAX_FILE_SIZE_MB",
|
||||||
|
"WARPBOX_GLOBAL_MAX_FILE_SIZE_BYTES",
|
||||||
|
"WARPBOX_GLOBAL_MAX_BOX_SIZE_MB",
|
||||||
|
"WARPBOX_GLOBAL_MAX_BOX_SIZE_BYTES",
|
||||||
|
"WARPBOX_DEFAULT_USER_MAX_FILE_SIZE_MB",
|
||||||
|
"WARPBOX_DEFAULT_USER_MAX_FILE_SIZE_BYTES",
|
||||||
|
"WARPBOX_DEFAULT_USER_MAX_BOX_SIZE_MB",
|
||||||
|
"WARPBOX_DEFAULT_USER_MAX_BOX_SIZE_BYTES",
|
||||||
|
"WARPBOX_SESSION_TTL_SECONDS",
|
||||||
|
"WARPBOX_BOX_POLL_INTERVAL_MS",
|
||||||
|
"WARPBOX_THUMBNAIL_BATCH_SIZE",
|
||||||
|
"WARPBOX_THUMBNAIL_INTERVAL_SECONDS",
|
||||||
|
} {
|
||||||
|
t.Setenv(name, "")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
package server
|
package server
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
"html/template"
|
"html/template"
|
||||||
|
"path/filepath"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/gin-contrib/gzip"
|
"github.com/gin-contrib/gzip"
|
||||||
@@ -14,6 +16,7 @@ import (
|
|||||||
|
|
||||||
type App struct {
|
type App struct {
|
||||||
config *config.Config
|
config *config.Config
|
||||||
|
settingsOverridesPath string
|
||||||
}
|
}
|
||||||
|
|
||||||
func Run(addr string) error {
|
func Run(addr string) error {
|
||||||
@@ -24,10 +27,18 @@ func Run(addr string) error {
|
|||||||
if err := cfg.EnsureDirectories(); err != nil {
|
if err := cfg.EnsureDirectories(); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
overridesPath := filepath.Join(cfg.DBDir, config.AdminSettingsOverrideFilename)
|
||||||
|
overrides, err := config.ReadAdminSettingsOverrides(overridesPath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := cfg.ApplyOverrides(overrides); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
applyBoxstoreRuntimeConfig(cfg)
|
applyBoxstoreRuntimeConfig(cfg)
|
||||||
|
|
||||||
app := &App{config: cfg}
|
app := &App{config: cfg, settingsOverridesPath: overridesPath}
|
||||||
|
|
||||||
router := gin.Default()
|
router := gin.Default()
|
||||||
htmlTemplates, err := loadHTMLTemplates()
|
htmlTemplates, err := loadHTMLTemplates()
|
||||||
@@ -56,6 +67,13 @@ func Run(addr string) error {
|
|||||||
AdminLogout: app.handleAdminLogout,
|
AdminLogout: app.handleAdminLogout,
|
||||||
AdminDashboard: app.handleAdminDashboard,
|
AdminDashboard: app.handleAdminDashboard,
|
||||||
AdminAlerts: app.handleAdminAlerts,
|
AdminAlerts: app.handleAdminAlerts,
|
||||||
|
AdminBoxes: app.handleAdminBoxes,
|
||||||
|
AdminBoxesAction: app.handleAdminBoxesAction,
|
||||||
|
AdminSettings: app.handleAdminSettings,
|
||||||
|
AdminSettingsExport: app.handleAdminSettingsExport,
|
||||||
|
AdminSettingsSave: app.handleAdminSettingsSave,
|
||||||
|
AdminSettingsImport: app.handleAdminSettingsImport,
|
||||||
|
AdminSettingsReset: app.handleAdminSettingsReset,
|
||||||
AdminAuth: app.adminAuthMiddleware,
|
AdminAuth: app.adminAuthMiddleware,
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -68,7 +86,15 @@ func Run(addr string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func loadHTMLTemplates() (*template.Template, error) {
|
func loadHTMLTemplates() (*template.Template, error) {
|
||||||
tmpl := template.New("")
|
tmpl := template.New("").Funcs(template.FuncMap{
|
||||||
|
"toJSON": func(value any) template.JS {
|
||||||
|
data, err := json.Marshal(value)
|
||||||
|
if err != nil {
|
||||||
|
return template.JS("null")
|
||||||
|
}
|
||||||
|
return template.JS(data)
|
||||||
|
},
|
||||||
|
})
|
||||||
for _, pattern := range []string{
|
for _, pattern := range []string{
|
||||||
"templates/*.html",
|
"templates/*.html",
|
||||||
"templates/admin/*.html",
|
"templates/admin/*.html",
|
||||||
|
|||||||
501
static/css/boxes.css
Normal file
501
static/css/boxes.css
Normal file
@@ -0,0 +1,501 @@
|
|||||||
|
.boxes-page-body {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.boxes-summary-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.boxes-stat-card {
|
||||||
|
min-width: 0;
|
||||||
|
padding: 8px;
|
||||||
|
background: #dfdfdf;
|
||||||
|
border-top: 1px solid #ffffff;
|
||||||
|
border-left: 1px solid #ffffff;
|
||||||
|
border-right: 1px solid #808080;
|
||||||
|
border-bottom: 1px solid #808080;
|
||||||
|
box-shadow: inset 1px 1px 0 #f7f7f7, inset -1px -1px 0 #b0b0b0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.boxes-stat-card.is-info { background: linear-gradient(180deg, #d7e6fb, #bfd7f8); }
|
||||||
|
.boxes-stat-card.is-ok { background: linear-gradient(180deg, #dbf4dc, #c3ebc5); }
|
||||||
|
.boxes-stat-card.is-warning { background: linear-gradient(180deg, #fff1c9, #ffe39f); }
|
||||||
|
.boxes-stat-card.is-danger { background: linear-gradient(180deg, #ffd8d8, #f1b3b3); }
|
||||||
|
|
||||||
|
.boxes-stat-label {
|
||||||
|
margin: 0 0 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 12px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: #333333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.boxes-stat-value {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 24px;
|
||||||
|
line-height: 24px;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.boxes-stat-note {
|
||||||
|
margin: 6px 0 0;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 18px;
|
||||||
|
padding: 0 6px;
|
||||||
|
color: #222222;
|
||||||
|
background: rgba(255,255,255,.65);
|
||||||
|
border-top: 1px solid #ffffff;
|
||||||
|
border-left: 1px solid #ffffff;
|
||||||
|
border-right: 1px solid #a0a0a0;
|
||||||
|
border-bottom: 1px solid #a0a0a0;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.boxes-hero-note {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
color: #000000;
|
||||||
|
background: #ffffcc;
|
||||||
|
border-top: 1px solid #ffffff;
|
||||||
|
border-left: 1px solid #ffffff;
|
||||||
|
border-right: 1px solid #a08000;
|
||||||
|
border-bottom: 1px solid #a08000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.boxes-hero-note strong {
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.boxes-hero-note span {
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.boxes-hero-tags {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.boxes-hero-tag,
|
||||||
|
.boxes-flag {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 18px;
|
||||||
|
padding: 0 6px;
|
||||||
|
color: #222222;
|
||||||
|
background: #f1f1f1;
|
||||||
|
border-top: 1px solid #ffffff;
|
||||||
|
border-left: 1px solid #ffffff;
|
||||||
|
border-right: 1px solid #b0b0b0;
|
||||||
|
border-bottom: 1px solid #b0b0b0;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.boxes-content-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1.45fr) minmax(320px, .75fr);
|
||||||
|
gap: 10px;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.boxes-column {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.boxes-panel {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 0;
|
||||||
|
background: #ffffff;
|
||||||
|
border-top: 1px solid #808080;
|
||||||
|
border-left: 1px solid #808080;
|
||||||
|
border-right: 1px solid #ffffff;
|
||||||
|
border-bottom: 1px solid #ffffff;
|
||||||
|
box-shadow: inset 1px 1px 0 rgba(255,255,255,.7), inset -1px -1px 0 rgba(0,0,0,.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.boxes-files-panel {
|
||||||
|
min-height: 300px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.boxes-panel-header {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
min-height: 34px;
|
||||||
|
padding: 6px 8px;
|
||||||
|
background: #dfdfdf;
|
||||||
|
border-bottom: 1px solid #b0b0b0;
|
||||||
|
box-shadow: inset 1px 1px 0 #f7f7f7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.boxes-panel-title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 22px;
|
||||||
|
font-weight: bold;
|
||||||
|
font-size: 15px;
|
||||||
|
line-height: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.boxes-panel-sub {
|
||||||
|
color: #444444;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 12px;
|
||||||
|
font-weight: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.boxes-panel-tools {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.boxes-panel-body {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 0;
|
||||||
|
padding: 10px;
|
||||||
|
overflow: hidden;
|
||||||
|
background-color: #ffffff;
|
||||||
|
background-image: linear-gradient(180deg, rgba(255,255,255,.9), rgba(238,238,238,.58));
|
||||||
|
}
|
||||||
|
|
||||||
|
.boxes-tool-button,
|
||||||
|
.boxes-page-button,
|
||||||
|
.boxes-action-button,
|
||||||
|
.boxes-row-button {
|
||||||
|
min-width: 62px;
|
||||||
|
height: 24px;
|
||||||
|
padding: 0 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.boxes-tool-button.is-danger,
|
||||||
|
.boxes-action-button.is-danger {
|
||||||
|
color: #ffffff;
|
||||||
|
background: #800000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.boxes-toolbar-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(200px, 1.3fr) repeat(4, minmax(110px, .55fr));
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.boxes-input,
|
||||||
|
.boxes-select {
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
height: 28px;
|
||||||
|
color: #000000;
|
||||||
|
background: #ffffff;
|
||||||
|
border-top: 1px solid #808080;
|
||||||
|
border-left: 1px solid #808080;
|
||||||
|
border-right: 1px solid #ffffff;
|
||||||
|
border-bottom: 1px solid #ffffff;
|
||||||
|
padding: 4px 6px;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.boxes-table-wrap {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 0;
|
||||||
|
height: 460px;
|
||||||
|
overflow-y: auto;
|
||||||
|
overflow-x: hidden;
|
||||||
|
background: #ffffff;
|
||||||
|
border-top: 2px solid #606060;
|
||||||
|
border-left: 2px solid #606060;
|
||||||
|
border-right: 2px solid #ffffff;
|
||||||
|
border-bottom: 2px solid #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.boxes-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
table-layout: fixed;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 14px;
|
||||||
|
color: #000000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.boxes-table thead th {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 2;
|
||||||
|
padding: 6px;
|
||||||
|
text-align: left;
|
||||||
|
background: #dfdfdf;
|
||||||
|
border-bottom: 1px solid #b0b0b0;
|
||||||
|
box-shadow: inset 0 1px 0 #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.boxes-table tbody tr:nth-child(odd) { background: rgba(255,255,255,.96); }
|
||||||
|
.boxes-table tbody tr:nth-child(even) { background: rgba(240,244,255,.9); }
|
||||||
|
.boxes-table tbody tr:hover { background: #d8e5f8; }
|
||||||
|
.boxes-table tbody tr.is-selected { background: #c5dcff; }
|
||||||
|
|
||||||
|
.boxes-table td {
|
||||||
|
padding: 6px;
|
||||||
|
border-bottom: 1px solid #e1e1e1;
|
||||||
|
vertical-align: middle;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.boxes-col-check { width: 34px; }
|
||||||
|
.boxes-col-id { width: 190px; }
|
||||||
|
.boxes-col-status { width: 84px; }
|
||||||
|
.boxes-col-files { width: 58px; }
|
||||||
|
.boxes-col-size { width: 76px; }
|
||||||
|
.boxes-col-retention { width: 96px; }
|
||||||
|
.boxes-col-expires { width: 126px; }
|
||||||
|
.boxes-col-actions { width: 98px; }
|
||||||
|
|
||||||
|
.boxes-status-pill {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 18px;
|
||||||
|
padding: 0 6px;
|
||||||
|
color: #222222;
|
||||||
|
background: #f1f1f1;
|
||||||
|
border-top: 1px solid #ffffff;
|
||||||
|
border-left: 1px solid #ffffff;
|
||||||
|
border-right: 1px solid #b0b0b0;
|
||||||
|
border-bottom: 1px solid #b0b0b0;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.boxes-status-pill.ready { background: #def2e0; }
|
||||||
|
.boxes-status-pill.uploading { background: #fff1c9; }
|
||||||
|
.boxes-status-pill.attention { background: #ffe2bf; }
|
||||||
|
.boxes-status-pill.expired { background: #ffdcdc; }
|
||||||
|
.boxes-status-pill.consumed { background: #ead7ff; }
|
||||||
|
.boxes-status-pill.legacy { background: #ececec; }
|
||||||
|
|
||||||
|
.boxes-flags-cell,
|
||||||
|
.boxes-action-cell {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.boxes-action-cell a {
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.boxes-empty-state {
|
||||||
|
padding: 24px 12px;
|
||||||
|
text-align: center;
|
||||||
|
color: #444444;
|
||||||
|
background: linear-gradient(180deg, rgba(255,255,255,.95), rgba(242,242,242,.95));
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.boxes-footer-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.boxes-pagination {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.boxes-detail-body {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.boxes-info-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.boxes-info-item {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 84px minmax(0, 1fr);
|
||||||
|
gap: 8px;
|
||||||
|
align-items: start;
|
||||||
|
padding: 6px 8px;
|
||||||
|
background: #f5f5f5;
|
||||||
|
border-top: 1px solid #ffffff;
|
||||||
|
border-left: 1px solid #ffffff;
|
||||||
|
border-right: 1px solid #c0c0c0;
|
||||||
|
border-bottom: 1px solid #c0c0c0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.boxes-info-item strong {
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.boxes-info-item span {
|
||||||
|
min-width: 0;
|
||||||
|
color: #222222;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.boxes-action-stack {
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.boxes-action-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.boxes-action-button {
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.boxes-file-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
min-height: 0;
|
||||||
|
height: 320px;
|
||||||
|
overflow-y: auto;
|
||||||
|
overflow-x: hidden;
|
||||||
|
padding-right: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.boxes-column:first-child > .boxes-panel {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.boxes-column:first-child > .boxes-panel > .boxes-panel-body {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.boxes-column:first-child .boxes-table-wrap {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
height: auto;
|
||||||
|
min-height: 560px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.boxes-file-card {
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 8px;
|
||||||
|
background: #f8f8f8;
|
||||||
|
border-top: 1px solid #ffffff;
|
||||||
|
border-left: 1px solid #ffffff;
|
||||||
|
border-right: 1px solid #c0c0c0;
|
||||||
|
border-bottom: 1px solid #c0c0c0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.boxes-file-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.boxes-file-name {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 13px;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.boxes-file-meta {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
color: #333333;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.boxes-file-link {
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1100px) {
|
||||||
|
.boxes-summary-grid,
|
||||||
|
.boxes-content-grid {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.boxes-column-side {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.boxes-toolbar-grid {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.boxes-summary-grid,
|
||||||
|
.boxes-content-grid,
|
||||||
|
.boxes-toolbar-grid,
|
||||||
|
.boxes-action-grid {
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.boxes-hero-note,
|
||||||
|
.boxes-footer-bar {
|
||||||
|
align-items: flex-start;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.boxes-table-wrap {
|
||||||
|
height: 420px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.boxes-column:first-child .boxes-table-wrap {
|
||||||
|
min-height: 420px;
|
||||||
|
}
|
||||||
|
}
|
||||||
510
static/css/settings.css
Normal file
510
static/css/settings.css
Normal file
@@ -0,0 +1,510 @@
|
|||||||
|
.settings-page-body {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-summary-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-stat-card {
|
||||||
|
min-width: 0;
|
||||||
|
padding: 8px;
|
||||||
|
background: #dfdfdf;
|
||||||
|
border-top: 1px solid #ffffff;
|
||||||
|
border-left: 1px solid #ffffff;
|
||||||
|
border-right: 1px solid #808080;
|
||||||
|
border-bottom: 1px solid #808080;
|
||||||
|
box-shadow: inset 1px 1px 0 #f7f7f7, inset -1px -1px 0 #b0b0b0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-stat-card.is-info { background: linear-gradient(180deg, #d7e6fb, #bfd7f8); }
|
||||||
|
.settings-stat-card.is-ok { background: linear-gradient(180deg, #dbf4dc, #c3ebc5); }
|
||||||
|
.settings-stat-card.is-warning { background: linear-gradient(180deg, #fff1c9, #ffe39f); }
|
||||||
|
.settings-stat-card.is-danger { background: linear-gradient(180deg, #ffd8d8, #f1b3b3); }
|
||||||
|
|
||||||
|
.settings-stat-label {
|
||||||
|
margin: 0 0 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 12px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: #333333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-stat-value {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 24px;
|
||||||
|
line-height: 24px;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-stat-note {
|
||||||
|
margin: 6px 0 0;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 18px;
|
||||||
|
padding: 0 6px;
|
||||||
|
color: #222222;
|
||||||
|
background: rgba(255,255,255,.65);
|
||||||
|
border-top: 1px solid #ffffff;
|
||||||
|
border-left: 1px solid #ffffff;
|
||||||
|
border-right: 1px solid #a0a0a0;
|
||||||
|
border-bottom: 1px solid #a0a0a0;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-main-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 238px minmax(0, 1fr);
|
||||||
|
gap: 10px;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-sidebar-panel {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-sidebar {
|
||||||
|
position: sticky;
|
||||||
|
top: 48px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-workbench {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-panel,
|
||||||
|
.settings-hero-panel {
|
||||||
|
min-width: 0;
|
||||||
|
background: #ffffff;
|
||||||
|
border-top: 1px solid #808080;
|
||||||
|
border-left: 1px solid #808080;
|
||||||
|
border-right: 1px solid #ffffff;
|
||||||
|
border-bottom: 1px solid #ffffff;
|
||||||
|
box-shadow: inset 1px 1px 0 rgba(255,255,255,.7), inset -1px -1px 0 rgba(0,0,0,.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-panel {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-panel-header {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
min-height: 34px;
|
||||||
|
padding: 6px 8px;
|
||||||
|
background: #dfdfdf;
|
||||||
|
border-bottom: 1px solid #b0b0b0;
|
||||||
|
box-shadow: inset 1px 1px 0 #f7f7f7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-panel-title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 22px;
|
||||||
|
font-weight: bold;
|
||||||
|
font-size: 15px;
|
||||||
|
line-height: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-panel-sub {
|
||||||
|
color: #444444;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 12px;
|
||||||
|
font-weight: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-panel-tools {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-panel-body {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 0;
|
||||||
|
padding: 10px;
|
||||||
|
overflow: hidden;
|
||||||
|
background-color: #ffffff;
|
||||||
|
background-image: linear-gradient(180deg, rgba(255,255,255,.9), rgba(238,238,238,.58));
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-hero-panel {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1.2fr) minmax(280px, .8fr);
|
||||||
|
gap: 10px;
|
||||||
|
padding: 10px;
|
||||||
|
background-image: linear-gradient(180deg, rgba(255,255,255,.92), rgba(238,238,238,.58));
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-hero-copy h2 {
|
||||||
|
margin: 0 0 6px;
|
||||||
|
font-size: 18px;
|
||||||
|
line-height: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-hero-copy p {
|
||||||
|
margin: 0;
|
||||||
|
color: #222222;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-hero-legend {
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-legend-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
color: #222222;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-search {
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-search label {
|
||||||
|
font-weight: bold;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-input,
|
||||||
|
.settings-select {
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
color: #000000;
|
||||||
|
background: #ffffff;
|
||||||
|
border-top: 1px solid #808080;
|
||||||
|
border-left: 1px solid #808080;
|
||||||
|
border-right: 1px solid #ffffff;
|
||||||
|
border-bottom: 1px solid #ffffff;
|
||||||
|
padding: 4px 6px;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-input,
|
||||||
|
.settings-select {
|
||||||
|
height: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-category-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 4px;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-category-button {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 30px;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 24px minmax(0, 1fr) auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: 7px;
|
||||||
|
padding: 4px 6px;
|
||||||
|
color: #000000;
|
||||||
|
background: #dfdfdf;
|
||||||
|
border-top: 1px solid #ffffff;
|
||||||
|
border-left: 1px solid #ffffff;
|
||||||
|
border-right: 1px solid #808080;
|
||||||
|
border-bottom: 1px solid #808080;
|
||||||
|
font-family: inherit;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-category-button.is-active {
|
||||||
|
color: #ffffff;
|
||||||
|
background: #000078;
|
||||||
|
border-top-color: #000000;
|
||||||
|
border-left-color: #000000;
|
||||||
|
border-right-color: #ffffff;
|
||||||
|
border-bottom-color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-category-count,
|
||||||
|
.settings-dirty-chip,
|
||||||
|
.settings-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 18px;
|
||||||
|
padding: 0 6px;
|
||||||
|
color: #222222;
|
||||||
|
background: #f1f1f1;
|
||||||
|
border-top: 1px solid #ffffff;
|
||||||
|
border-left: 1px solid #ffffff;
|
||||||
|
border-right: 1px solid #b0b0b0;
|
||||||
|
border-bottom: 1px solid #b0b0b0;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-category-button.is-active .settings-category-count {
|
||||||
|
color: #000000;
|
||||||
|
background: #ffffcc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-dirty-chip {
|
||||||
|
min-width: 78px;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-dirty-chip.is-dirty {
|
||||||
|
background: #ffffcc;
|
||||||
|
border: 3px solid transparent;
|
||||||
|
border-image: repeating-linear-gradient(45deg, #111111 0 8px, #ffcc00 8px 16px) 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-default { background: #ececec; }
|
||||||
|
.badge-env { background: #c7d8f2; }
|
||||||
|
.badge-db { background: #d2efcf; }
|
||||||
|
.badge-hard { background: #ffd9d9; }
|
||||||
|
|
||||||
|
.settings-tool-button,
|
||||||
|
.settings-mini-button,
|
||||||
|
.settings-popup-close {
|
||||||
|
min-width: 64px;
|
||||||
|
height: 24px;
|
||||||
|
padding: 0 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-action-summary {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
padding: 8px;
|
||||||
|
color: #000000;
|
||||||
|
background: #ffffcc;
|
||||||
|
border-top: 1px solid #ffffff;
|
||||||
|
border-left: 1px solid #ffffff;
|
||||||
|
border-right: 1px solid #a08000;
|
||||||
|
border-bottom: 1px solid #a08000;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-groups {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
min-height: 0;
|
||||||
|
max-height: 700px;
|
||||||
|
overflow-y: auto;
|
||||||
|
overflow-x: hidden;
|
||||||
|
padding-right: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-group {
|
||||||
|
display: grid;
|
||||||
|
gap: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-group[hidden] {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-group-title {
|
||||||
|
min-height: 28px;
|
||||||
|
padding: 6px 8px;
|
||||||
|
color: #000000;
|
||||||
|
background: #dfdfdf;
|
||||||
|
border-top: 1px solid #ffffff;
|
||||||
|
border-left: 1px solid #ffffff;
|
||||||
|
border-right: 1px solid #808080;
|
||||||
|
border-bottom: 1px solid #808080;
|
||||||
|
font-weight: bold;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-table-wrap {
|
||||||
|
border-top: 2px solid #606060;
|
||||||
|
border-left: 2px solid #606060;
|
||||||
|
border-right: 2px solid #ffffff;
|
||||||
|
border-bottom: 2px solid #ffffff;
|
||||||
|
background: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
table-layout: fixed;
|
||||||
|
color: #000000;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-table th,
|
||||||
|
.settings-table td {
|
||||||
|
padding: 6px;
|
||||||
|
text-align: left;
|
||||||
|
vertical-align: top;
|
||||||
|
border-bottom: 1px solid #e1e1e1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-table th {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 2;
|
||||||
|
background: #dfdfdf;
|
||||||
|
box-shadow: inset 0 1px 0 #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-table tbody tr:nth-child(odd) { background: rgba(255,255,255,.96); }
|
||||||
|
.settings-table tbody tr:nth-child(even) { background: rgba(240,244,255,.9); }
|
||||||
|
.setting-row.is-locked { color: #555555; background: #efefef; }
|
||||||
|
.setting-row.is-hidden { display: none; }
|
||||||
|
.setting-row.is-invalid { background: #fff1c9; }
|
||||||
|
|
||||||
|
.setting-meta {
|
||||||
|
display: grid;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-meta strong {
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-meta code {
|
||||||
|
color: #1b325f;
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 12px;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-control {
|
||||||
|
display: grid;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-hint {
|
||||||
|
color: #444444;
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-modal-backdrop {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
display: none;
|
||||||
|
background: rgba(0,0,0,.35);
|
||||||
|
z-index: 90;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-modal-backdrop.is-visible {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-popup {
|
||||||
|
position: fixed;
|
||||||
|
left: 50%;
|
||||||
|
top: 50%;
|
||||||
|
width: min(520px, calc(100vw - 24px));
|
||||||
|
display: none;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
color: #000000;
|
||||||
|
background: var(--w98-gray);
|
||||||
|
border-top: 2px solid #ffffff;
|
||||||
|
border-left: 2px solid #ffffff;
|
||||||
|
border-right: 2px solid #000000;
|
||||||
|
border-bottom: 2px solid #000000;
|
||||||
|
box-shadow: 6px 6px 0 rgba(0,0,0,.35);
|
||||||
|
z-index: 95;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-popup.is-visible {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-popup-titlebar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
min-height: 28px;
|
||||||
|
padding: 4px 6px;
|
||||||
|
color: #ffffff;
|
||||||
|
background: #000078;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-popup-body {
|
||||||
|
padding: 10px;
|
||||||
|
background: #f5f5f5;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-popup-body p,
|
||||||
|
.settings-popup-body ul {
|
||||||
|
margin: 0 0 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1100px) {
|
||||||
|
.settings-summary-grid,
|
||||||
|
.settings-main-grid,
|
||||||
|
.settings-hero-panel {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-sidebar-panel,
|
||||||
|
.settings-workbench {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-sidebar {
|
||||||
|
position: static;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.settings-summary-grid,
|
||||||
|
.settings-main-grid,
|
||||||
|
.settings-hero-panel {
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-panel-header {
|
||||||
|
align-items: flex-start;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-category-list {
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-table-wrap {
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-table {
|
||||||
|
min-width: 760px;
|
||||||
|
}
|
||||||
|
}
|
||||||
526
static/js/admin/boxes.js
Normal file
526
static/js/admin/boxes.js
Normal file
@@ -0,0 +1,526 @@
|
|||||||
|
(() => {
|
||||||
|
const menuController = window.WarpBoxUI?.bindMenuBar?.() || {
|
||||||
|
close() {
|
||||||
|
document.querySelectorAll(".menu-item.is-open").forEach((item) => {
|
||||||
|
item.classList.remove("is-open");
|
||||||
|
item.querySelector(".menu-button")?.setAttribute("aria-expanded", "false");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const toastTarget = document.getElementById("toast");
|
||||||
|
const dataNode = document.getElementById("boxes-data");
|
||||||
|
const tableBody = document.getElementById("boxes-table-body");
|
||||||
|
const emptyState = document.getElementById("boxes-empty-state");
|
||||||
|
const searchInput = document.getElementById("boxes-search");
|
||||||
|
const statusFilter = document.getElementById("boxes-status-filter");
|
||||||
|
const flagFilter = document.getElementById("boxes-flag-filter");
|
||||||
|
const sortFilter = document.getElementById("boxes-sort");
|
||||||
|
const pageSizeFilter = document.getElementById("boxes-page-size");
|
||||||
|
const selectAll = document.getElementById("boxes-select-all");
|
||||||
|
const prevPageButton = document.getElementById("boxes-prev-page");
|
||||||
|
const nextPageButton = document.getElementById("boxes-next-page");
|
||||||
|
const pageLabel = document.getElementById("boxes-page-label");
|
||||||
|
const rangeLabel = document.getElementById("boxes-range-label");
|
||||||
|
const selectedLabel = document.getElementById("boxes-selected-label");
|
||||||
|
const footerSummary = document.getElementById("boxes-footer-summary");
|
||||||
|
const detailFileList = document.getElementById("detail-file-list");
|
||||||
|
|
||||||
|
if (!dataNode || !tableBody || !searchInput || !detailFileList) return;
|
||||||
|
|
||||||
|
const statEls = {
|
||||||
|
total: document.querySelector("[data-stat-total]"),
|
||||||
|
ready: document.querySelector("[data-stat-ready]"),
|
||||||
|
uploading: document.querySelector("[data-stat-uploading]"),
|
||||||
|
expired: document.querySelector("[data-stat-expired]")
|
||||||
|
};
|
||||||
|
|
||||||
|
const detailEls = {
|
||||||
|
boxId: document.getElementById("detail-box-id"),
|
||||||
|
status: document.getElementById("detail-status"),
|
||||||
|
created: document.getElementById("detail-created"),
|
||||||
|
expires: document.getElementById("detail-expires"),
|
||||||
|
retention: document.getElementById("detail-retention"),
|
||||||
|
files: document.getElementById("detail-files"),
|
||||||
|
size: document.getElementById("detail-size"),
|
||||||
|
flags: document.getElementById("detail-flags"),
|
||||||
|
open: document.getElementById("detail-open"),
|
||||||
|
zip: document.getElementById("detail-zip")
|
||||||
|
};
|
||||||
|
|
||||||
|
function showToast(message, type = "info", duration = 2200) {
|
||||||
|
if (window.WarpBoxUI) {
|
||||||
|
window.WarpBoxUI.toast(message, type, { target: toastTarget, duration });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!toastTarget) return;
|
||||||
|
toastTarget.textContent = message;
|
||||||
|
toastTarget.classList.add("is-visible");
|
||||||
|
window.setTimeout(() => toastTarget.classList.remove("is-visible"), duration);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseData() {
|
||||||
|
try {
|
||||||
|
return JSON.parse(dataNode.textContent || "[]");
|
||||||
|
} catch (_) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const state = {
|
||||||
|
boxes: parseData(),
|
||||||
|
selected: new Set(),
|
||||||
|
activeId: null,
|
||||||
|
page: 1
|
||||||
|
};
|
||||||
|
|
||||||
|
function pageSize() {
|
||||||
|
return Number(pageSizeFilter.value || 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
function allBoxes() {
|
||||||
|
return state.boxes.slice();
|
||||||
|
}
|
||||||
|
|
||||||
|
function sortBoxes(boxes) {
|
||||||
|
const sorted = boxes.slice();
|
||||||
|
switch (sortFilter.value) {
|
||||||
|
case "name":
|
||||||
|
sorted.sort((a, b) => a.id.localeCompare(b.id));
|
||||||
|
break;
|
||||||
|
case "largest":
|
||||||
|
sorted.sort((a, b) => compareSizeLabel(a.total_size_label, b.total_size_label));
|
||||||
|
break;
|
||||||
|
case "expires":
|
||||||
|
sorted.sort((a, b) => compareExpiry(a.expires_at_iso, b.expires_at_iso));
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
sorted.sort((a, b) => (b.created_at_iso || "").localeCompare(a.created_at_iso || ""));
|
||||||
|
}
|
||||||
|
return sorted;
|
||||||
|
}
|
||||||
|
|
||||||
|
function compareSizeLabel(left, right) {
|
||||||
|
return sizeLabelToBytes(right) - sizeLabelToBytes(left);
|
||||||
|
}
|
||||||
|
|
||||||
|
function sizeLabelToBytes(label) {
|
||||||
|
const match = String(label || "").trim().match(/^([\d.]+)\s*([KMGT]?i?B|B)$/i);
|
||||||
|
if (!match) return 0;
|
||||||
|
const value = Number(match[1]);
|
||||||
|
const unit = match[2].toUpperCase();
|
||||||
|
const map = { B: 1, KIB: 1024, MIB: 1024 ** 2, GIB: 1024 ** 3, TIB: 1024 ** 4 };
|
||||||
|
return value * (map[unit] || 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function compareExpiry(left, right) {
|
||||||
|
if (!left && !right) return 0;
|
||||||
|
if (!left) return 1;
|
||||||
|
if (!right) return -1;
|
||||||
|
return left.localeCompare(right);
|
||||||
|
}
|
||||||
|
|
||||||
|
function filteredBoxes() {
|
||||||
|
const query = searchInput.value.trim().toLowerCase();
|
||||||
|
const status = statusFilter.value;
|
||||||
|
const flag = flagFilter.value;
|
||||||
|
|
||||||
|
return sortBoxes(allBoxes().filter((box) => {
|
||||||
|
const matchesSearch = !query || String(box.search_text || "").includes(query);
|
||||||
|
const matchesStatus = status === "all" || box.status === status;
|
||||||
|
const matchesFlag = flag === "all" || (box.flags || []).includes(flag);
|
||||||
|
return matchesSearch && matchesStatus && matchesFlag;
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function pagedBoxes(boxes) {
|
||||||
|
const size = pageSize();
|
||||||
|
const pages = Math.max(1, Math.ceil(boxes.length / size));
|
||||||
|
if (state.page > pages) state.page = pages;
|
||||||
|
if (state.page < 1) state.page = 1;
|
||||||
|
const start = (state.page - 1) * size;
|
||||||
|
return {
|
||||||
|
items: boxes.slice(start, start + size),
|
||||||
|
start,
|
||||||
|
pages
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectedBoxes() {
|
||||||
|
return allBoxes().filter((box) => state.selected.has(box.id));
|
||||||
|
}
|
||||||
|
|
||||||
|
function currentActiveBox() {
|
||||||
|
const boxes = allBoxes();
|
||||||
|
return boxes.find((box) => box.id === state.activeId) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureActiveBox(filtered) {
|
||||||
|
if (filtered.length === 0) {
|
||||||
|
state.activeId = null;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (!filtered.some((box) => box.id === state.activeId)) {
|
||||||
|
state.activeId = filtered[0].id;
|
||||||
|
}
|
||||||
|
return filtered.find((box) => box.id === state.activeId) || filtered[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSummary(filtered) {
|
||||||
|
const total = filtered.length;
|
||||||
|
const ready = filtered.filter((box) => box.status === "ready").length;
|
||||||
|
const uploading = filtered.filter((box) => box.status === "uploading").length;
|
||||||
|
const expired = filtered.filter((box) => box.status === "expired" || box.status === "consumed").length;
|
||||||
|
statEls.total.textContent = String(total);
|
||||||
|
statEls.ready.textContent = String(ready);
|
||||||
|
statEls.uploading.textContent = String(uploading);
|
||||||
|
statEls.expired.textContent = String(expired);
|
||||||
|
footerSummary.textContent = `${allBoxes().length} boxes loaded`;
|
||||||
|
selectedLabel.textContent = `Selected: ${state.selected.size}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTable() {
|
||||||
|
const filtered = filteredBoxes();
|
||||||
|
const active = ensureActiveBox(filtered);
|
||||||
|
const page = pagedBoxes(filtered);
|
||||||
|
|
||||||
|
tableBody.innerHTML = "";
|
||||||
|
page.items.forEach((box) => tableBody.appendChild(buildRow(box)));
|
||||||
|
emptyState.hidden = page.items.length !== 0;
|
||||||
|
|
||||||
|
const startIndex = filtered.length ? page.start + 1 : 0;
|
||||||
|
const endIndex = page.start + page.items.length;
|
||||||
|
rangeLabel.textContent = `Showing ${startIndex}-${endIndex} of ${filtered.length}`;
|
||||||
|
pageLabel.textContent = `Page ${state.page} / ${page.pages}`;
|
||||||
|
prevPageButton.disabled = state.page <= 1;
|
||||||
|
nextPageButton.disabled = state.page >= page.pages;
|
||||||
|
selectAll.checked = page.items.length > 0 && page.items.every((box) => state.selected.has(box.id));
|
||||||
|
|
||||||
|
renderSummary(filtered);
|
||||||
|
renderDetails(active);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildRow(box) {
|
||||||
|
const row = document.createElement("tr");
|
||||||
|
if (box.id === state.activeId) row.classList.add("is-selected");
|
||||||
|
|
||||||
|
row.innerHTML = `
|
||||||
|
<td><input type="checkbox" class="boxes-row-check"${state.selected.has(box.id) ? " checked" : ""}></td>
|
||||||
|
<td title="${escapeAttr(box.id)}">${box.id}</td>
|
||||||
|
<td><span class="boxes-status-pill ${box.status}">${box.status_label}</span></td>
|
||||||
|
<td>${box.complete_files}/${box.file_count}</td>
|
||||||
|
<td>${box.total_size_label}</td>
|
||||||
|
<td>${box.retention_label || "Not set"}</td>
|
||||||
|
<td>${box.expires_at_label || "Not set"}</td>
|
||||||
|
<td><div class="boxes-flags-cell">${renderFlags(box.flags)}</div></td>
|
||||||
|
<td><div class="boxes-action-cell">${renderRowActions(box)}</div></td>
|
||||||
|
`;
|
||||||
|
|
||||||
|
row.addEventListener("click", (event) => {
|
||||||
|
if (event.target.closest("button") || event.target.closest("a") || event.target.closest("input")) return;
|
||||||
|
state.activeId = box.id;
|
||||||
|
renderTable();
|
||||||
|
});
|
||||||
|
|
||||||
|
row.querySelector(".boxes-row-check")?.addEventListener("change", (event) => {
|
||||||
|
if (event.target.checked) {
|
||||||
|
state.selected.add(box.id);
|
||||||
|
} else {
|
||||||
|
state.selected.delete(box.id);
|
||||||
|
}
|
||||||
|
selectedLabel.textContent = `Selected: ${state.selected.size}`;
|
||||||
|
syncSelectAllForPage();
|
||||||
|
});
|
||||||
|
|
||||||
|
row.querySelector('[data-row-action="focus"]')?.addEventListener("click", () => {
|
||||||
|
state.activeId = box.id;
|
||||||
|
renderTable();
|
||||||
|
});
|
||||||
|
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderFlags(flags) {
|
||||||
|
if (!flags || !flags.length) return '<span class="boxes-flag">none</span>';
|
||||||
|
return flags.map((flag) => `<span class="boxes-flag">${escapeHtml(flag)}</span>`).join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderRowActions(box) {
|
||||||
|
const parts = [
|
||||||
|
`<a class="win98-button boxes-row-button" href="${escapeAttr(box.open_url)}" target="_blank" rel="noreferrer">Open</a>`,
|
||||||
|
`<button class="win98-button boxes-row-button" type="button" data-row-action="focus">View</button>`
|
||||||
|
];
|
||||||
|
if (box.zip_available && box.zip_url) {
|
||||||
|
parts.push(`<a class="win98-button boxes-row-button" href="${escapeAttr(box.zip_url)}" target="_blank" rel="noreferrer">ZIP</a>`);
|
||||||
|
}
|
||||||
|
return parts.join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderDetails(box) {
|
||||||
|
if (!box) {
|
||||||
|
detailEls.boxId.textContent = "-";
|
||||||
|
detailEls.status.textContent = "-";
|
||||||
|
detailEls.created.textContent = "-";
|
||||||
|
detailEls.expires.textContent = "-";
|
||||||
|
detailEls.retention.textContent = "-";
|
||||||
|
detailEls.files.textContent = "-";
|
||||||
|
detailEls.size.textContent = "-";
|
||||||
|
detailEls.flags.textContent = "-";
|
||||||
|
detailEls.open.href = "#";
|
||||||
|
detailEls.zip.href = "#";
|
||||||
|
detailEls.zip.setAttribute("aria-disabled", "true");
|
||||||
|
detailFileList.innerHTML = '<div class="boxes-file-card">No box selected.</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
detailEls.boxId.textContent = box.id;
|
||||||
|
detailEls.status.textContent = box.status_label;
|
||||||
|
detailEls.created.textContent = box.created_at_label || "Not set";
|
||||||
|
detailEls.expires.textContent = box.expires_at_label || "Not set";
|
||||||
|
detailEls.retention.textContent = box.retention_label || "Not set";
|
||||||
|
detailEls.files.textContent = `${box.complete_files}/${box.file_count} complete`;
|
||||||
|
detailEls.size.textContent = box.total_size_label;
|
||||||
|
detailEls.flags.textContent = (box.flags || []).join(", ") || "none";
|
||||||
|
detailEls.open.href = box.open_url || "#";
|
||||||
|
|
||||||
|
if (box.zip_available && box.zip_url) {
|
||||||
|
detailEls.zip.href = box.zip_url;
|
||||||
|
detailEls.zip.removeAttribute("aria-disabled");
|
||||||
|
detailEls.zip.style.pointerEvents = "";
|
||||||
|
detailEls.zip.style.opacity = "";
|
||||||
|
} else {
|
||||||
|
detailEls.zip.href = "#";
|
||||||
|
detailEls.zip.setAttribute("aria-disabled", "true");
|
||||||
|
detailEls.zip.style.pointerEvents = "none";
|
||||||
|
detailEls.zip.style.opacity = ".55";
|
||||||
|
}
|
||||||
|
|
||||||
|
renderFiles(box.files || []);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderFiles(files) {
|
||||||
|
if (!files.length) {
|
||||||
|
detailFileList.innerHTML = '<div class="boxes-file-card">No file inventory available for this box.</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
detailFileList.innerHTML = files.map((file) => `
|
||||||
|
<div class="boxes-file-card">
|
||||||
|
<div class="boxes-file-row">
|
||||||
|
<div class="boxes-file-name" title="${escapeAttr(file.name)}">${escapeHtml(file.name)}</div>
|
||||||
|
<span class="boxes-status-pill ${escapeAttr(file.status || "legacy")}">${escapeHtml(file.status_label || file.status || "Unknown")}</span>
|
||||||
|
</div>
|
||||||
|
<div class="boxes-file-meta">
|
||||||
|
<span>${escapeHtml(file.size_label || "0 B")}</span>
|
||||||
|
<span>${escapeHtml(file.mime_type || "application/octet-stream")}</span>
|
||||||
|
${file.is_complete && file.download_path ? `<a class="boxes-file-link" href="${escapeAttr(file.download_path)}" target="_blank" rel="noreferrer">download</a>` : "<span>pending</span>"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`).join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncSelectAllForPage() {
|
||||||
|
const filtered = filteredBoxes();
|
||||||
|
const page = pagedBoxes(filtered);
|
||||||
|
selectAll.checked = page.items.length > 0 && page.items.every((box) => state.selected.has(box.id));
|
||||||
|
selectedLabel.textContent = `Selected: ${state.selected.size}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearFilters() {
|
||||||
|
searchInput.value = "";
|
||||||
|
statusFilter.value = "all";
|
||||||
|
flagFilter.value = "all";
|
||||||
|
sortFilter.value = "newest";
|
||||||
|
pageSizeFilter.value = "10";
|
||||||
|
state.page = 1;
|
||||||
|
renderTable();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runBulkAction(action, ids, deltaSeconds = 0) {
|
||||||
|
if (!ids.length) {
|
||||||
|
showToast("Select one or more boxes first", "warning");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch("/admin/boxes/actions", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ action, box_ids: ids, delta_seconds: deltaSeconds })
|
||||||
|
});
|
||||||
|
const payload = await response.json();
|
||||||
|
if (!response.ok) {
|
||||||
|
const message = payload.error || payload.message || "Action failed";
|
||||||
|
const warning = Array.isArray(payload.warnings) && payload.warnings.length ? ` (${payload.warnings[0]})` : "";
|
||||||
|
showToast(`${message}${warning}`, "error", 3200);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
state.boxes = Array.isArray(payload.boxes) ? payload.boxes : state.boxes;
|
||||||
|
state.selected.clear();
|
||||||
|
if (state.activeId && !state.boxes.some((box) => box.id === state.activeId)) {
|
||||||
|
state.activeId = null;
|
||||||
|
}
|
||||||
|
renderTable();
|
||||||
|
|
||||||
|
let message = payload.message || "Action complete";
|
||||||
|
if (Array.isArray(payload.warnings) && payload.warnings.length) {
|
||||||
|
message += ` (${payload.warnings.length} warning${payload.warnings.length === 1 ? "" : "s"})`;
|
||||||
|
}
|
||||||
|
showToast(message, Array.isArray(payload.warnings) && payload.warnings.length ? "warning" : "success", 2800);
|
||||||
|
} catch (_) {
|
||||||
|
showToast("Network error while updating boxes", "error", 3200);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectedIDsOrActive() {
|
||||||
|
if (state.selected.size) return Array.from(state.selected);
|
||||||
|
const active = currentActiveBox();
|
||||||
|
return active ? [active.id] : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runCommand(command) {
|
||||||
|
switch (command) {
|
||||||
|
case "refresh":
|
||||||
|
window.location.reload();
|
||||||
|
return;
|
||||||
|
case "export":
|
||||||
|
exportVisibleCSV();
|
||||||
|
showToast("Visible boxes exported");
|
||||||
|
return;
|
||||||
|
case "status-ready":
|
||||||
|
statusFilter.value = "ready";
|
||||||
|
state.page = 1;
|
||||||
|
renderTable();
|
||||||
|
return;
|
||||||
|
case "status-expired":
|
||||||
|
statusFilter.value = "expired";
|
||||||
|
state.page = 1;
|
||||||
|
renderTable();
|
||||||
|
return;
|
||||||
|
case "clear-filters":
|
||||||
|
clearFilters();
|
||||||
|
showToast("Filters cleared");
|
||||||
|
return;
|
||||||
|
case "expire":
|
||||||
|
case "active-expire":
|
||||||
|
await runBulkAction("expire", selectedIDsOrActive());
|
||||||
|
return;
|
||||||
|
case "extend-day":
|
||||||
|
case "active-extend-day":
|
||||||
|
await runBulkAction("bump", selectedIDsOrActive(), 24 * 60 * 60);
|
||||||
|
return;
|
||||||
|
case "extend-week":
|
||||||
|
case "active-extend-week":
|
||||||
|
await runBulkAction("bump", selectedIDsOrActive(), 7 * 24 * 60 * 60);
|
||||||
|
return;
|
||||||
|
case "delete":
|
||||||
|
case "active-delete":
|
||||||
|
if (!window.confirm("Delete selected boxes? This removes stored files.")) return;
|
||||||
|
await runBulkAction("delete", selectedIDsOrActive());
|
||||||
|
return;
|
||||||
|
case "help-scope":
|
||||||
|
showToast("Ownership filter waits for account + box owner data in backend", "info", 3400);
|
||||||
|
return;
|
||||||
|
case "help-flags":
|
||||||
|
showToast("Flags: protected, one-time, zip off, legacy, consumed", "info", 3200);
|
||||||
|
return;
|
||||||
|
default:
|
||||||
|
showToast(`Unknown command: ${command}`, "warning");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function exportVisibleCSV() {
|
||||||
|
const rows = filteredBoxes().map((box) => ([
|
||||||
|
box.id,
|
||||||
|
box.status_label,
|
||||||
|
box.file_count,
|
||||||
|
box.total_size_label,
|
||||||
|
box.retention_label,
|
||||||
|
box.expires_at_label,
|
||||||
|
(box.flags || []).join("|")
|
||||||
|
]));
|
||||||
|
const csv = [
|
||||||
|
["box_id", "status", "files", "size", "retention", "expires", "flags"],
|
||||||
|
...rows
|
||||||
|
].map((row) => row.map(csvCell).join(",")).join("\n");
|
||||||
|
|
||||||
|
const blob = new Blob([csv], { type: "text/csv;charset=utf-8" });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const anchor = document.createElement("a");
|
||||||
|
anchor.href = url;
|
||||||
|
anchor.download = "warpbox-boxes.csv";
|
||||||
|
anchor.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
function csvCell(value) {
|
||||||
|
const text = String(value ?? "");
|
||||||
|
if (/[",\n]/.test(text)) return `"${text.replaceAll('"', '""')}"`;
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(value) {
|
||||||
|
return String(value ?? "")
|
||||||
|
.replaceAll("&", "&")
|
||||||
|
.replaceAll("<", "<")
|
||||||
|
.replaceAll(">", ">")
|
||||||
|
.replaceAll('"', """);
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeAttr(value) {
|
||||||
|
return escapeHtml(value).replaceAll("'", "'");
|
||||||
|
}
|
||||||
|
|
||||||
|
[searchInput, statusFilter, flagFilter, sortFilter].forEach((control) => {
|
||||||
|
control.addEventListener(control.tagName === "INPUT" ? "input" : "change", () => {
|
||||||
|
state.page = 1;
|
||||||
|
renderTable();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
pageSizeFilter.addEventListener("change", () => {
|
||||||
|
state.page = 1;
|
||||||
|
renderTable();
|
||||||
|
});
|
||||||
|
|
||||||
|
selectAll?.addEventListener("change", () => {
|
||||||
|
const filtered = filteredBoxes();
|
||||||
|
const page = pagedBoxes(filtered);
|
||||||
|
page.items.forEach((box) => {
|
||||||
|
if (selectAll.checked) state.selected.add(box.id);
|
||||||
|
else state.selected.delete(box.id);
|
||||||
|
});
|
||||||
|
renderTable();
|
||||||
|
});
|
||||||
|
|
||||||
|
prevPageButton?.addEventListener("click", () => {
|
||||||
|
state.page -= 1;
|
||||||
|
renderTable();
|
||||||
|
});
|
||||||
|
|
||||||
|
nextPageButton?.addEventListener("click", () => {
|
||||||
|
state.page += 1;
|
||||||
|
renderTable();
|
||||||
|
});
|
||||||
|
|
||||||
|
document.querySelectorAll("[data-command]").forEach((button) => {
|
||||||
|
button.addEventListener("click", async () => {
|
||||||
|
menuController.close();
|
||||||
|
await runCommand(button.dataset.command);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener("keydown", async (event) => {
|
||||||
|
if (event.key === "Escape") menuController.close();
|
||||||
|
if (event.key === "F5") {
|
||||||
|
event.preventDefault();
|
||||||
|
await runCommand("refresh");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (state.boxes.length > 0) {
|
||||||
|
state.activeId = state.boxes[0].id;
|
||||||
|
}
|
||||||
|
renderTable();
|
||||||
|
})();
|
||||||
434
static/js/admin/settings.js
Normal file
434
static/js/admin/settings.js
Normal file
@@ -0,0 +1,434 @@
|
|||||||
|
(() => {
|
||||||
|
const menuController = window.WarpBoxUI?.bindMenuBar?.() || { close() {} };
|
||||||
|
const rowsNode = document.getElementById("settings-rows");
|
||||||
|
const searchInput = document.getElementById("settingsSearch");
|
||||||
|
const categoryButtons = Array.from(document.querySelectorAll(".settings-category-button"));
|
||||||
|
const groups = Array.from(document.querySelectorAll(".settings-group"));
|
||||||
|
const saveButton = document.getElementById("saveButton");
|
||||||
|
const exportButton = document.getElementById("exportButton");
|
||||||
|
const importButton = document.getElementById("importButton");
|
||||||
|
const resetButton = document.getElementById("resetButton");
|
||||||
|
const importInput = document.getElementById("settingsImportInput");
|
||||||
|
const dirtyChip = document.getElementById("dirtyChip");
|
||||||
|
const actionSummary = document.getElementById("actionSummary");
|
||||||
|
const visibleCount = document.getElementById("visibleCount");
|
||||||
|
const editableCount = document.getElementById("editableCount");
|
||||||
|
const unsavedCount = document.getElementById("unsavedCount");
|
||||||
|
const lockedCount = document.getElementById("lockedCount");
|
||||||
|
const statusLeft = document.getElementById("statusLeft");
|
||||||
|
const statusMiddle = document.getElementById("statusMiddle");
|
||||||
|
const statusRight = document.getElementById("statusRight");
|
||||||
|
const popupClose = document.getElementById("doc-popup-close");
|
||||||
|
const toastTarget = document.getElementById("toast");
|
||||||
|
|
||||||
|
if (!rowsNode || !searchInput || !saveButton) return;
|
||||||
|
|
||||||
|
const state = {
|
||||||
|
currentCategory: "all",
|
||||||
|
showChangedOnly: false,
|
||||||
|
showLockedOnly: false
|
||||||
|
};
|
||||||
|
|
||||||
|
function parseRows() {
|
||||||
|
try {
|
||||||
|
return JSON.parse(rowsNode.textContent || "[]");
|
||||||
|
} catch (_) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const rowData = parseRows().reduce((map, row) => {
|
||||||
|
map[row.key] = row;
|
||||||
|
return map;
|
||||||
|
}, {});
|
||||||
|
|
||||||
|
const rows = Array.from(document.querySelectorAll(".setting-row")).map((row) => ({
|
||||||
|
element: row,
|
||||||
|
input: row.querySelector(".setting-input"),
|
||||||
|
hint: row.querySelector('[data-role="hint"]'),
|
||||||
|
badge: row.querySelector('[data-role="source-badge"]'),
|
||||||
|
key: row.dataset.key,
|
||||||
|
label: row.dataset.label,
|
||||||
|
category: row.dataset.category,
|
||||||
|
envName: row.dataset.envName,
|
||||||
|
type: row.dataset.type,
|
||||||
|
minimum: Number(row.dataset.minimum || 0),
|
||||||
|
locked: row.classList.contains("is-locked")
|
||||||
|
}));
|
||||||
|
|
||||||
|
function showToast(message, type = "info", duration = 2400) {
|
||||||
|
window.WarpBoxUI?.toast?.(message, type, { target: toastTarget, duration });
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(value) {
|
||||||
|
return window.WarpBoxUI?.htmlEscape?.(value) || String(value ?? "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function currentValue(row) {
|
||||||
|
if (!row.input) return row.element.dataset.original || "";
|
||||||
|
return String(row.input.value ?? "").trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function isDirty(row) {
|
||||||
|
return !row.locked && currentValue(row) !== (row.element.dataset.original || "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateRow(row) {
|
||||||
|
if (row.locked || !row.input) {
|
||||||
|
row.element.classList.remove("is-invalid");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const value = currentValue(row);
|
||||||
|
let valid = true;
|
||||||
|
|
||||||
|
if (row.type === "int" || row.type === "int64") {
|
||||||
|
if (!/^\d+$/.test(value)) valid = false;
|
||||||
|
else if (Number(value) < row.minimum) valid = false;
|
||||||
|
} else if (row.type === "bool") {
|
||||||
|
valid = value === "true" || value === "false";
|
||||||
|
}
|
||||||
|
|
||||||
|
row.element.classList.toggle("is-invalid", !valid);
|
||||||
|
return valid;
|
||||||
|
}
|
||||||
|
|
||||||
|
function rowMatchesSearch(row) {
|
||||||
|
const query = searchInput.value.trim().toLowerCase();
|
||||||
|
if (!query) return true;
|
||||||
|
const data = [
|
||||||
|
row.label,
|
||||||
|
row.envName,
|
||||||
|
row.element.dataset.description,
|
||||||
|
row.key
|
||||||
|
].join(" ").toLowerCase();
|
||||||
|
return data.includes(query);
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyFilters() {
|
||||||
|
let visible = 0;
|
||||||
|
|
||||||
|
groups.forEach((group) => {
|
||||||
|
let groupVisible = 0;
|
||||||
|
group.querySelectorAll(".setting-row").forEach((node) => {
|
||||||
|
const row = rows.find((item) => item.element === node);
|
||||||
|
const categoryMatch = state.currentCategory === "all" || row.category === state.currentCategory;
|
||||||
|
const searchMatch = rowMatchesSearch(row);
|
||||||
|
const changedMatch = !state.showChangedOnly || isDirty(row);
|
||||||
|
const lockedMatch = !state.showLockedOnly || row.locked;
|
||||||
|
const show = categoryMatch && searchMatch && changedMatch && lockedMatch;
|
||||||
|
node.classList.toggle("is-hidden", !show);
|
||||||
|
if (show) {
|
||||||
|
visible += 1;
|
||||||
|
groupVisible += 1;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
group.hidden = groupVisible === 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
visibleCount.textContent = String(visible);
|
||||||
|
statusMiddle.textContent = `category: ${state.currentCategory}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateStats() {
|
||||||
|
let dirty = 0;
|
||||||
|
let editable = 0;
|
||||||
|
let locked = 0;
|
||||||
|
let invalid = 0;
|
||||||
|
|
||||||
|
rows.forEach((row) => {
|
||||||
|
if (row.locked) locked += 1;
|
||||||
|
else editable += 1;
|
||||||
|
if (isDirty(row)) dirty += 1;
|
||||||
|
if (!validateRow(row)) invalid += 1;
|
||||||
|
});
|
||||||
|
|
||||||
|
editableCount.textContent = String(editable);
|
||||||
|
lockedCount.textContent = String(locked);
|
||||||
|
unsavedCount.textContent = String(dirty);
|
||||||
|
dirtyChip.textContent = `${dirty} unsaved`;
|
||||||
|
dirtyChip.classList.toggle("is-dirty", dirty > 0);
|
||||||
|
saveButton.disabled = dirty === 0 || invalid > 0;
|
||||||
|
|
||||||
|
if (invalid > 0) {
|
||||||
|
actionSummary.textContent = `${invalid} invalid setting value(s) must be fixed before save.`;
|
||||||
|
statusLeft.textContent = "Invalid values";
|
||||||
|
statusRight.textContent = "fix before save";
|
||||||
|
} else if (dirty > 0) {
|
||||||
|
actionSummary.textContent = `${dirty} unsaved change(s) ready to save or export.`;
|
||||||
|
statusLeft.textContent = "Unsaved changes";
|
||||||
|
statusRight.textContent = "draft ready";
|
||||||
|
} else {
|
||||||
|
actionSummary.textContent = "No unsaved changes.";
|
||||||
|
statusLeft.textContent = "No unsaved changes";
|
||||||
|
statusRight.textContent = "admin only";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateView() {
|
||||||
|
updateStats();
|
||||||
|
applyFilters();
|
||||||
|
}
|
||||||
|
|
||||||
|
function setCategory(category) {
|
||||||
|
state.currentCategory = category;
|
||||||
|
categoryButtons.forEach((button) => button.classList.toggle("is-active", button.dataset.category === category));
|
||||||
|
applyFilters();
|
||||||
|
}
|
||||||
|
|
||||||
|
function draftValues() {
|
||||||
|
const values = {};
|
||||||
|
rows.forEach((row) => {
|
||||||
|
if (!row.locked) values[row.key] = currentValue(row);
|
||||||
|
});
|
||||||
|
return values;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateRowFromPayload(payload) {
|
||||||
|
const row = rows.find((item) => item.key === payload.key);
|
||||||
|
if (!row) return;
|
||||||
|
|
||||||
|
row.element.dataset.original = payload.value;
|
||||||
|
row.element.dataset.default = payload.default_value || "";
|
||||||
|
row.element.dataset.source = payload.source || "default";
|
||||||
|
row.element.dataset.sourceBadge = payload.source_badge || payload.source || "default";
|
||||||
|
row.element.dataset.description = payload.description || "";
|
||||||
|
row.element.dataset.minimum = String(payload.minimum || 0);
|
||||||
|
row.element.classList.toggle("is-locked", Boolean(payload.locked));
|
||||||
|
row.locked = Boolean(payload.locked);
|
||||||
|
row.minimum = Number(payload.minimum || 0);
|
||||||
|
|
||||||
|
if (row.input) {
|
||||||
|
row.input.value = payload.value ?? "";
|
||||||
|
row.input.disabled = Boolean(payload.locked);
|
||||||
|
}
|
||||||
|
if (row.hint) {
|
||||||
|
row.hint.textContent = payload.locked
|
||||||
|
? "Locked by environment or hard runtime implication."
|
||||||
|
: (payload.default_value ? `Default: ${payload.default_value}` : "");
|
||||||
|
}
|
||||||
|
if (row.badge) {
|
||||||
|
row.badge.textContent = payload.source_badge || payload.source || "default";
|
||||||
|
row.badge.className = `settings-badge ${badgeClass(payload.source_badge || payload.source || "default")}`;
|
||||||
|
}
|
||||||
|
rowData[payload.key] = payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
function badgeClass(source) {
|
||||||
|
if (source === "default") return "badge-default";
|
||||||
|
if (source === "environment") return "badge-env";
|
||||||
|
if (source === "db override") return "badge-db";
|
||||||
|
return "badge-hard";
|
||||||
|
}
|
||||||
|
|
||||||
|
function hydrateRows(payloadRows) {
|
||||||
|
if (!Array.isArray(payloadRows)) return;
|
||||||
|
payloadRows.forEach(updateRowFromPayload);
|
||||||
|
updateView();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function postJSON(url, body) {
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(body)
|
||||||
|
});
|
||||||
|
const payload = await response.json().catch(() => ({}));
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(payload.error || "Request failed");
|
||||||
|
}
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveChanges() {
|
||||||
|
try {
|
||||||
|
const payload = await postJSON("/admin/settings/save", { values: draftValues() });
|
||||||
|
hydrateRows(payload.rows);
|
||||||
|
showToast(payload.message || "Settings saved", payload.warnings?.length ? "warning" : "success");
|
||||||
|
} catch (error) {
|
||||||
|
showToast(error.message, "error", 3200);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resetDefaults() {
|
||||||
|
if (!window.confirm("Reset all editable settings to built-in defaults?")) return;
|
||||||
|
try {
|
||||||
|
const payload = await postJSON("/admin/settings/reset", {});
|
||||||
|
hydrateRows(payload.rows);
|
||||||
|
showToast(payload.message || "Defaults restored", "success");
|
||||||
|
} catch (error) {
|
||||||
|
showToast(error.message, "error", 3200);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function exportSettings() {
|
||||||
|
try {
|
||||||
|
const response = await fetch("/admin/settings/export");
|
||||||
|
if (!response.ok) throw new Error("Could not export settings");
|
||||||
|
const payload = await response.json();
|
||||||
|
const blob = new Blob([JSON.stringify(payload, null, 2)], { type: "application/json;charset=utf-8" });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const anchor = document.createElement("a");
|
||||||
|
anchor.href = url;
|
||||||
|
anchor.download = `warpbox-settings-${new Date().toISOString().replaceAll(":", "-")}.json`;
|
||||||
|
anchor.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
showToast("Settings JSON exported");
|
||||||
|
} catch (error) {
|
||||||
|
showToast(error.message, "error", 3200);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function importSettingsFile(file) {
|
||||||
|
if (!file) return;
|
||||||
|
try {
|
||||||
|
const text = await file.text();
|
||||||
|
const payload = JSON.parse(text);
|
||||||
|
const result = await postJSON("/admin/settings/import", payload);
|
||||||
|
hydrateRows(result.rows);
|
||||||
|
showToast(result.message || "Settings imported", result.warnings?.length ? "warning" : "success", 3200);
|
||||||
|
} catch (error) {
|
||||||
|
showToast(error.message || "Could not import settings JSON", "error", 3200);
|
||||||
|
} finally {
|
||||||
|
importInput.value = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function discardUnsaved() {
|
||||||
|
rows.forEach((row) => {
|
||||||
|
if (!row.input) return;
|
||||||
|
row.input.value = row.element.dataset.original || "";
|
||||||
|
});
|
||||||
|
updateView();
|
||||||
|
showToast("Unsaved changes discarded");
|
||||||
|
}
|
||||||
|
|
||||||
|
function explainSources() {
|
||||||
|
window.WarpBoxUI?.openPopup?.(
|
||||||
|
"Setting Sources",
|
||||||
|
`
|
||||||
|
<ul>
|
||||||
|
<li><strong>default</strong>: built-in application value.</li>
|
||||||
|
<li><strong>environment</strong>: loaded from an environment variable.</li>
|
||||||
|
<li><strong>db override</strong>: saved from the admin settings page.</li>
|
||||||
|
<li><strong>hard env</strong>: visible here, but locked for safety.</li>
|
||||||
|
</ul>
|
||||||
|
`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function explainReset() {
|
||||||
|
window.WarpBoxUI?.openPopup?.(
|
||||||
|
"Reset Behavior",
|
||||||
|
`
|
||||||
|
<p>Reset defaults writes built-in WarpBox defaults as admin overrides for editable settings.</p>
|
||||||
|
<p>Environment-only settings stay locked and unchanged.</p>
|
||||||
|
`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function showRowInfo(row) {
|
||||||
|
window.WarpBoxUI?.openPopup?.(
|
||||||
|
row.label,
|
||||||
|
`
|
||||||
|
<p><strong>Environment variable:</strong> ${escapeHtml(row.envName || "n/a")}</p>
|
||||||
|
<p><strong>Current source:</strong> ${escapeHtml(row.badge?.textContent || row.element.dataset.sourceBadge || "default")}</p>
|
||||||
|
<p><strong>Description:</strong> ${escapeHtml(row.element.dataset.description || "No description available.")}</p>
|
||||||
|
${row.element.dataset.default ? `<p><strong>Default value:</strong> ${escapeHtml(row.element.dataset.default)}</p>` : ""}
|
||||||
|
`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runCommand(command) {
|
||||||
|
switch (command) {
|
||||||
|
case "save":
|
||||||
|
await saveChanges();
|
||||||
|
return;
|
||||||
|
case "export":
|
||||||
|
await exportSettings();
|
||||||
|
return;
|
||||||
|
case "import":
|
||||||
|
importInput.click();
|
||||||
|
return;
|
||||||
|
case "discard":
|
||||||
|
discardUnsaved();
|
||||||
|
return;
|
||||||
|
case "show-all":
|
||||||
|
state.showChangedOnly = false;
|
||||||
|
state.showLockedOnly = false;
|
||||||
|
applyFilters();
|
||||||
|
showToast("Showing all matching settings");
|
||||||
|
return;
|
||||||
|
case "show-changed":
|
||||||
|
state.showChangedOnly = !state.showChangedOnly;
|
||||||
|
if (state.showChangedOnly) state.showLockedOnly = false;
|
||||||
|
applyFilters();
|
||||||
|
showToast(state.showChangedOnly ? "Showing changed settings only" : "Showing all matching settings");
|
||||||
|
return;
|
||||||
|
case "show-locked":
|
||||||
|
state.showLockedOnly = !state.showLockedOnly;
|
||||||
|
if (state.showLockedOnly) state.showChangedOnly = false;
|
||||||
|
applyFilters();
|
||||||
|
showToast(state.showLockedOnly ? "Showing locked settings only" : "Showing all matching settings");
|
||||||
|
return;
|
||||||
|
case "reset-defaults":
|
||||||
|
await resetDefaults();
|
||||||
|
return;
|
||||||
|
case "reload":
|
||||||
|
window.location.reload();
|
||||||
|
return;
|
||||||
|
case "legend":
|
||||||
|
explainSources();
|
||||||
|
return;
|
||||||
|
case "reset-help":
|
||||||
|
explainReset();
|
||||||
|
return;
|
||||||
|
default:
|
||||||
|
showToast(`Unknown command: ${command}`, "warning");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rows.forEach((row) => {
|
||||||
|
row.input?.addEventListener(row.input.tagName === "SELECT" ? "change" : "input", updateView);
|
||||||
|
row.element.querySelector(".row-reset")?.addEventListener("click", () => {
|
||||||
|
if (row.locked || !row.input) return;
|
||||||
|
row.input.value = row.element.dataset.default || row.element.dataset.original || "";
|
||||||
|
updateView();
|
||||||
|
});
|
||||||
|
row.element.querySelector(".row-info")?.addEventListener("click", () => showRowInfo(row));
|
||||||
|
});
|
||||||
|
|
||||||
|
searchInput.addEventListener("input", applyFilters);
|
||||||
|
categoryButtons.forEach((button) => button.addEventListener("click", () => setCategory(button.dataset.category)));
|
||||||
|
saveButton.addEventListener("click", saveChanges);
|
||||||
|
exportButton.addEventListener("click", exportSettings);
|
||||||
|
importButton.addEventListener("click", () => importInput.click());
|
||||||
|
resetButton.addEventListener("click", resetDefaults);
|
||||||
|
importInput.addEventListener("change", (event) => importSettingsFile(event.target.files?.[0]));
|
||||||
|
popupClose?.addEventListener("click", () => window.WarpBoxUI?.closePopup?.());
|
||||||
|
document.getElementById("modal-backdrop")?.addEventListener("click", () => window.WarpBoxUI?.closePopup?.());
|
||||||
|
|
||||||
|
document.querySelectorAll("[data-command]").forEach((button) => {
|
||||||
|
button.addEventListener("click", async () => {
|
||||||
|
menuController.close();
|
||||||
|
await runCommand(button.dataset.command);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener("keydown", async (event) => {
|
||||||
|
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "s") {
|
||||||
|
event.preventDefault();
|
||||||
|
await saveChanges();
|
||||||
|
}
|
||||||
|
if (event.key === "F5") {
|
||||||
|
event.preventDefault();
|
||||||
|
window.location.reload();
|
||||||
|
}
|
||||||
|
if (event.key === "Escape") {
|
||||||
|
menuController.close();
|
||||||
|
window.WarpBoxUI?.closePopup?.();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
updateView();
|
||||||
|
})();
|
||||||
245
templates/admin/boxes.html
Normal file
245
templates/admin/boxes.html
Normal file
@@ -0,0 +1,245 @@
|
|||||||
|
{{ define "admin/boxes.html" }}
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>WarpBox Admin Boxes</title>
|
||||||
|
<link rel="icon" type="image/png" href="/static/WarpBoxLogo.png">
|
||||||
|
<link rel="stylesheet" href="/static/css/app.css">
|
||||||
|
<link rel="stylesheet" href="/static/css/window.css">
|
||||||
|
<link rel="stylesheet" href="/static/css/components/buttons.css">
|
||||||
|
<link rel="stylesheet" href="/static/css/components/toast.css">
|
||||||
|
<link rel="stylesheet" href="/static/css/admin.css">
|
||||||
|
<link rel="stylesheet" href="/static/css/boxes.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="admin-shell">
|
||||||
|
<div class="admin-frame">
|
||||||
|
{{ template "admin/header.html" . }}
|
||||||
|
|
||||||
|
<div class="win98-window admin-workspace-window" role="main">
|
||||||
|
<div class="win98-titlebar">
|
||||||
|
<div class="win98-titlebar-label">
|
||||||
|
<img class="win98-titlebar-icon" src="/static/WarpBoxLogo.png" alt="" aria-hidden="true">
|
||||||
|
<h1>WarpBox Boxes</h1>
|
||||||
|
</div>
|
||||||
|
<div class="win98-window-controls" aria-hidden="true">
|
||||||
|
<button class="win98-control" type="button">_</button>
|
||||||
|
<button class="win98-control" type="button">□</button>
|
||||||
|
<button class="win98-control" type="button">x</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav class="menu-bar" aria-label="Boxes toolbar">
|
||||||
|
<div class="menu-item">
|
||||||
|
<button class="menu-button" type="button" aria-expanded="false">File</button>
|
||||||
|
<div class="menu-popup">
|
||||||
|
<button class="menu-action" type="button" data-command="refresh"><span>R</span><span>Refresh list</span><span class="shortcut">F5</span></button>
|
||||||
|
<button class="menu-action" type="button" data-command="export"><span>E</span><span>Export visible CSV</span><span></span></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="menu-item">
|
||||||
|
<button class="menu-button" type="button" aria-expanded="false">View</button>
|
||||||
|
<div class="menu-popup">
|
||||||
|
<button class="menu-action" type="button" data-command="status-ready"><span>V</span><span>Show ready only</span><span></span></button>
|
||||||
|
<button class="menu-action" type="button" data-command="status-expired"><span>X</span><span>Show expired only</span><span></span></button>
|
||||||
|
<button class="menu-action" type="button" data-command="clear-filters"><span>C</span><span>Clear filters</span><span></span></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="menu-item">
|
||||||
|
<button class="menu-button" type="button" aria-expanded="false">Boxes</button>
|
||||||
|
<div class="menu-popup">
|
||||||
|
<button class="menu-action" type="button" data-command="expire"><span>!</span><span>Expire selected now</span><span></span></button>
|
||||||
|
<button class="menu-action" type="button" data-command="extend-day"><span>+</span><span>Extend selected by 24h</span><span></span></button>
|
||||||
|
<button class="menu-action" type="button" data-command="extend-week"><span>7</span><span>Extend selected by 7d</span><span></span></button>
|
||||||
|
<div class="menu-separator"></div>
|
||||||
|
<button class="menu-action" type="button" data-command="delete"><span>D</span><span>Delete selected</span><span></span></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="menu-item">
|
||||||
|
<button class="menu-button" type="button" aria-expanded="false">Help</button>
|
||||||
|
<div class="menu-popup">
|
||||||
|
<button class="menu-action" type="button" data-command="help-scope"><span>?</span><span>Ownership scope note</span><span></span></button>
|
||||||
|
<button class="menu-action" type="button" data-command="help-flags"><span>F</span><span>Flag meanings</span><span></span></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div class="admin-workspace-body boxes-page-body">
|
||||||
|
<section class="boxes-summary-grid" aria-label="Boxes summary">
|
||||||
|
<article class="boxes-stat-card is-info">
|
||||||
|
<p class="boxes-stat-label">Total boxes</p>
|
||||||
|
<p class="boxes-stat-value" data-stat-total>0</p>
|
||||||
|
<p class="boxes-stat-note">All stored manifests and legacy boxes</p>
|
||||||
|
</article>
|
||||||
|
<article class="boxes-stat-card is-ok">
|
||||||
|
<p class="boxes-stat-label">Ready</p>
|
||||||
|
<p class="boxes-stat-value" data-stat-ready>0</p>
|
||||||
|
<p class="boxes-stat-note">Complete and still available</p>
|
||||||
|
</article>
|
||||||
|
<article class="boxes-stat-card is-warning">
|
||||||
|
<p class="boxes-stat-label">Uploading</p>
|
||||||
|
<p class="boxes-stat-value" data-stat-uploading>0</p>
|
||||||
|
<p class="boxes-stat-note">Still waiting on files</p>
|
||||||
|
</article>
|
||||||
|
<article class="boxes-stat-card is-danger">
|
||||||
|
<p class="boxes-stat-label">Expired / consumed</p>
|
||||||
|
<p class="boxes-stat-value" data-stat-expired>0</p>
|
||||||
|
<p class="boxes-stat-note">Needs cleanup or review</p>
|
||||||
|
</article>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="boxes-hero-note">
|
||||||
|
<div>
|
||||||
|
<strong>Scope note.</strong>
|
||||||
|
<span>This page lists real stored boxes and real file state. Per-user ownership scoping is still pending backend account data.</span>
|
||||||
|
</div>
|
||||||
|
<div class="boxes-hero-tags">
|
||||||
|
<span class="boxes-hero-tag">real data</span>
|
||||||
|
<span class="boxes-hero-tag">real actions</span>
|
||||||
|
<span class="boxes-hero-tag">ownership TODO</span>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="boxes-content-grid">
|
||||||
|
<div class="boxes-column">
|
||||||
|
<section class="boxes-panel">
|
||||||
|
<div class="boxes-panel-header">
|
||||||
|
<div class="boxes-panel-title">Box list <span class="boxes-panel-sub">search, filter, bulk actions</span></div>
|
||||||
|
<div class="boxes-panel-tools">
|
||||||
|
<button class="win98-button boxes-tool-button" type="button" data-command="refresh">Refresh</button>
|
||||||
|
<button class="win98-button boxes-tool-button" type="button" data-command="export">Export CSV</button>
|
||||||
|
<button class="win98-button boxes-tool-button" type="button" data-command="expire">Expire</button>
|
||||||
|
<button class="win98-button boxes-tool-button" type="button" data-command="extend-day">+24h</button>
|
||||||
|
<button class="win98-button boxes-tool-button is-danger" type="button" data-command="delete">Delete</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="boxes-panel-body">
|
||||||
|
<div class="boxes-toolbar-grid">
|
||||||
|
<input class="boxes-input" id="boxes-search" type="search" placeholder="Search box id, file name, mime, retention">
|
||||||
|
<select class="boxes-select" id="boxes-status-filter">
|
||||||
|
<option value="all" selected>All statuses</option>
|
||||||
|
<option value="ready">Ready</option>
|
||||||
|
<option value="uploading">Uploading</option>
|
||||||
|
<option value="attention">Needs review</option>
|
||||||
|
<option value="expired">Expired</option>
|
||||||
|
<option value="consumed">Consumed</option>
|
||||||
|
<option value="legacy">Legacy</option>
|
||||||
|
</select>
|
||||||
|
<select class="boxes-select" id="boxes-flag-filter">
|
||||||
|
<option value="all" selected>All flags</option>
|
||||||
|
<option value="protected">Protected</option>
|
||||||
|
<option value="one-time">One-time</option>
|
||||||
|
<option value="zip off">ZIP off</option>
|
||||||
|
<option value="legacy">Legacy</option>
|
||||||
|
</select>
|
||||||
|
<select class="boxes-select" id="boxes-sort">
|
||||||
|
<option value="newest" selected>Newest first</option>
|
||||||
|
<option value="expires">Soonest expiry</option>
|
||||||
|
<option value="largest">Largest size</option>
|
||||||
|
<option value="name">Box id</option>
|
||||||
|
</select>
|
||||||
|
<select class="boxes-select" id="boxes-page-size">
|
||||||
|
<option value="10" selected>10 / page</option>
|
||||||
|
<option value="25">25 / page</option>
|
||||||
|
<option value="50">50 / page</option>
|
||||||
|
<option value="9999">All rows</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="boxes-table-wrap">
|
||||||
|
<table class="boxes-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th class="boxes-col-check"><input type="checkbox" id="boxes-select-all"></th>
|
||||||
|
<th class="boxes-col-id">Box ID</th>
|
||||||
|
<th class="boxes-col-status">Status</th>
|
||||||
|
<th class="boxes-col-files">Files</th>
|
||||||
|
<th class="boxes-col-size">Size</th>
|
||||||
|
<th class="boxes-col-retention">Retention</th>
|
||||||
|
<th class="boxes-col-expires">Expires</th>
|
||||||
|
<th>Flags</th>
|
||||||
|
<th class="boxes-col-actions">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="boxes-table-body"></tbody>
|
||||||
|
</table>
|
||||||
|
<div class="boxes-empty-state" id="boxes-empty-state" hidden>No boxes match current filters.</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="boxes-footer-bar">
|
||||||
|
<span id="boxes-range-label">Showing 0-0 of 0</span>
|
||||||
|
<span id="boxes-selected-label">Selected: 0</span>
|
||||||
|
<div class="boxes-pagination">
|
||||||
|
<button class="win98-button boxes-page-button" type="button" id="boxes-prev-page">Prev</button>
|
||||||
|
<span id="boxes-page-label">Page 1 / 1</span>
|
||||||
|
<button class="win98-button boxes-page-button" type="button" id="boxes-next-page">Next</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="boxes-column boxes-column-side">
|
||||||
|
<section class="boxes-panel">
|
||||||
|
<div class="boxes-panel-header">
|
||||||
|
<div class="boxes-panel-title">Box details <span class="boxes-panel-sub">selected box preview</span></div>
|
||||||
|
</div>
|
||||||
|
<div class="boxes-panel-body boxes-detail-body">
|
||||||
|
<ul class="boxes-info-list">
|
||||||
|
<li class="boxes-info-item"><strong>Box</strong><span id="detail-box-id">-</span></li>
|
||||||
|
<li class="boxes-info-item"><strong>Status</strong><span id="detail-status">-</span></li>
|
||||||
|
<li class="boxes-info-item"><strong>Created</strong><span id="detail-created">-</span></li>
|
||||||
|
<li class="boxes-info-item"><strong>Expires</strong><span id="detail-expires">-</span></li>
|
||||||
|
<li class="boxes-info-item"><strong>Retention</strong><span id="detail-retention">-</span></li>
|
||||||
|
<li class="boxes-info-item"><strong>Files</strong><span id="detail-files">-</span></li>
|
||||||
|
<li class="boxes-info-item"><strong>Size</strong><span id="detail-size">-</span></li>
|
||||||
|
<li class="boxes-info-item"><strong>Flags</strong><span id="detail-flags">-</span></li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<div class="boxes-action-stack">
|
||||||
|
<div class="boxes-action-grid">
|
||||||
|
<a class="win98-button boxes-action-button" id="detail-open" href="#" target="_blank" rel="noreferrer">Open</a>
|
||||||
|
<a class="win98-button boxes-action-button" id="detail-zip" href="#" target="_blank" rel="noreferrer">ZIP</a>
|
||||||
|
</div>
|
||||||
|
<div class="boxes-action-grid">
|
||||||
|
<button class="win98-button boxes-action-button" type="button" data-command="active-expire">Expire now</button>
|
||||||
|
<button class="win98-button boxes-action-button" type="button" data-command="active-extend-day">+24h</button>
|
||||||
|
</div>
|
||||||
|
<div class="boxes-action-grid">
|
||||||
|
<button class="win98-button boxes-action-button" type="button" data-command="active-extend-week">+7d</button>
|
||||||
|
<button class="win98-button boxes-action-button is-danger" type="button" data-command="active-delete">Delete</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="boxes-panel boxes-files-panel">
|
||||||
|
<div class="boxes-panel-header">
|
||||||
|
<div class="boxes-panel-title">Files <span class="boxes-panel-sub">real file inventory</span></div>
|
||||||
|
</div>
|
||||||
|
<div class="boxes-panel-body">
|
||||||
|
<div class="boxes-file-list" id="detail-file-list"></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer class="status-bar admin-dashboard-statusbar">
|
||||||
|
<span id="boxes-footer-summary">0 boxes loaded</span>
|
||||||
|
<span id="boxes-footer-scope">scope: global admin view</span>
|
||||||
|
<span id="boxes-footer-zip">{{ if .ZipDownloadsOn }}zip downloads enabled{{ else }}zip downloads disabled{{ end }}</span>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="toast" class="wb-toast" role="status" aria-live="polite"></div>
|
||||||
|
<script id="boxes-data" type="application/json">{{ toJSON .Boxes }}</script>
|
||||||
|
<script src="/static/js/warpbox-ui.js"></script>
|
||||||
|
<script src="/static/js/admin/boxes.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
{{ end }}
|
||||||
@@ -7,8 +7,8 @@
|
|||||||
<nav class="admin-taskbar-nav" aria-label="Primary">
|
<nav class="admin-taskbar-nav" aria-label="Primary">
|
||||||
<a class="admin-taskbar-button{{ if eq .ActivePage "dashboard" }} is-active{{ end }}" href="/admin/dashboard">Dashboard</a>
|
<a class="admin-taskbar-button{{ if eq .ActivePage "dashboard" }} is-active{{ end }}" href="/admin/dashboard">Dashboard</a>
|
||||||
<a class="admin-taskbar-button{{ if eq .ActivePage "alerts" }} is-active{{ end }}" href="/admin/alerts">Alerts</a>
|
<a class="admin-taskbar-button{{ if eq .ActivePage "alerts" }} is-active{{ end }}" href="/admin/alerts">Alerts</a>
|
||||||
<a class="admin-taskbar-button" href="/admin/dashboard#recent-boxes">Boxes</a>
|
<a class="admin-taskbar-button{{ if eq .ActivePage "boxes" }} is-active{{ end }}" href="/admin/boxes">Boxes</a>
|
||||||
<a class="admin-taskbar-button" href="/admin/dashboard#recent-activity">Activity</a>
|
<a class="admin-taskbar-button{{ if eq .ActivePage "settings" }} is-active{{ end }}" href="/admin/settings">Settings</a>
|
||||||
</nav>
|
</nav>
|
||||||
<div class="admin-taskbar-session" aria-label="Admin session summary">
|
<div class="admin-taskbar-session" aria-label="Admin session summary">
|
||||||
<a class="admin-alert-chip is-warning" href="/admin/alerts" id="topAlertChip">! 15 alerts</a>
|
<a class="admin-alert-chip is-warning" href="/admin/alerts" id="topAlertChip">! 15 alerts</a>
|
||||||
|
|||||||
241
templates/admin/settings.html
Normal file
241
templates/admin/settings.html
Normal file
@@ -0,0 +1,241 @@
|
|||||||
|
{{ define "admin/settings.html" }}
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>WarpBox Admin Settings</title>
|
||||||
|
<link rel="icon" type="image/png" href="/static/WarpBoxLogo.png">
|
||||||
|
<link rel="stylesheet" href="/static/css/app.css">
|
||||||
|
<link rel="stylesheet" href="/static/css/window.css">
|
||||||
|
<link rel="stylesheet" href="/static/css/components/buttons.css">
|
||||||
|
<link rel="stylesheet" href="/static/css/components/toast.css">
|
||||||
|
<link rel="stylesheet" href="/static/css/admin.css">
|
||||||
|
<link rel="stylesheet" href="/static/css/settings.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="admin-shell">
|
||||||
|
<div class="admin-frame">
|
||||||
|
{{ template "admin/header.html" . }}
|
||||||
|
|
||||||
|
<div class="win98-window admin-workspace-window" role="main">
|
||||||
|
<div class="win98-titlebar">
|
||||||
|
<div class="win98-titlebar-label">
|
||||||
|
<img class="win98-titlebar-icon" src="/static/WarpBoxLogo.png" alt="" aria-hidden="true">
|
||||||
|
<h1>WarpBox Settings</h1>
|
||||||
|
</div>
|
||||||
|
<div class="win98-window-controls" aria-hidden="true">
|
||||||
|
<button class="win98-control" type="button">_</button>
|
||||||
|
<button class="win98-control" type="button">□</button>
|
||||||
|
<button class="win98-control" type="button">x</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav class="menu-bar" aria-label="Settings toolbar">
|
||||||
|
<div class="menu-item">
|
||||||
|
<button class="menu-button" type="button" aria-expanded="false">File</button>
|
||||||
|
<div class="menu-popup">
|
||||||
|
<button class="menu-action" type="button" data-command="save"><span>S</span><span>Save overrides</span><span class="shortcut">Ctrl+S</span></button>
|
||||||
|
<button class="menu-action" type="button" data-command="export"><span>E</span><span>Export settings JSON</span><span></span></button>
|
||||||
|
<button class="menu-action" type="button" data-command="import"><span>I</span><span>Import settings JSON</span><span></span></button>
|
||||||
|
<div class="menu-separator"></div>
|
||||||
|
<button class="menu-action" type="button" data-command="discard"><span>D</span><span>Discard unsaved changes</span><span></span></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="menu-item">
|
||||||
|
<button class="menu-button" type="button" aria-expanded="false">View</button>
|
||||||
|
<div class="menu-popup">
|
||||||
|
<button class="menu-action" type="button" data-command="show-all"><span>A</span><span>Show all settings</span><span></span></button>
|
||||||
|
<button class="menu-action" type="button" data-command="show-changed"><span>C</span><span>Show changed only</span><span></span></button>
|
||||||
|
<button class="menu-action" type="button" data-command="show-locked"><span>L</span><span>Show locked only</span><span></span></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="menu-item">
|
||||||
|
<button class="menu-button" type="button" aria-expanded="false">Settings</button>
|
||||||
|
<div class="menu-popup">
|
||||||
|
<button class="menu-action" type="button" data-command="reset-defaults"><span>R</span><span>Reset editable to defaults</span><span></span></button>
|
||||||
|
<button class="menu-action" type="button" data-command="reload"><span>F</span><span>Reload current values</span><span class="shortcut">F5</span></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="menu-item">
|
||||||
|
<button class="menu-button" type="button" aria-expanded="false">Help</button>
|
||||||
|
<div class="menu-popup">
|
||||||
|
<button class="menu-action" type="button" data-command="legend"><span>?</span><span>Explain sources</span><span></span></button>
|
||||||
|
<button class="menu-action" type="button" data-command="reset-help"><span>!</span><span>Reset semantics</span><span></span></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div class="admin-workspace-body settings-page-body">
|
||||||
|
<section class="settings-summary-grid" aria-label="Settings summary">
|
||||||
|
<article class="settings-stat-card is-info">
|
||||||
|
<p class="settings-stat-label">Visible settings</p>
|
||||||
|
<p class="settings-stat-value" id="visibleCount">{{ len .Rows }}</p>
|
||||||
|
<p class="settings-stat-note">Filtered by search and category</p>
|
||||||
|
</article>
|
||||||
|
<article class="settings-stat-card is-ok">
|
||||||
|
<p class="settings-stat-label">Editable</p>
|
||||||
|
<p class="settings-stat-value" id="editableCount">0</p>
|
||||||
|
<p class="settings-stat-note">Runtime override supported</p>
|
||||||
|
</article>
|
||||||
|
<article class="settings-stat-card is-warning">
|
||||||
|
<p class="settings-stat-label">Unsaved</p>
|
||||||
|
<p class="settings-stat-value" id="unsavedCount">0</p>
|
||||||
|
<p class="settings-stat-note">Draft changes in browser</p>
|
||||||
|
</article>
|
||||||
|
<article class="settings-stat-card is-danger">
|
||||||
|
<p class="settings-stat-label">Locked</p>
|
||||||
|
<p class="settings-stat-value" id="lockedCount">0</p>
|
||||||
|
<p class="settings-stat-note">Environment only</p>
|
||||||
|
</article>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="settings-main-grid">
|
||||||
|
<aside class="settings-sidebar-panel">
|
||||||
|
<section class="settings-panel settings-sidebar">
|
||||||
|
<div class="settings-panel-header">
|
||||||
|
<div class="settings-panel-title">Categories <span class="settings-panel-sub">search and scope</span></div>
|
||||||
|
</div>
|
||||||
|
<div class="settings-panel-body">
|
||||||
|
<div class="settings-search">
|
||||||
|
<label for="settingsSearch">Search</label>
|
||||||
|
<input class="settings-input" id="settingsSearch" type="search" placeholder="Search label, env var, description">
|
||||||
|
</div>
|
||||||
|
<ul class="settings-category-list" id="categoryList">
|
||||||
|
{{ range .Categories }}
|
||||||
|
<li>
|
||||||
|
<button class="settings-category-button{{ if eq .Key "all" }} is-active{{ end }}" type="button" data-category="{{ .Key }}">
|
||||||
|
<span>{{ .Icon }}</span>
|
||||||
|
<span>{{ .Label }}</span>
|
||||||
|
<span class="settings-category-count">{{ .Count }}</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
{{ end }}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<section class="settings-workbench">
|
||||||
|
<section class="settings-hero-panel">
|
||||||
|
<div class="settings-hero-copy">
|
||||||
|
<h2>Administrative runtime settings</h2>
|
||||||
|
<p>Edit safe runtime overrides without hiding where each value came from. Hard storage and security-sensitive environment settings stay visible but locked.</p>
|
||||||
|
</div>
|
||||||
|
<div class="settings-hero-legend">
|
||||||
|
<div class="settings-legend-row"><span class="settings-badge badge-default">default</span><span>Built-in application value</span></div>
|
||||||
|
<div class="settings-legend-row"><span class="settings-badge badge-env">environment</span><span>Loaded from env</span></div>
|
||||||
|
<div class="settings-legend-row"><span class="settings-badge badge-db">db override</span><span>Saved from admin UI</span></div>
|
||||||
|
<div class="settings-legend-row"><span class="settings-badge badge-hard">hard env</span><span>Visible, not editable here</span></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="settings-panel">
|
||||||
|
<div class="settings-panel-header">
|
||||||
|
<div class="settings-panel-title">Settings grid <span class="settings-panel-sub">edit, inspect, import, export</span></div>
|
||||||
|
<div class="settings-panel-tools">
|
||||||
|
<span class="settings-dirty-chip" id="dirtyChip">0 unsaved</span>
|
||||||
|
<button class="win98-button settings-tool-button" id="exportButton" type="button">Export JSON</button>
|
||||||
|
<button class="win98-button settings-tool-button" id="importButton" type="button">Import JSON</button>
|
||||||
|
<button class="win98-button settings-tool-button" id="resetButton" type="button">Reset Defaults</button>
|
||||||
|
<button class="win98-button settings-tool-button" id="saveButton" type="button" disabled>Save</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="settings-panel-body">
|
||||||
|
<div class="settings-action-summary" id="actionSummary">No unsaved changes.</div>
|
||||||
|
<div class="settings-groups" id="settingsGroups">
|
||||||
|
{{ range .Categories }}
|
||||||
|
{{ if ne .Key "all" }}
|
||||||
|
<section class="settings-group" data-category="{{ .Key }}">
|
||||||
|
<div class="settings-group-title">{{ .Label }}</div>
|
||||||
|
<div class="settings-table-wrap">
|
||||||
|
<table class="settings-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Setting</th>
|
||||||
|
<th>Source</th>
|
||||||
|
<th>Value</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{{ range .Rows }}
|
||||||
|
<tr class="setting-row{{ if .Locked }} is-locked{{ end }}"
|
||||||
|
data-key="{{ .Key }}"
|
||||||
|
data-label="{{ .Label }}"
|
||||||
|
data-category="{{ .Category }}"
|
||||||
|
data-type="{{ .Type }}"
|
||||||
|
data-original="{{ .Value }}"
|
||||||
|
data-default="{{ .DefaultValue }}"
|
||||||
|
data-env-name="{{ .EnvName }}"
|
||||||
|
data-source="{{ .Source }}"
|
||||||
|
data-source-badge="{{ .SourceBadge }}"
|
||||||
|
data-description="{{ .Description }}"
|
||||||
|
data-minimum="{{ .Minimum }}">
|
||||||
|
<td>
|
||||||
|
<div class="setting-meta">
|
||||||
|
<strong>{{ .Label }}</strong>
|
||||||
|
<code>{{ .EnvName }}</code>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span class="settings-badge{{ if eq .SourceBadge "default" }} badge-default{{ else if eq .SourceBadge "environment" }} badge-env{{ else if eq .SourceBadge "db override" }} badge-db{{ else }} badge-hard{{ end }}" data-role="source-badge">{{ .SourceBadge }}</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div class="setting-control">
|
||||||
|
{{ if eq .Type "bool" }}
|
||||||
|
<select class="settings-select setting-input"{{ if .Locked }} disabled{{ end }}>
|
||||||
|
<option value="true"{{ if eq .Value "true" }} selected{{ end }}>true</option>
|
||||||
|
<option value="false"{{ if eq .Value "false" }} selected{{ end }}>false</option>
|
||||||
|
</select>
|
||||||
|
{{ else }}
|
||||||
|
<input class="settings-input setting-input" type="text" value="{{ .Value }}"{{ if .Locked }} disabled{{ end }}>
|
||||||
|
{{ end }}
|
||||||
|
<div class="setting-hint" data-role="hint">{{ if .Locked }}Locked by environment or hard runtime implication.{{ else if .DefaultValue }}Default: {{ .DefaultValue }}{{ end }}</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="setting-actions">
|
||||||
|
<button class="win98-button settings-mini-button row-reset" type="button"{{ if .Locked }} disabled{{ end }}>Reset</button>
|
||||||
|
<button class="win98-button settings-mini-button row-info" type="button">Info</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{{ end }}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{{ end }}
|
||||||
|
{{ end }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</section>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer class="status-bar admin-dashboard-statusbar">
|
||||||
|
<span id="statusLeft">No unsaved changes</span>
|
||||||
|
<span id="statusMiddle">category: all</span>
|
||||||
|
<span id="statusRight">admin only</span>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="modal-backdrop" class="settings-modal-backdrop"></div>
|
||||||
|
<div id="doc-popup" class="settings-popup" role="dialog" aria-modal="true" aria-labelledby="doc-popup-title">
|
||||||
|
<div class="settings-popup-titlebar">
|
||||||
|
<strong id="doc-popup-title">Details</strong>
|
||||||
|
<button class="win98-button settings-popup-close" id="doc-popup-close" type="button">Close</button>
|
||||||
|
</div>
|
||||||
|
<div class="settings-popup-body" id="doc-popup-body"></div>
|
||||||
|
</div>
|
||||||
|
<input id="settingsImportInput" type="file" accept="application/json,.json" hidden>
|
||||||
|
<div id="toast" class="wb-toast" role="status" aria-live="polite"></div>
|
||||||
|
|
||||||
|
<script id="settings-rows" type="application/json">{{ toJSON .RowsJSON }}</script>
|
||||||
|
<script src="/static/js/warpbox-ui.js"></script>
|
||||||
|
<script src="/static/js/admin/settings.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
{{ end }}
|
||||||
Reference in New Issue
Block a user