Compare commits
2 Commits
d0aa86205f
...
9b57b2a535
| Author | SHA1 | Date | |
|---|---|---|---|
| 9b57b2a535 | |||
| 1cf38d126d |
@@ -50,8 +50,8 @@ ENV WARPBOX_DATA_DIR=/app/data \
|
|||||||
WARPBOX_ONE_TIME_DOWNLOAD_EXPIRY_SECONDS=604800 \
|
WARPBOX_ONE_TIME_DOWNLOAD_EXPIRY_SECONDS=604800 \
|
||||||
WARPBOX_ONE_TIME_DOWNLOAD_RETRY_ON_FAILURE=false \
|
WARPBOX_ONE_TIME_DOWNLOAD_RETRY_ON_FAILURE=false \
|
||||||
WARPBOX_ADMIN_ENABLED=true \
|
WARPBOX_ADMIN_ENABLED=true \
|
||||||
WARPBOX_GLOBAL_MAX_FILE_SIZE_MB=2048 \
|
WARPBOX_GLOBAL_MAX_FILE_SIZE_GB=2 \
|
||||||
WARPBOX_GLOBAL_MAX_BOX_SIZE_MB=4096 \
|
WARPBOX_GLOBAL_MAX_BOX_SIZE_GB=4 \
|
||||||
WARPBOX_DEFAULT_GUEST_EXPIRY_SECONDS=3600 \
|
WARPBOX_DEFAULT_GUEST_EXPIRY_SECONDS=3600 \
|
||||||
WARPBOX_MAX_GUEST_EXPIRY_SECONDS=172800 \
|
WARPBOX_MAX_GUEST_EXPIRY_SECONDS=172800 \
|
||||||
WARPBOX_BOX_POLL_INTERVAL_MS=5000 \
|
WARPBOX_BOX_POLL_INTERVAL_MS=5000 \
|
||||||
|
|||||||
14
README.md
14
README.md
@@ -86,8 +86,8 @@ go run ./cmd run --addr :3000
|
|||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
WarpBox loads defaults, applies environment variables at startup, then applies
|
WarpBox loads defaults, applies environment variables at startup, then applies
|
||||||
safe admin settings overrides from BadgerDB. Hard storage and global limit
|
safe admin settings overrides from BadgerDB. Storage path settings remain
|
||||||
settings remain environment controlled.
|
environment controlled.
|
||||||
|
|
||||||
| Variable | Default | What it does |
|
| Variable | Default | What it does |
|
||||||
| --- | ---: | --- |
|
| --- | ---: | --- |
|
||||||
@@ -108,16 +108,16 @@ settings remain environment controlled.
|
|||||||
| `WARPBOX_RENEW_ON_DOWNLOAD_ENABLED` | `false` | Renews expiring boxes on download. |
|
| `WARPBOX_RENEW_ON_DOWNLOAD_ENABLED` | `false` | Renews expiring boxes on download. |
|
||||||
| `WARPBOX_DEFAULT_GUEST_EXPIRY_SECONDS` | `10` | Default guest retention. |
|
| `WARPBOX_DEFAULT_GUEST_EXPIRY_SECONDS` | `10` | Default guest retention. |
|
||||||
| `WARPBOX_MAX_GUEST_EXPIRY_SECONDS` | `172800` | Max guest retention shown/accepted. |
|
| `WARPBOX_MAX_GUEST_EXPIRY_SECONDS` | `172800` | Max guest retention shown/accepted. |
|
||||||
| `WARPBOX_GLOBAL_MAX_FILE_SIZE_BYTES` | `0` | Hard per-file cap; `0` means unlimited. |
|
| `WARPBOX_GLOBAL_MAX_FILE_SIZE_GB` | `0` | Per-file cap in GB using `1024^3` conversion; `0` means unlimited. Decimals allowed, like `0.5`. |
|
||||||
| `WARPBOX_GLOBAL_MAX_BOX_SIZE_BYTES` | `0` | Hard per-box cap; `0` means unlimited. |
|
| `WARPBOX_GLOBAL_MAX_BOX_SIZE_GB` | `0` | Per-box cap in GB using `1024^3` conversion; `0` means unlimited. Decimals allowed. |
|
||||||
| `WARPBOX_DEFAULT_USER_MAX_FILE_SIZE_BYTES` | `0` | Default user file cap. |
|
| `WARPBOX_DEFAULT_USER_MAX_FILE_SIZE_GB` | `0` | Default user file cap in GB using `1024^3` conversion. |
|
||||||
| `WARPBOX_DEFAULT_USER_MAX_BOX_SIZE_BYTES` | `0` | Default user box cap. |
|
| `WARPBOX_DEFAULT_USER_MAX_BOX_SIZE_GB` | `0` | Default user box cap in GB using `1024^3` conversion. |
|
||||||
| `WARPBOX_SESSION_TTL_SECONDS` | `86400` | Admin session lifetime. |
|
| `WARPBOX_SESSION_TTL_SECONDS` | `86400` | Admin session lifetime. |
|
||||||
| `WARPBOX_BOX_POLL_INTERVAL_MS` | `5000` | Browser polling interval for box/file status updates. |
|
| `WARPBOX_BOX_POLL_INTERVAL_MS` | `5000` | Browser polling interval for box/file status updates. |
|
||||||
| `WARPBOX_THUMBNAIL_BATCH_SIZE` | `10` | Number of pending thumbnails processed per worker pass. |
|
| `WARPBOX_THUMBNAIL_BATCH_SIZE` | `10` | Number of pending thumbnails processed per worker pass. |
|
||||||
| `WARPBOX_THUMBNAIL_INTERVAL_SECONDS` | `30` | Delay between thumbnail worker passes. |
|
| `WARPBOX_THUMBNAIL_INTERVAL_SECONDS` | `30` | Delay between thumbnail worker passes. |
|
||||||
|
|
||||||
Size limits also accept `_MB` variants for the same settings.
|
Legacy `_MB` and `_BYTES` size env names are still accepted for compatibility, but GB env names are the intended format now. GB input uses `1024^3` bytes so UI limits and displayed space stay consistent.
|
||||||
|
|
||||||
Example:
|
Example:
|
||||||
|
|
||||||
|
|||||||
@@ -175,22 +175,6 @@ func buildExtraEnvRows(includeHidden bool) []envRow {
|
|||||||
{EnvName: "WARPBOX_ALLOW_ADMIN_SETTINGS_OVERRIDE", Key: "allow_admin_override", Label: "Allow admin UI to override settings", Type: config.SettingTypeBool, Editable: false, HardLimit: true, Default: "true"},
|
{EnvName: "WARPBOX_ALLOW_ADMIN_SETTINGS_OVERRIDE", Key: "allow_admin_override", Label: "Allow admin UI to override settings", Type: config.SettingTypeBool, Editable: false, HardLimit: true, Default: "true"},
|
||||||
}
|
}
|
||||||
|
|
||||||
sizePairs := []struct {
|
|
||||||
bytesEnv string
|
|
||||||
mbEnv string
|
|
||||||
label string
|
|
||||||
}{
|
|
||||||
{"WARPBOX_GLOBAL_MAX_FILE_SIZE_BYTES", "WARPBOX_GLOBAL_MAX_FILE_SIZE_MB", "Global max file size"},
|
|
||||||
{"WARPBOX_GLOBAL_MAX_BOX_SIZE_BYTES", "WARPBOX_GLOBAL_MAX_BOX_SIZE_MB", "Global max box size"},
|
|
||||||
{"WARPBOX_DEFAULT_USER_MAX_FILE_SIZE_BYTES", "WARPBOX_DEFAULT_USER_MAX_FILE_SIZE_MB", "Default user max file size"},
|
|
||||||
{"WARPBOX_DEFAULT_USER_MAX_BOX_SIZE_BYTES", "WARPBOX_DEFAULT_USER_MAX_BOX_SIZE_MB", "Default user max box size"},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, pair := range sizePairs {
|
|
||||||
extra = append(extra, envRow{EnvName: pair.bytesEnv, Key: pair.bytesEnv, Label: pair.label + " (bytes)", Type: config.SettingTypeInt64, Editable: false, HardLimit: true, Minimum: 0, Default: "(use bytes or MB variant)"})
|
|
||||||
extra = append(extra, envRow{EnvName: pair.mbEnv, Key: pair.mbEnv, Label: pair.label + " (MB)", Type: config.SettingTypeInt64, Editable: false, HardLimit: true, Minimum: 0, Default: "(use bytes or MB variant)"})
|
|
||||||
}
|
|
||||||
|
|
||||||
return extra
|
return extra
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
10
docs/tech.md
10
docs/tech.md
@@ -150,16 +150,16 @@ Primary environment variables:
|
|||||||
- `WARPBOX_RENEW_ON_DOWNLOAD_ENABLED`
|
- `WARPBOX_RENEW_ON_DOWNLOAD_ENABLED`
|
||||||
- `WARPBOX_DEFAULT_GUEST_EXPIRY_SECONDS`
|
- `WARPBOX_DEFAULT_GUEST_EXPIRY_SECONDS`
|
||||||
- `WARPBOX_MAX_GUEST_EXPIRY_SECONDS`
|
- `WARPBOX_MAX_GUEST_EXPIRY_SECONDS`
|
||||||
- `WARPBOX_GLOBAL_MAX_FILE_SIZE_BYTES`
|
- `WARPBOX_GLOBAL_MAX_FILE_SIZE_GB`
|
||||||
- `WARPBOX_GLOBAL_MAX_BOX_SIZE_BYTES`
|
- `WARPBOX_GLOBAL_MAX_BOX_SIZE_GB`
|
||||||
- `WARPBOX_DEFAULT_USER_MAX_FILE_SIZE_BYTES`
|
- `WARPBOX_DEFAULT_USER_MAX_FILE_SIZE_GB`
|
||||||
- `WARPBOX_DEFAULT_USER_MAX_BOX_SIZE_BYTES`
|
- `WARPBOX_DEFAULT_USER_MAX_BOX_SIZE_GB`
|
||||||
- `WARPBOX_SESSION_TTL_SECONDS`
|
- `WARPBOX_SESSION_TTL_SECONDS`
|
||||||
- `WARPBOX_BOX_POLL_INTERVAL_MS`
|
- `WARPBOX_BOX_POLL_INTERVAL_MS`
|
||||||
- `WARPBOX_THUMBNAIL_BATCH_SIZE`
|
- `WARPBOX_THUMBNAIL_BATCH_SIZE`
|
||||||
- `WARPBOX_THUMBNAIL_INTERVAL_SECONDS`
|
- `WARPBOX_THUMBNAIL_INTERVAL_SECONDS`
|
||||||
|
|
||||||
Size limit settings accept `_MB` or `_BYTES` env names. `WARPBOX_ADMIN_ENABLED`
|
Size limit settings use `_GB` env names with `1024^3` conversion. Legacy `_MB` and `_BYTES` names remain accepted for compatibility. `WARPBOX_ADMIN_ENABLED`
|
||||||
accepts `auto`, `true`, or `false`.
|
accepts `auto`, `true`, or `false`.
|
||||||
|
|
||||||
The HTTP listen address is configured through the CLI flag:
|
The HTTP listen address is configured through the CLI flag:
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ func TestEnvironmentOverrides(t *testing.T) {
|
|||||||
t.Setenv("WARPBOX_DATA_DIR", "/tmp/warpbox-test")
|
t.Setenv("WARPBOX_DATA_DIR", "/tmp/warpbox-test")
|
||||||
t.Setenv("WARPBOX_GUEST_UPLOADS_ENABLED", "false")
|
t.Setenv("WARPBOX_GUEST_UPLOADS_ENABLED", "false")
|
||||||
t.Setenv("WARPBOX_API_ENABLED", "false")
|
t.Setenv("WARPBOX_API_ENABLED", "false")
|
||||||
t.Setenv("WARPBOX_GLOBAL_MAX_FILE_SIZE_BYTES", "100")
|
t.Setenv("WARPBOX_GLOBAL_MAX_FILE_SIZE_GB", "0.5")
|
||||||
t.Setenv("WARPBOX_BOX_POLL_INTERVAL_MS", "2000")
|
t.Setenv("WARPBOX_BOX_POLL_INTERVAL_MS", "2000")
|
||||||
t.Setenv("WARPBOX_ADMIN_USERNAME", "root")
|
t.Setenv("WARPBOX_ADMIN_USERNAME", "root")
|
||||||
t.Setenv("WARPBOX_ONE_TIME_DOWNLOAD_RETRY_ON_FAILURE", "true")
|
t.Setenv("WARPBOX_ONE_TIME_DOWNLOAD_RETRY_ON_FAILURE", "true")
|
||||||
@@ -51,7 +51,7 @@ func TestEnvironmentOverrides(t *testing.T) {
|
|||||||
if cfg.GuestUploadsEnabled || cfg.APIEnabled {
|
if cfg.GuestUploadsEnabled || cfg.APIEnabled {
|
||||||
t.Fatal("expected boolean environment overrides to be applied")
|
t.Fatal("expected boolean environment overrides to be applied")
|
||||||
}
|
}
|
||||||
if cfg.GlobalMaxFileSizeBytes != 100 {
|
if cfg.GlobalMaxFileSizeBytes != 512*1024*1024 {
|
||||||
t.Fatalf("unexpected global max file size: %d", cfg.GlobalMaxFileSizeBytes)
|
t.Fatalf("unexpected global max file size: %d", cfg.GlobalMaxFileSizeBytes)
|
||||||
}
|
}
|
||||||
if cfg.BoxPollIntervalMS != 2000 {
|
if cfg.BoxPollIntervalMS != 2000 {
|
||||||
@@ -70,25 +70,25 @@ func TestEnvironmentOverrides(t *testing.T) {
|
|||||||
|
|
||||||
func TestMegabyteSizeEnvironmentOverrides(t *testing.T) {
|
func TestMegabyteSizeEnvironmentOverrides(t *testing.T) {
|
||||||
clearConfigEnv(t)
|
clearConfigEnv(t)
|
||||||
t.Setenv("WARPBOX_GLOBAL_MAX_FILE_SIZE_MB", "2048")
|
t.Setenv("WARPBOX_GLOBAL_MAX_FILE_SIZE_GB", "2")
|
||||||
t.Setenv("WARPBOX_GLOBAL_MAX_BOX_SIZE_MB", "4096")
|
t.Setenv("WARPBOX_GLOBAL_MAX_BOX_SIZE_GB", "4")
|
||||||
|
|
||||||
cfg, err := Load()
|
cfg, err := Load()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Load returned error: %v", err)
|
t.Fatalf("Load returned error: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if cfg.GlobalMaxFileSizeBytes != 2048*1024*1024 {
|
if cfg.GlobalMaxFileSizeBytes != 2*1024*1024*1024 {
|
||||||
t.Fatalf("unexpected global max file size: %d", cfg.GlobalMaxFileSizeBytes)
|
t.Fatalf("unexpected global max file size: %d", cfg.GlobalMaxFileSizeBytes)
|
||||||
}
|
}
|
||||||
if cfg.GlobalMaxBoxSizeBytes != 4096*1024*1024 {
|
if cfg.GlobalMaxBoxSizeBytes != 4*1024*1024*1024 {
|
||||||
t.Fatalf("unexpected global max box size: %d", cfg.GlobalMaxBoxSizeBytes)
|
t.Fatalf("unexpected global max box size: %d", cfg.GlobalMaxBoxSizeBytes)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestByteSizeEnvironmentOverridesTakePrecedence(t *testing.T) {
|
func TestGBEnvironmentOverridesTakePrecedenceOverLegacySizeEnvNames(t *testing.T) {
|
||||||
clearConfigEnv(t)
|
clearConfigEnv(t)
|
||||||
t.Setenv("WARPBOX_GLOBAL_MAX_FILE_SIZE_MB", "2048")
|
t.Setenv("WARPBOX_GLOBAL_MAX_FILE_SIZE_GB", "2")
|
||||||
t.Setenv("WARPBOX_GLOBAL_MAX_FILE_SIZE_BYTES", "100")
|
t.Setenv("WARPBOX_GLOBAL_MAX_FILE_SIZE_BYTES", "100")
|
||||||
|
|
||||||
cfg, err := Load()
|
cfg, err := Load()
|
||||||
@@ -96,7 +96,7 @@ func TestByteSizeEnvironmentOverridesTakePrecedence(t *testing.T) {
|
|||||||
t.Fatalf("Load returned error: %v", err)
|
t.Fatalf("Load returned error: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if cfg.GlobalMaxFileSizeBytes != 100 {
|
if cfg.GlobalMaxFileSizeBytes != 2*1024*1024*1024 {
|
||||||
t.Fatalf("unexpected global max file size: %d", cfg.GlobalMaxFileSizeBytes)
|
t.Fatalf("unexpected global max file size: %d", cfg.GlobalMaxFileSizeBytes)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -145,10 +145,10 @@ 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, "0.5"); err != nil {
|
||||||
t.Fatalf("expected global max file size override to succeed, got %v", err)
|
t.Fatalf("expected global max file size override to succeed, got %v", err)
|
||||||
}
|
}
|
||||||
if cfg.GlobalMaxFileSizeBytes != 1 {
|
if cfg.GlobalMaxFileSizeBytes != 512*1024*1024 {
|
||||||
t.Fatalf("expected global max file size override to apply, got %d", cfg.GlobalMaxFileSizeBytes)
|
t.Fatalf("expected global max file size override to apply, got %d", cfg.GlobalMaxFileSizeBytes)
|
||||||
}
|
}
|
||||||
if err := cfg.ApplyOverride(SettingDataDir, "/tmp/elsewhere"); err == nil {
|
if err := cfg.ApplyOverride(SettingDataDir, "/tmp/elsewhere"); err == nil {
|
||||||
@@ -175,12 +175,16 @@ func clearConfigEnv(t *testing.T) {
|
|||||||
"WARPBOX_RENEW_ON_DOWNLOAD_ENABLED",
|
"WARPBOX_RENEW_ON_DOWNLOAD_ENABLED",
|
||||||
"WARPBOX_DEFAULT_GUEST_EXPIRY_SECONDS",
|
"WARPBOX_DEFAULT_GUEST_EXPIRY_SECONDS",
|
||||||
"WARPBOX_MAX_GUEST_EXPIRY_SECONDS",
|
"WARPBOX_MAX_GUEST_EXPIRY_SECONDS",
|
||||||
|
"WARPBOX_GLOBAL_MAX_FILE_SIZE_GB",
|
||||||
"WARPBOX_GLOBAL_MAX_FILE_SIZE_MB",
|
"WARPBOX_GLOBAL_MAX_FILE_SIZE_MB",
|
||||||
"WARPBOX_GLOBAL_MAX_FILE_SIZE_BYTES",
|
"WARPBOX_GLOBAL_MAX_FILE_SIZE_BYTES",
|
||||||
|
"WARPBOX_GLOBAL_MAX_BOX_SIZE_GB",
|
||||||
"WARPBOX_GLOBAL_MAX_BOX_SIZE_MB",
|
"WARPBOX_GLOBAL_MAX_BOX_SIZE_MB",
|
||||||
"WARPBOX_GLOBAL_MAX_BOX_SIZE_BYTES",
|
"WARPBOX_GLOBAL_MAX_BOX_SIZE_BYTES",
|
||||||
|
"WARPBOX_DEFAULT_USER_MAX_FILE_SIZE_GB",
|
||||||
"WARPBOX_DEFAULT_USER_MAX_FILE_SIZE_MB",
|
"WARPBOX_DEFAULT_USER_MAX_FILE_SIZE_MB",
|
||||||
"WARPBOX_DEFAULT_USER_MAX_FILE_SIZE_BYTES",
|
"WARPBOX_DEFAULT_USER_MAX_FILE_SIZE_BYTES",
|
||||||
|
"WARPBOX_DEFAULT_USER_MAX_BOX_SIZE_GB",
|
||||||
"WARPBOX_DEFAULT_USER_MAX_BOX_SIZE_MB",
|
"WARPBOX_DEFAULT_USER_MAX_BOX_SIZE_MB",
|
||||||
"WARPBOX_DEFAULT_USER_MAX_BOX_SIZE_BYTES",
|
"WARPBOX_DEFAULT_USER_MAX_BOX_SIZE_BYTES",
|
||||||
"WARPBOX_SESSION_TTL_SECONDS",
|
"WARPBOX_SESSION_TTL_SECONDS",
|
||||||
|
|||||||
@@ -12,10 +12,10 @@ 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: true, Minimum: 0},
|
{Key: SettingGlobalMaxFileSizeBytes, EnvName: "WARPBOX_GLOBAL_MAX_FILE_SIZE_GB", Label: "Global max file size GB", Type: SettingTypeSizeGB, Editable: 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: SettingGlobalMaxBoxSizeBytes, EnvName: "WARPBOX_GLOBAL_MAX_BOX_SIZE_GB", Label: "Global max box size GB", Type: SettingTypeSizeGB, 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_GB", Label: "Default user max file size GB", Type: SettingTypeSizeGB, 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_GB", Label: "Default user max box size GB", Type: SettingTypeSizeGB, 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},
|
||||||
{Key: SettingBoxPollIntervalMS, EnvName: "WARPBOX_BOX_POLL_INTERVAL_MS", Label: "Box poll interval milliseconds", Type: SettingTypeInt, Editable: true, Minimum: 1000},
|
{Key: SettingBoxPollIntervalMS, EnvName: "WARPBOX_BOX_POLL_INTERVAL_MS", Label: "Box poll interval milliseconds", Type: SettingTypeInt, Editable: true, Minimum: 1000},
|
||||||
{Key: SettingThumbnailBatchSize, EnvName: "WARPBOX_THUMBNAIL_BATCH_SIZE", Label: "Thumbnail batch size", Type: SettingTypeInt, Editable: true, Minimum: 1},
|
{Key: SettingThumbnailBatchSize, EnvName: "WARPBOX_THUMBNAIL_BATCH_SIZE", Label: "Thumbnail batch size", Type: SettingTypeInt, Editable: true, Minimum: 1},
|
||||||
@@ -50,6 +50,7 @@ func (cfg *Config) AdminLoginEnabled(hasAdminUser bool) bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func Definition(key string) (SettingDefinition, bool) {
|
func Definition(key string) (SettingDefinition, bool) {
|
||||||
|
key = NormalizeLegacySettingKey(key)
|
||||||
for _, def := range Definitions {
|
for _, def := range Definitions {
|
||||||
if def.Key == key {
|
if def.Key == key {
|
||||||
return def, true
|
return def, true
|
||||||
@@ -58,6 +59,35 @@ func Definition(key string) (SettingDefinition, bool) {
|
|||||||
return SettingDefinition{}, false
|
return SettingDefinition{}, false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func NormalizeLegacySettingKey(key string) string {
|
||||||
|
switch key {
|
||||||
|
case "global_max_file_size_bytes":
|
||||||
|
return SettingGlobalMaxFileSizeBytes
|
||||||
|
case "global_max_box_size_bytes":
|
||||||
|
return SettingGlobalMaxBoxSizeBytes
|
||||||
|
case "default_user_max_file_size_bytes":
|
||||||
|
return SettingDefaultUserMaxFileBytes
|
||||||
|
case "default_user_max_box_size_bytes":
|
||||||
|
return SettingDefaultUserMaxBoxBytes
|
||||||
|
default:
|
||||||
|
return key
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func NormalizeOverrideInput(key string, value string) (string, string, error) {
|
||||||
|
normalizedKey := NormalizeLegacySettingKey(key)
|
||||||
|
switch key {
|
||||||
|
case "global_max_file_size_bytes", "global_max_box_size_bytes", "default_user_max_file_size_bytes", "default_user_max_box_size_bytes":
|
||||||
|
parsed, err := parseInt64(value, 0)
|
||||||
|
if err != nil {
|
||||||
|
return normalizedKey, "", err
|
||||||
|
}
|
||||||
|
return normalizedKey, formatGigabytesFromBytes(parsed), nil
|
||||||
|
default:
|
||||||
|
return normalizedKey, value, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func EditableDefinitions() []SettingDefinition {
|
func EditableDefinitions() []SettingDefinition {
|
||||||
defs := make([]SettingDefinition, 0, len(Definitions))
|
defs := make([]SettingDefinition, 0, len(Definitions))
|
||||||
for _, def := range Definitions {
|
for _, def := range Definitions {
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ package config
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"math"
|
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strconv"
|
"strconv"
|
||||||
@@ -99,17 +98,18 @@ func Load() (*Config, error) {
|
|||||||
}
|
}
|
||||||
sizeEnvVars := []struct {
|
sizeEnvVars := []struct {
|
||||||
key string
|
key string
|
||||||
|
gbName string
|
||||||
mbName string
|
mbName string
|
||||||
bytesName string
|
bytesName string
|
||||||
target *int64
|
target *int64
|
||||||
}{
|
}{
|
||||||
{SettingGlobalMaxFileSizeBytes, "WARPBOX_GLOBAL_MAX_FILE_SIZE_MB", "WARPBOX_GLOBAL_MAX_FILE_SIZE_BYTES", &cfg.GlobalMaxFileSizeBytes},
|
{SettingGlobalMaxFileSizeBytes, "WARPBOX_GLOBAL_MAX_FILE_SIZE_GB", "WARPBOX_GLOBAL_MAX_FILE_SIZE_MB", "WARPBOX_GLOBAL_MAX_FILE_SIZE_BYTES", &cfg.GlobalMaxFileSizeBytes},
|
||||||
{SettingGlobalMaxBoxSizeBytes, "WARPBOX_GLOBAL_MAX_BOX_SIZE_MB", "WARPBOX_GLOBAL_MAX_BOX_SIZE_BYTES", &cfg.GlobalMaxBoxSizeBytes},
|
{SettingGlobalMaxBoxSizeBytes, "WARPBOX_GLOBAL_MAX_BOX_SIZE_GB", "WARPBOX_GLOBAL_MAX_BOX_SIZE_MB", "WARPBOX_GLOBAL_MAX_BOX_SIZE_BYTES", &cfg.GlobalMaxBoxSizeBytes},
|
||||||
{SettingDefaultUserMaxFileBytes, "WARPBOX_DEFAULT_USER_MAX_FILE_SIZE_MB", "WARPBOX_DEFAULT_USER_MAX_FILE_SIZE_BYTES", &cfg.DefaultUserMaxFileSizeBytes},
|
{SettingDefaultUserMaxFileBytes, "WARPBOX_DEFAULT_USER_MAX_FILE_SIZE_GB", "WARPBOX_DEFAULT_USER_MAX_FILE_SIZE_MB", "WARPBOX_DEFAULT_USER_MAX_FILE_SIZE_BYTES", &cfg.DefaultUserMaxFileSizeBytes},
|
||||||
{SettingDefaultUserMaxBoxBytes, "WARPBOX_DEFAULT_USER_MAX_BOX_SIZE_MB", "WARPBOX_DEFAULT_USER_MAX_BOX_SIZE_BYTES", &cfg.DefaultUserMaxBoxSizeBytes},
|
{SettingDefaultUserMaxBoxBytes, "WARPBOX_DEFAULT_USER_MAX_BOX_SIZE_GB", "WARPBOX_DEFAULT_USER_MAX_BOX_SIZE_MB", "WARPBOX_DEFAULT_USER_MAX_BOX_SIZE_BYTES", &cfg.DefaultUserMaxBoxSizeBytes},
|
||||||
}
|
}
|
||||||
for _, item := range sizeEnvVars {
|
for _, item := range sizeEnvVars {
|
||||||
if err := cfg.applyMegabytesOrBytesEnv(item.key, item.mbName, item.bytesName, 0, item.target); err != nil {
|
if err := cfg.applySizeEnv(item.key, item.gbName, item.mbName, item.bytesName, 0, item.target); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -164,10 +164,10 @@ func (cfg *Config) captureDefaults() {
|
|||||||
cfg.captureDefaultValue(SettingRenewOnDownloadEnabled, formatBool(cfg.RenewOnDownloadEnabled))
|
cfg.captureDefaultValue(SettingRenewOnDownloadEnabled, formatBool(cfg.RenewOnDownloadEnabled))
|
||||||
cfg.captureDefaultValue(SettingDefaultGuestExpirySecs, strconv.FormatInt(cfg.DefaultGuestExpirySeconds, 10))
|
cfg.captureDefaultValue(SettingDefaultGuestExpirySecs, strconv.FormatInt(cfg.DefaultGuestExpirySeconds, 10))
|
||||||
cfg.captureDefaultValue(SettingMaxGuestExpirySecs, strconv.FormatInt(cfg.MaxGuestExpirySeconds, 10))
|
cfg.captureDefaultValue(SettingMaxGuestExpirySecs, strconv.FormatInt(cfg.MaxGuestExpirySeconds, 10))
|
||||||
cfg.captureDefaultValue(SettingGlobalMaxFileSizeBytes, strconv.FormatInt(cfg.GlobalMaxFileSizeBytes, 10))
|
cfg.captureDefaultValue(SettingGlobalMaxFileSizeBytes, formatGigabytesFromBytes(cfg.GlobalMaxFileSizeBytes))
|
||||||
cfg.captureDefaultValue(SettingGlobalMaxBoxSizeBytes, strconv.FormatInt(cfg.GlobalMaxBoxSizeBytes, 10))
|
cfg.captureDefaultValue(SettingGlobalMaxBoxSizeBytes, formatGigabytesFromBytes(cfg.GlobalMaxBoxSizeBytes))
|
||||||
cfg.captureDefaultValue(SettingDefaultUserMaxFileBytes, strconv.FormatInt(cfg.DefaultUserMaxFileSizeBytes, 10))
|
cfg.captureDefaultValue(SettingDefaultUserMaxFileBytes, formatGigabytesFromBytes(cfg.DefaultUserMaxFileSizeBytes))
|
||||||
cfg.captureDefaultValue(SettingDefaultUserMaxBoxBytes, strconv.FormatInt(cfg.DefaultUserMaxBoxSizeBytes, 10))
|
cfg.captureDefaultValue(SettingDefaultUserMaxBoxBytes, formatGigabytesFromBytes(cfg.DefaultUserMaxBoxSizeBytes))
|
||||||
cfg.captureDefaultValue(SettingSessionTTLSeconds, strconv.FormatInt(cfg.SessionTTLSeconds, 10))
|
cfg.captureDefaultValue(SettingSessionTTLSeconds, strconv.FormatInt(cfg.SessionTTLSeconds, 10))
|
||||||
cfg.captureDefaultValue(SettingBoxPollIntervalMS, strconv.Itoa(cfg.BoxPollIntervalMS))
|
cfg.captureDefaultValue(SettingBoxPollIntervalMS, strconv.Itoa(cfg.BoxPollIntervalMS))
|
||||||
cfg.captureDefaultValue(SettingThumbnailBatchSize, strconv.Itoa(cfg.ThumbnailBatchSize))
|
cfg.captureDefaultValue(SettingThumbnailBatchSize, strconv.Itoa(cfg.ThumbnailBatchSize))
|
||||||
@@ -225,14 +225,23 @@ func (cfg *Config) applyInt64Env(key string, name string, min int64, target *int
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (cfg *Config) applyMegabytesOrBytesEnv(key string, mbName string, bytesName string, min int64, target *int64) error {
|
func (cfg *Config) applySizeEnv(key string, gbName string, mbName string, bytesName string, min int64, target *int64) error {
|
||||||
|
if rawGB := strings.TrimSpace(os.Getenv(gbName)); rawGB != "" {
|
||||||
|
parsed, err := parseGigabytes(rawGB, float64(min))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("%s: %w", gbName, err)
|
||||||
|
}
|
||||||
|
*target = parsed
|
||||||
|
cfg.setValue(key, formatGigabytesFromBytes(parsed), SourceEnv)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
if rawBytes := strings.TrimSpace(os.Getenv(bytesName)); rawBytes != "" {
|
if rawBytes := strings.TrimSpace(os.Getenv(bytesName)); rawBytes != "" {
|
||||||
parsed, err := parseInt64(rawBytes, min)
|
parsed, err := parseInt64(rawBytes, min)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("%s: %w", bytesName, err)
|
return fmt.Errorf("%s: %w", bytesName, err)
|
||||||
}
|
}
|
||||||
*target = parsed
|
*target = parsed
|
||||||
cfg.setValue(key, strconv.FormatInt(parsed, 10), SourceEnv)
|
cfg.setValue(key, formatGigabytesFromBytes(parsed), SourceEnv)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -244,12 +253,9 @@ func (cfg *Config) applyMegabytesOrBytesEnv(key string, mbName string, bytesName
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("%s: %w", mbName, err)
|
return fmt.Errorf("%s: %w", mbName, err)
|
||||||
}
|
}
|
||||||
if parsedMB > math.MaxInt64/(1024*1024) {
|
parsedBytes := parsedMB * 1000 * 1000
|
||||||
return fmt.Errorf("%s: is too large", mbName)
|
|
||||||
}
|
|
||||||
parsedBytes := parsedMB * 1024 * 1024
|
|
||||||
*target = parsedBytes
|
*target = parsedBytes
|
||||||
cfg.setValue(key, strconv.FormatInt(parsedBytes, 10), SourceEnv)
|
cfg.setValue(key, formatGigabytesFromBytes(parsedBytes), SourceEnv)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -27,10 +27,10 @@ const (
|
|||||||
SettingRenewOnDownloadEnabled = "renew_on_download_enabled"
|
SettingRenewOnDownloadEnabled = "renew_on_download_enabled"
|
||||||
SettingDefaultGuestExpirySecs = "default_guest_expiry_seconds"
|
SettingDefaultGuestExpirySecs = "default_guest_expiry_seconds"
|
||||||
SettingMaxGuestExpirySecs = "max_guest_expiry_seconds"
|
SettingMaxGuestExpirySecs = "max_guest_expiry_seconds"
|
||||||
SettingGlobalMaxFileSizeBytes = "global_max_file_size_bytes"
|
SettingGlobalMaxFileSizeBytes = "global_max_file_size_gb"
|
||||||
SettingGlobalMaxBoxSizeBytes = "global_max_box_size_bytes"
|
SettingGlobalMaxBoxSizeBytes = "global_max_box_size_gb"
|
||||||
SettingDefaultUserMaxFileBytes = "default_user_max_file_size_bytes"
|
SettingDefaultUserMaxFileBytes = "default_user_max_file_size_gb"
|
||||||
SettingDefaultUserMaxBoxBytes = "default_user_max_box_size_bytes"
|
SettingDefaultUserMaxBoxBytes = "default_user_max_box_size_gb"
|
||||||
SettingSessionTTLSeconds = "session_ttl_seconds"
|
SettingSessionTTLSeconds = "session_ttl_seconds"
|
||||||
SettingBoxPollIntervalMS = "box_poll_interval_ms"
|
SettingBoxPollIntervalMS = "box_poll_interval_ms"
|
||||||
SettingThumbnailBatchSize = "thumbnail_batch_size"
|
SettingThumbnailBatchSize = "thumbnail_batch_size"
|
||||||
@@ -45,6 +45,7 @@ const (
|
|||||||
SettingTypeInt64 SettingType = "int64"
|
SettingTypeInt64 SettingType = "int64"
|
||||||
SettingTypeInt SettingType = "int"
|
SettingTypeInt SettingType = "int"
|
||||||
SettingTypeText SettingType = "text"
|
SettingTypeText SettingType = "text"
|
||||||
|
SettingTypeSizeGB SettingType = "size_gb"
|
||||||
)
|
)
|
||||||
|
|
||||||
type SettingDefinition struct {
|
type SettingDefinition struct {
|
||||||
|
|||||||
@@ -32,7 +32,15 @@ func ReadAdminSettingsOverrides(path string) (map[string]string, error) {
|
|||||||
if payload.Overrides == nil {
|
if payload.Overrides == nil {
|
||||||
return map[string]string{}, nil
|
return map[string]string{}, nil
|
||||||
}
|
}
|
||||||
return payload.Overrides, nil
|
normalized := make(map[string]string, len(payload.Overrides))
|
||||||
|
for key, value := range payload.Overrides {
|
||||||
|
nextKey, nextValue, err := NormalizeOverrideInput(key, value)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
normalized[nextKey] = nextValue
|
||||||
|
}
|
||||||
|
return normalized, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func WriteAdminSettingsOverrides(path string, overrides map[string]string) error {
|
func WriteAdminSettingsOverrides(path string, overrides map[string]string) error {
|
||||||
|
|||||||
@@ -39,6 +39,12 @@ func (cfg *Config) ApplyOverride(key string, value string) error {
|
|||||||
return fmt.Errorf("%s: %w", key, err)
|
return fmt.Errorf("%s: %w", key, err)
|
||||||
}
|
}
|
||||||
cfg.assignInt64(key, parsed, SourceDB)
|
cfg.assignInt64(key, parsed, SourceDB)
|
||||||
|
case SettingTypeSizeGB:
|
||||||
|
parsed, err := parseGigabytes(value, float64(def.Minimum))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("%s: %w", key, err)
|
||||||
|
}
|
||||||
|
cfg.assignInt64(key, parsed, SourceDB)
|
||||||
case SettingTypeInt:
|
case SettingTypeInt:
|
||||||
parsed64, err := parseInt64(value, def.Minimum)
|
parsed64, err := parseInt64(value, def.Minimum)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -87,6 +93,10 @@ func (cfg *Config) assignInt64(key string, value int64, source Source) {
|
|||||||
case SettingSessionTTLSeconds:
|
case SettingSessionTTLSeconds:
|
||||||
cfg.SessionTTLSeconds = value
|
cfg.SessionTTLSeconds = value
|
||||||
}
|
}
|
||||||
|
if key == SettingGlobalMaxFileSizeBytes || key == SettingGlobalMaxBoxSizeBytes || key == SettingDefaultUserMaxFileBytes || key == SettingDefaultUserMaxBoxBytes {
|
||||||
|
cfg.setValue(key, formatGigabytesFromBytes(value), source)
|
||||||
|
return
|
||||||
|
}
|
||||||
cfg.setValue(key, strconv.FormatInt(value, 10), source)
|
cfg.setValue(key, strconv.FormatInt(value, 10), source)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package config
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"math"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
@@ -39,6 +40,46 @@ func parseInt(value string, min int) (int, error) {
|
|||||||
return int(parsed64), nil
|
return int(parsed64), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const bytesPerGigabyte = 1024 * 1024 * 1024
|
||||||
|
|
||||||
|
func parseGigabytes(value string, min float64) (int64, error) {
|
||||||
|
raw := strings.TrimSpace(value)
|
||||||
|
lower := strings.ToLower(raw)
|
||||||
|
if strings.HasSuffix(lower, "gb") {
|
||||||
|
raw = strings.TrimSpace(raw[:len(raw)-2])
|
||||||
|
}
|
||||||
|
parsed, err := strconv.ParseFloat(raw, 64)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("must be a number of GB")
|
||||||
|
}
|
||||||
|
if parsed < min {
|
||||||
|
return 0, fmt.Errorf("must be at least %s", trimTrailingZeros(min))
|
||||||
|
}
|
||||||
|
bytes := parsed * bytesPerGigabyte
|
||||||
|
if bytes > math.MaxInt64 {
|
||||||
|
return 0, fmt.Errorf("is too large")
|
||||||
|
}
|
||||||
|
return int64(math.Round(bytes)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatGigabytesFromBytes(bytes int64) string {
|
||||||
|
if bytes <= 0 {
|
||||||
|
return "0"
|
||||||
|
}
|
||||||
|
value := float64(bytes) / bytesPerGigabyte
|
||||||
|
return trimTrailingZeros(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
func trimTrailingZeros(value float64) string {
|
||||||
|
text := strconv.FormatFloat(value, 'f', 3, 64)
|
||||||
|
text = strings.TrimRight(text, "0")
|
||||||
|
text = strings.TrimRight(text, ".")
|
||||||
|
if text == "" {
|
||||||
|
return "0"
|
||||||
|
}
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
|
||||||
func formatBool(value bool) string {
|
func formatBool(value bool) string {
|
||||||
if value {
|
if value {
|
||||||
return "true"
|
return "true"
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ type Handlers struct {
|
|||||||
AdminAlerts gin.HandlerFunc
|
AdminAlerts gin.HandlerFunc
|
||||||
AdminBoxes gin.HandlerFunc
|
AdminBoxes gin.HandlerFunc
|
||||||
AdminBoxesAction gin.HandlerFunc
|
AdminBoxesAction gin.HandlerFunc
|
||||||
|
AdminUsers gin.HandlerFunc
|
||||||
AdminSettings gin.HandlerFunc
|
AdminSettings gin.HandlerFunc
|
||||||
AdminSettingsExport gin.HandlerFunc
|
AdminSettingsExport gin.HandlerFunc
|
||||||
AdminSettingsSave gin.HandlerFunc
|
AdminSettingsSave gin.HandlerFunc
|
||||||
@@ -61,6 +62,7 @@ func Register(router *gin.Engine, handlers Handlers) {
|
|||||||
protected.GET("/alerts", handlers.AdminAlerts)
|
protected.GET("/alerts", handlers.AdminAlerts)
|
||||||
protected.GET("/boxes", handlers.AdminBoxes)
|
protected.GET("/boxes", handlers.AdminBoxes)
|
||||||
protected.POST("/boxes/actions", handlers.AdminBoxesAction)
|
protected.POST("/boxes/actions", handlers.AdminBoxesAction)
|
||||||
|
protected.GET("/users", handlers.AdminUsers)
|
||||||
protected.GET("/settings", handlers.AdminSettings)
|
protected.GET("/settings", handlers.AdminSettings)
|
||||||
protected.GET("/settings/export", handlers.AdminSettingsExport)
|
protected.GET("/settings/export", handlers.AdminSettingsExport)
|
||||||
protected.POST("/settings/save", handlers.AdminSettingsSave)
|
protected.POST("/settings/save", handlers.AdminSettingsSave)
|
||||||
|
|||||||
@@ -84,8 +84,19 @@ func (app *App) handleAdminSettingsSave(ctx *gin.Context) {
|
|||||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid save payload"})
|
ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid save payload"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
currentOverrides, err := config.ReadAdminSettingsOverrides(app.settingsOverridesPath)
|
||||||
|
if err != nil {
|
||||||
|
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "Could not load current settings overrides"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if currentOverrides == nil {
|
||||||
|
currentOverrides = map[string]string{}
|
||||||
|
}
|
||||||
|
for key, value := range request.Values {
|
||||||
|
currentOverrides[key] = value
|
||||||
|
}
|
||||||
|
|
||||||
rows, warnings, err := app.applySettingsOverrideSet(request.Values)
|
rows, warnings, err := app.applySettingsOverrideSet(currentOverrides)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
@@ -168,30 +179,24 @@ func (app *App) handleAdminSettingsReset(ctx *gin.Context) {
|
|||||||
var request adminSettingsResetRequest
|
var request adminSettingsResetRequest
|
||||||
_ = ctx.ShouldBindJSON(&request)
|
_ = ctx.ShouldBindJSON(&request)
|
||||||
|
|
||||||
defs := config.EditableDefinitions()
|
overrideSet, err := config.ReadAdminSettingsOverrides(app.settingsOverridesPath)
|
||||||
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 {
|
if err != nil {
|
||||||
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "Could not load settings overrides"})
|
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "Could not load settings overrides"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
for key, value := range currentOverrides {
|
if overrideSet == nil {
|
||||||
overrideSet[key] = value
|
overrideSet = map[string]string{}
|
||||||
}
|
}
|
||||||
for _, def := range defs {
|
targetKeys := map[string]bool{}
|
||||||
if targetKeys[def.Key] {
|
for _, key := range request.Keys {
|
||||||
overrideSet[def.Key] = app.config.DefaultValue(def.Key)
|
targetKeys[config.NormalizeLegacySettingKey(key)] = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if len(targetKeys) == 0 {
|
||||||
|
overrideSet = map[string]string{}
|
||||||
|
} else {
|
||||||
|
for key := range targetKeys {
|
||||||
|
delete(overrideSet, key)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -203,7 +208,7 @@ func (app *App) handleAdminSettingsReset(ctx *gin.Context) {
|
|||||||
|
|
||||||
ctx.JSON(http.StatusOK, gin.H{
|
ctx.JSON(http.StatusOK, gin.H{
|
||||||
"ok": true,
|
"ok": true,
|
||||||
"message": "Editable settings reset to application defaults",
|
"message": "Selected overrides cleared; environment and defaults now apply",
|
||||||
"warnings": warnings,
|
"warnings": warnings,
|
||||||
"rows": rows,
|
"rows": rows,
|
||||||
})
|
})
|
||||||
@@ -231,7 +236,12 @@ func (app *App) applySettingsOverrideSet(values map[string]string) ([]adminSetti
|
|||||||
sort.Strings(keys)
|
sort.Strings(keys)
|
||||||
|
|
||||||
for _, key := range keys {
|
for _, key := range keys {
|
||||||
value := strings.TrimSpace(values[key])
|
normalizedKey, normalizedValue, err := config.NormalizeOverrideInput(key, strings.TrimSpace(values[key]))
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, fmt.Errorf("%s: %w", key, err)
|
||||||
|
}
|
||||||
|
key = normalizedKey
|
||||||
|
value := normalizedValue
|
||||||
def, ok := editable[key]
|
def, ok := editable[key]
|
||||||
if !ok {
|
if !ok {
|
||||||
if _, found := config.Definition(key); found {
|
if _, found := config.Definition(key); found {
|
||||||
@@ -447,10 +457,10 @@ func settingsDescription(key string) string {
|
|||||||
config.SettingRenewOnDownloadEnabled: "Extend retention when file or ZIP downloads happen.",
|
config.SettingRenewOnDownloadEnabled: "Extend retention when file or ZIP downloads happen.",
|
||||||
config.SettingDefaultGuestExpirySecs: "Default retention presented to guest uploads.",
|
config.SettingDefaultGuestExpirySecs: "Default retention presented to guest uploads.",
|
||||||
config.SettingMaxGuestExpirySecs: "Maximum retention guests may request.",
|
config.SettingMaxGuestExpirySecs: "Maximum retention guests may request.",
|
||||||
config.SettingGlobalMaxFileSizeBytes: "Global single-file upload ceiling applied to future requests across the whole app.",
|
config.SettingGlobalMaxFileSizeBytes: "Global single-file upload ceiling in GB applied to future requests across the whole app. Decimal values allowed.",
|
||||||
config.SettingGlobalMaxBoxSizeBytes: "Global total box size ceiling applied to future requests across the whole app.",
|
config.SettingGlobalMaxBoxSizeBytes: "Global total box size ceiling in GB applied to future requests across the whole app. Decimal values allowed.",
|
||||||
config.SettingDefaultUserMaxFileBytes: "Default per-user file size ceiling used by future account-aware flows.",
|
config.SettingDefaultUserMaxFileBytes: "Default per-user file size ceiling in GB used by future account-aware flows. Decimal values allowed.",
|
||||||
config.SettingDefaultUserMaxBoxBytes: "Default per-user box size ceiling used by future account-aware flows.",
|
config.SettingDefaultUserMaxBoxBytes: "Default per-user box size ceiling in GB used by future account-aware flows. Decimal values allowed.",
|
||||||
config.SettingSessionTTLSeconds: "Lifetime for authenticated browser sessions, including admin session cookies.",
|
config.SettingSessionTTLSeconds: "Lifetime for authenticated browser sessions, including admin session cookies.",
|
||||||
config.SettingBoxPollIntervalMS: "Browser polling cadence for box status refreshes.",
|
config.SettingBoxPollIntervalMS: "Browser polling cadence for box status refreshes.",
|
||||||
config.SettingThumbnailBatchSize: "How many thumbnail jobs the worker handles per batch.",
|
config.SettingThumbnailBatchSize: "How many thumbnail jobs the worker handles per batch.",
|
||||||
|
|||||||
@@ -82,7 +82,7 @@ func TestAdminSettingsExportIncludesCurrentValues(t *testing.T) {
|
|||||||
func TestAdminSettingsSavePersistsEditableOverrides(t *testing.T) {
|
func TestAdminSettingsSavePersistsEditableOverrides(t *testing.T) {
|
||||||
app, router := setupAdminSettingsTest(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 := httptest.NewRequest(http.MethodPost, "/admin/settings/save", strings.NewReader(`{"values":{"api_enabled":"true","box_poll_interval_ms":"6000","global_max_file_size_gb":"0.5"}}`))
|
||||||
request.Header.Set("Content-Type", "application/json")
|
request.Header.Set("Content-Type", "application/json")
|
||||||
request.AddCookie(authCookie(app))
|
request.AddCookie(authCookie(app))
|
||||||
response := httptest.NewRecorder()
|
response := httptest.NewRecorder()
|
||||||
@@ -97,6 +97,9 @@ func TestAdminSettingsSavePersistsEditableOverrides(t *testing.T) {
|
|||||||
if app.config.BoxPollIntervalMS != 6000 {
|
if app.config.BoxPollIntervalMS != 6000 {
|
||||||
t.Fatalf("expected poll interval override, got %d", app.config.BoxPollIntervalMS)
|
t.Fatalf("expected poll interval override, got %d", app.config.BoxPollIntervalMS)
|
||||||
}
|
}
|
||||||
|
if app.config.GlobalMaxFileSizeBytes != 512*1024*1024 {
|
||||||
|
t.Fatalf("expected size override in bytes, got %d", app.config.GlobalMaxFileSizeBytes)
|
||||||
|
}
|
||||||
|
|
||||||
overrides, err := config.ReadAdminSettingsOverrides(app.settingsOverridesPath)
|
overrides, err := config.ReadAdminSettingsOverrides(app.settingsOverridesPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -105,6 +108,12 @@ func TestAdminSettingsSavePersistsEditableOverrides(t *testing.T) {
|
|||||||
if overrides[config.SettingAPIEnabled] != "true" {
|
if overrides[config.SettingAPIEnabled] != "true" {
|
||||||
t.Fatalf("expected persisted API override, got %#v", overrides)
|
t.Fatalf("expected persisted API override, got %#v", overrides)
|
||||||
}
|
}
|
||||||
|
if _, exists := overrides[config.SettingBoxPollIntervalMS]; !exists {
|
||||||
|
t.Fatalf("expected changed poll interval override to be persisted, got %#v", overrides)
|
||||||
|
}
|
||||||
|
if _, exists := overrides[config.SettingSessionTTLSeconds]; exists {
|
||||||
|
t.Fatalf("expected untouched setting to stay out of overrides, got %#v", overrides)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAdminSettingsSaveRejectsLockedSetting(t *testing.T) {
|
func TestAdminSettingsSaveRejectsLockedSetting(t *testing.T) {
|
||||||
@@ -160,8 +169,8 @@ func TestAdminSettingsResetUsesBuiltInDefaults(t *testing.T) {
|
|||||||
if response.Code != http.StatusOK {
|
if response.Code != http.StatusOK {
|
||||||
t.Fatalf("expected 200, got %d: %s", response.Code, response.Body.String())
|
t.Fatalf("expected 200, got %d: %s", response.Code, response.Body.String())
|
||||||
}
|
}
|
||||||
if !app.config.APIEnabled {
|
if app.config.APIEnabled {
|
||||||
t.Fatal("expected reset to built-in defaults to restore APIEnabled=true")
|
t.Fatal("expected reset to respect environment and restore APIEnabled=false")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -240,12 +249,16 @@ func clearAdminSettingsEnv(t *testing.T) {
|
|||||||
"WARPBOX_RENEW_ON_DOWNLOAD_ENABLED",
|
"WARPBOX_RENEW_ON_DOWNLOAD_ENABLED",
|
||||||
"WARPBOX_DEFAULT_GUEST_EXPIRY_SECONDS",
|
"WARPBOX_DEFAULT_GUEST_EXPIRY_SECONDS",
|
||||||
"WARPBOX_MAX_GUEST_EXPIRY_SECONDS",
|
"WARPBOX_MAX_GUEST_EXPIRY_SECONDS",
|
||||||
|
"WARPBOX_GLOBAL_MAX_FILE_SIZE_GB",
|
||||||
"WARPBOX_GLOBAL_MAX_FILE_SIZE_MB",
|
"WARPBOX_GLOBAL_MAX_FILE_SIZE_MB",
|
||||||
"WARPBOX_GLOBAL_MAX_FILE_SIZE_BYTES",
|
"WARPBOX_GLOBAL_MAX_FILE_SIZE_BYTES",
|
||||||
|
"WARPBOX_GLOBAL_MAX_BOX_SIZE_GB",
|
||||||
"WARPBOX_GLOBAL_MAX_BOX_SIZE_MB",
|
"WARPBOX_GLOBAL_MAX_BOX_SIZE_MB",
|
||||||
"WARPBOX_GLOBAL_MAX_BOX_SIZE_BYTES",
|
"WARPBOX_GLOBAL_MAX_BOX_SIZE_BYTES",
|
||||||
|
"WARPBOX_DEFAULT_USER_MAX_FILE_SIZE_GB",
|
||||||
"WARPBOX_DEFAULT_USER_MAX_FILE_SIZE_MB",
|
"WARPBOX_DEFAULT_USER_MAX_FILE_SIZE_MB",
|
||||||
"WARPBOX_DEFAULT_USER_MAX_FILE_SIZE_BYTES",
|
"WARPBOX_DEFAULT_USER_MAX_FILE_SIZE_BYTES",
|
||||||
|
"WARPBOX_DEFAULT_USER_MAX_BOX_SIZE_GB",
|
||||||
"WARPBOX_DEFAULT_USER_MAX_BOX_SIZE_MB",
|
"WARPBOX_DEFAULT_USER_MAX_BOX_SIZE_MB",
|
||||||
"WARPBOX_DEFAULT_USER_MAX_BOX_SIZE_BYTES",
|
"WARPBOX_DEFAULT_USER_MAX_BOX_SIZE_BYTES",
|
||||||
"WARPBOX_SESSION_TTL_SECONDS",
|
"WARPBOX_SESSION_TTL_SECONDS",
|
||||||
|
|||||||
20
lib/server/admin_users.go
Normal file
20
lib/server/admin_users.go
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (app *App) handleAdminUsers(ctx *gin.Context) {
|
||||||
|
if !app.adminLoginEnabled() {
|
||||||
|
ctx.Redirect(http.StatusSeeOther, "/")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.HTML(http.StatusOK, "admin/users.html", gin.H{
|
||||||
|
"AdminUsername": app.config.AdminUsername,
|
||||||
|
"AdminEmail": app.config.AdminEmail,
|
||||||
|
"ActivePage": "users",
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -69,6 +69,7 @@ func Run(addr string) error {
|
|||||||
AdminAlerts: app.handleAdminAlerts,
|
AdminAlerts: app.handleAdminAlerts,
|
||||||
AdminBoxes: app.handleAdminBoxes,
|
AdminBoxes: app.handleAdminBoxes,
|
||||||
AdminBoxesAction: app.handleAdminBoxesAction,
|
AdminBoxesAction: app.handleAdminBoxesAction,
|
||||||
|
AdminUsers: app.handleAdminUsers,
|
||||||
AdminSettings: app.handleAdminSettings,
|
AdminSettings: app.handleAdminSettings,
|
||||||
AdminSettingsExport: app.handleAdminSettingsExport,
|
AdminSettingsExport: app.handleAdminSettingsExport,
|
||||||
AdminSettingsSave: app.handleAdminSettingsSave,
|
AdminSettingsSave: app.handleAdminSettingsSave,
|
||||||
|
|||||||
6
run.sh
6
run.sh
@@ -15,9 +15,9 @@ export WARPBOX_ONE_TIME_DOWNLOAD_EXPIRY_SECONDS="${WARPBOX_ONE_TIME_DOWNLOAD_EXP
|
|||||||
export WARPBOX_ONE_TIME_DOWNLOAD_RETRY_ON_FAILURE="${WARPBOX_ONE_TIME_DOWNLOAD_RETRY_ON_FAILURE:-false}"
|
export WARPBOX_ONE_TIME_DOWNLOAD_RETRY_ON_FAILURE="${WARPBOX_ONE_TIME_DOWNLOAD_RETRY_ON_FAILURE:-false}"
|
||||||
|
|
||||||
# Storage and expiry limits used by the upload UI and backend validators.
|
# Storage and expiry limits used by the upload UI and backend validators.
|
||||||
# Use megabytes here; WarpBox converts these to bytes internally.
|
# Use decimal gigabytes here. Examples: 2, 4, 0.5
|
||||||
export WARPBOX_GLOBAL_MAX_FILE_SIZE_MB="${WARPBOX_GLOBAL_MAX_FILE_SIZE_MB:-2048}" # 2 GiB
|
export WARPBOX_GLOBAL_MAX_FILE_SIZE_GB="${WARPBOX_GLOBAL_MAX_FILE_SIZE_GB:-2}" # 2 GB
|
||||||
export WARPBOX_GLOBAL_MAX_BOX_SIZE_MB="${WARPBOX_GLOBAL_MAX_BOX_SIZE_MB:-4096}" # 4 GiB
|
export WARPBOX_GLOBAL_MAX_BOX_SIZE_GB="${WARPBOX_GLOBAL_MAX_BOX_SIZE_GB:-4}" # 4 GB
|
||||||
export WARPBOX_DEFAULT_GUEST_EXPIRY_SECONDS="${WARPBOX_DEFAULT_GUEST_EXPIRY_SECONDS:-3600}" # 1 hour
|
export WARPBOX_DEFAULT_GUEST_EXPIRY_SECONDS="${WARPBOX_DEFAULT_GUEST_EXPIRY_SECONDS:-3600}" # 1 hour
|
||||||
export WARPBOX_MAX_GUEST_EXPIRY_SECONDS="${WARPBOX_MAX_GUEST_EXPIRY_SECONDS:-172800}" # 48 hours
|
export WARPBOX_MAX_GUEST_EXPIRY_SECONDS="${WARPBOX_MAX_GUEST_EXPIRY_SECONDS:-172800}" # 48 hours
|
||||||
|
|
||||||
|
|||||||
@@ -398,6 +398,12 @@
|
|||||||
gap: 4px;
|
gap: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.setting-input-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
.setting-hint {
|
.setting-hint {
|
||||||
color: #444444;
|
color: #444444;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
|
|||||||
309
static/css/users.css
Normal file
309
static/css/users.css
Normal file
@@ -0,0 +1,309 @@
|
|||||||
|
.users-page-body {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.users-hero {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1.1fr) minmax(300px, .9fr);
|
||||||
|
gap: 10px;
|
||||||
|
padding: 10px;
|
||||||
|
background: #ffffff;
|
||||||
|
border-top: 1px solid #808080;
|
||||||
|
border-left: 1px solid #808080;
|
||||||
|
border-right: 1px solid #ffffff;
|
||||||
|
border-bottom: 1px solid #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.users-hero h2 {
|
||||||
|
margin: 0 0 6px;
|
||||||
|
font-size: 24px;
|
||||||
|
line-height: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.users-hero p {
|
||||||
|
margin: 0;
|
||||||
|
color: #333333;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.users-hero-actions {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 8px;
|
||||||
|
align-content: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.users-summary-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.users-stat-card {
|
||||||
|
padding: 8px;
|
||||||
|
background: #dfdfdf;
|
||||||
|
border-top: 1px solid #ffffff;
|
||||||
|
border-left: 1px solid #ffffff;
|
||||||
|
border-right: 1px solid #808080;
|
||||||
|
border-bottom: 1px solid #808080;
|
||||||
|
}
|
||||||
|
|
||||||
|
.users-stat-card p {
|
||||||
|
margin: 0 0 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 12px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.users-stat-card strong {
|
||||||
|
font-size: 24px;
|
||||||
|
line-height: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.users-stat-card.is-info { background: linear-gradient(180deg, #d7e6fb, #bfd7f8); }
|
||||||
|
.users-stat-card.is-ok { background: linear-gradient(180deg, #dbf4dc, #c3ebc5); }
|
||||||
|
.users-stat-card.is-warning { background: linear-gradient(180deg, #fff1c9, #ffe39f); }
|
||||||
|
.users-stat-card.is-danger { background: linear-gradient(180deg, #ffd8d8, #f1b3b3); }
|
||||||
|
|
||||||
|
.users-main-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(320px, .65fr) minmax(0, 1.35fr);
|
||||||
|
gap: 10px;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.users-panel {
|
||||||
|
min-height: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background: #ffffff;
|
||||||
|
border-top: 1px solid #808080;
|
||||||
|
border-left: 1px solid #808080;
|
||||||
|
border-right: 1px solid #ffffff;
|
||||||
|
border-bottom: 1px solid #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.users-panel-header {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
min-height: 34px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 6px 8px;
|
||||||
|
background: #dfdfdf;
|
||||||
|
border-bottom: 1px solid #b0b0b0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.users-panel-title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
min-width: 0;
|
||||||
|
font-weight: bold;
|
||||||
|
font-size: 15px;
|
||||||
|
line-height: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.users-panel-title span {
|
||||||
|
font-weight: normal;
|
||||||
|
color: #444444;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.users-panel-tools {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.users-panel-body {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 0;
|
||||||
|
padding: 10px;
|
||||||
|
background: linear-gradient(180deg, rgba(255,255,255,.9), rgba(238,238,238,.58));
|
||||||
|
}
|
||||||
|
|
||||||
|
.users-list-body {
|
||||||
|
display: grid;
|
||||||
|
grid-template-rows: auto minmax(0, 1fr) auto;
|
||||||
|
gap: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.users-form-grid {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.users-row-two {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.users-field {
|
||||||
|
display: grid;
|
||||||
|
gap: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.users-input,
|
||||||
|
.users-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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.users-check {
|
||||||
|
min-height: 20px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.users-form-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.users-action-button,
|
||||||
|
.users-tool-button,
|
||||||
|
.users-page-button {
|
||||||
|
min-width: 70px;
|
||||||
|
height: 24px;
|
||||||
|
padding: 0 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.users-toolbar-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(220px, 1.2fr) repeat(4, minmax(100px, .6fr));
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.users-table-wrap {
|
||||||
|
min-height: 420px;
|
||||||
|
height: 420px;
|
||||||
|
overflow: auto;
|
||||||
|
background: #ffffff;
|
||||||
|
border-top: 2px solid #606060;
|
||||||
|
border-left: 2px solid #606060;
|
||||||
|
border-right: 2px solid #ffffff;
|
||||||
|
border-bottom: 2px solid #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.users-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
table-layout: fixed;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.users-table th,
|
||||||
|
.users-table td {
|
||||||
|
padding: 6px;
|
||||||
|
border-bottom: 1px solid #e1e1e1;
|
||||||
|
vertical-align: middle;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.users-table th {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 2;
|
||||||
|
text-align: left;
|
||||||
|
background: #dfdfdf;
|
||||||
|
border-bottom: 1px solid #b0b0b0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.users-table tbody tr:nth-child(odd) { background: rgba(255,255,255,.96); }
|
||||||
|
.users-table tbody tr:nth-child(even) { background: rgba(240,244,255,.9); }
|
||||||
|
.users-table tbody tr:hover { background: #d8e5f8; }
|
||||||
|
|
||||||
|
.users-col-check { width: 30px; }
|
||||||
|
.users-col-actions { width: 136px; }
|
||||||
|
|
||||||
|
.users-username {
|
||||||
|
display: grid;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.users-username strong {
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.users-muted {
|
||||||
|
color: #555555;
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.users-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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.users-pill.active { background: #def2e0; }
|
||||||
|
.users-pill.pending { background: #fff1c9; }
|
||||||
|
.users-pill.disabled { background: #ffdcdc; }
|
||||||
|
|
||||||
|
.users-row-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.users-row-button {
|
||||||
|
min-width: 60px;
|
||||||
|
height: 22px;
|
||||||
|
padding: 0 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.users-pagination {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
.users-main-grid,
|
||||||
|
.users-hero {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -82,7 +82,10 @@
|
|||||||
const value = currentValue(row);
|
const value = currentValue(row);
|
||||||
let valid = true;
|
let valid = true;
|
||||||
|
|
||||||
if (row.type === "int" || row.type === "int64") {
|
if (row.type === "size_gb") {
|
||||||
|
if (!/^\d+(?:\.\d+)?$/.test(value)) valid = false;
|
||||||
|
else if (Number(value) < row.minimum) valid = false;
|
||||||
|
} else if (row.type === "int" || row.type === "int64") {
|
||||||
if (!/^\d+$/.test(value)) valid = false;
|
if (!/^\d+$/.test(value)) valid = false;
|
||||||
else if (Number(value) < row.minimum) valid = false;
|
else if (Number(value) < row.minimum) valid = false;
|
||||||
} else if (row.type === "bool") {
|
} else if (row.type === "bool") {
|
||||||
@@ -179,7 +182,7 @@
|
|||||||
function draftValues() {
|
function draftValues() {
|
||||||
const values = {};
|
const values = {};
|
||||||
rows.forEach((row) => {
|
rows.forEach((row) => {
|
||||||
if (!row.locked) values[row.key] = currentValue(row);
|
if (!row.locked && isDirty(row)) values[row.key] = currentValue(row);
|
||||||
});
|
});
|
||||||
return values;
|
return values;
|
||||||
}
|
}
|
||||||
@@ -205,6 +208,8 @@
|
|||||||
if (row.hint) {
|
if (row.hint) {
|
||||||
row.hint.textContent = payload.locked
|
row.hint.textContent = payload.locked
|
||||||
? "Locked by environment or hard runtime implication."
|
? "Locked by environment or hard runtime implication."
|
||||||
|
: payload.type === "size_gb"
|
||||||
|
? "Use GB values. Decimals allowed, for example `0.5`."
|
||||||
: (payload.default_value ? `Default: ${payload.default_value}` : "");
|
: (payload.default_value ? `Default: ${payload.default_value}` : "");
|
||||||
}
|
}
|
||||||
if (row.badge) {
|
if (row.badge) {
|
||||||
@@ -241,8 +246,13 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function saveChanges() {
|
async function saveChanges() {
|
||||||
|
const values = draftValues();
|
||||||
|
if (Object.keys(values).length === 0) {
|
||||||
|
showToast("No changed settings to save", "info");
|
||||||
|
return;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const payload = await postJSON("/admin/settings/save", { values: draftValues() });
|
const payload = await postJSON("/admin/settings/save", { values });
|
||||||
hydrateRows(payload.rows);
|
hydrateRows(payload.rows);
|
||||||
showToast(payload.message || "Settings saved", payload.warnings?.length ? "warning" : "success");
|
showToast(payload.message || "Settings saved", payload.warnings?.length ? "warning" : "success");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -261,6 +271,25 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function resetSingleSetting(row) {
|
||||||
|
if (row.locked || !row.input) return;
|
||||||
|
|
||||||
|
if (isDirty(row)) {
|
||||||
|
row.input.value = row.element.dataset.original || "";
|
||||||
|
updateView();
|
||||||
|
showToast(`${row.label} draft cleared`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const payload = await postJSON("/admin/settings/reset", { keys: [row.key] });
|
||||||
|
hydrateRows(payload.rows);
|
||||||
|
showToast(`${row.label} reset`, "success");
|
||||||
|
} catch (error) {
|
||||||
|
showToast(error.message, "error", 3200);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function exportSettings() {
|
async function exportSettings() {
|
||||||
try {
|
try {
|
||||||
const response = await fetch("/admin/settings/export");
|
const response = await fetch("/admin/settings/export");
|
||||||
@@ -321,8 +350,8 @@
|
|||||||
window.WarpBoxUI?.openPopup?.(
|
window.WarpBoxUI?.openPopup?.(
|
||||||
"Reset Behavior",
|
"Reset Behavior",
|
||||||
`
|
`
|
||||||
<p>Reset defaults writes built-in WarpBox defaults as admin overrides for editable settings.</p>
|
<p>Reset clears saved admin overrides.</p>
|
||||||
<p>Environment-only settings stay locked and unchanged.</p>
|
<p>After reset, environment values win again. If no environment value exists, built-in defaults apply.</p>
|
||||||
`
|
`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -390,11 +419,7 @@
|
|||||||
|
|
||||||
rows.forEach((row) => {
|
rows.forEach((row) => {
|
||||||
row.input?.addEventListener(row.input.tagName === "SELECT" ? "change" : "input", updateView);
|
row.input?.addEventListener(row.input.tagName === "SELECT" ? "change" : "input", updateView);
|
||||||
row.element.querySelector(".row-reset")?.addEventListener("click", () => {
|
row.element.querySelector(".row-reset")?.addEventListener("click", () => resetSingleSetting(row));
|
||||||
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));
|
row.element.querySelector(".row-info")?.addEventListener("click", () => showRowInfo(row));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
304
static/js/admin/users.js
Normal file
304
static/js/admin/users.js
Normal file
@@ -0,0 +1,304 @@
|
|||||||
|
(() => {
|
||||||
|
const menuController = window.WarpBoxUI?.bindMenuBar?.() || { close() {} };
|
||||||
|
const toastTarget = document.getElementById("toast");
|
||||||
|
const body = document.getElementById("users-body");
|
||||||
|
const search = document.getElementById("users-search");
|
||||||
|
const status = document.getElementById("users-status");
|
||||||
|
const role = document.getElementById("users-role-filter");
|
||||||
|
const sort = document.getElementById("users-sort");
|
||||||
|
const size = document.getElementById("users-size");
|
||||||
|
const masterCheck = document.getElementById("users-master-check");
|
||||||
|
const pageInfo = document.getElementById("users-page-info");
|
||||||
|
const visiblePill = document.getElementById("visible-pill");
|
||||||
|
const selectedPill = document.getElementById("users-selected-pill");
|
||||||
|
const prevBtn = document.getElementById("users-prev");
|
||||||
|
const nextBtn = document.getElementById("users-next");
|
||||||
|
const selectVisible = document.getElementById("select-visible");
|
||||||
|
const form = document.getElementById("users-form");
|
||||||
|
const modeInput = document.getElementById("users-mode");
|
||||||
|
const usernameInput = document.getElementById("users-username");
|
||||||
|
const emailInput = document.getElementById("users-email");
|
||||||
|
const roleInput = document.getElementById("users-role");
|
||||||
|
const planInput = document.getElementById("users-plan");
|
||||||
|
const statusLeft = document.getElementById("users-status-left");
|
||||||
|
|
||||||
|
if (!body || !search || !status || !role || !sort || !size) return;
|
||||||
|
|
||||||
|
const users = [
|
||||||
|
{ id: "u_admin", username: "admin", email: "admin@warpbox.local", status: "active", role: "admin", plan: "unlimited", boxes: 18, created: "2026-04-12", lastSeen: "active now" },
|
||||||
|
{ id: "u_geo", username: "geo", email: "geo@example.test", status: "active", role: "uploader", plan: "trusted", boxes: 7, created: "2026-04-21", lastSeen: "today 12:10" },
|
||||||
|
{ id: "u_reo", username: "reo", email: "reo@example.test", status: "active", role: "uploader", plan: "standard", boxes: 3, created: "2026-04-20", lastSeen: "today 09:44" },
|
||||||
|
{ id: "u_teo", username: "teo", email: "teo@example.test", status: "active", role: "uploader", plan: "trusted", boxes: 5, created: "2026-04-19", lastSeen: "yesterday" },
|
||||||
|
{ id: "u_mara", username: "mara", email: "mara@example.test", status: "pending", role: "viewer", plan: "guest-like", boxes: 0, created: "2026-04-28", lastSeen: "never" },
|
||||||
|
{ id: "u_ion", username: "ion", email: "ion@example.test", status: "disabled", role: "uploader", plan: "standard", boxes: 2, created: "2026-04-01", lastSeen: "2026-04-15" },
|
||||||
|
{ id: "u_sara", username: "sara", email: "sara@example.test", status: "active", role: "operator", plan: "trusted", boxes: 12, created: "2026-03-30", lastSeen: "today 08:25" },
|
||||||
|
{ id: "u_vlad", username: "vlad", email: "vlad@example.test", status: "pending", role: "uploader", plan: "standard", boxes: 0, created: "2026-04-27", lastSeen: "never" },
|
||||||
|
{ id: "u_lina", username: "lina", email: "lina@example.test", status: "active", role: "viewer", plan: "guest-like", boxes: 1, created: "2026-03-22", lastSeen: "2026-04-29" },
|
||||||
|
{ id: "u_adi", username: "adi", email: "adi@example.test", status: "active", role: "uploader", plan: "standard", boxes: 4, created: "2026-02-18", lastSeen: "2026-04-26" },
|
||||||
|
{ id: "u_nora", username: "nora", email: "nora@example.test", status: "disabled", role: "viewer", plan: "guest-like", boxes: 0, created: "2026-01-14", lastSeen: "2026-03-02" },
|
||||||
|
{ id: "u_alex", username: "alex", email: "alex@example.test", status: "active", role: "uploader", plan: "trusted", boxes: 9, created: "2026-04-10", lastSeen: "2026-04-30" },
|
||||||
|
{ id: "u_rina", username: "rina", email: "rina@example.test", status: "pending", role: "uploader", plan: "standard", boxes: 0, created: "2026-04-29", lastSeen: "never" },
|
||||||
|
{ id: "u_mihai", username: "mihai", email: "mihai@example.test", status: "active", role: "operator", plan: "trusted", boxes: 6, created: "2026-02-08", lastSeen: "2026-04-22" }
|
||||||
|
];
|
||||||
|
|
||||||
|
const state = { page: 1, selected: new Set() };
|
||||||
|
|
||||||
|
function toast(message, type = "info") {
|
||||||
|
if (window.WarpBoxUI) {
|
||||||
|
window.WarpBoxUI.toast(message, type, { target: toastTarget, duration: 2200 });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!toastTarget) return;
|
||||||
|
toastTarget.textContent = message;
|
||||||
|
toastTarget.classList.add("is-visible");
|
||||||
|
}
|
||||||
|
|
||||||
|
function filtered() {
|
||||||
|
const query = search.value.trim().toLowerCase();
|
||||||
|
const statusFilter = status.value;
|
||||||
|
const roleFilter = role.value;
|
||||||
|
const sortBy = sort.value;
|
||||||
|
const rows = users.filter((user) => {
|
||||||
|
const matchesQuery = !query || user.username.toLowerCase().includes(query) || user.email.toLowerCase().includes(query);
|
||||||
|
const matchesStatus = statusFilter === "all" || user.status === statusFilter;
|
||||||
|
const matchesRole = roleFilter === "all" || user.role === roleFilter;
|
||||||
|
return matchesQuery && matchesStatus && matchesRole;
|
||||||
|
});
|
||||||
|
|
||||||
|
rows.sort((a, b) => {
|
||||||
|
if (sortBy === "createdDesc") return b.created.localeCompare(a.created);
|
||||||
|
if (sortBy === "lastSeenDesc") return b.lastSeen.localeCompare(a.lastSeen);
|
||||||
|
if (sortBy === "boxesDesc") return b.boxes - a.boxes;
|
||||||
|
return a.username.localeCompare(b.username);
|
||||||
|
});
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
function paged(rows) {
|
||||||
|
const perPage = Number(size.value || 12);
|
||||||
|
const pages = Math.max(1, Math.ceil(rows.length / perPage));
|
||||||
|
if (state.page > pages) state.page = pages;
|
||||||
|
if (state.page < 1) state.page = 1;
|
||||||
|
const start = (state.page - 1) * perPage;
|
||||||
|
return { rows: rows.slice(start, start + perPage), pages, start };
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusPill(value) {
|
||||||
|
return `<span class="users-pill ${value}">${value}</span>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderRow(user) {
|
||||||
|
const checked = state.selected.has(user.id) ? " checked" : "";
|
||||||
|
const row = document.createElement("tr");
|
||||||
|
row.innerHTML = `
|
||||||
|
<td><input type="checkbox" class="row-check"${checked}></td>
|
||||||
|
<td><div class="users-username"><strong>${user.username}</strong><span class="users-muted">${user.id}</span></div></td>
|
||||||
|
<td title="${user.email}">${user.email}</td>
|
||||||
|
<td>${statusPill(user.status)}</td>
|
||||||
|
<td>${user.role}</td>
|
||||||
|
<td>${user.plan}</td>
|
||||||
|
<td>${user.boxes}</td>
|
||||||
|
<td>${user.lastSeen}</td>
|
||||||
|
<td><div class="users-row-actions"><button class="win98-button users-row-button" type="button" data-action="open">Open</button></div></td>
|
||||||
|
`;
|
||||||
|
|
||||||
|
row.querySelector(".row-check")?.addEventListener("change", (event) => {
|
||||||
|
if (event.target.checked) state.selected.add(user.id);
|
||||||
|
else state.selected.delete(user.id);
|
||||||
|
syncSelected();
|
||||||
|
syncMasterCheck();
|
||||||
|
});
|
||||||
|
row.querySelector('[data-action="open"]')?.addEventListener("click", () => {
|
||||||
|
toast(`Mock user preview: ${user.username}`);
|
||||||
|
});
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncSelected() {
|
||||||
|
selectedPill.textContent = `${state.selected.size} selected`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncMasterCheck() {
|
||||||
|
const checks = Array.from(body.querySelectorAll(".row-check"));
|
||||||
|
masterCheck.checked = checks.length > 0 && checks.every((item) => item.checked);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderStats() {
|
||||||
|
document.getElementById("stat-total").textContent = String(users.length);
|
||||||
|
document.getElementById("stat-active").textContent = String(users.filter((u) => u.status === "active").length);
|
||||||
|
document.getElementById("stat-pending").textContent = String(users.filter((u) => u.status === "pending").length);
|
||||||
|
document.getElementById("stat-disabled").textContent = String(users.filter((u) => u.status === "disabled").length);
|
||||||
|
}
|
||||||
|
|
||||||
|
function render() {
|
||||||
|
const rows = filtered();
|
||||||
|
const page = paged(rows);
|
||||||
|
body.innerHTML = "";
|
||||||
|
page.rows.forEach((user) => body.appendChild(renderRow(user)));
|
||||||
|
|
||||||
|
visiblePill.textContent = `${rows.length} visible`;
|
||||||
|
pageInfo.textContent = `Page ${state.page} / ${page.pages}`;
|
||||||
|
prevBtn.disabled = state.page <= 1;
|
||||||
|
nextBtn.disabled = state.page >= page.pages;
|
||||||
|
statusLeft.textContent = `Ready. ${rows.length} user rows in current filter.`;
|
||||||
|
syncSelected();
|
||||||
|
syncMasterCheck();
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearFilters() {
|
||||||
|
search.value = "";
|
||||||
|
status.value = "all";
|
||||||
|
role.value = "all";
|
||||||
|
sort.value = "username";
|
||||||
|
state.page = 1;
|
||||||
|
render();
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyBulk(nextStatus) {
|
||||||
|
const selected = users.filter((user) => state.selected.has(user.id));
|
||||||
|
if (!selected.length) {
|
||||||
|
toast("Select one or more users first", "warning");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
selected.forEach((user) => { user.status = nextStatus; });
|
||||||
|
toast(`Updated ${selected.length} user(s) to ${nextStatus}`);
|
||||||
|
renderStats();
|
||||||
|
render();
|
||||||
|
}
|
||||||
|
|
||||||
|
function runCommand(command) {
|
||||||
|
switch (command) {
|
||||||
|
case "invite":
|
||||||
|
modeInput.value = "invite";
|
||||||
|
toast("Invite mode selected");
|
||||||
|
break;
|
||||||
|
case "create":
|
||||||
|
modeInput.value = "create";
|
||||||
|
toast("Create mode selected");
|
||||||
|
break;
|
||||||
|
case "export":
|
||||||
|
toast("Mock CSV export complete");
|
||||||
|
break;
|
||||||
|
case "bulk-disable":
|
||||||
|
applyBulk("disabled");
|
||||||
|
break;
|
||||||
|
case "bulk-enable":
|
||||||
|
applyBulk("active");
|
||||||
|
break;
|
||||||
|
case "bulk-revoke":
|
||||||
|
toast("Mock session revocation queued");
|
||||||
|
break;
|
||||||
|
case "refresh":
|
||||||
|
toast("Users list refreshed");
|
||||||
|
render();
|
||||||
|
break;
|
||||||
|
case "pending-only":
|
||||||
|
status.value = "pending";
|
||||||
|
state.page = 1;
|
||||||
|
render();
|
||||||
|
break;
|
||||||
|
case "clear-filters":
|
||||||
|
clearFilters();
|
||||||
|
break;
|
||||||
|
case "policy-help":
|
||||||
|
toast("Policy editor will be added in user details later.");
|
||||||
|
break;
|
||||||
|
case "mock-note":
|
||||||
|
toast("Mock-only page: no backend writes yet.");
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
toast(`Mock action: ${command}`);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[search, status, role, sort, size].forEach((el) => {
|
||||||
|
el.addEventListener(el.tagName === "INPUT" ? "input" : "change", () => {
|
||||||
|
state.page = 1;
|
||||||
|
render();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
prevBtn.addEventListener("click", () => {
|
||||||
|
state.page -= 1;
|
||||||
|
render();
|
||||||
|
});
|
||||||
|
|
||||||
|
nextBtn.addEventListener("click", () => {
|
||||||
|
state.page += 1;
|
||||||
|
render();
|
||||||
|
});
|
||||||
|
|
||||||
|
masterCheck.addEventListener("change", () => {
|
||||||
|
Array.from(body.querySelectorAll("tr")).forEach((row) => {
|
||||||
|
const checkbox = row.querySelector(".row-check");
|
||||||
|
if (!checkbox) return;
|
||||||
|
checkbox.checked = masterCheck.checked;
|
||||||
|
const userID = row.querySelector(".users-muted")?.textContent || "";
|
||||||
|
if (masterCheck.checked) state.selected.add(userID);
|
||||||
|
else state.selected.delete(userID);
|
||||||
|
});
|
||||||
|
syncSelected();
|
||||||
|
});
|
||||||
|
|
||||||
|
selectVisible.addEventListener("click", () => {
|
||||||
|
Array.from(body.querySelectorAll("tr")).forEach((row) => {
|
||||||
|
const checkbox = row.querySelector(".row-check");
|
||||||
|
const userID = row.querySelector(".users-muted")?.textContent || "";
|
||||||
|
if (!checkbox) return;
|
||||||
|
checkbox.checked = true;
|
||||||
|
state.selected.add(userID);
|
||||||
|
});
|
||||||
|
syncSelected();
|
||||||
|
syncMasterCheck();
|
||||||
|
});
|
||||||
|
|
||||||
|
form.addEventListener("submit", (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
const username = usernameInput.value.trim();
|
||||||
|
const email = emailInput.value.trim();
|
||||||
|
const mode = modeInput.value;
|
||||||
|
if (!username || !email) {
|
||||||
|
toast("Username and email are required", "warning");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
users.unshift({
|
||||||
|
id: `u_${username.toLowerCase().replaceAll(/[^a-z0-9]+/g, "_")}`,
|
||||||
|
username,
|
||||||
|
email,
|
||||||
|
status: mode === "invite" ? "pending" : "active",
|
||||||
|
role: roleInput.value,
|
||||||
|
plan: planInput.value,
|
||||||
|
boxes: 0,
|
||||||
|
created: new Date().toISOString().slice(0, 10),
|
||||||
|
lastSeen: "never"
|
||||||
|
});
|
||||||
|
form.reset();
|
||||||
|
modeInput.value = "invite";
|
||||||
|
renderStats();
|
||||||
|
render();
|
||||||
|
toast(mode === "invite" ? "Mock invite created" : "Mock user created");
|
||||||
|
});
|
||||||
|
|
||||||
|
document.querySelectorAll("[data-command]").forEach((button) => {
|
||||||
|
button.addEventListener("click", () => {
|
||||||
|
menuController.close();
|
||||||
|
runCommand(button.dataset.command);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener("keydown", (event) => {
|
||||||
|
if (event.key === "Escape") menuController.close();
|
||||||
|
if (event.key === "F5") {
|
||||||
|
event.preventDefault();
|
||||||
|
runCommand("refresh");
|
||||||
|
}
|
||||||
|
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "i") {
|
||||||
|
event.preventDefault();
|
||||||
|
runCommand("invite");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
renderStats();
|
||||||
|
render();
|
||||||
|
})();
|
||||||
@@ -8,6 +8,7 @@
|
|||||||
<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{{ if eq .ActivePage "boxes" }} is-active{{ end }}" href="/admin/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{{ if eq .ActivePage "users" }} is-active{{ end }}" href="/admin/users">Users</a>
|
||||||
<a class="admin-taskbar-button{{ if eq .ActivePage "settings" }} is-active{{ end }}" href="/admin/settings">Settings</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">
|
||||||
|
|||||||
@@ -189,9 +189,12 @@
|
|||||||
<option value="false"{{ if eq .Value "false" }} selected{{ end }}>false</option>
|
<option value="false"{{ if eq .Value "false" }} selected{{ end }}>false</option>
|
||||||
</select>
|
</select>
|
||||||
{{ else }}
|
{{ else }}
|
||||||
|
<div class="setting-input-row">
|
||||||
<input class="settings-input setting-input" type="text" value="{{ .Value }}"{{ if .Locked }} disabled{{ end }}>
|
<input class="settings-input setting-input" type="text" value="{{ .Value }}"{{ if .Locked }} disabled{{ end }}>
|
||||||
|
{{ if eq .Type "size_gb" }}<span class="settings-badge badge-default">GB</span>{{ end }}
|
||||||
|
</div>
|
||||||
{{ 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 class="setting-hint" data-role="hint">{{ if .Locked }}Locked by environment or hard runtime implication.{{ else if eq .Type "size_gb" }}Use GB values. Decimals allowed, for example `0.5`.{{ else if .DefaultValue }}Default: {{ .DefaultValue }}{{ end }}</div>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td class="setting-actions">
|
<td class="setting-actions">
|
||||||
|
|||||||
195
templates/admin/users.html
Normal file
195
templates/admin/users.html
Normal file
@@ -0,0 +1,195 @@
|
|||||||
|
{{ define "admin/users.html" }}
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>WarpBox Admin Users</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/users.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 Users</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="Users 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="invite"><span>I</span><span>Invite user</span><span>Ctrl+I</span></button>
|
||||||
|
<button class="menu-action" type="button" data-command="create"><span>C</span><span>Create local user</span><span></span></button>
|
||||||
|
<div class="menu-separator"></div>
|
||||||
|
<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">Users</button>
|
||||||
|
<div class="menu-popup">
|
||||||
|
<button class="menu-action" type="button" data-command="bulk-disable"><span>D</span><span>Disable selected</span><span></span></button>
|
||||||
|
<button class="menu-action" type="button" data-command="bulk-enable"><span>U</span><span>Enable selected</span><span></span></button>
|
||||||
|
<button class="menu-action" type="button" data-command="bulk-revoke"><span>R</span><span>Revoke sessions</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="refresh"><span>F</span><span>Refresh list</span><span>F5</span></button>
|
||||||
|
<button class="menu-action" type="button" data-command="pending-only"><span>P</span><span>Show pending invites</span><span></span></button>
|
||||||
|
<button class="menu-action" type="button" data-command="clear-filters"><span>X</span><span>Clear filters</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="policy-help"><span>?</span><span>User policy notes</span><span></span></button>
|
||||||
|
<button class="menu-action" type="button" data-command="mock-note"><span>M</span><span>Mock-only notes</span><span></span></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div class="admin-workspace-body users-page-body">
|
||||||
|
<section class="users-hero">
|
||||||
|
<div>
|
||||||
|
<h2>Accounts, invites, and access</h2>
|
||||||
|
<p>Mock administrative users view for creation, invitation, filtering, and safe bulk actions.</p>
|
||||||
|
</div>
|
||||||
|
<div class="users-hero-actions">
|
||||||
|
<button class="win98-button users-action-button" type="button" data-command="invite">Invite user</button>
|
||||||
|
<button class="win98-button users-action-button" type="button" data-command="create">Create local user</button>
|
||||||
|
<button class="win98-button users-action-button" type="button" data-command="export">Export CSV</button>
|
||||||
|
<button class="win98-button users-action-button" type="button" data-command="policy-help">Policy notes</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="users-summary-grid">
|
||||||
|
<article class="users-stat-card is-info"><p>Total users</p><strong id="stat-total">0</strong></article>
|
||||||
|
<article class="users-stat-card is-ok"><p>Active</p><strong id="stat-active">0</strong></article>
|
||||||
|
<article class="users-stat-card is-warning"><p>Pending invites</p><strong id="stat-pending">0</strong></article>
|
||||||
|
<article class="users-stat-card is-danger"><p>Disabled</p><strong id="stat-disabled">0</strong></article>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="users-main-grid">
|
||||||
|
<section class="users-panel">
|
||||||
|
<div class="users-panel-header">
|
||||||
|
<div class="users-panel-title">Create or invite <span>mock only</span></div>
|
||||||
|
</div>
|
||||||
|
<div class="users-panel-body">
|
||||||
|
<form id="users-form" class="users-form-grid">
|
||||||
|
<label class="users-field">Mode
|
||||||
|
<select class="users-select" id="users-mode">
|
||||||
|
<option value="invite">Send invite</option>
|
||||||
|
<option value="create">Create local user</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label class="users-field">Username<input class="users-input" id="users-username" type="text" autocomplete="off"></label>
|
||||||
|
<label class="users-field">Email<input class="users-input" id="users-email" type="email" autocomplete="off"></label>
|
||||||
|
<div class="users-row-two">
|
||||||
|
<label class="users-field">Role
|
||||||
|
<select class="users-select" id="users-role">
|
||||||
|
<option value="uploader">uploader</option>
|
||||||
|
<option value="operator">operator</option>
|
||||||
|
<option value="viewer">viewer</option>
|
||||||
|
<option value="admin">admin</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label class="users-field">Plan
|
||||||
|
<select class="users-select" id="users-plan">
|
||||||
|
<option value="standard">standard</option>
|
||||||
|
<option value="trusted">trusted</option>
|
||||||
|
<option value="guest-like">guest-like</option>
|
||||||
|
<option value="unlimited">unlimited</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<label class="users-check"><input type="checkbox" id="users-send-setup" checked>Send setup instructions</label>
|
||||||
|
<div class="users-form-actions">
|
||||||
|
<button class="win98-button users-action-button" type="reset">Clear</button>
|
||||||
|
<button class="win98-button users-action-button" type="submit">Apply</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="users-panel">
|
||||||
|
<div class="users-panel-header">
|
||||||
|
<div class="users-panel-title">Users <span id="visible-pill">0 visible</span></div>
|
||||||
|
<div class="users-panel-tools">
|
||||||
|
<button class="win98-button users-tool-button" type="button" id="select-visible">Select visible</button>
|
||||||
|
<button class="win98-button users-tool-button" type="button" data-command="bulk-disable">Disable</button>
|
||||||
|
<button class="win98-button users-tool-button" type="button" data-command="bulk-enable">Enable</button>
|
||||||
|
<button class="win98-button users-tool-button" type="button" data-command="bulk-revoke">Revoke</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="users-panel-body users-list-body">
|
||||||
|
<div class="users-toolbar-grid">
|
||||||
|
<input class="users-input" id="users-search" type="search" placeholder="Search username or email">
|
||||||
|
<select class="users-select" id="users-status"><option value="all">all statuses</option><option value="active">active</option><option value="pending">pending</option><option value="disabled">disabled</option></select>
|
||||||
|
<select class="users-select" id="users-role-filter"><option value="all">all roles</option><option value="admin">admin</option><option value="operator">operator</option><option value="uploader">uploader</option><option value="viewer">viewer</option></select>
|
||||||
|
<select class="users-select" id="users-sort"><option value="username">sort username</option><option value="createdDesc">newest first</option><option value="lastSeenDesc">last seen</option><option value="boxesDesc">box count</option></select>
|
||||||
|
<select class="users-select" id="users-size"><option value="8">8 rows</option><option value="12" selected>12 rows</option><option value="20">20 rows</option></select>
|
||||||
|
</div>
|
||||||
|
<div class="users-table-wrap">
|
||||||
|
<table class="users-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th class="users-col-check"><input type="checkbox" id="users-master-check"></th>
|
||||||
|
<th>User</th>
|
||||||
|
<th>Email</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Role</th>
|
||||||
|
<th>Plan</th>
|
||||||
|
<th>Boxes</th>
|
||||||
|
<th>Last seen</th>
|
||||||
|
<th class="users-col-actions">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="users-body"></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div class="users-pagination">
|
||||||
|
<span id="users-page-info">Page 1</span>
|
||||||
|
<span id="users-selected-pill">0 selected</span>
|
||||||
|
<div>
|
||||||
|
<button class="win98-button users-page-button" type="button" id="users-prev">Prev</button>
|
||||||
|
<button class="win98-button users-page-button" type="button" id="users-next">Next</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer class="status-bar admin-dashboard-statusbar">
|
||||||
|
<span id="users-status-left">Ready. Client-side mock data only.</span>
|
||||||
|
<span>server paging planned</span>
|
||||||
|
<span>admin only</span>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="toast" class="wb-toast" role="status" aria-live="polite"></div>
|
||||||
|
<script src="/static/js/warpbox-ui.js"></script>
|
||||||
|
<script src="/static/js/admin/users.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
{{ end }}
|
||||||
Reference in New Issue
Block a user