Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 82d4dc815b | |||
| 89c885f637 | |||
| e103829870 | |||
| 2714907ff4 |
@@ -151,11 +151,6 @@ func buildAllEnvRows(includeHidden bool) []envRow {
|
|||||||
}
|
}
|
||||||
|
|
||||||
extra := buildExtraEnvRows(includeHidden)
|
extra := buildExtraEnvRows(includeHidden)
|
||||||
if loadErr == nil {
|
|
||||||
for i := range extra {
|
|
||||||
extra[i].Default = extra[i].Default
|
|
||||||
}
|
|
||||||
}
|
|
||||||
rows = append(rows, extra...)
|
rows = append(rows, extra...)
|
||||||
|
|
||||||
return rows
|
return rows
|
||||||
|
|||||||
@@ -28,6 +28,12 @@ func TestDefaults(t *testing.T) {
|
|||||||
if cfg.AdminPassword != "" {
|
if cfg.AdminPassword != "" {
|
||||||
t.Fatal("expected default admin password to be empty")
|
t.Fatal("expected default admin password to be empty")
|
||||||
}
|
}
|
||||||
|
if !cfg.BoxOwnerEditEnabled || !cfg.BoxOwnerRefreshEnabled || !cfg.BoxOwnerPasswordEditEnabled {
|
||||||
|
t.Fatal("expected box owner policy defaults to be enabled")
|
||||||
|
}
|
||||||
|
if cfg.BoxOwnerMaxRefreshCount != 3 || cfg.BoxOwnerMaxRefreshAmountSeconds != 86400 || cfg.BoxOwnerMaxTotalExpirySeconds != 604800 {
|
||||||
|
t.Fatalf("unexpected box owner policy defaults: %#v", cfg)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestEnvironmentOverrides(t *testing.T) {
|
func TestEnvironmentOverrides(t *testing.T) {
|
||||||
@@ -39,6 +45,8 @@ func TestEnvironmentOverrides(t *testing.T) {
|
|||||||
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")
|
||||||
|
t.Setenv("WARPBOX_BOX_OWNER_MAX_REFRESH_COUNT", "5")
|
||||||
|
t.Setenv("WARPBOX_BOX_OWNER_PASSWORD_EDIT_ENABLED", "false")
|
||||||
|
|
||||||
cfg, err := Load()
|
cfg, err := Load()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -63,6 +71,9 @@ func TestEnvironmentOverrides(t *testing.T) {
|
|||||||
if !cfg.OneTimeDownloadRetryOnFailure {
|
if !cfg.OneTimeDownloadRetryOnFailure {
|
||||||
t.Fatal("expected one-time retry-on-failure env override to be applied")
|
t.Fatal("expected one-time retry-on-failure env override to be applied")
|
||||||
}
|
}
|
||||||
|
if cfg.BoxOwnerMaxRefreshCount != 5 || cfg.BoxOwnerPasswordEditEnabled {
|
||||||
|
t.Fatal("expected box owner policy env overrides to be applied")
|
||||||
|
}
|
||||||
if cfg.Source(SettingAPIEnabled) != SourceEnv {
|
if cfg.Source(SettingAPIEnabled) != SourceEnv {
|
||||||
t.Fatalf("expected API setting source to be env, got %s", cfg.Source(SettingAPIEnabled))
|
t.Fatalf("expected API setting source to be env, got %s", cfg.Source(SettingAPIEnabled))
|
||||||
}
|
}
|
||||||
@@ -148,6 +159,12 @@ func TestSettingsOverrideValidation(t *testing.T) {
|
|||||||
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.Fatal("expected hard limit override to fail")
|
||||||
}
|
}
|
||||||
|
if err := cfg.ApplyOverride(SettingBoxOwnerMaxRefreshCount, "2"); err != nil {
|
||||||
|
t.Fatalf("expected box owner policy override to pass: %v", err)
|
||||||
|
}
|
||||||
|
if cfg.BoxOwnerMaxRefreshCount != 2 {
|
||||||
|
t.Fatalf("expected box owner policy override to apply, got %d", cfg.BoxOwnerMaxRefreshCount)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func clearConfigEnv(t *testing.T) {
|
func clearConfigEnv(t *testing.T) {
|
||||||
@@ -181,6 +198,12 @@ func clearConfigEnv(t *testing.T) {
|
|||||||
"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",
|
||||||
|
"WARPBOX_BOX_OWNER_EDIT_ENABLED",
|
||||||
|
"WARPBOX_BOX_OWNER_REFRESH_ENABLED",
|
||||||
|
"WARPBOX_BOX_OWNER_MAX_REFRESH_COUNT",
|
||||||
|
"WARPBOX_BOX_OWNER_MAX_REFRESH_AMOUNT_SECONDS",
|
||||||
|
"WARPBOX_BOX_OWNER_MAX_TOTAL_EXPIRY_SECONDS",
|
||||||
|
"WARPBOX_BOX_OWNER_PASSWORD_EDIT_ENABLED",
|
||||||
} {
|
} {
|
||||||
t.Setenv(name, "")
|
t.Setenv(name, "")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,12 @@ var Definitions = []SettingDefinition{
|
|||||||
{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},
|
||||||
{Key: SettingThumbnailIntervalSeconds, EnvName: "WARPBOX_THUMBNAIL_INTERVAL_SECONDS", Label: "Thumbnail interval seconds", Type: SettingTypeInt, Editable: true, Minimum: 1},
|
{Key: SettingThumbnailIntervalSeconds, EnvName: "WARPBOX_THUMBNAIL_INTERVAL_SECONDS", Label: "Thumbnail interval seconds", Type: SettingTypeInt, Editable: true, Minimum: 1},
|
||||||
|
{Key: SettingBoxOwnerEditEnabled, EnvName: "WARPBOX_BOX_OWNER_EDIT_ENABLED", Label: "Box owner edit enabled", Type: SettingTypeBool, Editable: true},
|
||||||
|
{Key: SettingBoxOwnerRefreshEnabled, EnvName: "WARPBOX_BOX_OWNER_REFRESH_ENABLED", Label: "Box owner refresh enabled", Type: SettingTypeBool, Editable: true},
|
||||||
|
{Key: SettingBoxOwnerMaxRefreshCount, EnvName: "WARPBOX_BOX_OWNER_MAX_REFRESH_COUNT", Label: "Box owner max refresh count", Type: SettingTypeInt, Editable: true, Minimum: 0},
|
||||||
|
{Key: SettingBoxOwnerMaxRefreshAmount, EnvName: "WARPBOX_BOX_OWNER_MAX_REFRESH_AMOUNT_SECONDS", Label: "Box owner max refresh amount seconds", Type: SettingTypeInt64, Editable: true, Minimum: 0},
|
||||||
|
{Key: SettingBoxOwnerMaxTotalExpiry, EnvName: "WARPBOX_BOX_OWNER_MAX_TOTAL_EXPIRY_SECONDS", Label: "Box owner max total expiry seconds", Type: SettingTypeInt64, Editable: true, Minimum: 0},
|
||||||
|
{Key: SettingBoxOwnerPasswordEdit, EnvName: "WARPBOX_BOX_OWNER_PASSWORD_EDIT_ENABLED", Label: "Box owner password edit enabled", Type: SettingTypeBool, Editable: true},
|
||||||
}
|
}
|
||||||
|
|
||||||
func (cfg *Config) SettingRows() []SettingRow {
|
func (cfg *Config) SettingRows() []SettingRow {
|
||||||
@@ -38,6 +44,10 @@ func (cfg *Config) Source(key string) Source {
|
|||||||
return cfg.sourceFor(key)
|
return cfg.sourceFor(key)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (cfg *Config) SettingValue(key string) string {
|
||||||
|
return cfg.values[key]
|
||||||
|
}
|
||||||
|
|
||||||
func (cfg *Config) AdminLoginEnabled(hasAdminUser bool) bool {
|
func (cfg *Config) AdminLoginEnabled(hasAdminUser bool) bool {
|
||||||
switch cfg.AdminEnabled {
|
switch cfg.AdminEnabled {
|
||||||
case AdminEnabledFalse:
|
case AdminEnabledFalse:
|
||||||
|
|||||||
@@ -11,24 +11,30 @@ import (
|
|||||||
|
|
||||||
func Load() (*Config, error) {
|
func Load() (*Config, error) {
|
||||||
cfg := &Config{
|
cfg := &Config{
|
||||||
DataDir: "./data",
|
DataDir: "./data",
|
||||||
AdminUsername: "admin",
|
AdminUsername: "admin",
|
||||||
AdminEnabled: AdminEnabledAuto,
|
AdminEnabled: AdminEnabledAuto,
|
||||||
AllowAdminSettingsOverride: true,
|
AllowAdminSettingsOverride: true,
|
||||||
GuestUploadsEnabled: true,
|
GuestUploadsEnabled: true,
|
||||||
APIEnabled: true,
|
APIEnabled: true,
|
||||||
ZipDownloadsEnabled: true,
|
ZipDownloadsEnabled: true,
|
||||||
OneTimeDownloadsEnabled: true,
|
OneTimeDownloadsEnabled: true,
|
||||||
OneTimeDownloadExpirySeconds: 7 * 24 * 60 * 60,
|
OneTimeDownloadExpirySeconds: 7 * 24 * 60 * 60,
|
||||||
OneTimeDownloadRetryOnFailure: false,
|
OneTimeDownloadRetryOnFailure: false,
|
||||||
DefaultGuestExpirySeconds: 10,
|
DefaultGuestExpirySeconds: 10,
|
||||||
MaxGuestExpirySeconds: 48 * 60 * 60,
|
MaxGuestExpirySeconds: 48 * 60 * 60,
|
||||||
SessionTTLSeconds: 24 * 60 * 60,
|
SessionTTLSeconds: 24 * 60 * 60,
|
||||||
BoxPollIntervalMS: 5000,
|
BoxPollIntervalMS: 5000,
|
||||||
ThumbnailBatchSize: 10,
|
ThumbnailBatchSize: 10,
|
||||||
ThumbnailIntervalSeconds: 30,
|
ThumbnailIntervalSeconds: 30,
|
||||||
sources: make(map[string]Source),
|
BoxOwnerEditEnabled: true,
|
||||||
values: make(map[string]string),
|
BoxOwnerRefreshEnabled: true,
|
||||||
|
BoxOwnerMaxRefreshCount: 3,
|
||||||
|
BoxOwnerMaxRefreshAmountSeconds: 24 * 60 * 60,
|
||||||
|
BoxOwnerMaxTotalExpirySeconds: 7 * 24 * 60 * 60,
|
||||||
|
BoxOwnerPasswordEditEnabled: true,
|
||||||
|
sources: make(map[string]Source),
|
||||||
|
values: make(map[string]string),
|
||||||
}
|
}
|
||||||
|
|
||||||
// Config precedence: defaults -> env -> overrides.
|
// Config precedence: defaults -> env -> overrides.
|
||||||
@@ -73,6 +79,9 @@ func Load() (*Config, error) {
|
|||||||
{SettingOneTimeDownloadRetryFail, "WARPBOX_ONE_TIME_DOWNLOAD_RETRY_ON_FAILURE", &cfg.OneTimeDownloadRetryOnFailure},
|
{SettingOneTimeDownloadRetryFail, "WARPBOX_ONE_TIME_DOWNLOAD_RETRY_ON_FAILURE", &cfg.OneTimeDownloadRetryOnFailure},
|
||||||
{SettingRenewOnAccessEnabled, "WARPBOX_RENEW_ON_ACCESS_ENABLED", &cfg.RenewOnAccessEnabled},
|
{SettingRenewOnAccessEnabled, "WARPBOX_RENEW_ON_ACCESS_ENABLED", &cfg.RenewOnAccessEnabled},
|
||||||
{SettingRenewOnDownloadEnabled, "WARPBOX_RENEW_ON_DOWNLOAD_ENABLED", &cfg.RenewOnDownloadEnabled},
|
{SettingRenewOnDownloadEnabled, "WARPBOX_RENEW_ON_DOWNLOAD_ENABLED", &cfg.RenewOnDownloadEnabled},
|
||||||
|
{SettingBoxOwnerEditEnabled, "WARPBOX_BOX_OWNER_EDIT_ENABLED", &cfg.BoxOwnerEditEnabled},
|
||||||
|
{SettingBoxOwnerRefreshEnabled, "WARPBOX_BOX_OWNER_REFRESH_ENABLED", &cfg.BoxOwnerRefreshEnabled},
|
||||||
|
{SettingBoxOwnerPasswordEdit, "WARPBOX_BOX_OWNER_PASSWORD_EDIT_ENABLED", &cfg.BoxOwnerPasswordEditEnabled},
|
||||||
}
|
}
|
||||||
for _, item := range envBools {
|
for _, item := range envBools {
|
||||||
if err := cfg.applyBoolEnv(item.key, item.name, item.target); err != nil {
|
if err := cfg.applyBoolEnv(item.key, item.name, item.target); err != nil {
|
||||||
@@ -90,6 +99,8 @@ func Load() (*Config, error) {
|
|||||||
{SettingMaxGuestExpirySecs, "WARPBOX_MAX_GUEST_EXPIRY_SECONDS", 0, &cfg.MaxGuestExpirySeconds},
|
{SettingMaxGuestExpirySecs, "WARPBOX_MAX_GUEST_EXPIRY_SECONDS", 0, &cfg.MaxGuestExpirySeconds},
|
||||||
{SettingOneTimeDownloadExpirySecs, "WARPBOX_ONE_TIME_DOWNLOAD_EXPIRY_SECONDS", 0, &cfg.OneTimeDownloadExpirySeconds},
|
{SettingOneTimeDownloadExpirySecs, "WARPBOX_ONE_TIME_DOWNLOAD_EXPIRY_SECONDS", 0, &cfg.OneTimeDownloadExpirySeconds},
|
||||||
{SettingSessionTTLSeconds, "WARPBOX_SESSION_TTL_SECONDS", 60, &cfg.SessionTTLSeconds},
|
{SettingSessionTTLSeconds, "WARPBOX_SESSION_TTL_SECONDS", 60, &cfg.SessionTTLSeconds},
|
||||||
|
{SettingBoxOwnerMaxRefreshAmount, "WARPBOX_BOX_OWNER_MAX_REFRESH_AMOUNT_SECONDS", 0, &cfg.BoxOwnerMaxRefreshAmountSeconds},
|
||||||
|
{SettingBoxOwnerMaxTotalExpiry, "WARPBOX_BOX_OWNER_MAX_TOTAL_EXPIRY_SECONDS", 0, &cfg.BoxOwnerMaxTotalExpirySeconds},
|
||||||
}
|
}
|
||||||
for _, item := range envInt64s {
|
for _, item := range envInt64s {
|
||||||
if err := cfg.applyInt64Env(item.key, item.name, item.min, item.target); err != nil {
|
if err := cfg.applyInt64Env(item.key, item.name, item.min, item.target); err != nil {
|
||||||
@@ -122,6 +133,7 @@ func Load() (*Config, error) {
|
|||||||
{SettingBoxPollIntervalMS, "WARPBOX_BOX_POLL_INTERVAL_MS", 1000, &cfg.BoxPollIntervalMS},
|
{SettingBoxPollIntervalMS, "WARPBOX_BOX_POLL_INTERVAL_MS", 1000, &cfg.BoxPollIntervalMS},
|
||||||
{SettingThumbnailBatchSize, "WARPBOX_THUMBNAIL_BATCH_SIZE", 1, &cfg.ThumbnailBatchSize},
|
{SettingThumbnailBatchSize, "WARPBOX_THUMBNAIL_BATCH_SIZE", 1, &cfg.ThumbnailBatchSize},
|
||||||
{SettingThumbnailIntervalSeconds, "WARPBOX_THUMBNAIL_INTERVAL_SECONDS", 1, &cfg.ThumbnailIntervalSeconds},
|
{SettingThumbnailIntervalSeconds, "WARPBOX_THUMBNAIL_INTERVAL_SECONDS", 1, &cfg.ThumbnailIntervalSeconds},
|
||||||
|
{SettingBoxOwnerMaxRefreshCount, "WARPBOX_BOX_OWNER_MAX_REFRESH_COUNT", 0, &cfg.BoxOwnerMaxRefreshCount},
|
||||||
}
|
}
|
||||||
for _, item := range envInts {
|
for _, item := range envInts {
|
||||||
if err := cfg.applyIntEnv(item.key, item.name, item.min, item.target); err != nil {
|
if err := cfg.applyIntEnv(item.key, item.name, item.min, item.target); err != nil {
|
||||||
@@ -171,6 +183,12 @@ func (cfg *Config) captureDefaults() {
|
|||||||
cfg.setValue(SettingBoxPollIntervalMS, strconv.Itoa(cfg.BoxPollIntervalMS), SourceDefault)
|
cfg.setValue(SettingBoxPollIntervalMS, strconv.Itoa(cfg.BoxPollIntervalMS), SourceDefault)
|
||||||
cfg.setValue(SettingThumbnailBatchSize, strconv.Itoa(cfg.ThumbnailBatchSize), SourceDefault)
|
cfg.setValue(SettingThumbnailBatchSize, strconv.Itoa(cfg.ThumbnailBatchSize), SourceDefault)
|
||||||
cfg.setValue(SettingThumbnailIntervalSeconds, strconv.Itoa(cfg.ThumbnailIntervalSeconds), SourceDefault)
|
cfg.setValue(SettingThumbnailIntervalSeconds, strconv.Itoa(cfg.ThumbnailIntervalSeconds), SourceDefault)
|
||||||
|
cfg.setValue(SettingBoxOwnerEditEnabled, formatBool(cfg.BoxOwnerEditEnabled), SourceDefault)
|
||||||
|
cfg.setValue(SettingBoxOwnerRefreshEnabled, formatBool(cfg.BoxOwnerRefreshEnabled), SourceDefault)
|
||||||
|
cfg.setValue(SettingBoxOwnerMaxRefreshCount, strconv.Itoa(cfg.BoxOwnerMaxRefreshCount), SourceDefault)
|
||||||
|
cfg.setValue(SettingBoxOwnerMaxRefreshAmount, strconv.FormatInt(cfg.BoxOwnerMaxRefreshAmountSeconds, 10), SourceDefault)
|
||||||
|
cfg.setValue(SettingBoxOwnerMaxTotalExpiry, strconv.FormatInt(cfg.BoxOwnerMaxTotalExpirySeconds, 10), SourceDefault)
|
||||||
|
cfg.setValue(SettingBoxOwnerPasswordEdit, formatBool(cfg.BoxOwnerPasswordEditEnabled), SourceDefault)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (cfg *Config) applyStringEnv(key string, name string, target *string) error {
|
func (cfg *Config) applyStringEnv(key string, name string, target *string) error {
|
||||||
|
|||||||
@@ -36,6 +36,12 @@ const (
|
|||||||
SettingThumbnailBatchSize = "thumbnail_batch_size"
|
SettingThumbnailBatchSize = "thumbnail_batch_size"
|
||||||
SettingThumbnailIntervalSeconds = "thumbnail_interval_seconds"
|
SettingThumbnailIntervalSeconds = "thumbnail_interval_seconds"
|
||||||
SettingDataDir = "data_dir"
|
SettingDataDir = "data_dir"
|
||||||
|
SettingBoxOwnerEditEnabled = "box_owner_edit_enabled"
|
||||||
|
SettingBoxOwnerRefreshEnabled = "box_owner_refresh_enabled"
|
||||||
|
SettingBoxOwnerMaxRefreshCount = "box_owner_max_refresh_count"
|
||||||
|
SettingBoxOwnerMaxRefreshAmount = "box_owner_max_refresh_amount_seconds"
|
||||||
|
SettingBoxOwnerMaxTotalExpiry = "box_owner_max_total_expiry_seconds"
|
||||||
|
SettingBoxOwnerPasswordEdit = "box_owner_password_edit_enabled"
|
||||||
)
|
)
|
||||||
|
|
||||||
type SettingType string
|
type SettingType string
|
||||||
@@ -84,16 +90,22 @@ type Config struct {
|
|||||||
RenewOnAccessEnabled bool
|
RenewOnAccessEnabled bool
|
||||||
RenewOnDownloadEnabled bool
|
RenewOnDownloadEnabled bool
|
||||||
|
|
||||||
DefaultGuestExpirySeconds int64
|
DefaultGuestExpirySeconds int64
|
||||||
MaxGuestExpirySeconds int64
|
MaxGuestExpirySeconds int64
|
||||||
GlobalMaxFileSizeBytes int64
|
GlobalMaxFileSizeBytes int64
|
||||||
GlobalMaxBoxSizeBytes int64
|
GlobalMaxBoxSizeBytes int64
|
||||||
DefaultUserMaxFileSizeBytes int64
|
DefaultUserMaxFileSizeBytes int64
|
||||||
DefaultUserMaxBoxSizeBytes int64
|
DefaultUserMaxBoxSizeBytes int64
|
||||||
SessionTTLSeconds int64
|
SessionTTLSeconds int64
|
||||||
BoxPollIntervalMS int
|
BoxPollIntervalMS int
|
||||||
ThumbnailBatchSize int
|
ThumbnailBatchSize int
|
||||||
ThumbnailIntervalSeconds int
|
ThumbnailIntervalSeconds int
|
||||||
|
BoxOwnerEditEnabled bool
|
||||||
|
BoxOwnerRefreshEnabled bool
|
||||||
|
BoxOwnerMaxRefreshCount int
|
||||||
|
BoxOwnerMaxRefreshAmountSeconds int64
|
||||||
|
BoxOwnerMaxTotalExpirySeconds int64
|
||||||
|
BoxOwnerPasswordEditEnabled bool
|
||||||
|
|
||||||
sources map[string]Source
|
sources map[string]Source
|
||||||
values map[string]string
|
values map[string]string
|
||||||
|
|||||||
@@ -64,6 +64,12 @@ func (cfg *Config) assignBool(key string, value bool, source Source) {
|
|||||||
cfg.RenewOnAccessEnabled = value
|
cfg.RenewOnAccessEnabled = value
|
||||||
case SettingRenewOnDownloadEnabled:
|
case SettingRenewOnDownloadEnabled:
|
||||||
cfg.RenewOnDownloadEnabled = value
|
cfg.RenewOnDownloadEnabled = value
|
||||||
|
case SettingBoxOwnerEditEnabled:
|
||||||
|
cfg.BoxOwnerEditEnabled = value
|
||||||
|
case SettingBoxOwnerRefreshEnabled:
|
||||||
|
cfg.BoxOwnerRefreshEnabled = value
|
||||||
|
case SettingBoxOwnerPasswordEdit:
|
||||||
|
cfg.BoxOwnerPasswordEditEnabled = value
|
||||||
}
|
}
|
||||||
cfg.setValue(key, formatBool(value), source)
|
cfg.setValue(key, formatBool(value), source)
|
||||||
}
|
}
|
||||||
@@ -82,6 +88,10 @@ func (cfg *Config) assignInt64(key string, value int64, source Source) {
|
|||||||
cfg.DefaultUserMaxBoxSizeBytes = value
|
cfg.DefaultUserMaxBoxSizeBytes = value
|
||||||
case SettingSessionTTLSeconds:
|
case SettingSessionTTLSeconds:
|
||||||
cfg.SessionTTLSeconds = value
|
cfg.SessionTTLSeconds = value
|
||||||
|
case SettingBoxOwnerMaxRefreshAmount:
|
||||||
|
cfg.BoxOwnerMaxRefreshAmountSeconds = value
|
||||||
|
case SettingBoxOwnerMaxTotalExpiry:
|
||||||
|
cfg.BoxOwnerMaxTotalExpirySeconds = value
|
||||||
}
|
}
|
||||||
cfg.setValue(key, strconv.FormatInt(value, 10), source)
|
cfg.setValue(key, strconv.FormatInt(value, 10), source)
|
||||||
}
|
}
|
||||||
@@ -94,6 +104,8 @@ func (cfg *Config) assignInt(key string, value int, source Source) {
|
|||||||
cfg.ThumbnailBatchSize = value
|
cfg.ThumbnailBatchSize = value
|
||||||
case SettingThumbnailIntervalSeconds:
|
case SettingThumbnailIntervalSeconds:
|
||||||
cfg.ThumbnailIntervalSeconds = value
|
cfg.ThumbnailIntervalSeconds = value
|
||||||
|
case SettingBoxOwnerMaxRefreshCount:
|
||||||
|
cfg.BoxOwnerMaxRefreshCount = value
|
||||||
}
|
}
|
||||||
cfg.setValue(key, strconv.Itoa(value), source)
|
cfg.setValue(key, strconv.Itoa(value), source)
|
||||||
}
|
}
|
||||||
|
|||||||
247
lib/metastore/alerts.go
Normal file
247
lib/metastore/alerts.go
Normal file
@@ -0,0 +1,247 @@
|
|||||||
|
package metastore
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/dgraph-io/badger/v4"
|
||||||
|
|
||||||
|
"warpbox/lib/helpers"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
AlertSeverityLow = "low"
|
||||||
|
AlertSeverityMedium = "medium"
|
||||||
|
AlertSeverityHigh = "high"
|
||||||
|
|
||||||
|
AlertStatusOpen = "open"
|
||||||
|
AlertStatusAcknowledged = "acknowledged"
|
||||||
|
AlertStatusClosed = "closed"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (store *Store) CreateAlert(input AlertInput) (Alert, error) {
|
||||||
|
alert, err := normalizeAlertInput(input)
|
||||||
|
if err != nil {
|
||||||
|
return Alert{}, err
|
||||||
|
}
|
||||||
|
id, err := helpers.RandomHexID(16)
|
||||||
|
if err != nil {
|
||||||
|
return Alert{}, err
|
||||||
|
}
|
||||||
|
now := time.Now().UTC()
|
||||||
|
alert.ID = id
|
||||||
|
alert.Status = AlertStatusOpen
|
||||||
|
alert.CreatedAt = now
|
||||||
|
alert.UpdatedAt = now
|
||||||
|
|
||||||
|
err = store.db.Update(func(txn *badger.Txn) error {
|
||||||
|
return putJSON(txn, alertKey(alert.ID), alert)
|
||||||
|
})
|
||||||
|
return alert, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (store *Store) ListAlerts(filters AlertFilters) ([]Alert, error) {
|
||||||
|
alerts := []Alert{}
|
||||||
|
err := store.db.View(func(txn *badger.Txn) error {
|
||||||
|
opts := badger.DefaultIteratorOptions
|
||||||
|
opts.Prefix = []byte("alert/")
|
||||||
|
it := txn.NewIterator(opts)
|
||||||
|
defer it.Close()
|
||||||
|
|
||||||
|
for it.Rewind(); it.Valid(); it.Next() {
|
||||||
|
var alert Alert
|
||||||
|
if err := it.Item().Value(func(data []byte) error {
|
||||||
|
return json.Unmarshal(data, &alert)
|
||||||
|
}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if alertMatchesFilters(alert, filters) {
|
||||||
|
alerts = append(alerts, alert)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
sortAlerts(alerts, filters.Sort)
|
||||||
|
return alerts, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (store *Store) GetAlert(id string) (Alert, bool, error) {
|
||||||
|
id = strings.TrimSpace(id)
|
||||||
|
if id == "" {
|
||||||
|
return Alert{}, false, nil
|
||||||
|
}
|
||||||
|
var alert Alert
|
||||||
|
err := store.db.View(func(txn *badger.Txn) error {
|
||||||
|
return getJSON(txn, alertKey(id), &alert)
|
||||||
|
})
|
||||||
|
if errors.Is(err, ErrNotFound) {
|
||||||
|
return Alert{}, false, nil
|
||||||
|
}
|
||||||
|
return alert, err == nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (store *Store) AcknowledgeAlert(id string) error {
|
||||||
|
return store.updateAlertStatus(id, AlertStatusAcknowledged)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (store *Store) CloseAlert(id string) error {
|
||||||
|
return store.updateAlertStatus(id, AlertStatusClosed)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (store *Store) updateAlertStatus(id string, status string) error {
|
||||||
|
id = strings.TrimSpace(id)
|
||||||
|
if id == "" {
|
||||||
|
return fmt.Errorf("%w: alert id cannot be empty", ErrInvalid)
|
||||||
|
}
|
||||||
|
status, err := normalizeAlertStatus(status)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
now := time.Now().UTC()
|
||||||
|
return store.db.Update(func(txn *badger.Txn) error {
|
||||||
|
var alert Alert
|
||||||
|
if err := getJSON(txn, alertKey(id), &alert); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
alert.Status = status
|
||||||
|
alert.UpdatedAt = now
|
||||||
|
switch status {
|
||||||
|
case AlertStatusAcknowledged:
|
||||||
|
alert.AcknowledgedAt = &now
|
||||||
|
case AlertStatusClosed:
|
||||||
|
alert.ClosedAt = &now
|
||||||
|
}
|
||||||
|
return putJSON(txn, alertKey(id), alert)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeAlertInput(input AlertInput) (Alert, error) {
|
||||||
|
title := strings.TrimSpace(input.Title)
|
||||||
|
description := strings.TrimSpace(input.Description)
|
||||||
|
code := strings.TrimSpace(input.Code)
|
||||||
|
trace := strings.TrimSpace(input.Trace)
|
||||||
|
severity, err := normalizeAlertSeverity(input.Severity)
|
||||||
|
if err != nil {
|
||||||
|
return Alert{}, err
|
||||||
|
}
|
||||||
|
if title == "" {
|
||||||
|
return Alert{}, fmt.Errorf("%w: alert title cannot be empty", ErrInvalid)
|
||||||
|
}
|
||||||
|
if code == "" {
|
||||||
|
return Alert{}, fmt.Errorf("%w: alert code cannot be empty", ErrInvalid)
|
||||||
|
}
|
||||||
|
if trace == "" {
|
||||||
|
return Alert{}, fmt.Errorf("%w: alert trace cannot be empty", ErrInvalid)
|
||||||
|
}
|
||||||
|
metadata := input.Metadata
|
||||||
|
if len(metadata) == 0 {
|
||||||
|
metadata = json.RawMessage(`{}`)
|
||||||
|
}
|
||||||
|
var object map[string]any
|
||||||
|
if err := json.Unmarshal(metadata, &object); err != nil {
|
||||||
|
return Alert{}, fmt.Errorf("%w: alert metadata must be a JSON object", ErrInvalid)
|
||||||
|
}
|
||||||
|
normalizedMetadata, err := json.Marshal(object)
|
||||||
|
if err != nil {
|
||||||
|
return Alert{}, err
|
||||||
|
}
|
||||||
|
return Alert{
|
||||||
|
Title: title,
|
||||||
|
Description: description,
|
||||||
|
Severity: severity,
|
||||||
|
Code: code,
|
||||||
|
Trace: trace,
|
||||||
|
Metadata: normalizedMetadata,
|
||||||
|
CreatedBy: strings.TrimSpace(input.CreatedBy),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeAlertSeverity(value string) (string, error) {
|
||||||
|
switch strings.ToLower(strings.TrimSpace(value)) {
|
||||||
|
case AlertSeverityLow, AlertSeverityMedium, AlertSeverityHigh:
|
||||||
|
return strings.ToLower(strings.TrimSpace(value)), nil
|
||||||
|
default:
|
||||||
|
return "", fmt.Errorf("%w: invalid alert severity", ErrInvalid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeAlertStatus(value string) (string, error) {
|
||||||
|
switch strings.ToLower(strings.TrimSpace(value)) {
|
||||||
|
case AlertStatusOpen, AlertStatusAcknowledged, AlertStatusClosed:
|
||||||
|
return strings.ToLower(strings.TrimSpace(value)), nil
|
||||||
|
default:
|
||||||
|
return "", fmt.Errorf("%w: invalid alert status", ErrInvalid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func alertMatchesFilters(alert Alert, filters AlertFilters) bool {
|
||||||
|
query := strings.ToLower(strings.TrimSpace(filters.Query))
|
||||||
|
if query != "" {
|
||||||
|
haystack := strings.ToLower(strings.Join([]string{alert.Title, alert.Description, alert.Code, alert.Trace}, " "))
|
||||||
|
if !strings.Contains(haystack, query) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if severity := strings.ToLower(strings.TrimSpace(filters.Severity)); severity != "" && severity != "all" && alert.Severity != severity {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if status := strings.ToLower(strings.TrimSpace(filters.Status)); status != "" && status != "all" && alert.Status != status {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if group := strings.ToLower(strings.TrimSpace(filters.Group)); group != "" && group != "all" && alertGroup(alert.Trace) != group {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func sortAlerts(alerts []Alert, sortKey string) {
|
||||||
|
switch strings.ToLower(strings.TrimSpace(sortKey)) {
|
||||||
|
case "oldest":
|
||||||
|
sort.Slice(alerts, func(i int, j int) bool { return alerts[i].CreatedAt.Before(alerts[j].CreatedAt) })
|
||||||
|
case "severity":
|
||||||
|
sort.Slice(alerts, func(i int, j int) bool {
|
||||||
|
left := alertSeverityRank(alerts[i].Severity)
|
||||||
|
right := alertSeverityRank(alerts[j].Severity)
|
||||||
|
if left == right {
|
||||||
|
return alerts[i].CreatedAt.After(alerts[j].CreatedAt)
|
||||||
|
}
|
||||||
|
return left > right
|
||||||
|
})
|
||||||
|
default:
|
||||||
|
sort.Slice(alerts, func(i int, j int) bool { return alerts[i].CreatedAt.After(alerts[j].CreatedAt) })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func alertSeverityRank(severity string) int {
|
||||||
|
switch severity {
|
||||||
|
case AlertSeverityHigh:
|
||||||
|
return 3
|
||||||
|
case AlertSeverityMedium:
|
||||||
|
return 2
|
||||||
|
default:
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func alertGroup(trace string) string {
|
||||||
|
trace = strings.TrimSpace(trace)
|
||||||
|
if trace == "" {
|
||||||
|
return "system"
|
||||||
|
}
|
||||||
|
before, _, found := strings.Cut(trace, ".")
|
||||||
|
if !found || before == "" {
|
||||||
|
return "system"
|
||||||
|
}
|
||||||
|
return strings.ToLower(before)
|
||||||
|
}
|
||||||
|
|
||||||
|
func alertKey(id string) []byte {
|
||||||
|
return []byte("alert/" + strings.TrimSpace(id))
|
||||||
|
}
|
||||||
89
lib/metastore/alerts_test.go
Normal file
89
lib/metastore/alerts_test.go
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
package metastore
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestAlertCreateListFilterLifecycle(t *testing.T) {
|
||||||
|
store, err := Open(t.TempDir())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Open returned error: %v", err)
|
||||||
|
}
|
||||||
|
defer store.Close()
|
||||||
|
|
||||||
|
alert, err := store.CreateAlert(AlertInput{
|
||||||
|
Title: "Thumbnail failed",
|
||||||
|
Description: "Could not generate preview.",
|
||||||
|
Severity: AlertSeverityMedium,
|
||||||
|
Code: "601",
|
||||||
|
Trace: "thumbnail.generate.failed",
|
||||||
|
Metadata: json.RawMessage(`{"box":"box-1","file":"photo.jpg"}`),
|
||||||
|
CreatedBy: "system",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateAlert returned error: %v", err)
|
||||||
|
}
|
||||||
|
if alert.ID == "" || alert.Status != AlertStatusOpen {
|
||||||
|
t.Fatalf("unexpected alert: %#v", alert)
|
||||||
|
}
|
||||||
|
|
||||||
|
alerts, err := store.ListAlerts(AlertFilters{Severity: AlertSeverityMedium, Status: AlertStatusOpen})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListAlerts returned error: %v", err)
|
||||||
|
}
|
||||||
|
if len(alerts) != 1 || alerts[0].Trace != "thumbnail.generate.failed" {
|
||||||
|
t.Fatalf("unexpected filtered alerts: %#v", alerts)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !json.Valid(alerts[0].Metadata) {
|
||||||
|
t.Fatalf("expected valid metadata JSON: %s", string(alerts[0].Metadata))
|
||||||
|
}
|
||||||
|
var metadata map[string]string
|
||||||
|
if err := json.Unmarshal(alerts[0].Metadata, &metadata); err != nil {
|
||||||
|
t.Fatalf("Unmarshal metadata returned error: %v", err)
|
||||||
|
}
|
||||||
|
if metadata["file"] != "photo.jpg" {
|
||||||
|
t.Fatalf("metadata did not survive round trip: %#v", metadata)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := store.AcknowledgeAlert(alert.ID); err != nil {
|
||||||
|
t.Fatalf("AcknowledgeAlert returned error: %v", err)
|
||||||
|
}
|
||||||
|
acknowledged, ok, err := store.GetAlert(alert.ID)
|
||||||
|
if err != nil || !ok {
|
||||||
|
t.Fatalf("GetAlert returned ok=%v err=%v", ok, err)
|
||||||
|
}
|
||||||
|
if acknowledged.Status != AlertStatusAcknowledged || acknowledged.AcknowledgedAt == nil {
|
||||||
|
t.Fatalf("expected acknowledged alert, got %#v", acknowledged)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := store.CloseAlert(alert.ID); err != nil {
|
||||||
|
t.Fatalf("CloseAlert returned error: %v", err)
|
||||||
|
}
|
||||||
|
closed, ok, err := store.GetAlert(alert.ID)
|
||||||
|
if err != nil || !ok {
|
||||||
|
t.Fatalf("GetAlert returned ok=%v err=%v", ok, err)
|
||||||
|
}
|
||||||
|
if closed.Status != AlertStatusClosed || closed.ClosedAt == nil {
|
||||||
|
t.Fatalf("expected closed alert, got %#v", closed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAlertRejectsInvalidMetadata(t *testing.T) {
|
||||||
|
store, err := Open(t.TempDir())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Open returned error: %v", err)
|
||||||
|
}
|
||||||
|
defer store.Close()
|
||||||
|
|
||||||
|
if _, err := store.CreateAlert(AlertInput{
|
||||||
|
Title: "Bad alert",
|
||||||
|
Severity: AlertSeverityLow,
|
||||||
|
Code: "999",
|
||||||
|
Trace: "test.bad",
|
||||||
|
Metadata: json.RawMessage(`[]`),
|
||||||
|
}); err == nil {
|
||||||
|
t.Fatal("expected non-object metadata to be rejected")
|
||||||
|
}
|
||||||
|
}
|
||||||
188
lib/metastore/boxes.go
Normal file
188
lib/metastore/boxes.go
Normal file
@@ -0,0 +1,188 @@
|
|||||||
|
package metastore
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/dgraph-io/badger/v4"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (store *Store) UpsertBoxRecord(record BoxRecord) error {
|
||||||
|
record.ID = strings.TrimSpace(record.ID)
|
||||||
|
if record.ID == "" {
|
||||||
|
return errors.New("box id cannot be empty")
|
||||||
|
}
|
||||||
|
record.OwnerID = strings.TrimSpace(record.OwnerID)
|
||||||
|
record.OwnerUsername = strings.TrimSpace(record.OwnerUsername)
|
||||||
|
record.FileNames = uniqueStrings(record.FileNames)
|
||||||
|
record.UpdatedAt = time.Now().UTC()
|
||||||
|
return store.db.Update(func(txn *badger.Txn) error {
|
||||||
|
return putJSON(txn, boxRecordKey(record.ID), record)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (store *Store) GetBoxRecord(id string) (BoxRecord, bool, error) {
|
||||||
|
var record BoxRecord
|
||||||
|
err := store.db.View(func(txn *badger.Txn) error {
|
||||||
|
return getJSON(txn, boxRecordKey(id), &record)
|
||||||
|
})
|
||||||
|
if errors.Is(err, ErrNotFound) {
|
||||||
|
return BoxRecord{}, false, nil
|
||||||
|
}
|
||||||
|
return record, err == nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (store *Store) DeleteBoxRecord(id string) error {
|
||||||
|
return store.db.Update(func(txn *badger.Txn) error {
|
||||||
|
err := txn.Delete(boxRecordKey(id))
|
||||||
|
if errors.Is(err, badger.ErrKeyNotFound) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (store *Store) ListBoxRecords(filters BoxFilters, page BoxPageRequest) (BoxRecordPage, error) {
|
||||||
|
if page.Page < 1 {
|
||||||
|
page.Page = 1
|
||||||
|
}
|
||||||
|
switch page.PageSize {
|
||||||
|
case 25, 50, 100:
|
||||||
|
default:
|
||||||
|
page.PageSize = 25
|
||||||
|
}
|
||||||
|
|
||||||
|
rows := []BoxRecord{}
|
||||||
|
err := store.db.View(func(txn *badger.Txn) error {
|
||||||
|
opts := badger.DefaultIteratorOptions
|
||||||
|
opts.Prefix = []byte("box_record/")
|
||||||
|
it := txn.NewIterator(opts)
|
||||||
|
defer it.Close()
|
||||||
|
|
||||||
|
for it.Rewind(); it.Valid(); it.Next() {
|
||||||
|
var record BoxRecord
|
||||||
|
if err := it.Item().Value(func(data []byte) error {
|
||||||
|
return json.Unmarshal(data, &record)
|
||||||
|
}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if boxRecordMatches(record, filters) {
|
||||||
|
rows = append(rows, record)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return BoxRecordPage{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
sortBoxRecords(rows, filters.Sort)
|
||||||
|
total := len(rows)
|
||||||
|
start := (page.Page - 1) * page.PageSize
|
||||||
|
if start > total {
|
||||||
|
start = total
|
||||||
|
}
|
||||||
|
end := start + page.PageSize
|
||||||
|
if end > total {
|
||||||
|
end = total
|
||||||
|
}
|
||||||
|
totalPages := 1
|
||||||
|
if total > 0 {
|
||||||
|
totalPages = (total + page.PageSize - 1) / page.PageSize
|
||||||
|
}
|
||||||
|
return BoxRecordPage{
|
||||||
|
Rows: rows[start:end],
|
||||||
|
Page: page.Page,
|
||||||
|
PageSize: page.PageSize,
|
||||||
|
Total: total,
|
||||||
|
HasPrev: page.Page > 1,
|
||||||
|
HasNext: end < total,
|
||||||
|
PrevPage: maxInt(page.Page-1, 1),
|
||||||
|
NextPage: page.Page + 1,
|
||||||
|
TotalPages: totalPages,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func boxRecordMatches(record BoxRecord, filters BoxFilters) bool {
|
||||||
|
query := strings.ToLower(strings.TrimSpace(filters.Query))
|
||||||
|
if query != "" {
|
||||||
|
haystack := strings.ToLower(record.ID + " " + record.OwnerUsername + " " + strings.Join(record.FileNames, " "))
|
||||||
|
if !strings.Contains(haystack, query) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
owner := strings.ToLower(strings.TrimSpace(filters.Owner))
|
||||||
|
if owner != "" && owner != "all" && strings.ToLower(record.OwnerUsername) != owner && strings.ToLower(record.OwnerID) != owner {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
status := strings.ToLower(strings.TrimSpace(filters.Status))
|
||||||
|
if status != "" && status != "all" && boxRecordStatus(record) != status {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
switch strings.ToLower(strings.TrimSpace(filters.Flag)) {
|
||||||
|
case "", "all":
|
||||||
|
return true
|
||||||
|
case "password":
|
||||||
|
return record.PasswordProtected
|
||||||
|
case "one-time":
|
||||||
|
return record.OneTimeDownload
|
||||||
|
case "zip-disabled":
|
||||||
|
return record.DisableZip
|
||||||
|
case "expired":
|
||||||
|
return boxRecordExpired(record)
|
||||||
|
case "refreshable":
|
||||||
|
return !record.OneTimeDownload && !boxRecordExpired(record)
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func sortBoxRecords(rows []BoxRecord, sortKey string) {
|
||||||
|
switch strings.ToLower(strings.TrimSpace(sortKey)) {
|
||||||
|
case "oldest":
|
||||||
|
sort.Slice(rows, func(i int, j int) bool { return rows[i].CreatedAt.Before(rows[j].CreatedAt) })
|
||||||
|
case "largest":
|
||||||
|
sort.Slice(rows, func(i int, j int) bool { return rows[i].TotalSize > rows[j].TotalSize })
|
||||||
|
case "expires":
|
||||||
|
sort.Slice(rows, func(i int, j int) bool { return rows[i].ExpiresAt.Before(rows[j].ExpiresAt) })
|
||||||
|
case "expired":
|
||||||
|
sort.Slice(rows, func(i int, j int) bool {
|
||||||
|
left := boxRecordExpired(rows[i])
|
||||||
|
right := boxRecordExpired(rows[j])
|
||||||
|
if left == right {
|
||||||
|
return rows[i].CreatedAt.After(rows[j].CreatedAt)
|
||||||
|
}
|
||||||
|
return left
|
||||||
|
})
|
||||||
|
default:
|
||||||
|
sort.Slice(rows, func(i int, j int) bool { return rows[i].CreatedAt.After(rows[j].CreatedAt) })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func boxRecordStatus(record BoxRecord) string {
|
||||||
|
if boxRecordExpired(record) {
|
||||||
|
return "expired"
|
||||||
|
}
|
||||||
|
if record.ExpiresAt.IsZero() {
|
||||||
|
return "pending"
|
||||||
|
}
|
||||||
|
return "active"
|
||||||
|
}
|
||||||
|
|
||||||
|
func boxRecordExpired(record BoxRecord) bool {
|
||||||
|
return !record.ExpiresAt.IsZero() && time.Now().UTC().After(record.ExpiresAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
func boxRecordKey(id string) []byte {
|
||||||
|
return []byte("box_record/" + strings.TrimSpace(id))
|
||||||
|
}
|
||||||
|
|
||||||
|
func maxInt(a int, b int) int {
|
||||||
|
if a > b {
|
||||||
|
return a
|
||||||
|
}
|
||||||
|
return b
|
||||||
|
}
|
||||||
@@ -1,21 +1,38 @@
|
|||||||
package metastore
|
package metastore
|
||||||
|
|
||||||
import "time"
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
const AdminTagName = "admin"
|
const AdminTagName = "admin"
|
||||||
|
|
||||||
type User struct {
|
type User struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Username string `json:"username"`
|
Username string `json:"username"`
|
||||||
Email string `json:"email,omitempty"`
|
Email string `json:"email,omitempty"`
|
||||||
PasswordHash string `json:"password_hash"`
|
PasswordHash string `json:"password_hash"`
|
||||||
TagIDs []string `json:"tag_ids"`
|
TagIDs []string `json:"tag_ids"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
Disabled bool `json:"disabled"`
|
Disabled bool `json:"disabled"`
|
||||||
MaxFileSizeBytes *int64 `json:"max_file_size_bytes,omitempty"`
|
AdminNote string `json:"admin_note,omitempty"`
|
||||||
MaxBoxSizeBytes *int64 `json:"max_box_size_bytes,omitempty"`
|
MaxFileSizeBytes *int64 `json:"max_file_size_bytes,omitempty"`
|
||||||
MaxExpirySeconds *int64 `json:"max_expiry_seconds,omitempty"`
|
MaxBoxSizeBytes *int64 `json:"max_box_size_bytes,omitempty"`
|
||||||
|
MaxExpirySeconds *int64 `json:"max_expiry_seconds,omitempty"`
|
||||||
|
PermOverrides *UserPermOverrides `json:"perm_overrides,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type UserPermOverrides struct {
|
||||||
|
UploadAllowed *bool `json:"upload_allowed,omitempty"`
|
||||||
|
ManageOwnBoxes *bool `json:"manage_own_boxes,omitempty"`
|
||||||
|
ZipDownloadAllowed *bool `json:"zip_download_allowed,omitempty"`
|
||||||
|
OneTimeDownloadAllowed *bool `json:"one_time_download_allowed,omitempty"`
|
||||||
|
RenewableAllowed *bool `json:"renewable_allowed,omitempty"`
|
||||||
|
AllowPasswordProtected *bool `json:"allow_password_protected,omitempty"`
|
||||||
|
RenewOnAccess *bool `json:"renew_on_access,omitempty"`
|
||||||
|
RenewOnDownload *bool `json:"renew_on_download,omitempty"`
|
||||||
|
AllowOwnerBoxEditing *bool `json:"allow_owner_box_editing,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type Tag struct {
|
type Tag struct {
|
||||||
@@ -39,6 +56,7 @@ type TagPermissions struct {
|
|||||||
RenewOnAccessSeconds int64 `json:"renew_on_access_seconds,omitempty"`
|
RenewOnAccessSeconds int64 `json:"renew_on_access_seconds,omitempty"`
|
||||||
RenewOnDownloadSeconds int64 `json:"renew_on_download_seconds,omitempty"`
|
RenewOnDownloadSeconds int64 `json:"renew_on_download_seconds,omitempty"`
|
||||||
AdminAccess bool `json:"admin_access"`
|
AdminAccess bool `json:"admin_access"`
|
||||||
|
AdminUsersView bool `json:"admin_users_view"`
|
||||||
AdminUsersManage bool `json:"admin_users_manage"`
|
AdminUsersManage bool `json:"admin_users_manage"`
|
||||||
AdminSettingsManage bool `json:"admin_settings_manage"`
|
AdminSettingsManage bool `json:"admin_settings_manage"`
|
||||||
AdminBoxesView bool `json:"admin_boxes_view"`
|
AdminBoxesView bool `json:"admin_boxes_view"`
|
||||||
@@ -64,6 +82,7 @@ type EffectivePermissions struct {
|
|||||||
RenewOnAccessSeconds int64
|
RenewOnAccessSeconds int64
|
||||||
RenewOnDownloadSeconds int64
|
RenewOnDownloadSeconds int64
|
||||||
AdminAccess bool
|
AdminAccess bool
|
||||||
|
AdminUsersView bool
|
||||||
AdminUsersManage bool
|
AdminUsersManage bool
|
||||||
AdminSettingsManage bool
|
AdminSettingsManage bool
|
||||||
AdminBoxesView bool
|
AdminBoxesView bool
|
||||||
@@ -74,3 +93,150 @@ type BootstrapResult struct {
|
|||||||
AdminUser *User
|
AdminUser *User
|
||||||
AdminLoginEnabled bool
|
AdminLoginEnabled bool
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type Alert struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
Severity string `json:"severity"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
Code string `json:"code"`
|
||||||
|
Trace string `json:"trace"`
|
||||||
|
Metadata json.RawMessage `json:"metadata,omitempty"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
AcknowledgedAt *time.Time `json:"acknowledged_at,omitempty"`
|
||||||
|
ClosedAt *time.Time `json:"closed_at,omitempty"`
|
||||||
|
CreatedBy string `json:"created_by"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type AlertInput struct {
|
||||||
|
Title string
|
||||||
|
Description string
|
||||||
|
Severity string
|
||||||
|
Code string
|
||||||
|
Trace string
|
||||||
|
Metadata json.RawMessage
|
||||||
|
CreatedBy string
|
||||||
|
}
|
||||||
|
|
||||||
|
type AlertFilters struct {
|
||||||
|
Query string
|
||||||
|
Severity string
|
||||||
|
Status string
|
||||||
|
Group string
|
||||||
|
Sort string
|
||||||
|
}
|
||||||
|
|
||||||
|
type BoxRecord struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
OwnerID string `json:"owner_id,omitempty"`
|
||||||
|
OwnerUsername string `json:"owner_username,omitempty"`
|
||||||
|
FileNames []string `json:"file_names,omitempty"`
|
||||||
|
FileCount int `json:"file_count"`
|
||||||
|
TotalSize int64 `json:"total_size"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
ExpiresAt time.Time `json:"expires_at"`
|
||||||
|
PasswordProtected bool `json:"password_protected"`
|
||||||
|
OneTimeDownload bool `json:"one_time_download"`
|
||||||
|
DisableZip bool `json:"disable_zip"`
|
||||||
|
RefreshCount int `json:"refresh_count"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type BoxFilters struct {
|
||||||
|
Query string
|
||||||
|
Owner string
|
||||||
|
Status string
|
||||||
|
Flag string
|
||||||
|
Sort string
|
||||||
|
}
|
||||||
|
|
||||||
|
type BoxPageRequest struct {
|
||||||
|
Page int
|
||||||
|
PageSize int
|
||||||
|
}
|
||||||
|
|
||||||
|
type BoxRecordPage struct {
|
||||||
|
Rows []BoxRecord
|
||||||
|
Page int
|
||||||
|
PageSize int
|
||||||
|
Total int
|
||||||
|
HasPrev bool
|
||||||
|
HasNext bool
|
||||||
|
PrevPage int
|
||||||
|
NextPage int
|
||||||
|
TotalPages int
|
||||||
|
}
|
||||||
|
|
||||||
|
type UserFilters struct {
|
||||||
|
Query string
|
||||||
|
Status string
|
||||||
|
Role string
|
||||||
|
Sort string
|
||||||
|
}
|
||||||
|
|
||||||
|
type UserPageRequest struct {
|
||||||
|
Page int
|
||||||
|
PageSize int
|
||||||
|
}
|
||||||
|
|
||||||
|
type UserRow struct {
|
||||||
|
ID string
|
||||||
|
Username string
|
||||||
|
Email string
|
||||||
|
Status string
|
||||||
|
Role string
|
||||||
|
TagIDs []string
|
||||||
|
Tags string
|
||||||
|
Plan string
|
||||||
|
PolicySummary string
|
||||||
|
BoxCount int
|
||||||
|
APIKeyCount int
|
||||||
|
CreatedAt string
|
||||||
|
LastSeen string
|
||||||
|
Disabled bool
|
||||||
|
IsCurrent bool
|
||||||
|
IsInvite bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type UserPage struct {
|
||||||
|
Rows []UserRow
|
||||||
|
Page int
|
||||||
|
PageSize int
|
||||||
|
Total int
|
||||||
|
HasPrev bool
|
||||||
|
HasNext bool
|
||||||
|
PrevPage int
|
||||||
|
NextPage int
|
||||||
|
TotalPages int
|
||||||
|
Stats UserPageStats
|
||||||
|
}
|
||||||
|
|
||||||
|
type UserPageStats struct {
|
||||||
|
TotalUsers int
|
||||||
|
ActiveUsers int
|
||||||
|
PendingInvites int
|
||||||
|
DisabledUsers int
|
||||||
|
}
|
||||||
|
|
||||||
|
type CreateUserInput struct {
|
||||||
|
Username string
|
||||||
|
Email string
|
||||||
|
Password string
|
||||||
|
Mode string
|
||||||
|
Role string
|
||||||
|
Plan string
|
||||||
|
AdminNote string
|
||||||
|
SendSetup bool
|
||||||
|
ForceChange bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type CreateUserResult struct {
|
||||||
|
User User
|
||||||
|
InviteToken string
|
||||||
|
InviteLink string
|
||||||
|
IsInvite bool
|
||||||
|
PasswordSet string
|
||||||
|
InviteNotSent bool
|
||||||
|
}
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ func ResolveUserPermissions(cfg *config.Config, user User, tags []Tag) Effective
|
|||||||
perms.ZipDownloadAllowed = perms.ZipDownloadAllowed || tagPerms.ZipDownloadAllowed
|
perms.ZipDownloadAllowed = perms.ZipDownloadAllowed || tagPerms.ZipDownloadAllowed
|
||||||
perms.RenewableAllowed = perms.RenewableAllowed || tagPerms.RenewableAllowed
|
perms.RenewableAllowed = perms.RenewableAllowed || tagPerms.RenewableAllowed
|
||||||
perms.AdminAccess = perms.AdminAccess || tagPerms.AdminAccess
|
perms.AdminAccess = perms.AdminAccess || tagPerms.AdminAccess
|
||||||
|
perms.AdminUsersView = perms.AdminUsersView || tagPerms.AdminUsersView
|
||||||
perms.AdminUsersManage = perms.AdminUsersManage || tagPerms.AdminUsersManage
|
perms.AdminUsersManage = perms.AdminUsersManage || tagPerms.AdminUsersManage
|
||||||
perms.AdminSettingsManage = perms.AdminSettingsManage || tagPerms.AdminSettingsManage
|
perms.AdminSettingsManage = perms.AdminSettingsManage || tagPerms.AdminSettingsManage
|
||||||
perms.AdminBoxesView = perms.AdminBoxesView || tagPerms.AdminBoxesView
|
perms.AdminBoxesView = perms.AdminBoxesView || tagPerms.AdminBoxesView
|
||||||
@@ -50,6 +51,21 @@ func ResolveUserPermissions(cfg *config.Config, user User, tags []Tag) Effective
|
|||||||
perms.MaxExpirySeconds = *user.MaxExpirySeconds
|
perms.MaxExpirySeconds = *user.MaxExpirySeconds
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if o := user.PermOverrides; o != nil {
|
||||||
|
if o.UploadAllowed != nil {
|
||||||
|
perms.UploadAllowed = *o.UploadAllowed
|
||||||
|
}
|
||||||
|
if o.ZipDownloadAllowed != nil {
|
||||||
|
perms.ZipDownloadAllowed = *o.ZipDownloadAllowed
|
||||||
|
}
|
||||||
|
if o.OneTimeDownloadAllowed != nil {
|
||||||
|
perms.OneTimeDownloadAllowed = *o.OneTimeDownloadAllowed
|
||||||
|
}
|
||||||
|
if o.RenewableAllowed != nil {
|
||||||
|
perms.RenewableAllowed = *o.RenewableAllowed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
perms.MaxFileSizeBytes = capLimit(perms.MaxFileSizeBytes, cfg.GlobalMaxFileSizeBytes)
|
perms.MaxFileSizeBytes = capLimit(perms.MaxFileSizeBytes, cfg.GlobalMaxFileSizeBytes)
|
||||||
perms.MaxBoxSizeBytes = capLimit(perms.MaxBoxSizeBytes, cfg.GlobalMaxBoxSizeBytes)
|
perms.MaxBoxSizeBytes = capLimit(perms.MaxBoxSizeBytes, cfg.GlobalMaxBoxSizeBytes)
|
||||||
perms.AllowedExpirySeconds = sortedExpirySet(expirySet)
|
perms.AllowedExpirySeconds = sortedExpirySet(expirySet)
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -291,6 +292,295 @@ func (store *Store) ListUsers() ([]User, error) {
|
|||||||
return users, err
|
return users, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (store *Store) ListUsersPaginated(filters UserFilters, pageReq UserPageRequest) (UserPage, error) {
|
||||||
|
users, err := store.ListUsers()
|
||||||
|
if err != nil {
|
||||||
|
return UserPage{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
tags, err := store.ListTags()
|
||||||
|
if err != nil {
|
||||||
|
return UserPage{}, err
|
||||||
|
}
|
||||||
|
tagMap := make(map[string]Tag, len(tags))
|
||||||
|
for _, tag := range tags {
|
||||||
|
tagMap[tag.ID] = tag
|
||||||
|
}
|
||||||
|
|
||||||
|
query := strings.ToLower(strings.TrimSpace(filters.Query))
|
||||||
|
filtered := make([]User, 0, len(users))
|
||||||
|
for _, user := range users {
|
||||||
|
if query != "" {
|
||||||
|
if !strings.Contains(strings.ToLower(user.Username), query) &&
|
||||||
|
!strings.Contains(strings.ToLower(user.Email), query) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
switch filters.Status {
|
||||||
|
case "active":
|
||||||
|
if user.Disabled || strings.HasPrefix(user.PasswordHash, "invite/") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
case "disabled":
|
||||||
|
if !user.Disabled || strings.HasPrefix(user.PasswordHash, "invite/") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
case "pending":
|
||||||
|
if !strings.HasPrefix(user.PasswordHash, "invite/") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if filters.Role != "" && filters.Role != "all" {
|
||||||
|
match := false
|
||||||
|
for _, tagID := range user.TagIDs {
|
||||||
|
if tag, ok := tagMap[tagID]; ok && strings.EqualFold(tag.Name, filters.Role) {
|
||||||
|
match = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !match {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
filtered = append(filtered, user)
|
||||||
|
}
|
||||||
|
|
||||||
|
switch filters.Sort {
|
||||||
|
case "createdDesc":
|
||||||
|
sort.Slice(filtered, func(i, j int) bool {
|
||||||
|
return filtered[i].CreatedAt.After(filtered[j].CreatedAt)
|
||||||
|
})
|
||||||
|
case "username":
|
||||||
|
fallthrough
|
||||||
|
default:
|
||||||
|
sort.Slice(filtered, func(i, j int) bool {
|
||||||
|
return strings.ToLower(filtered[i].Username) < strings.ToLower(filtered[j].Username)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
total := len(filtered)
|
||||||
|
pageSize := pageReq.PageSize
|
||||||
|
if pageSize <= 0 {
|
||||||
|
pageSize = 12
|
||||||
|
}
|
||||||
|
if pageSize > 100 {
|
||||||
|
pageSize = 100
|
||||||
|
}
|
||||||
|
totalPages := (total + pageSize - 1) / pageSize
|
||||||
|
if totalPages < 1 {
|
||||||
|
totalPages = 1
|
||||||
|
}
|
||||||
|
page := pageReq.Page
|
||||||
|
if page < 1 {
|
||||||
|
page = 1
|
||||||
|
}
|
||||||
|
if page > totalPages {
|
||||||
|
page = totalPages
|
||||||
|
}
|
||||||
|
|
||||||
|
start := (page - 1) * pageSize
|
||||||
|
end := start + pageSize
|
||||||
|
if end > total {
|
||||||
|
end = total
|
||||||
|
}
|
||||||
|
pageUsers := filtered[start:end]
|
||||||
|
|
||||||
|
stats := UserPageStats{TotalUsers: len(users)}
|
||||||
|
for _, user := range users {
|
||||||
|
if strings.HasPrefix(user.PasswordHash, "invite/") {
|
||||||
|
stats.PendingInvites++
|
||||||
|
} else if user.Disabled {
|
||||||
|
stats.DisabledUsers++
|
||||||
|
} else {
|
||||||
|
stats.ActiveUsers++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rows := make([]UserRow, len(pageUsers))
|
||||||
|
for i, user := range pageUsers {
|
||||||
|
role := ""
|
||||||
|
tagNames := make([]string, 0, len(user.TagIDs))
|
||||||
|
for _, tagID := range user.TagIDs {
|
||||||
|
if tag, ok := tagMap[tagID]; ok {
|
||||||
|
tagNames = append(tagNames, tag.Name)
|
||||||
|
if tag.Permissions.AdminAccess && role == "" {
|
||||||
|
role = tag.Name
|
||||||
|
} else if role == "" {
|
||||||
|
role = tag.Name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if role == "" {
|
||||||
|
role = "user"
|
||||||
|
}
|
||||||
|
|
||||||
|
plan := "standard"
|
||||||
|
for _, tagID := range user.TagIDs {
|
||||||
|
if tag, ok := tagMap[tagID]; ok && strings.EqualFold(tag.Name, "admin") {
|
||||||
|
plan = "unlimited"
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
isInvite := strings.HasPrefix(user.PasswordHash, "invite/")
|
||||||
|
status := userStatus(user.Disabled)
|
||||||
|
if isInvite {
|
||||||
|
status = "pending"
|
||||||
|
}
|
||||||
|
|
||||||
|
rows[i] = UserRow{
|
||||||
|
ID: user.ID,
|
||||||
|
Username: user.Username,
|
||||||
|
Email: user.Email,
|
||||||
|
Status: status,
|
||||||
|
Role: role,
|
||||||
|
TagIDs: user.TagIDs,
|
||||||
|
Tags: strings.Join(tagNames, ", "),
|
||||||
|
Plan: plan,
|
||||||
|
PolicySummary: "system default",
|
||||||
|
BoxCount: 0,
|
||||||
|
APIKeyCount: 0,
|
||||||
|
CreatedAt: formatTime(user.CreatedAt),
|
||||||
|
LastSeen: "-",
|
||||||
|
Disabled: user.Disabled,
|
||||||
|
IsCurrent: false,
|
||||||
|
IsInvite: isInvite,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return UserPage{
|
||||||
|
Rows: rows,
|
||||||
|
Page: page,
|
||||||
|
PageSize: pageSize,
|
||||||
|
Total: total,
|
||||||
|
HasPrev: page > 1,
|
||||||
|
HasNext: page < totalPages,
|
||||||
|
PrevPage: page - 1,
|
||||||
|
NextPage: page + 1,
|
||||||
|
TotalPages: totalPages,
|
||||||
|
Stats: stats,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func userStatus(disabled bool) string {
|
||||||
|
if disabled {
|
||||||
|
return "disabled"
|
||||||
|
}
|
||||||
|
return "active"
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatTime(t time.Time) string {
|
||||||
|
if t.IsZero() {
|
||||||
|
return "-"
|
||||||
|
}
|
||||||
|
return t.UTC().Format("2006-01-02 15:04")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (store *Store) BulkSetUsersDisabled(ids []string, disabled bool) error {
|
||||||
|
return store.db.Update(func(txn *badger.Txn) error {
|
||||||
|
for _, id := range ids {
|
||||||
|
var user User
|
||||||
|
if err := getJSON(txn, userKey(id), &user); err != nil {
|
||||||
|
if errors.Is(err, ErrNotFound) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
user.Disabled = disabled
|
||||||
|
user.UpdatedAt = time.Now().UTC()
|
||||||
|
if err := putJSON(txn, userKey(id), user); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (store *Store) RevokeUserSessions(userID string) error {
|
||||||
|
tokens, err := store.sessionTokensForUser(userID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return store.db.Update(func(txn *badger.Txn) error {
|
||||||
|
for _, token := range tokens {
|
||||||
|
if err := txn.Delete(sessionKey(token)); err != nil && !errors.Is(err, badger.ErrKeyNotFound) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (store *Store) BulkRevokeUserSessions(ids []string) error {
|
||||||
|
for _, id := range ids {
|
||||||
|
if err := store.RevokeUserSessions(id); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (store *Store) sessionTokensForUser(userID string) ([]string, error) {
|
||||||
|
tokens := []string{}
|
||||||
|
err := store.db.View(func(txn *badger.Txn) error {
|
||||||
|
opts := badger.DefaultIteratorOptions
|
||||||
|
opts.Prefix = []byte("session/")
|
||||||
|
it := txn.NewIterator(opts)
|
||||||
|
defer it.Close()
|
||||||
|
for it.Rewind(); it.Valid(); it.Next() {
|
||||||
|
var session Session
|
||||||
|
if err := it.Item().Value(func(data []byte) error {
|
||||||
|
return json.Unmarshal(data, &session)
|
||||||
|
}); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if session.UserID == userID {
|
||||||
|
tokens = append(tokens, session.Token)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
return tokens, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (store *Store) CountAdminUsers(adminTagID string) (int, error) {
|
||||||
|
users, err := store.ListUsers()
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
count := 0
|
||||||
|
for _, user := range users {
|
||||||
|
if user.Disabled {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, tagID := range user.TagIDs {
|
||||||
|
if tagID == adminTagID {
|
||||||
|
count++
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return count, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (store *Store) CreateUserWithoutPassword(username string, email string, tagIDs []string) (User, error) {
|
||||||
|
hash, err := helpers.RandomHexID(32)
|
||||||
|
if err != nil {
|
||||||
|
return User{}, err
|
||||||
|
}
|
||||||
|
user := User{
|
||||||
|
Username: username,
|
||||||
|
Email: email,
|
||||||
|
PasswordHash: "invite/" + hash,
|
||||||
|
TagIDs: uniqueStrings(tagIDs),
|
||||||
|
Disabled: true,
|
||||||
|
}
|
||||||
|
if err := store.CreateUser(&user); err != nil {
|
||||||
|
return User{}, err
|
||||||
|
}
|
||||||
|
return user, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (store *Store) getUserByIndex(key []byte) (User, bool, error) {
|
func (store *Store) getUserByIndex(key []byte) (User, bool, error) {
|
||||||
var id string
|
var id string
|
||||||
err := store.db.View(func(txn *badger.Txn) error {
|
err := store.db.View(func(txn *badger.Txn) error {
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ func AdminPermissions() TagPermissions {
|
|||||||
ZipDownloadAllowed: true,
|
ZipDownloadAllowed: true,
|
||||||
RenewableAllowed: true,
|
RenewableAllowed: true,
|
||||||
AdminAccess: true,
|
AdminAccess: true,
|
||||||
|
AdminUsersView: true,
|
||||||
AdminUsersManage: true,
|
AdminUsersManage: true,
|
||||||
AdminSettingsManage: true,
|
AdminSettingsManage: true,
|
||||||
AdminBoxesView: true,
|
AdminBoxesView: true,
|
||||||
|
|||||||
@@ -41,19 +41,28 @@ type BoxFile struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type BoxManifest struct {
|
type BoxManifest struct {
|
||||||
Files []BoxFile `json:"files"`
|
Files []BoxFile `json:"files"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
OwnerID string `json:"owner_id,omitempty"`
|
||||||
ExpiresAt time.Time `json:"expires_at"`
|
OwnerUsername string `json:"owner_username,omitempty"`
|
||||||
RetentionKey string `json:"retention_key"`
|
Activity []BoxActivity `json:"activity,omitempty"`
|
||||||
RetentionLabel string `json:"retention_label"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
RetentionSecs int64 `json:"retention_seconds"`
|
ExpiresAt time.Time `json:"expires_at"`
|
||||||
PasswordSalt string `json:"password_salt,omitempty"`
|
RetentionKey string `json:"retention_key"`
|
||||||
PasswordHash string `json:"password_hash,omitempty"`
|
RetentionLabel string `json:"retention_label"`
|
||||||
PasswordHashAlg string `json:"password_hash_alg,omitempty"`
|
RetentionSecs int64 `json:"retention_seconds"`
|
||||||
AuthToken string `json:"auth_token,omitempty"`
|
PasswordSalt string `json:"password_salt,omitempty"`
|
||||||
DisableZip bool `json:"disable_zip,omitempty"`
|
PasswordHash string `json:"password_hash,omitempty"`
|
||||||
OneTimeDownload bool `json:"one_time_download,omitempty"`
|
PasswordHashAlg string `json:"password_hash_alg,omitempty"`
|
||||||
Consumed bool `json:"consumed,omitempty"`
|
AuthToken string `json:"auth_token,omitempty"`
|
||||||
|
DisableZip bool `json:"disable_zip,omitempty"`
|
||||||
|
OneTimeDownload bool `json:"one_time_download,omitempty"`
|
||||||
|
Consumed bool `json:"consumed,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type BoxActivity struct {
|
||||||
|
At time.Time `json:"at"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
Actor string `json:"actor,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type BoxSummary struct {
|
type BoxSummary struct {
|
||||||
|
|||||||
386
lib/server/account_alerts.go
Normal file
386
lib/server/account_alerts.go
Normal file
@@ -0,0 +1,386 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"warpbox/lib/metastore"
|
||||||
|
)
|
||||||
|
|
||||||
|
type AlertPageView struct {
|
||||||
|
PageTitle string
|
||||||
|
WindowTitle string
|
||||||
|
WindowIcon string
|
||||||
|
PageScripts []string
|
||||||
|
AccountNav AccountNavView
|
||||||
|
CSRFToken string
|
||||||
|
Filters AlertFiltersView
|
||||||
|
Stats AlertStatsView
|
||||||
|
Alerts []AlertRowView
|
||||||
|
SelectedAlert *AlertRowView
|
||||||
|
Groups []string
|
||||||
|
CanManageAlerts bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type AlertFiltersView struct {
|
||||||
|
Query string
|
||||||
|
Severity string
|
||||||
|
Status string
|
||||||
|
Group string
|
||||||
|
Sort string
|
||||||
|
}
|
||||||
|
|
||||||
|
type AlertStatsView struct {
|
||||||
|
Open int
|
||||||
|
Acknowledged int
|
||||||
|
Closed int
|
||||||
|
High int
|
||||||
|
Medium int
|
||||||
|
Low int
|
||||||
|
}
|
||||||
|
|
||||||
|
type AlertRowView struct {
|
||||||
|
ID string
|
||||||
|
Title string
|
||||||
|
Description string
|
||||||
|
Severity string
|
||||||
|
Status string
|
||||||
|
Code string
|
||||||
|
Trace string
|
||||||
|
Group string
|
||||||
|
MetadataPretty string
|
||||||
|
CreatedAt string
|
||||||
|
UpdatedAt string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *App) handleAccountAlerts(ctx *gin.Context) {
|
||||||
|
actor, ok := currentAccountUser(ctx)
|
||||||
|
if !ok {
|
||||||
|
ctx.Redirect(http.StatusSeeOther, "/account/login")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
page, err := app.ListAlerts(ctx, actor, accountAlertFiltersFromRequest(ctx))
|
||||||
|
if err != nil {
|
||||||
|
ctx.String(http.StatusForbidden, "Permission denied")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx.HTML(http.StatusOK, "account_alerts.html", page)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *App) handleAccountAlertAcknowledge(ctx *gin.Context) {
|
||||||
|
app.handleAccountAlertAction(ctx, func(actor metastore.User, id string) error {
|
||||||
|
return app.AcknowledgeAlert(ctx, actor, id)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *App) handleAccountAlertClose(ctx *gin.Context) {
|
||||||
|
app.handleAccountAlertAction(ctx, func(actor metastore.User, id string) error {
|
||||||
|
return app.CloseAlert(ctx, actor, id)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *App) handleAccountAlertBulkAcknowledge(ctx *gin.Context) {
|
||||||
|
app.handleAccountAlertBulkAction(ctx, func(actor metastore.User, ids []string) error {
|
||||||
|
return app.BulkAcknowledgeAlerts(ctx, actor, ids)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *App) handleAccountAlertBulkClose(ctx *gin.Context) {
|
||||||
|
app.handleAccountAlertBulkAction(ctx, func(actor metastore.User, ids []string) error {
|
||||||
|
return app.BulkCloseAlerts(ctx, actor, ids)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *App) handleAccountAlertsExport(ctx *gin.Context) {
|
||||||
|
actor, ok := currentAccountUser(ctx)
|
||||||
|
if !ok {
|
||||||
|
ctx.Redirect(http.StatusSeeOther, "/account/login")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
page, err := app.ListAlerts(ctx, actor, accountAlertFiltersFromRequest(ctx))
|
||||||
|
if err != nil {
|
||||||
|
ctx.String(http.StatusForbidden, "Permission denied")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx.Header("Content-Disposition", `attachment; filename="warpbox-alerts.json"`)
|
||||||
|
ctx.JSON(http.StatusOK, gin.H{"alerts": page.Alerts, "filters": page.Filters, "stats": page.Stats})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *App) handleAccountAlertAction(ctx *gin.Context, action func(metastore.User, string) error) {
|
||||||
|
actor, ok := currentAccountUser(ctx)
|
||||||
|
if !ok {
|
||||||
|
ctx.Redirect(http.StatusSeeOther, "/account/login")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := action(actor, ctx.Param("id")); err != nil {
|
||||||
|
ctx.String(http.StatusForbidden, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx.Redirect(http.StatusSeeOther, "/account/alerts")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *App) handleAccountAlertBulkAction(ctx *gin.Context, action func(metastore.User, []string) error) {
|
||||||
|
actor, ok := currentAccountUser(ctx)
|
||||||
|
if !ok {
|
||||||
|
ctx.Redirect(http.StatusSeeOther, "/account/login")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := action(actor, ctx.PostFormArray("alert_ids")); err != nil {
|
||||||
|
ctx.String(http.StatusForbidden, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx.Redirect(http.StatusSeeOther, "/account/alerts")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *App) CreateAlert(ctx *gin.Context, actor metastore.User, input metastore.AlertInput) (metastore.Alert, error) {
|
||||||
|
if err := app.requireAlertManage(ctx); err != nil {
|
||||||
|
return metastore.Alert{}, err
|
||||||
|
}
|
||||||
|
if input.CreatedBy == "" {
|
||||||
|
input.CreatedBy = actor.Username
|
||||||
|
}
|
||||||
|
return app.store.CreateAlert(input)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *App) ListAlerts(ctx *gin.Context, actor metastore.User, filters metastore.AlertFilters) (AlertPageView, error) {
|
||||||
|
if err := app.requireAlertView(ctx); err != nil {
|
||||||
|
return AlertPageView{}, err
|
||||||
|
}
|
||||||
|
alerts, err := app.store.ListAlerts(filters)
|
||||||
|
if err != nil {
|
||||||
|
return AlertPageView{}, err
|
||||||
|
}
|
||||||
|
rows := make([]AlertRowView, 0, len(alerts))
|
||||||
|
stats := AlertStatsView{}
|
||||||
|
groupSet := map[string]bool{}
|
||||||
|
for _, alert := range alerts {
|
||||||
|
row := alertRowView(alert)
|
||||||
|
rows = append(rows, row)
|
||||||
|
groupSet[row.Group] = true
|
||||||
|
switch alert.Status {
|
||||||
|
case metastore.AlertStatusAcknowledged:
|
||||||
|
stats.Acknowledged++
|
||||||
|
case metastore.AlertStatusClosed:
|
||||||
|
stats.Closed++
|
||||||
|
default:
|
||||||
|
stats.Open++
|
||||||
|
}
|
||||||
|
switch alert.Severity {
|
||||||
|
case metastore.AlertSeverityHigh:
|
||||||
|
stats.High++
|
||||||
|
case metastore.AlertSeverityMedium:
|
||||||
|
stats.Medium++
|
||||||
|
default:
|
||||||
|
stats.Low++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
groups := make([]string, 0, len(groupSet))
|
||||||
|
for group := range groupSet {
|
||||||
|
groups = append(groups, group)
|
||||||
|
}
|
||||||
|
if len(groups) == 0 {
|
||||||
|
groups = []string{"system"}
|
||||||
|
}
|
||||||
|
|
||||||
|
nav := app.accountNavView(ctx, "alerts")
|
||||||
|
nav.AlertCount, nav.AlertSeverity = app.openAlertSummary()
|
||||||
|
|
||||||
|
var selected *AlertRowView
|
||||||
|
if len(rows) > 0 {
|
||||||
|
selected = &rows[0]
|
||||||
|
}
|
||||||
|
return AlertPageView{
|
||||||
|
PageTitle: "WarpBox Alerts",
|
||||||
|
WindowTitle: "WarpBox Alerts",
|
||||||
|
WindowIcon: "!",
|
||||||
|
PageScripts: []string{"/static/js/account-alerts.js"},
|
||||||
|
AccountNav: nav,
|
||||||
|
CSRFToken: app.currentCSRFToken(ctx),
|
||||||
|
Filters: AlertFiltersView{Query: filters.Query, Severity: filters.Severity, Status: filters.Status, Group: filters.Group, Sort: filters.Sort},
|
||||||
|
Stats: stats,
|
||||||
|
Alerts: rows,
|
||||||
|
SelectedAlert: selected,
|
||||||
|
Groups: groups,
|
||||||
|
CanManageAlerts: currentAccountPermissions(ctx).AdminAccess,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *App) AcknowledgeAlert(ctx *gin.Context, actor metastore.User, id string) error {
|
||||||
|
if err := app.requireAlertManage(ctx); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return app.store.AcknowledgeAlert(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *App) CloseAlert(ctx *gin.Context, actor metastore.User, id string) error {
|
||||||
|
if err := app.requireAlertManage(ctx); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return app.store.CloseAlert(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *App) BulkAcknowledgeAlerts(ctx *gin.Context, actor metastore.User, ids []string) error {
|
||||||
|
if err := app.requireAlertManage(ctx); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, id := range uniqueNonEmpty(ids) {
|
||||||
|
if err := app.store.AcknowledgeAlert(id); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *App) BulkCloseAlerts(ctx *gin.Context, actor metastore.User, ids []string) error {
|
||||||
|
if err := app.requireAlertManage(ctx); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, id := range uniqueNonEmpty(ids) {
|
||||||
|
if err := app.store.CloseAlert(id); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *App) EmitSystemAlert(code string, severity string, title string, description string, trace string, metadata any) error {
|
||||||
|
raw, err := json.Marshal(metadata)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("alert metadata marshal failed: %v", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err = app.store.CreateAlert(metastore.AlertInput{
|
||||||
|
Title: title,
|
||||||
|
Description: description,
|
||||||
|
Severity: severity,
|
||||||
|
Code: code,
|
||||||
|
Trace: trace,
|
||||||
|
Metadata: raw,
|
||||||
|
CreatedBy: "system",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("alert persistence failed: %v", err)
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *App) requireAlertView(ctx *gin.Context) error {
|
||||||
|
if !currentAccountPermissions(ctx).AdminAccess {
|
||||||
|
return fmt.Errorf("permission denied")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *App) requireAlertManage(ctx *gin.Context) error {
|
||||||
|
if !currentAccountPermissions(ctx).AdminAccess {
|
||||||
|
return fmt.Errorf("permission denied")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func accountAlertFiltersFromRequest(ctx *gin.Context) metastore.AlertFilters {
|
||||||
|
return metastore.AlertFilters{
|
||||||
|
Query: strings.TrimSpace(ctx.Query("q")),
|
||||||
|
Severity: emptyAsAll(ctx.Query("severity")),
|
||||||
|
Status: emptyAsAll(ctx.Query("status")),
|
||||||
|
Group: emptyAsAll(ctx.Query("group")),
|
||||||
|
Sort: emptyAsNewest(ctx.Query("sort")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func emptyAsAll(value string) string {
|
||||||
|
value = strings.TrimSpace(value)
|
||||||
|
if value == "" {
|
||||||
|
return "all"
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
func emptyAsNewest(value string) string {
|
||||||
|
value = strings.TrimSpace(value)
|
||||||
|
if value == "" {
|
||||||
|
return "newest"
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
func alertRowView(alert metastore.Alert) AlertRowView {
|
||||||
|
return AlertRowView{
|
||||||
|
ID: alert.ID,
|
||||||
|
Title: alert.Title,
|
||||||
|
Description: alert.Description,
|
||||||
|
Severity: alert.Severity,
|
||||||
|
Status: alert.Status,
|
||||||
|
Code: alert.Code,
|
||||||
|
Trace: alert.Trace,
|
||||||
|
Group: alertGroupFromTrace(alert.Trace),
|
||||||
|
MetadataPretty: prettyAlertMetadata(alert.Metadata),
|
||||||
|
CreatedAt: formatAdminTime(alert.CreatedAt),
|
||||||
|
UpdatedAt: formatAdminTime(alert.UpdatedAt),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func prettyAlertMetadata(raw json.RawMessage) string {
|
||||||
|
if len(raw) == 0 {
|
||||||
|
return "{}"
|
||||||
|
}
|
||||||
|
var value any
|
||||||
|
if err := json.Unmarshal(raw, &value); err != nil {
|
||||||
|
return string(raw)
|
||||||
|
}
|
||||||
|
pretty, err := json.MarshalIndent(value, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return string(raw)
|
||||||
|
}
|
||||||
|
return string(pretty)
|
||||||
|
}
|
||||||
|
|
||||||
|
func alertGroupFromTrace(trace string) string {
|
||||||
|
trace = strings.TrimSpace(trace)
|
||||||
|
if trace == "" {
|
||||||
|
return "system"
|
||||||
|
}
|
||||||
|
before, _, found := strings.Cut(trace, ".")
|
||||||
|
if !found || before == "" {
|
||||||
|
return "system"
|
||||||
|
}
|
||||||
|
return strings.ToLower(before)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *App) openAlertSummary() (int, string) {
|
||||||
|
alerts, err := app.store.ListAlerts(metastore.AlertFilters{Status: metastore.AlertStatusOpen})
|
||||||
|
if err != nil {
|
||||||
|
return 0, "ok"
|
||||||
|
}
|
||||||
|
severity := "ok"
|
||||||
|
for _, alert := range alerts {
|
||||||
|
if alert.Severity == metastore.AlertSeverityHigh {
|
||||||
|
return len(alerts), "danger"
|
||||||
|
}
|
||||||
|
if alert.Severity == metastore.AlertSeverityMedium {
|
||||||
|
severity = "warning"
|
||||||
|
} else if severity == "ok" {
|
||||||
|
severity = "info"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return len(alerts), severity
|
||||||
|
}
|
||||||
|
|
||||||
|
func uniqueNonEmpty(values []string) []string {
|
||||||
|
seen := map[string]bool{}
|
||||||
|
out := []string{}
|
||||||
|
for _, value := range values {
|
||||||
|
value = strings.TrimSpace(value)
|
||||||
|
if value == "" || seen[value] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[value] = true
|
||||||
|
out = append(out, value)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
155
lib/server/account_alerts_test.go
Normal file
155
lib/server/account_alerts_test.go
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"warpbox/lib/metastore"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestAccountAlertsPageListsAndFiltersAlerts(t *testing.T) {
|
||||||
|
app, user := setupAccountTestApp(t)
|
||||||
|
router := setupAccountTestRouter(t, app)
|
||||||
|
session := createAccountTestSession(t, app, user)
|
||||||
|
createTestAlert(t, app, "601", metastore.AlertSeverityMedium, "thumbnail.generate.failed")
|
||||||
|
createTestAlert(t, app, "701", metastore.AlertSeverityHigh, "storage.connector.health_failed")
|
||||||
|
|
||||||
|
request := httptest.NewRequest(http.MethodGet, "/account/alerts?severity=high", nil)
|
||||||
|
request.AddCookie(&http.Cookie{Name: accountSessionCookie, Value: session.Token})
|
||||||
|
response := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(response, request)
|
||||||
|
if response.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected alerts page, got %d body=%s", response.Code, response.Body.String())
|
||||||
|
}
|
||||||
|
body := response.Body.String()
|
||||||
|
if !strings.Contains(body, "storage.connector.health_failed") {
|
||||||
|
t.Fatal("expected high severity alert")
|
||||||
|
}
|
||||||
|
if strings.Contains(body, "thumbnail.generate.failed") {
|
||||||
|
t.Fatal("did not expect medium severity alert in high filter")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAccountAlertAcknowledgeAndClose(t *testing.T) {
|
||||||
|
app, user := setupAccountTestApp(t)
|
||||||
|
router := setupAccountTestRouter(t, app)
|
||||||
|
session := createAccountTestSession(t, app, user)
|
||||||
|
alert := createTestAlert(t, app, "601", metastore.AlertSeverityMedium, "thumbnail.generate.failed")
|
||||||
|
|
||||||
|
response := postAlertAction(router, session, "/account/alerts/"+alert.ID+"/acknowledge", nil)
|
||||||
|
if response.Code != http.StatusSeeOther {
|
||||||
|
t.Fatalf("expected acknowledge redirect, got %d", response.Code)
|
||||||
|
}
|
||||||
|
updated, ok, err := app.store.GetAlert(alert.ID)
|
||||||
|
if err != nil || !ok {
|
||||||
|
t.Fatalf("GetAlert returned ok=%v err=%v", ok, err)
|
||||||
|
}
|
||||||
|
if updated.Status != metastore.AlertStatusAcknowledged {
|
||||||
|
t.Fatalf("expected acknowledged alert, got %s", updated.Status)
|
||||||
|
}
|
||||||
|
|
||||||
|
response = postAlertAction(router, session, "/account/alerts/"+alert.ID+"/close", nil)
|
||||||
|
if response.Code != http.StatusSeeOther {
|
||||||
|
t.Fatalf("expected close redirect, got %d", response.Code)
|
||||||
|
}
|
||||||
|
updated, ok, err = app.store.GetAlert(alert.ID)
|
||||||
|
if err != nil || !ok {
|
||||||
|
t.Fatalf("GetAlert returned ok=%v err=%v", ok, err)
|
||||||
|
}
|
||||||
|
if updated.Status != metastore.AlertStatusClosed {
|
||||||
|
t.Fatalf("expected closed alert, got %s", updated.Status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAccountAlertManagePermissionDenied(t *testing.T) {
|
||||||
|
app, _ := setupAccountTestApp(t)
|
||||||
|
regular, err := app.store.CreateUserWithPassword("regular-alerts", "regular-alerts@example.test", "secret", nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateUserWithPassword returned error: %v", err)
|
||||||
|
}
|
||||||
|
router := setupAccountTestRouter(t, app)
|
||||||
|
session := createAccountTestSession(t, app, regular)
|
||||||
|
alert := createTestAlert(t, app, "601", metastore.AlertSeverityMedium, "thumbnail.generate.failed")
|
||||||
|
|
||||||
|
response := postAlertAction(router, session, "/account/alerts/"+alert.ID+"/acknowledge", nil)
|
||||||
|
if response.Code != http.StatusForbidden {
|
||||||
|
t.Fatalf("expected permission denied, got %d", response.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDashboardUsesRealAlertCount(t *testing.T) {
|
||||||
|
app, user := setupAccountTestApp(t)
|
||||||
|
router := setupAccountTestRouter(t, app)
|
||||||
|
session := createAccountTestSession(t, app, user)
|
||||||
|
createTestAlert(t, app, "601", metastore.AlertSeverityMedium, "thumbnail.generate.failed")
|
||||||
|
|
||||||
|
request := httptest.NewRequest(http.MethodGet, "/account", nil)
|
||||||
|
request.AddCookie(&http.Cookie{Name: accountSessionCookie, Value: session.Token})
|
||||||
|
response := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(response, request)
|
||||||
|
if response.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected dashboard, got %d", response.Code)
|
||||||
|
}
|
||||||
|
if !strings.Contains(response.Body.String(), "1 alerts") {
|
||||||
|
t.Fatal("expected dashboard alert chip/count")
|
||||||
|
}
|
||||||
|
if !strings.Contains(response.Body.String(), "Thumbnail alert") {
|
||||||
|
t.Fatal("expected dashboard alert preview")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAccountAlertsExportJSON(t *testing.T) {
|
||||||
|
app, user := setupAccountTestApp(t)
|
||||||
|
router := setupAccountTestRouter(t, app)
|
||||||
|
session := createAccountTestSession(t, app, user)
|
||||||
|
createTestAlert(t, app, "601", metastore.AlertSeverityMedium, "thumbnail.generate.failed")
|
||||||
|
|
||||||
|
request := httptest.NewRequest(http.MethodGet, "/account/alerts/export.json", nil)
|
||||||
|
request.AddCookie(&http.Cookie{Name: accountSessionCookie, Value: session.Token})
|
||||||
|
response := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(response, request)
|
||||||
|
if response.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected export success, got %d", response.Code)
|
||||||
|
}
|
||||||
|
var payload map[string]any
|
||||||
|
if err := json.Unmarshal(response.Body.Bytes(), &payload); err != nil {
|
||||||
|
t.Fatalf("Unmarshal returned error: %v", err)
|
||||||
|
}
|
||||||
|
if _, ok := payload["alerts"]; !ok {
|
||||||
|
t.Fatal("expected alerts export shape")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func createTestAlert(t *testing.T, app *App, code string, severity string, trace string) metastore.Alert {
|
||||||
|
t.Helper()
|
||||||
|
alert, err := app.store.CreateAlert(metastore.AlertInput{
|
||||||
|
Title: "Thumbnail alert",
|
||||||
|
Description: "Alert test description.",
|
||||||
|
Severity: severity,
|
||||||
|
Code: code,
|
||||||
|
Trace: trace,
|
||||||
|
Metadata: json.RawMessage(`{"box":"box-1","file":"photo.jpg"}`),
|
||||||
|
CreatedBy: "system",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateAlert returned error: %v", err)
|
||||||
|
}
|
||||||
|
return alert
|
||||||
|
}
|
||||||
|
|
||||||
|
func postAlertAction(router http.Handler, session metastore.Session, path string, values url.Values) *httptest.ResponseRecorder {
|
||||||
|
if values == nil {
|
||||||
|
values = url.Values{}
|
||||||
|
}
|
||||||
|
values.Set("csrf_token", session.CSRFToken)
|
||||||
|
request := httptest.NewRequest(http.MethodPost, path, strings.NewReader(values.Encode()))
|
||||||
|
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
request.AddCookie(&http.Cookie{Name: accountSessionCookie, Value: session.Token})
|
||||||
|
response := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(response, request)
|
||||||
|
return response
|
||||||
|
}
|
||||||
194
lib/server/account_auth.go
Normal file
194
lib/server/account_auth.go
Normal file
@@ -0,0 +1,194 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"warpbox/lib/metastore"
|
||||||
|
)
|
||||||
|
|
||||||
|
const accountSessionCookie = "warpbox_account_session"
|
||||||
|
|
||||||
|
func (app *App) registerAccountRoutes(router *gin.Engine) {
|
||||||
|
account := router.Group("/account")
|
||||||
|
account.Use(noStoreAdminHeaders)
|
||||||
|
account.GET("/login", app.handleAccountLogin)
|
||||||
|
account.POST("/login", app.handleAccountLoginPost)
|
||||||
|
|
||||||
|
protected := account.Group("")
|
||||||
|
protected.Use(app.requireAccountSession)
|
||||||
|
protected.GET("", app.handleAccountDashboard)
|
||||||
|
protected.GET("/", app.handleAccountDashboard)
|
||||||
|
protected.POST("/logout", app.handleAccountLogout)
|
||||||
|
protected.GET("/settings", app.handleAccountSettings)
|
||||||
|
protected.POST("/settings", app.handleAccountSettingsPost)
|
||||||
|
protected.POST("/settings/reset", app.handleAccountSettingsReset)
|
||||||
|
protected.GET("/settings/export.json", app.handleAccountSettingsExport)
|
||||||
|
protected.POST("/settings/import.json", app.handleAccountSettingsImport)
|
||||||
|
protected.GET("/alerts", app.handleAccountAlerts)
|
||||||
|
protected.GET("/alerts/export.json", app.handleAccountAlertsExport)
|
||||||
|
protected.POST("/alerts/bulk/acknowledge", app.handleAccountAlertBulkAcknowledge)
|
||||||
|
protected.POST("/alerts/bulk/close", app.handleAccountAlertBulkClose)
|
||||||
|
protected.POST("/alerts/:id/acknowledge", app.handleAccountAlertAcknowledge)
|
||||||
|
protected.POST("/alerts/:id/close", app.handleAccountAlertClose)
|
||||||
|
protected.GET("/boxes", app.handleAccountBoxes)
|
||||||
|
protected.GET("/boxes/export.csv", app.handleAccountBoxesExport)
|
||||||
|
protected.POST("/boxes/bulk/expire", app.handleAccountBoxesBulkExpire)
|
||||||
|
protected.POST("/boxes/bulk/delete", app.handleAccountBoxesBulkDelete)
|
||||||
|
protected.POST("/boxes/bulk/bump-expiry", app.handleAccountBoxesBulkBumpExpiry)
|
||||||
|
protected.POST("/boxes/delete-largest", app.handleAccountBoxesDeleteLargest)
|
||||||
|
protected.GET("/boxes/:id", app.handleAccountBoxManager)
|
||||||
|
protected.POST("/boxes/:id", app.handleAccountBoxUpdate)
|
||||||
|
protected.POST("/boxes/:id/extend", app.handleAccountBoxExtend)
|
||||||
|
protected.POST("/boxes/:id/expire", app.handleAccountBoxExpire)
|
||||||
|
protected.POST("/boxes/:id/delete", app.handleAccountBoxDelete)
|
||||||
|
protected.POST("/boxes/:id/password", app.handleAccountBoxPassword)
|
||||||
|
protected.POST("/boxes/:id/password/remove", app.handleAccountBoxPasswordRemove)
|
||||||
|
protected.POST("/boxes/:id/files/delete", app.handleAccountBoxFilesDelete)
|
||||||
|
protected.GET("/users", app.handleAccountUsers)
|
||||||
|
protected.POST("/users", app.handleAccountUsersPost)
|
||||||
|
protected.POST("/users/bulk/disable", app.handleAccountUsersBulkDisable)
|
||||||
|
protected.POST("/users/bulk/enable", app.handleAccountUsersBulkEnable)
|
||||||
|
protected.POST("/users/bulk/revoke-sessions", app.handleAccountUsersBulkRevokeSessions)
|
||||||
|
protected.POST("/users/:id/invite/resend", app.handleAccountUsersResendInvite)
|
||||||
|
protected.GET("/users/:id", app.handleAccountUserEdit)
|
||||||
|
protected.POST("/users/:id", app.handleAccountUserEditPost)
|
||||||
|
protected.POST("/users/:id/disable", app.handleAccountUserDisable)
|
||||||
|
protected.POST("/users/:id/enable", app.handleAccountUserEnable)
|
||||||
|
protected.POST("/users/:id/password/reset", app.handleAccountUserPasswordReset)
|
||||||
|
protected.POST("/users/:id/sessions/revoke", app.handleAccountUserRevokeSessions)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *App) handleAccountLogin(ctx *gin.Context) {
|
||||||
|
if app.isAccountSessionValid(ctx) {
|
||||||
|
ctx.Redirect(http.StatusSeeOther, "/account")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
app.renderAccountLogin(ctx, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *App) handleAccountLoginPost(ctx *gin.Context) {
|
||||||
|
if !app.adminLoginEnabled {
|
||||||
|
app.renderAccountLogin(ctx, "Account login is disabled.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
username := strings.TrimSpace(ctx.PostForm("username"))
|
||||||
|
password := ctx.PostForm("password")
|
||||||
|
user, ok, err := app.store.GetUserByUsername(username)
|
||||||
|
if err != nil {
|
||||||
|
ctx.String(http.StatusInternalServerError, "Could not load user")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !ok || user.Disabled || !metastore.VerifyPassword(user.PasswordHash, password) {
|
||||||
|
app.renderAccountLogin(ctx, "The username or password was not accepted.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := app.permissionsForUser(user); err != nil {
|
||||||
|
ctx.String(http.StatusInternalServerError, "Could not load permissions")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
session, err := app.store.CreateSession(user.ID, time.Duration(app.config.SessionTTLSeconds)*time.Second)
|
||||||
|
if err != nil {
|
||||||
|
ctx.String(http.StatusInternalServerError, "Could not create session")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx.SetSameSite(http.SameSiteLaxMode)
|
||||||
|
ctx.SetCookie(accountSessionCookie, session.Token, int(app.config.SessionTTLSeconds), "/account", "", app.config.AdminCookieSecure, true)
|
||||||
|
ctx.Redirect(http.StatusSeeOther, "/account")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *App) handleAccountLogout(ctx *gin.Context) {
|
||||||
|
if token, err := ctx.Cookie(accountSessionCookie); err == nil {
|
||||||
|
_ = app.store.DeleteSession(token)
|
||||||
|
}
|
||||||
|
ctx.SetSameSite(http.SameSiteLaxMode)
|
||||||
|
ctx.SetCookie(accountSessionCookie, "", -1, "/account", "", app.config.AdminCookieSecure, true)
|
||||||
|
ctx.Redirect(http.StatusSeeOther, "/account/login")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *App) requireAccountSession(ctx *gin.Context) {
|
||||||
|
token, err := ctx.Cookie(accountSessionCookie)
|
||||||
|
if err != nil {
|
||||||
|
ctx.Redirect(http.StatusSeeOther, "/account/login")
|
||||||
|
ctx.Abort()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
session, ok, err := app.store.GetSession(token)
|
||||||
|
if err != nil || !ok {
|
||||||
|
ctx.Redirect(http.StatusSeeOther, "/account/login")
|
||||||
|
ctx.Abort()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !validAdminCSRF(ctx, session) {
|
||||||
|
ctx.String(http.StatusForbidden, "Permission denied")
|
||||||
|
ctx.Abort()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
user, ok, err := app.store.GetUser(session.UserID)
|
||||||
|
if err != nil || !ok || user.Disabled {
|
||||||
|
ctx.Redirect(http.StatusSeeOther, "/account/login")
|
||||||
|
ctx.Abort()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
perms, err := app.permissionsForUser(user)
|
||||||
|
if err != nil {
|
||||||
|
ctx.Redirect(http.StatusSeeOther, "/account/login")
|
||||||
|
ctx.Abort()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.Set("accountUser", user)
|
||||||
|
ctx.Set("adminUser", user)
|
||||||
|
ctx.Set("accountPerms", perms)
|
||||||
|
ctx.Set("adminPerms", perms)
|
||||||
|
ctx.Set("accountSession", session)
|
||||||
|
ctx.Set("accountCSRFToken", session.CSRFToken)
|
||||||
|
ctx.Set("adminCSRFToken", session.CSRFToken)
|
||||||
|
ctx.Next()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *App) isAccountSessionValid(ctx *gin.Context) bool {
|
||||||
|
token, err := ctx.Cookie(accountSessionCookie)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
session, ok, err := app.store.GetSession(token)
|
||||||
|
if err != nil || !ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
user, ok, err := app.store.GetUser(session.UserID)
|
||||||
|
if err != nil || !ok || user.Disabled {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
_, err = app.permissionsForUser(user)
|
||||||
|
return err == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *App) renderAccountLogin(ctx *gin.Context, errorMessage string) {
|
||||||
|
ctx.HTML(http.StatusOK, "account_login.html", gin.H{
|
||||||
|
"PageTitle": "WarpBox Account Login",
|
||||||
|
"AdminLoginEnabled": app.adminLoginEnabled,
|
||||||
|
"AccountLoginEnabled": app.adminLoginEnabled,
|
||||||
|
"Error": errorMessage,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func currentAccountUser(ctx *gin.Context) (metastore.User, bool) {
|
||||||
|
if current, ok := ctx.Get("accountUser"); ok {
|
||||||
|
if user, ok := current.(metastore.User); ok {
|
||||||
|
return user, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if current, ok := ctx.Get("adminUser"); ok {
|
||||||
|
if user, ok := current.(metastore.User); ok {
|
||||||
|
return user, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return metastore.User{}, false
|
||||||
|
}
|
||||||
515
lib/server/account_box_manager.go
Normal file
515
lib/server/account_box_manager.go
Normal file
@@ -0,0 +1,515 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
|
||||||
|
"warpbox/lib/boxstore"
|
||||||
|
"warpbox/lib/helpers"
|
||||||
|
"warpbox/lib/metastore"
|
||||||
|
"warpbox/lib/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
type BoxManagerView struct {
|
||||||
|
PageTitle string
|
||||||
|
WindowTitle string
|
||||||
|
WindowIcon string
|
||||||
|
AccountNav AccountNavView
|
||||||
|
CSRFToken string
|
||||||
|
Box BoxManagerSummary
|
||||||
|
Files []BoxManagerFileRow
|
||||||
|
Policy BoxActionPolicy
|
||||||
|
PolicyJSON string
|
||||||
|
Activity []BoxManagerActivityRow
|
||||||
|
Error string
|
||||||
|
}
|
||||||
|
|
||||||
|
type BoxManagerSummary struct {
|
||||||
|
ID string
|
||||||
|
Owner string
|
||||||
|
Status string
|
||||||
|
Storage string
|
||||||
|
CreatedAt string
|
||||||
|
ExpiresAt string
|
||||||
|
Flags string
|
||||||
|
OpenURL string
|
||||||
|
DisableZip bool
|
||||||
|
OneTimeDownload bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type BoxManagerFileRow struct {
|
||||||
|
ID string
|
||||||
|
Name string
|
||||||
|
Size string
|
||||||
|
Status string
|
||||||
|
Download string
|
||||||
|
}
|
||||||
|
|
||||||
|
type BoxManagerActivityRow struct {
|
||||||
|
At string
|
||||||
|
Message string
|
||||||
|
Actor string
|
||||||
|
}
|
||||||
|
|
||||||
|
type BoxActionPolicy struct {
|
||||||
|
CanViewManager bool `json:"can_view_manager"`
|
||||||
|
CanEditMetadata bool `json:"can_edit_metadata"`
|
||||||
|
CanEditSharingRules bool `json:"can_edit_sharing_rules"`
|
||||||
|
CanEditPassword bool `json:"can_edit_password"`
|
||||||
|
CanDeleteBox bool `json:"can_delete_box"`
|
||||||
|
CanDeleteFiles bool `json:"can_delete_files"`
|
||||||
|
CanExtendExpiry bool `json:"can_extend_expiry"`
|
||||||
|
MaxExtensionSeconds int64 `json:"max_extension_seconds"`
|
||||||
|
MaxRefreshCount int `json:"max_refresh_count"`
|
||||||
|
MaxTotalLifetimeSecs int64 `json:"max_total_lifetime_seconds"`
|
||||||
|
Reasons map[string]string `json:"reasons,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type BoxRulesInput struct {
|
||||||
|
DisableZip bool
|
||||||
|
OneTimeDownload bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type BoxPasswordInput struct {
|
||||||
|
Password string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *App) handleAccountBoxManager(ctx *gin.Context) {
|
||||||
|
actor, ok := currentAccountUser(ctx)
|
||||||
|
if !ok {
|
||||||
|
ctx.Redirect(http.StatusSeeOther, "/account/login")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
view, err := app.GetBoxManager(ctx, actor, ctx.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
ctx.String(http.StatusForbidden, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx.HTML(http.StatusOK, "account_box_manager.html", view)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *App) handleAccountBoxUpdate(ctx *gin.Context) {
|
||||||
|
actor, ok := currentAccountUser(ctx)
|
||||||
|
if !ok {
|
||||||
|
ctx.Redirect(http.StatusSeeOther, "/account/login")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
input := BoxRulesInput{
|
||||||
|
DisableZip: ctx.PostForm("disable_zip") == "true",
|
||||||
|
OneTimeDownload: ctx.PostForm("one_time_download") == "true",
|
||||||
|
}
|
||||||
|
if err := app.UpdateBoxRules(ctx, actor, ctx.Param("id"), input); err != nil {
|
||||||
|
app.renderBoxManagerError(ctx, actor, ctx.Param("id"), err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx.Redirect(http.StatusSeeOther, "/account/boxes/"+ctx.Param("id"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *App) handleAccountBoxExtend(ctx *gin.Context) {
|
||||||
|
actor, ok := currentAccountUser(ctx)
|
||||||
|
if !ok {
|
||||||
|
ctx.Redirect(http.StatusSeeOther, "/account/login")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
seconds := parsePositiveInt64Default(ctx.PostForm("extend_seconds"), app.config.BoxOwnerMaxRefreshAmountSeconds)
|
||||||
|
if err := app.ExtendBoxExpiry(ctx, actor, ctx.Param("id"), seconds); err != nil {
|
||||||
|
app.renderBoxManagerError(ctx, actor, ctx.Param("id"), err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx.Redirect(http.StatusSeeOther, "/account/boxes/"+ctx.Param("id"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *App) handleAccountBoxExpire(ctx *gin.Context) {
|
||||||
|
actor, ok := currentAccountUser(ctx)
|
||||||
|
if !ok {
|
||||||
|
ctx.Redirect(http.StatusSeeOther, "/account/login")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := app.ExpireBoxNow(ctx, actor, ctx.Param("id")); err != nil {
|
||||||
|
app.renderBoxManagerError(ctx, actor, ctx.Param("id"), err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx.Redirect(http.StatusSeeOther, "/account/boxes/"+ctx.Param("id"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *App) handleAccountBoxDelete(ctx *gin.Context) {
|
||||||
|
actor, ok := currentAccountUser(ctx)
|
||||||
|
if !ok {
|
||||||
|
ctx.Redirect(http.StatusSeeOther, "/account/login")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := app.DeleteBox(ctx, actor, ctx.Param("id")); err != nil {
|
||||||
|
app.renderBoxManagerError(ctx, actor, ctx.Param("id"), err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx.Redirect(http.StatusSeeOther, "/account/boxes")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *App) handleAccountBoxPassword(ctx *gin.Context) {
|
||||||
|
actor, ok := currentAccountUser(ctx)
|
||||||
|
if !ok {
|
||||||
|
ctx.Redirect(http.StatusSeeOther, "/account/login")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := app.SetBoxPassword(ctx, actor, ctx.Param("id"), BoxPasswordInput{Password: ctx.PostForm("password")}); err != nil {
|
||||||
|
app.renderBoxManagerError(ctx, actor, ctx.Param("id"), err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx.Redirect(http.StatusSeeOther, "/account/boxes/"+ctx.Param("id"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *App) handleAccountBoxPasswordRemove(ctx *gin.Context) {
|
||||||
|
actor, ok := currentAccountUser(ctx)
|
||||||
|
if !ok {
|
||||||
|
ctx.Redirect(http.StatusSeeOther, "/account/login")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := app.RemoveBoxPassword(ctx, actor, ctx.Param("id")); err != nil {
|
||||||
|
app.renderBoxManagerError(ctx, actor, ctx.Param("id"), err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx.Redirect(http.StatusSeeOther, "/account/boxes/"+ctx.Param("id"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *App) handleAccountBoxFilesDelete(ctx *gin.Context) {
|
||||||
|
actor, ok := currentAccountUser(ctx)
|
||||||
|
if !ok {
|
||||||
|
ctx.Redirect(http.StatusSeeOther, "/account/login")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := app.DeleteBoxFiles(ctx, actor, ctx.Param("id"), ctx.PostFormArray("file_ids")); err != nil {
|
||||||
|
app.renderBoxManagerError(ctx, actor, ctx.Param("id"), err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx.Redirect(http.StatusSeeOther, "/account/boxes/"+ctx.Param("id"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *App) GetBoxManager(ctx *gin.Context, actor metastore.User, boxID string) (BoxManagerView, error) {
|
||||||
|
record, manifest, err := app.loadBoxForManager(boxID)
|
||||||
|
if err != nil {
|
||||||
|
return BoxManagerView{}, err
|
||||||
|
}
|
||||||
|
policy := app.resolveBoxPolicy(ctx, actor, record, manifest)
|
||||||
|
if !policy.CanViewManager {
|
||||||
|
return BoxManagerView{}, fmt.Errorf(policyReason(policy, "view", "permission denied"))
|
||||||
|
}
|
||||||
|
files := make([]BoxManagerFileRow, 0, len(manifest.Files))
|
||||||
|
for _, file := range boxstore.DecorateFiles(boxID, manifest.Files) {
|
||||||
|
files = append(files, BoxManagerFileRow{ID: file.ID, Name: file.Name, Size: file.SizeLabel, Status: file.StatusLabel, Download: file.DownloadPath})
|
||||||
|
}
|
||||||
|
policyJSON, _ := json.MarshalIndent(policy, "", " ")
|
||||||
|
nav := app.accountNavView(ctx, "boxes")
|
||||||
|
nav.AlertCount, nav.AlertSeverity = app.openAlertSummary()
|
||||||
|
return BoxManagerView{
|
||||||
|
PageTitle: "WarpBox Box Manager",
|
||||||
|
WindowTitle: "WarpBox Box Manager",
|
||||||
|
WindowIcon: "B",
|
||||||
|
AccountNav: nav,
|
||||||
|
CSRFToken: app.currentCSRFToken(ctx),
|
||||||
|
Box: BoxManagerSummary{
|
||||||
|
ID: record.ID,
|
||||||
|
Owner: boxOwnerLabel(record),
|
||||||
|
Status: boxStatus(record),
|
||||||
|
Storage: helpers.FormatBytes(record.TotalSize),
|
||||||
|
CreatedAt: formatAdminTime(record.CreatedAt),
|
||||||
|
ExpiresAt: formatAdminTime(record.ExpiresAt),
|
||||||
|
Flags: boxFlags(record),
|
||||||
|
OpenURL: "/box/" + record.ID,
|
||||||
|
DisableZip: record.DisableZip,
|
||||||
|
OneTimeDownload: record.OneTimeDownload,
|
||||||
|
},
|
||||||
|
Files: files,
|
||||||
|
Policy: policy,
|
||||||
|
PolicyJSON: string(policyJSON),
|
||||||
|
Activity: boxActivityRows(manifest.Activity),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *App) UpdateBoxRules(ctx *gin.Context, actor metastore.User, boxID string, input BoxRulesInput) error {
|
||||||
|
record, manifest, policy, err := app.boxMutationContext(ctx, actor, boxID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !policy.CanEditSharingRules {
|
||||||
|
return fmt.Errorf(policyReason(policy, "sharing", "sharing edits disabled"))
|
||||||
|
}
|
||||||
|
manifest.DisableZip = input.DisableZip
|
||||||
|
manifest.OneTimeDownload = input.OneTimeDownload
|
||||||
|
appendBoxActivity(&manifest, actor.Username, "sharing rules updated")
|
||||||
|
return app.saveManagedBox(record, manifest)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *App) ExtendBoxExpiry(ctx *gin.Context, actor metastore.User, boxID string, amount int64) error {
|
||||||
|
record, manifest, policy, err := app.boxMutationContext(ctx, actor, boxID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !policy.CanExtendExpiry {
|
||||||
|
return fmt.Errorf(policyReason(policy, "extend", "expiry refresh disabled"))
|
||||||
|
}
|
||||||
|
if amount <= 0 {
|
||||||
|
return fmt.Errorf("extension amount must be positive")
|
||||||
|
}
|
||||||
|
if policy.MaxExtensionSeconds > 0 && amount > policy.MaxExtensionSeconds {
|
||||||
|
return fmt.Errorf("extension exceeds maximum single extension")
|
||||||
|
}
|
||||||
|
if policy.MaxRefreshCount > 0 && record.RefreshCount >= policy.MaxRefreshCount {
|
||||||
|
return fmt.Errorf("refresh count limit reached")
|
||||||
|
}
|
||||||
|
base := manifest.ExpiresAt
|
||||||
|
if base.IsZero() || time.Now().UTC().After(base) {
|
||||||
|
base = time.Now().UTC()
|
||||||
|
}
|
||||||
|
next := base.Add(time.Duration(amount) * time.Second)
|
||||||
|
if policy.MaxTotalLifetimeSecs > 0 && next.After(manifest.CreatedAt.Add(time.Duration(policy.MaxTotalLifetimeSecs)*time.Second)) {
|
||||||
|
return fmt.Errorf("extension exceeds maximum total lifetime")
|
||||||
|
}
|
||||||
|
manifest.ExpiresAt = next
|
||||||
|
record.RefreshCount++
|
||||||
|
appendBoxActivity(&manifest, actor.Username, "expiry extended")
|
||||||
|
return app.saveManagedBox(record, manifest)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *App) ExpireBoxNow(ctx *gin.Context, actor metastore.User, boxID string) error {
|
||||||
|
record, manifest, policy, err := app.boxMutationContext(ctx, actor, boxID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !policy.CanEditMetadata {
|
||||||
|
return fmt.Errorf(policyReason(policy, "edit", "edit disabled"))
|
||||||
|
}
|
||||||
|
manifest.ExpiresAt = time.Now().UTC().Add(-time.Second)
|
||||||
|
appendBoxActivity(&manifest, actor.Username, "box expired")
|
||||||
|
return app.saveManagedBox(record, manifest)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *App) DeleteBox(ctx *gin.Context, actor metastore.User, boxID string) error {
|
||||||
|
record, manifest, policy, err := app.boxMutationContext(ctx, actor, boxID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_ = manifest
|
||||||
|
if !policy.CanDeleteBox {
|
||||||
|
return fmt.Errorf(policyReason(policy, "delete", "delete disabled"))
|
||||||
|
}
|
||||||
|
if err := boxstore.DeleteBox(record.ID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return app.store.DeleteBoxRecord(record.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *App) SetBoxPassword(ctx *gin.Context, actor metastore.User, boxID string, input BoxPasswordInput) error {
|
||||||
|
record, manifest, policy, err := app.boxMutationContext(ctx, actor, boxID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !policy.CanEditPassword {
|
||||||
|
return fmt.Errorf(policyReason(policy, "password", "password edits disabled"))
|
||||||
|
}
|
||||||
|
password := strings.TrimSpace(input.Password)
|
||||||
|
if password == "" {
|
||||||
|
return fmt.Errorf("password cannot be empty")
|
||||||
|
}
|
||||||
|
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
token, err := helpers.RandomHexID(16)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
manifest.PasswordHash = string(hash)
|
||||||
|
manifest.PasswordHashAlg = "bcrypt"
|
||||||
|
manifest.AuthToken = token
|
||||||
|
appendBoxActivity(&manifest, actor.Username, "password set")
|
||||||
|
return app.saveManagedBox(record, manifest)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *App) RemoveBoxPassword(ctx *gin.Context, actor metastore.User, boxID string) error {
|
||||||
|
record, manifest, policy, err := app.boxMutationContext(ctx, actor, boxID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !policy.CanEditPassword {
|
||||||
|
return fmt.Errorf(policyReason(policy, "password", "password edits disabled"))
|
||||||
|
}
|
||||||
|
manifest.PasswordHash = ""
|
||||||
|
manifest.PasswordHashAlg = ""
|
||||||
|
manifest.PasswordSalt = ""
|
||||||
|
manifest.AuthToken = ""
|
||||||
|
appendBoxActivity(&manifest, actor.Username, "password removed")
|
||||||
|
return app.saveManagedBox(record, manifest)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *App) DeleteBoxFiles(ctx *gin.Context, actor metastore.User, boxID string, fileIDs []string) error {
|
||||||
|
record, manifest, policy, err := app.boxMutationContext(ctx, actor, boxID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !policy.CanDeleteFiles {
|
||||||
|
return fmt.Errorf(policyReason(policy, "files", "file deletion disabled"))
|
||||||
|
}
|
||||||
|
fileIDs = uniqueNonEmpty(fileIDs)
|
||||||
|
if len(fileIDs) == 0 {
|
||||||
|
return fmt.Errorf("no files selected")
|
||||||
|
}
|
||||||
|
remove := map[string]bool{}
|
||||||
|
for _, id := range fileIDs {
|
||||||
|
remove[id] = true
|
||||||
|
}
|
||||||
|
kept := make([]models.BoxFile, 0, len(manifest.Files))
|
||||||
|
for _, file := range manifest.Files {
|
||||||
|
if remove[file.ID] {
|
||||||
|
if path, ok := boxstore.SafeBoxFilePath(boxID, file.Name); ok {
|
||||||
|
_ = os.Remove(path)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
kept = append(kept, file)
|
||||||
|
}
|
||||||
|
manifest.Files = kept
|
||||||
|
appendBoxActivity(&manifest, actor.Username, "files deleted")
|
||||||
|
return app.saveManagedBox(record, manifest)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *App) renderBoxManagerError(ctx *gin.Context, actor metastore.User, boxID string, actionErr error) {
|
||||||
|
view, err := app.GetBoxManager(ctx, actor, boxID)
|
||||||
|
if err != nil {
|
||||||
|
ctx.String(http.StatusForbidden, actionErr.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
view.Error = actionErr.Error()
|
||||||
|
ctx.HTML(http.StatusOK, "account_box_manager.html", view)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *App) boxMutationContext(ctx *gin.Context, actor metastore.User, boxID string) (metastore.BoxRecord, models.BoxManifest, BoxActionPolicy, error) {
|
||||||
|
record, manifest, err := app.loadBoxForManager(boxID)
|
||||||
|
if err != nil {
|
||||||
|
return record, manifest, BoxActionPolicy{}, err
|
||||||
|
}
|
||||||
|
policy := app.resolveBoxPolicy(ctx, actor, record, manifest)
|
||||||
|
if !policy.CanViewManager {
|
||||||
|
return record, manifest, policy, fmt.Errorf(policyReason(policy, "view", "permission denied"))
|
||||||
|
}
|
||||||
|
return record, manifest, policy, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *App) loadBoxForManager(boxID string) (metastore.BoxRecord, models.BoxManifest, error) {
|
||||||
|
if !boxstore.ValidBoxID(boxID) {
|
||||||
|
return metastore.BoxRecord{}, models.BoxManifest{}, fmt.Errorf("invalid box id")
|
||||||
|
}
|
||||||
|
record, ok, err := app.store.GetBoxRecord(boxID)
|
||||||
|
if err != nil {
|
||||||
|
return record, models.BoxManifest{}, err
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
return record, models.BoxManifest{}, fmt.Errorf("box not found")
|
||||||
|
}
|
||||||
|
manifest, err := boxstore.ReadManifest(boxID)
|
||||||
|
if err != nil {
|
||||||
|
return record, manifest, err
|
||||||
|
}
|
||||||
|
return record, manifest, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *App) resolveBoxPolicy(ctx *gin.Context, actor metastore.User, record metastore.BoxRecord, manifest models.BoxManifest) BoxActionPolicy {
|
||||||
|
perms := currentAccountPermissions(ctx)
|
||||||
|
isAdmin := perms.AdminBoxesView
|
||||||
|
isOwner := record.OwnerID != "" && record.OwnerID == actor.ID
|
||||||
|
policy := BoxActionPolicy{
|
||||||
|
MaxExtensionSeconds: app.config.BoxOwnerMaxRefreshAmountSeconds,
|
||||||
|
MaxRefreshCount: app.config.BoxOwnerMaxRefreshCount,
|
||||||
|
MaxTotalLifetimeSecs: app.config.BoxOwnerMaxTotalExpirySeconds,
|
||||||
|
Reasons: map[string]string{},
|
||||||
|
}
|
||||||
|
if isAdmin {
|
||||||
|
policy.CanViewManager = true
|
||||||
|
policy.CanEditMetadata = true
|
||||||
|
policy.CanEditSharingRules = true
|
||||||
|
policy.CanEditPassword = true
|
||||||
|
policy.CanDeleteBox = true
|
||||||
|
policy.CanDeleteFiles = true
|
||||||
|
policy.CanExtendExpiry = !manifest.OneTimeDownload
|
||||||
|
return policy
|
||||||
|
}
|
||||||
|
if !isOwner {
|
||||||
|
policy.Reasons["view"] = "not box owner"
|
||||||
|
return policy
|
||||||
|
}
|
||||||
|
if !app.config.BoxOwnerEditEnabled {
|
||||||
|
policy.Reasons["view"] = "box owner editing disabled"
|
||||||
|
return policy
|
||||||
|
}
|
||||||
|
policy.CanViewManager = true
|
||||||
|
policy.CanEditMetadata = true
|
||||||
|
policy.CanEditSharingRules = true
|
||||||
|
policy.CanDeleteBox = true
|
||||||
|
policy.CanDeleteFiles = true
|
||||||
|
if app.config.BoxOwnerPasswordEditEnabled {
|
||||||
|
policy.CanEditPassword = true
|
||||||
|
} else {
|
||||||
|
policy.Reasons["password"] = "password editing disabled by policy"
|
||||||
|
}
|
||||||
|
if !app.config.BoxOwnerRefreshEnabled {
|
||||||
|
policy.Reasons["extend"] = "refresh disabled by policy"
|
||||||
|
} else if manifest.OneTimeDownload {
|
||||||
|
policy.Reasons["extend"] = "one-time boxes cannot be refreshed"
|
||||||
|
} else if app.config.BoxOwnerMaxRefreshCount > 0 && record.RefreshCount >= app.config.BoxOwnerMaxRefreshCount {
|
||||||
|
policy.Reasons["extend"] = "refresh count limit reached"
|
||||||
|
} else {
|
||||||
|
policy.CanExtendExpiry = true
|
||||||
|
}
|
||||||
|
return policy
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *App) saveManagedBox(record metastore.BoxRecord, manifest models.BoxManifest) error {
|
||||||
|
if err := boxstore.WriteManifest(record.ID, manifest); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
next := boxRecordFromManifest(record.ID, manifest)
|
||||||
|
next.RefreshCount = record.RefreshCount
|
||||||
|
return app.store.UpsertBoxRecord(next)
|
||||||
|
}
|
||||||
|
|
||||||
|
func appendBoxActivity(manifest *models.BoxManifest, actor string, message string) {
|
||||||
|
manifest.Activity = append([]models.BoxActivity{{
|
||||||
|
At: time.Now().UTC(),
|
||||||
|
Actor: actor,
|
||||||
|
Message: message,
|
||||||
|
}}, manifest.Activity...)
|
||||||
|
if len(manifest.Activity) > 12 {
|
||||||
|
manifest.Activity = manifest.Activity[:12]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func boxActivityRows(activity []models.BoxActivity) []BoxManagerActivityRow {
|
||||||
|
rows := make([]BoxManagerActivityRow, 0, len(activity))
|
||||||
|
for _, item := range activity {
|
||||||
|
rows = append(rows, BoxManagerActivityRow{At: formatAdminTime(item.At), Message: item.Message, Actor: item.Actor})
|
||||||
|
}
|
||||||
|
if len(rows) == 0 {
|
||||||
|
rows = append(rows, BoxManagerActivityRow{At: "-", Message: "No box activity yet.", Actor: "system"})
|
||||||
|
}
|
||||||
|
return rows
|
||||||
|
}
|
||||||
|
|
||||||
|
func policyReason(policy BoxActionPolicy, key string, fallback string) string {
|
||||||
|
if policy.Reasons != nil && policy.Reasons[key] != "" {
|
||||||
|
return policy.Reasons[key]
|
||||||
|
}
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
func boxOwnerLabel(record metastore.BoxRecord) string {
|
||||||
|
if record.OwnerUsername != "" {
|
||||||
|
return record.OwnerUsername
|
||||||
|
}
|
||||||
|
return "guest"
|
||||||
|
}
|
||||||
219
lib/server/account_box_manager_test.go
Normal file
219
lib/server/account_box_manager_test.go
Normal file
@@ -0,0 +1,219 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"warpbox/lib/boxstore"
|
||||||
|
"warpbox/lib/metastore"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestAccountBoxManagerAdminCanViewAndEdit(t *testing.T) {
|
||||||
|
app, admin := setupAccountTestApp(t)
|
||||||
|
router := setupAccountTestRouter(t, app)
|
||||||
|
session := createAccountTestSession(t, app, admin)
|
||||||
|
id := "abababababababababababababababab"
|
||||||
|
createIndexedBox(t, app, id, "", "", 10, false)
|
||||||
|
|
||||||
|
response := getAccountBoxManager(router, session, id)
|
||||||
|
if response.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected manager page, got %d body=%s", response.Code, response.Body.String())
|
||||||
|
}
|
||||||
|
if !strings.Contains(response.Body.String(), "WarpBox Box Manager") {
|
||||||
|
t.Fatal("expected manager UI")
|
||||||
|
}
|
||||||
|
|
||||||
|
form := url.Values{"disable_zip": []string{"true"}}
|
||||||
|
response = postAccountBoxForm(router, session, "/account/boxes/"+id, form)
|
||||||
|
if response.Code != http.StatusSeeOther {
|
||||||
|
t.Fatalf("expected update redirect, got %d", response.Code)
|
||||||
|
}
|
||||||
|
manifest, err := boxstore.ReadManifest(id)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ReadManifest returned error: %v", err)
|
||||||
|
}
|
||||||
|
if !manifest.DisableZip {
|
||||||
|
t.Fatal("expected sharing rule update")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAccountBoxManagerOwnerViewAllowedAndDeniedByPolicy(t *testing.T) {
|
||||||
|
app, _ := setupAccountTestApp(t)
|
||||||
|
user, err := app.store.CreateUserWithPassword("owner-view", "owner-view@example.test", "secret", nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateUserWithPassword returned error: %v", err)
|
||||||
|
}
|
||||||
|
router := setupAccountTestRouter(t, app)
|
||||||
|
session := createAccountTestSession(t, app, user)
|
||||||
|
id := "bcbcbcbcbcbcbcbcbcbcbcbcbcbcbcbc"
|
||||||
|
createIndexedBox(t, app, id, user.ID, user.Username, 10, false)
|
||||||
|
|
||||||
|
response := getAccountBoxManager(router, session, id)
|
||||||
|
if response.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected owner manager page, got %d", response.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
app.config.BoxOwnerEditEnabled = false
|
||||||
|
response = getAccountBoxManager(router, session, id)
|
||||||
|
if response.Code != http.StatusForbidden {
|
||||||
|
t.Fatalf("expected owner denied by policy, got %d", response.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAccountBoxManagerOwnerRefreshLimits(t *testing.T) {
|
||||||
|
app, _ := setupAccountTestApp(t)
|
||||||
|
app.config.BoxOwnerMaxRefreshCount = 1
|
||||||
|
app.config.BoxOwnerMaxRefreshAmountSeconds = 60
|
||||||
|
app.config.BoxOwnerMaxTotalExpirySeconds = 7200
|
||||||
|
user, err := app.store.CreateUserWithPassword("owner-refresh", "owner-refresh@example.test", "secret", nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateUserWithPassword returned error: %v", err)
|
||||||
|
}
|
||||||
|
router := setupAccountTestRouter(t, app)
|
||||||
|
session := createAccountTestSession(t, app, user)
|
||||||
|
id := "cdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcd"
|
||||||
|
createIndexedBox(t, app, id, user.ID, user.Username, 10, false)
|
||||||
|
|
||||||
|
response := postAccountBoxForm(router, session, "/account/boxes/"+id+"/extend", url.Values{"extend_seconds": []string{"60"}})
|
||||||
|
if response.Code != http.StatusSeeOther {
|
||||||
|
t.Fatalf("expected owner refresh success, got %d body=%s", response.Code, response.Body.String())
|
||||||
|
}
|
||||||
|
record, ok, err := app.store.GetBoxRecord(id)
|
||||||
|
if err != nil || !ok {
|
||||||
|
t.Fatalf("GetBoxRecord returned ok=%v err=%v", ok, err)
|
||||||
|
}
|
||||||
|
if record.RefreshCount != 1 {
|
||||||
|
t.Fatalf("expected refresh count 1, got %d", record.RefreshCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
response = postAccountBoxForm(router, session, "/account/boxes/"+id+"/extend", url.Values{"extend_seconds": []string{"60"}})
|
||||||
|
if response.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected refresh count rejection render, got %d", response.Code)
|
||||||
|
}
|
||||||
|
if !strings.Contains(response.Body.String(), "refresh count") {
|
||||||
|
t.Fatal("expected refresh count error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAccountBoxManagerOwnerRefreshRejectedOverMaxDuration(t *testing.T) {
|
||||||
|
app, _ := setupAccountTestApp(t)
|
||||||
|
app.config.BoxOwnerMaxRefreshAmountSeconds = 60
|
||||||
|
user, err := app.store.CreateUserWithPassword("owner-duration", "owner-duration@example.test", "secret", nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateUserWithPassword returned error: %v", err)
|
||||||
|
}
|
||||||
|
router := setupAccountTestRouter(t, app)
|
||||||
|
session := createAccountTestSession(t, app, user)
|
||||||
|
id := "dededededededededededededededede"
|
||||||
|
createIndexedBox(t, app, id, user.ID, user.Username, 10, false)
|
||||||
|
|
||||||
|
response := postAccountBoxForm(router, session, "/account/boxes/"+id+"/extend", url.Values{"extend_seconds": []string{"120"}})
|
||||||
|
if response.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected max duration rejection render, got %d", response.Code)
|
||||||
|
}
|
||||||
|
if !strings.Contains(response.Body.String(), "maximum single extension") {
|
||||||
|
t.Fatal("expected max duration error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAccountBoxManagerPasswordSetRemovePermissions(t *testing.T) {
|
||||||
|
app, _ := setupAccountTestApp(t)
|
||||||
|
user, err := app.store.CreateUserWithPassword("owner-pass", "owner-pass@example.test", "secret", nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateUserWithPassword returned error: %v", err)
|
||||||
|
}
|
||||||
|
router := setupAccountTestRouter(t, app)
|
||||||
|
session := createAccountTestSession(t, app, user)
|
||||||
|
id := "efefefefefefefefefefefefefefefef"
|
||||||
|
createIndexedBox(t, app, id, user.ID, user.Username, 10, false)
|
||||||
|
|
||||||
|
response := postAccountBoxForm(router, session, "/account/boxes/"+id+"/password", url.Values{"password": []string{"new-secret"}})
|
||||||
|
if response.Code != http.StatusSeeOther {
|
||||||
|
t.Fatalf("expected password set redirect, got %d", response.Code)
|
||||||
|
}
|
||||||
|
manifest, err := boxstore.ReadManifest(id)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ReadManifest returned error: %v", err)
|
||||||
|
}
|
||||||
|
if manifest.PasswordHash == "" || manifest.AuthToken == "" {
|
||||||
|
t.Fatal("expected password set")
|
||||||
|
}
|
||||||
|
|
||||||
|
app.config.BoxOwnerPasswordEditEnabled = false
|
||||||
|
response = postAccountBoxForm(router, session, "/account/boxes/"+id+"/password/remove", nil)
|
||||||
|
if response.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected password permission render, got %d", response.Code)
|
||||||
|
}
|
||||||
|
if !strings.Contains(response.Body.String(), "password editing disabled") {
|
||||||
|
t.Fatal("expected password permission error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAccountBoxManagerFileDeleteAndBoxDeletePermissions(t *testing.T) {
|
||||||
|
app, _ := setupAccountTestApp(t)
|
||||||
|
user, err := app.store.CreateUserWithPassword("owner-delete", "owner-delete@example.test", "secret", nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateUserWithPassword returned error: %v", err)
|
||||||
|
}
|
||||||
|
router := setupAccountTestRouter(t, app)
|
||||||
|
session := createAccountTestSession(t, app, user)
|
||||||
|
id := "fafafafafafafafafafafafafafafafa"
|
||||||
|
createIndexedBox(t, app, id, user.ID, user.Username, 10, false)
|
||||||
|
manifest, err := boxstore.ReadManifest(id)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ReadManifest returned error: %v", err)
|
||||||
|
}
|
||||||
|
fileID := manifest.Files[0].ID
|
||||||
|
|
||||||
|
response := postAccountBoxForm(router, session, "/account/boxes/"+id+"/files/delete", url.Values{"file_ids": []string{fileID}})
|
||||||
|
if response.Code != http.StatusSeeOther {
|
||||||
|
t.Fatalf("expected file delete redirect, got %d", response.Code)
|
||||||
|
}
|
||||||
|
manifest, err = boxstore.ReadManifest(id)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ReadManifest returned error: %v", err)
|
||||||
|
}
|
||||||
|
if len(manifest.Files) != 0 {
|
||||||
|
t.Fatalf("expected file removed, got %#v", manifest.Files)
|
||||||
|
}
|
||||||
|
|
||||||
|
app.config.BoxOwnerEditEnabled = false
|
||||||
|
response = postAccountBoxForm(router, session, "/account/boxes/"+id+"/delete", nil)
|
||||||
|
if response.Code != http.StatusForbidden {
|
||||||
|
t.Fatalf("expected delete permission denied after policy disabled, got %d", response.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
app.config.BoxOwnerEditEnabled = true
|
||||||
|
response = postAccountBoxForm(router, session, "/account/boxes/"+id+"/delete", nil)
|
||||||
|
if response.Code != http.StatusSeeOther {
|
||||||
|
t.Fatalf("expected box delete redirect, got %d", response.Code)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(boxstore.BoxPath(id)); !os.IsNotExist(err) {
|
||||||
|
t.Fatalf("expected box directory deleted, stat err=%v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func getAccountBoxManager(router http.Handler, session metastore.Session, id string) *httptest.ResponseRecorder {
|
||||||
|
request := httptest.NewRequest(http.MethodGet, "/account/boxes/"+id, nil)
|
||||||
|
request.AddCookie(&http.Cookie{Name: accountSessionCookie, Value: session.Token})
|
||||||
|
response := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(response, request)
|
||||||
|
return response
|
||||||
|
}
|
||||||
|
|
||||||
|
func postAccountBoxForm(router http.Handler, session metastore.Session, path string, values url.Values) *httptest.ResponseRecorder {
|
||||||
|
if values == nil {
|
||||||
|
values = url.Values{}
|
||||||
|
}
|
||||||
|
values.Set("csrf_token", session.CSRFToken)
|
||||||
|
request := httptest.NewRequest(http.MethodPost, path, strings.NewReader(values.Encode()))
|
||||||
|
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
request.AddCookie(&http.Cookie{Name: accountSessionCookie, Value: session.Token})
|
||||||
|
response := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(response, request)
|
||||||
|
return response
|
||||||
|
}
|
||||||
454
lib/server/account_boxes.go
Normal file
454
lib/server/account_boxes.go
Normal file
@@ -0,0 +1,454 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/csv"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"warpbox/lib/boxstore"
|
||||||
|
"warpbox/lib/helpers"
|
||||||
|
"warpbox/lib/metastore"
|
||||||
|
"warpbox/lib/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
type BoxIndexView struct {
|
||||||
|
PageTitle string
|
||||||
|
WindowTitle string
|
||||||
|
WindowIcon string
|
||||||
|
AccountNav AccountNavView
|
||||||
|
CSRFToken string
|
||||||
|
Filters BoxFiltersView
|
||||||
|
Rows []BoxRowView
|
||||||
|
Stats BoxIndexStats
|
||||||
|
Page int
|
||||||
|
PageSize int
|
||||||
|
Total int
|
||||||
|
TotalPages int
|
||||||
|
HasPrev bool
|
||||||
|
HasNext bool
|
||||||
|
PrevURL string
|
||||||
|
NextURL string
|
||||||
|
CanManage bool
|
||||||
|
PolicySummary string
|
||||||
|
Error string
|
||||||
|
}
|
||||||
|
|
||||||
|
type BoxFiltersView struct {
|
||||||
|
Query string
|
||||||
|
Owner string
|
||||||
|
Status string
|
||||||
|
Flag string
|
||||||
|
Sort string
|
||||||
|
PageSize int
|
||||||
|
}
|
||||||
|
|
||||||
|
type BoxIndexStats struct {
|
||||||
|
Visible int
|
||||||
|
Total int
|
||||||
|
Expired int
|
||||||
|
Storage string
|
||||||
|
}
|
||||||
|
|
||||||
|
type BoxRowView struct {
|
||||||
|
ID string
|
||||||
|
Owner string
|
||||||
|
Status string
|
||||||
|
FileCount int
|
||||||
|
Size string
|
||||||
|
CreatedAt string
|
||||||
|
ExpiresAt string
|
||||||
|
Flags string
|
||||||
|
Policy string
|
||||||
|
CanManage bool
|
||||||
|
ManageURL string
|
||||||
|
OpenURL string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *App) handleAccountBoxes(ctx *gin.Context) {
|
||||||
|
actor, ok := currentAccountUser(ctx)
|
||||||
|
if !ok {
|
||||||
|
ctx.Redirect(http.StatusSeeOther, "/account/login")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
view, err := app.ListBoxes(ctx, actor, boxFiltersFromRequest(ctx), boxPageFromRequest(ctx))
|
||||||
|
if err != nil {
|
||||||
|
ctx.String(http.StatusForbidden, "Permission denied")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx.HTML(http.StatusOK, "account_boxes.html", view)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *App) handleAccountBoxesBulkExpire(ctx *gin.Context) {
|
||||||
|
app.handleAccountBoxesBulkAction(ctx, app.ExpireBoxes)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *App) handleAccountBoxesBulkDelete(ctx *gin.Context) {
|
||||||
|
app.handleAccountBoxesBulkAction(ctx, app.DeleteBoxes)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *App) handleAccountBoxesBulkBumpExpiry(ctx *gin.Context) {
|
||||||
|
app.handleAccountBoxesBulkAction(ctx, func(ctx *gin.Context, actor metastore.User, ids []string) error {
|
||||||
|
seconds := parsePositiveInt64Default(ctx.PostForm("bump_seconds"), app.config.BoxOwnerMaxRefreshAmountSeconds)
|
||||||
|
return app.BumpBoxExpiries(ctx, actor, ids, seconds)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *App) handleAccountBoxesDeleteLargest(ctx *gin.Context) {
|
||||||
|
actor, ok := currentAccountUser(ctx)
|
||||||
|
if !ok {
|
||||||
|
ctx.Redirect(http.StatusSeeOther, "/account/login")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
filters := boxFiltersFromRequest(ctx)
|
||||||
|
filters.Sort = "largest"
|
||||||
|
page := metastore.BoxPageRequest{Page: 1, PageSize: 25}
|
||||||
|
boxPage, err := app.visibleBoxRecords(ctx, actor, filters, page)
|
||||||
|
if err != nil {
|
||||||
|
ctx.String(http.StatusForbidden, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ids := make([]string, 0, 10)
|
||||||
|
for _, row := range boxPage.Rows {
|
||||||
|
if len(ids) == 10 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
ids = append(ids, row.ID)
|
||||||
|
}
|
||||||
|
if err := app.DeleteBoxes(ctx, actor, ids); err != nil {
|
||||||
|
ctx.String(http.StatusForbidden, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx.Redirect(http.StatusSeeOther, "/account/boxes")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *App) handleAccountBoxesExport(ctx *gin.Context) {
|
||||||
|
actor, ok := currentAccountUser(ctx)
|
||||||
|
if !ok {
|
||||||
|
ctx.Redirect(http.StatusSeeOther, "/account/login")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
page, err := app.visibleBoxRecords(ctx, actor, boxFiltersFromRequest(ctx), metastore.BoxPageRequest{Page: 1, PageSize: 100})
|
||||||
|
if err != nil {
|
||||||
|
ctx.String(http.StatusForbidden, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var buffer bytes.Buffer
|
||||||
|
writer := csv.NewWriter(&buffer)
|
||||||
|
_ = writer.Write([]string{"id", "owner", "status", "file_count", "total_size", "created_at", "expires_at", "flags"})
|
||||||
|
for _, record := range page.Rows {
|
||||||
|
_ = writer.Write([]string{record.ID, record.OwnerUsername, boxStatus(record), strconv.Itoa(record.FileCount), strconv.FormatInt(record.TotalSize, 10), record.CreatedAt.Format(time.RFC3339), record.ExpiresAt.Format(time.RFC3339), boxFlags(record)})
|
||||||
|
}
|
||||||
|
writer.Flush()
|
||||||
|
ctx.Header("Content-Disposition", `attachment; filename="warpbox-boxes.csv"`)
|
||||||
|
ctx.Data(http.StatusOK, "text/csv; charset=utf-8", buffer.Bytes())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *App) handleAccountBoxesBulkAction(ctx *gin.Context, action func(*gin.Context, metastore.User, []string) error) {
|
||||||
|
actor, ok := currentAccountUser(ctx)
|
||||||
|
if !ok {
|
||||||
|
ctx.Redirect(http.StatusSeeOther, "/account/login")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := action(ctx, actor, ctx.PostFormArray("box_ids")); err != nil {
|
||||||
|
ctx.String(http.StatusForbidden, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx.Redirect(http.StatusSeeOther, "/account/boxes")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *App) ListBoxes(ctx *gin.Context, actor metastore.User, filters metastore.BoxFilters, page metastore.BoxPageRequest) (BoxIndexView, error) {
|
||||||
|
boxPage, err := app.visibleBoxRecords(ctx, actor, filters, page)
|
||||||
|
if err != nil {
|
||||||
|
return BoxIndexView{}, err
|
||||||
|
}
|
||||||
|
rows := make([]BoxRowView, 0, len(boxPage.Rows))
|
||||||
|
stats := BoxIndexStats{Visible: len(boxPage.Rows), Total: boxPage.Total}
|
||||||
|
totalSize := int64(0)
|
||||||
|
for _, record := range boxPage.Rows {
|
||||||
|
totalSize += record.TotalSize
|
||||||
|
if boxExpired(record) {
|
||||||
|
stats.Expired++
|
||||||
|
}
|
||||||
|
rows = append(rows, app.boxRowView(ctx, actor, record))
|
||||||
|
}
|
||||||
|
stats.Storage = helpers.FormatBytes(totalSize)
|
||||||
|
nav := app.accountNavView(ctx, "boxes")
|
||||||
|
nav.AlertCount, nav.AlertSeverity = app.openAlertSummary()
|
||||||
|
return BoxIndexView{
|
||||||
|
PageTitle: "WarpBox Boxes",
|
||||||
|
WindowTitle: "WarpBox Boxes",
|
||||||
|
WindowIcon: "B",
|
||||||
|
AccountNav: nav,
|
||||||
|
CSRFToken: app.currentCSRFToken(ctx),
|
||||||
|
Filters: BoxFiltersView{Query: filters.Query, Owner: filters.Owner, Status: filters.Status, Flag: filters.Flag, Sort: filters.Sort, PageSize: boxPage.PageSize},
|
||||||
|
Rows: rows,
|
||||||
|
Stats: stats,
|
||||||
|
Page: boxPage.Page,
|
||||||
|
PageSize: boxPage.PageSize,
|
||||||
|
Total: boxPage.Total,
|
||||||
|
TotalPages: boxPage.TotalPages,
|
||||||
|
HasPrev: boxPage.HasPrev,
|
||||||
|
HasNext: boxPage.HasNext,
|
||||||
|
PrevURL: boxPageURL(ctx, boxPage.PrevPage),
|
||||||
|
NextURL: boxPageURL(ctx, boxPage.NextPage),
|
||||||
|
CanManage: currentAccountPermissions(ctx).AdminBoxesView,
|
||||||
|
PolicySummary: app.boxPolicySummary(),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *App) ExpireBoxes(ctx *gin.Context, actor metastore.User, ids []string) error {
|
||||||
|
records, err := app.authorizedBoxRecords(ctx, actor, ids)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
now := time.Now().UTC().Add(-time.Second)
|
||||||
|
for _, record := range records {
|
||||||
|
manifest, err := boxstore.ReadManifest(record.ID)
|
||||||
|
if err == nil {
|
||||||
|
manifest.ExpiresAt = now
|
||||||
|
_ = boxstore.WriteManifest(record.ID, manifest)
|
||||||
|
}
|
||||||
|
record.ExpiresAt = now
|
||||||
|
if err := app.store.UpsertBoxRecord(record); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *App) DeleteBoxes(ctx *gin.Context, actor metastore.User, ids []string) error {
|
||||||
|
records, err := app.authorizedBoxRecords(ctx, actor, ids)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, record := range records {
|
||||||
|
if err := boxstore.DeleteBox(record.ID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := app.store.DeleteBoxRecord(record.ID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *App) BumpBoxExpiries(ctx *gin.Context, actor metastore.User, ids []string, seconds int64) error {
|
||||||
|
if seconds <= 0 {
|
||||||
|
return fmt.Errorf("bump expiry requires a positive duration")
|
||||||
|
}
|
||||||
|
if !app.config.BoxOwnerRefreshEnabled {
|
||||||
|
return fmt.Errorf("box owner refresh policy is disabled")
|
||||||
|
}
|
||||||
|
if app.config.BoxOwnerMaxRefreshAmountSeconds > 0 && seconds > app.config.BoxOwnerMaxRefreshAmountSeconds {
|
||||||
|
return fmt.Errorf("bump expiry exceeds maximum refresh amount")
|
||||||
|
}
|
||||||
|
records, err := app.authorizedBoxRecords(ctx, actor, ids)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, record := range records {
|
||||||
|
if record.OneTimeDownload {
|
||||||
|
return fmt.Errorf("one-time boxes cannot be refreshed")
|
||||||
|
}
|
||||||
|
if app.config.BoxOwnerMaxRefreshCount > 0 && record.RefreshCount >= app.config.BoxOwnerMaxRefreshCount {
|
||||||
|
return fmt.Errorf("box refresh count limit reached")
|
||||||
|
}
|
||||||
|
base := record.ExpiresAt
|
||||||
|
if base.IsZero() || time.Now().UTC().After(base) {
|
||||||
|
base = time.Now().UTC()
|
||||||
|
}
|
||||||
|
newExpiry := base.Add(time.Duration(seconds) * time.Second)
|
||||||
|
if app.config.BoxOwnerMaxTotalExpirySeconds > 0 && !record.CreatedAt.IsZero() && newExpiry.After(record.CreatedAt.Add(time.Duration(app.config.BoxOwnerMaxTotalExpirySeconds)*time.Second)) {
|
||||||
|
return fmt.Errorf("bump expiry exceeds maximum total expiry")
|
||||||
|
}
|
||||||
|
manifest, err := boxstore.ReadManifest(record.ID)
|
||||||
|
if err == nil {
|
||||||
|
manifest.ExpiresAt = newExpiry
|
||||||
|
_ = boxstore.WriteManifest(record.ID, manifest)
|
||||||
|
}
|
||||||
|
record.ExpiresAt = newExpiry
|
||||||
|
record.RefreshCount++
|
||||||
|
if err := app.store.UpsertBoxRecord(record); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *App) visibleBoxRecords(ctx *gin.Context, actor metastore.User, filters metastore.BoxFilters, page metastore.BoxPageRequest) (metastore.BoxRecordPage, error) {
|
||||||
|
perms := currentAccountPermissions(ctx)
|
||||||
|
if !perms.AdminBoxesView {
|
||||||
|
filters.Owner = actor.ID
|
||||||
|
}
|
||||||
|
return app.store.ListBoxRecords(filters, page)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *App) authorizedBoxRecords(ctx *gin.Context, actor metastore.User, ids []string) ([]metastore.BoxRecord, error) {
|
||||||
|
ids = uniqueNonEmpty(ids)
|
||||||
|
if len(ids) == 0 {
|
||||||
|
return nil, fmt.Errorf("no boxes selected")
|
||||||
|
}
|
||||||
|
perms := currentAccountPermissions(ctx)
|
||||||
|
records := make([]metastore.BoxRecord, 0, len(ids))
|
||||||
|
for _, id := range ids {
|
||||||
|
record, ok, err := app.store.GetBoxRecord(id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("box %s not found", id)
|
||||||
|
}
|
||||||
|
if !perms.AdminBoxesView && record.OwnerID != actor.ID {
|
||||||
|
return nil, fmt.Errorf("permission denied")
|
||||||
|
}
|
||||||
|
if !perms.AdminBoxesView && !app.config.BoxOwnerEditEnabled {
|
||||||
|
return nil, fmt.Errorf("box owner edit policy is disabled")
|
||||||
|
}
|
||||||
|
records = append(records, record)
|
||||||
|
}
|
||||||
|
return records, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *App) boxRowView(ctx *gin.Context, actor metastore.User, record metastore.BoxRecord) BoxRowView {
|
||||||
|
owner := record.OwnerUsername
|
||||||
|
if owner == "" {
|
||||||
|
owner = "guest"
|
||||||
|
}
|
||||||
|
return BoxRowView{
|
||||||
|
ID: record.ID,
|
||||||
|
Owner: owner,
|
||||||
|
Status: boxStatus(record),
|
||||||
|
FileCount: record.FileCount,
|
||||||
|
Size: helpers.FormatBytes(record.TotalSize),
|
||||||
|
CreatedAt: formatAdminTime(record.CreatedAt),
|
||||||
|
ExpiresAt: formatAdminTime(record.ExpiresAt),
|
||||||
|
Flags: boxFlags(record),
|
||||||
|
Policy: app.boxRecordPolicy(record),
|
||||||
|
CanManage: currentAccountPermissions(ctx).AdminBoxesView || record.OwnerID == actor.ID,
|
||||||
|
ManageURL: "/account/boxes/" + record.ID,
|
||||||
|
OpenURL: "/box/" + record.ID,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *App) indexBoxFromManifest(boxID string) {
|
||||||
|
manifest, err := boxstore.ReadManifest(boxID)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = app.store.UpsertBoxRecord(boxRecordFromManifest(boxID, manifest))
|
||||||
|
}
|
||||||
|
|
||||||
|
func boxRecordFromManifest(boxID string, manifest models.BoxManifest) metastore.BoxRecord {
|
||||||
|
total := int64(0)
|
||||||
|
names := make([]string, 0, len(manifest.Files))
|
||||||
|
for _, file := range manifest.Files {
|
||||||
|
total += file.Size
|
||||||
|
names = append(names, file.Name)
|
||||||
|
}
|
||||||
|
return metastore.BoxRecord{
|
||||||
|
ID: boxID,
|
||||||
|
OwnerID: manifest.OwnerID,
|
||||||
|
OwnerUsername: manifest.OwnerUsername,
|
||||||
|
FileNames: names,
|
||||||
|
FileCount: len(manifest.Files),
|
||||||
|
TotalSize: total,
|
||||||
|
CreatedAt: manifest.CreatedAt,
|
||||||
|
ExpiresAt: manifest.ExpiresAt,
|
||||||
|
PasswordProtected: boxstore.IsPasswordProtected(manifest),
|
||||||
|
OneTimeDownload: manifest.OneTimeDownload,
|
||||||
|
DisableZip: manifest.DisableZip,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func boxFiltersFromRequest(ctx *gin.Context) metastore.BoxFilters {
|
||||||
|
return metastore.BoxFilters{
|
||||||
|
Query: strings.TrimSpace(ctx.Query("q")),
|
||||||
|
Owner: emptyAsAll(ctx.Query("owner")),
|
||||||
|
Status: emptyAsAll(ctx.Query("status")),
|
||||||
|
Flag: emptyAsAll(ctx.Query("flag")),
|
||||||
|
Sort: emptyAsNewest(ctx.Query("sort")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func boxPageFromRequest(ctx *gin.Context) metastore.BoxPageRequest {
|
||||||
|
page, _ := strconv.Atoi(ctx.DefaultQuery("page", "1"))
|
||||||
|
pageSize, _ := strconv.Atoi(ctx.DefaultQuery("page_size", "25"))
|
||||||
|
return metastore.BoxPageRequest{Page: page, PageSize: pageSize}
|
||||||
|
}
|
||||||
|
|
||||||
|
func boxStatus(record metastore.BoxRecord) string {
|
||||||
|
if boxExpired(record) {
|
||||||
|
return "expired"
|
||||||
|
}
|
||||||
|
if record.ExpiresAt.IsZero() {
|
||||||
|
return "pending"
|
||||||
|
}
|
||||||
|
return "active"
|
||||||
|
}
|
||||||
|
|
||||||
|
func boxExpired(record metastore.BoxRecord) bool {
|
||||||
|
return !record.ExpiresAt.IsZero() && time.Now().UTC().After(record.ExpiresAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
func boxFlags(record metastore.BoxRecord) string {
|
||||||
|
flags := []string{}
|
||||||
|
if record.PasswordProtected {
|
||||||
|
flags = append(flags, "password")
|
||||||
|
}
|
||||||
|
if record.OneTimeDownload {
|
||||||
|
flags = append(flags, "one-time")
|
||||||
|
}
|
||||||
|
if record.DisableZip {
|
||||||
|
flags = append(flags, "zip disabled")
|
||||||
|
}
|
||||||
|
if boxExpired(record) {
|
||||||
|
flags = append(flags, "expired")
|
||||||
|
}
|
||||||
|
if len(flags) == 0 {
|
||||||
|
return "normal"
|
||||||
|
}
|
||||||
|
return strings.Join(flags, ", ")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *App) boxRecordPolicy(record metastore.BoxRecord) string {
|
||||||
|
if record.OneTimeDownload {
|
||||||
|
return "one-time: no refresh"
|
||||||
|
}
|
||||||
|
if !app.config.BoxOwnerEditEnabled {
|
||||||
|
return "owner edits disabled"
|
||||||
|
}
|
||||||
|
if !app.config.BoxOwnerRefreshEnabled {
|
||||||
|
return "editable, no refresh"
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("editable, refresh %d/%d", record.RefreshCount, app.config.BoxOwnerMaxRefreshCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *App) boxPolicySummary() string {
|
||||||
|
if !app.config.BoxOwnerEditEnabled {
|
||||||
|
return "Owners cannot edit boxes by default."
|
||||||
|
}
|
||||||
|
if !app.config.BoxOwnerRefreshEnabled {
|
||||||
|
return "Owners can edit boxes but cannot refresh expiry."
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("Owners can edit and refresh up to %d times by %s.", app.config.BoxOwnerMaxRefreshCount, formatDurationForSettings(app.config.BoxOwnerMaxRefreshAmountSeconds))
|
||||||
|
}
|
||||||
|
|
||||||
|
func boxPageURL(ctx *gin.Context, page int) string {
|
||||||
|
query := ctx.Request.URL.Query()
|
||||||
|
query.Set("page", strconv.Itoa(page))
|
||||||
|
return "/account/boxes?" + query.Encode()
|
||||||
|
}
|
||||||
|
|
||||||
|
func parsePositiveInt64Default(raw string, fallback int64) int64 {
|
||||||
|
value, err := strconv.ParseInt(strings.TrimSpace(raw), 10, 64)
|
||||||
|
if err != nil || value <= 0 {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
220
lib/server/account_boxes_test.go
Normal file
220
lib/server/account_boxes_test.go
Normal file
@@ -0,0 +1,220 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"warpbox/lib/boxstore"
|
||||||
|
"warpbox/lib/metastore"
|
||||||
|
"warpbox/lib/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestAccountBoxesAdminListsBoxes(t *testing.T) {
|
||||||
|
app, user := setupAccountTestApp(t)
|
||||||
|
router := setupAccountTestRouter(t, app)
|
||||||
|
session := createAccountTestSession(t, app, user)
|
||||||
|
createIndexedBox(t, app, "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "", "", 10, false)
|
||||||
|
|
||||||
|
response := getAccountBoxes(router, session, "/account/boxes")
|
||||||
|
if response.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected boxes page, got %d body=%s", response.Code, response.Body.String())
|
||||||
|
}
|
||||||
|
if !strings.Contains(response.Body.String(), "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa") {
|
||||||
|
t.Fatal("expected indexed box in admin list")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAccountBoxesRegularUserSeesOnlyOwnBoxes(t *testing.T) {
|
||||||
|
app, _ := setupAccountTestApp(t)
|
||||||
|
user, err := app.store.CreateUserWithPassword("box-user", "box-user@example.test", "secret", nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateUserWithPassword returned error: %v", err)
|
||||||
|
}
|
||||||
|
router := setupAccountTestRouter(t, app)
|
||||||
|
session := createAccountTestSession(t, app, user)
|
||||||
|
createIndexedBox(t, app, "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", user.ID, user.Username, 10, false)
|
||||||
|
createIndexedBox(t, app, "cccccccccccccccccccccccccccccccc", "other", "other", 20, false)
|
||||||
|
|
||||||
|
response := getAccountBoxes(router, session, "/account/boxes")
|
||||||
|
if response.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected boxes page, got %d", response.Code)
|
||||||
|
}
|
||||||
|
body := response.Body.String()
|
||||||
|
if !strings.Contains(body, "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb") {
|
||||||
|
t.Fatal("expected own box")
|
||||||
|
}
|
||||||
|
if strings.Contains(body, "cccccccccccccccccccccccccccccccc") {
|
||||||
|
t.Fatal("did not expect other user's box")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAccountBoxesFiltersSortAndPagination(t *testing.T) {
|
||||||
|
app, user := setupAccountTestApp(t)
|
||||||
|
router := setupAccountTestRouter(t, app)
|
||||||
|
session := createAccountTestSession(t, app, user)
|
||||||
|
createIndexedBox(t, app, "11111111111111111111111111111111", "", "", 10, false)
|
||||||
|
createIndexedBox(t, app, "22222222222222222222222222222222", "", "", 1000, true)
|
||||||
|
createIndexedBox(t, app, "33333333333333333333333333333333", "", "", 500, false)
|
||||||
|
|
||||||
|
response := getAccountBoxes(router, session, "/account/boxes?flag=password&sort=largest&page_size=25")
|
||||||
|
if response.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected boxes page, got %d", response.Code)
|
||||||
|
}
|
||||||
|
body := response.Body.String()
|
||||||
|
if !strings.Contains(body, "22222222222222222222222222222222") {
|
||||||
|
t.Fatal("expected password filtered box")
|
||||||
|
}
|
||||||
|
if strings.Contains(body, "11111111111111111111111111111111") {
|
||||||
|
t.Fatal("did not expect unfiltered box")
|
||||||
|
}
|
||||||
|
|
||||||
|
page, err := app.store.ListBoxRecords(metastore.BoxFilters{Sort: "largest"}, metastore.BoxPageRequest{Page: 1, PageSize: 25})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListBoxRecords returned error: %v", err)
|
||||||
|
}
|
||||||
|
if len(page.Rows) != 3 || page.Rows[0].ID != "22222222222222222222222222222222" {
|
||||||
|
t.Fatalf("expected largest sort first, got %#v", page.Rows)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAccountBoxesBulkExpireAndDelete(t *testing.T) {
|
||||||
|
app, user := setupAccountTestApp(t)
|
||||||
|
router := setupAccountTestRouter(t, app)
|
||||||
|
session := createAccountTestSession(t, app, user)
|
||||||
|
id := "dddddddddddddddddddddddddddddddd"
|
||||||
|
createIndexedBox(t, app, id, "", "", 10, false)
|
||||||
|
|
||||||
|
values := url.Values{"box_ids": []string{id}}
|
||||||
|
response := postAccountBoxesForm(router, session, "/account/boxes/bulk/expire", values)
|
||||||
|
if response.Code != http.StatusSeeOther {
|
||||||
|
t.Fatalf("expected expire redirect, got %d", response.Code)
|
||||||
|
}
|
||||||
|
record, ok, err := app.store.GetBoxRecord(id)
|
||||||
|
if err != nil || !ok {
|
||||||
|
t.Fatalf("GetBoxRecord returned ok=%v err=%v", ok, err)
|
||||||
|
}
|
||||||
|
if record.ExpiresAt.After(time.Now().UTC()) {
|
||||||
|
t.Fatal("expected box to be expired")
|
||||||
|
}
|
||||||
|
|
||||||
|
response = postAccountBoxesForm(router, session, "/account/boxes/bulk/delete", values)
|
||||||
|
if response.Code != http.StatusSeeOther {
|
||||||
|
t.Fatalf("expected delete redirect, got %d", response.Code)
|
||||||
|
}
|
||||||
|
if _, ok, err := app.store.GetBoxRecord(id); err != nil || ok {
|
||||||
|
t.Fatalf("expected deleted record, ok=%v err=%v", ok, err)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(boxstore.BoxPath(id)); !os.IsNotExist(err) {
|
||||||
|
t.Fatalf("expected box directory deleted, stat err=%v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAccountBoxesBulkDeletePermissionDenied(t *testing.T) {
|
||||||
|
app, _ := setupAccountTestApp(t)
|
||||||
|
user, err := app.store.CreateUserWithPassword("box-limited", "box-limited@example.test", "secret", nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateUserWithPassword returned error: %v", err)
|
||||||
|
}
|
||||||
|
router := setupAccountTestRouter(t, app)
|
||||||
|
session := createAccountTestSession(t, app, user)
|
||||||
|
id := "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee"
|
||||||
|
createIndexedBox(t, app, id, "other", "other", 10, false)
|
||||||
|
|
||||||
|
response := postAccountBoxesForm(router, session, "/account/boxes/bulk/delete", url.Values{"box_ids": []string{id}})
|
||||||
|
if response.Code != http.StatusForbidden {
|
||||||
|
t.Fatalf("expected permission denied, got %d", response.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAccountBoxesBumpExpiryPolicyRejection(t *testing.T) {
|
||||||
|
app, user := setupAccountTestApp(t)
|
||||||
|
app.config.BoxOwnerRefreshEnabled = false
|
||||||
|
router := setupAccountTestRouter(t, app)
|
||||||
|
session := createAccountTestSession(t, app, user)
|
||||||
|
id := "ffffffffffffffffffffffffffffffff"
|
||||||
|
createIndexedBox(t, app, id, "", "", 10, false)
|
||||||
|
|
||||||
|
response := postAccountBoxesForm(router, session, "/account/boxes/bulk/bump-expiry", url.Values{"box_ids": []string{id}, "bump_seconds": []string{"60"}})
|
||||||
|
if response.Code != http.StatusForbidden {
|
||||||
|
t.Fatalf("expected policy rejection, got %d", response.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAccountBoxesDeleteLargest(t *testing.T) {
|
||||||
|
app, user := setupAccountTestApp(t)
|
||||||
|
router := setupAccountTestRouter(t, app)
|
||||||
|
session := createAccountTestSession(t, app, user)
|
||||||
|
small := "12345123451234512345123451234512"
|
||||||
|
large := "99999999999999999999999999999999"
|
||||||
|
createIndexedBox(t, app, small, "", "", 10, false)
|
||||||
|
createIndexedBox(t, app, large, "", "", 1000, false)
|
||||||
|
|
||||||
|
response := postAccountBoxesForm(router, session, "/account/boxes/delete-largest", nil)
|
||||||
|
if response.Code != http.StatusSeeOther {
|
||||||
|
t.Fatalf("expected delete-largest redirect, got %d", response.Code)
|
||||||
|
}
|
||||||
|
if _, ok, err := app.store.GetBoxRecord(large); err != nil || ok {
|
||||||
|
t.Fatalf("expected largest deleted, ok=%v err=%v", ok, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func createIndexedBox(t *testing.T, app *App, id string, ownerID string, ownerUsername string, size int64, password bool) {
|
||||||
|
t.Helper()
|
||||||
|
if err := os.MkdirAll(boxstore.BoxPath(id), 0755); err != nil {
|
||||||
|
t.Fatalf("MkdirAll returned error: %v", err)
|
||||||
|
}
|
||||||
|
filename := "file-" + id[:4] + ".txt"
|
||||||
|
if err := os.WriteFile(filepath.Join(boxstore.BoxPath(id), filename), []byte(strings.Repeat("x", int(size))), 0644); err != nil {
|
||||||
|
t.Fatalf("WriteFile returned error: %v", err)
|
||||||
|
}
|
||||||
|
manifest := models.BoxManifest{
|
||||||
|
OwnerID: ownerID,
|
||||||
|
OwnerUsername: ownerUsername,
|
||||||
|
Files: []models.BoxFile{{
|
||||||
|
ID: "abcdabcdabcdabcd",
|
||||||
|
Name: filename,
|
||||||
|
Size: size,
|
||||||
|
Status: models.FileStatusReady,
|
||||||
|
}},
|
||||||
|
CreatedAt: time.Now().UTC().Add(-time.Duration(size) * time.Second),
|
||||||
|
ExpiresAt: time.Now().UTC().Add(time.Hour),
|
||||||
|
RetentionSecs: 3600,
|
||||||
|
}
|
||||||
|
if password {
|
||||||
|
manifest.PasswordHash = "hash"
|
||||||
|
manifest.AuthToken = "token"
|
||||||
|
}
|
||||||
|
if err := boxstore.WriteManifest(id, manifest); err != nil {
|
||||||
|
t.Fatalf("WriteManifest returned error: %v", err)
|
||||||
|
}
|
||||||
|
if err := app.store.UpsertBoxRecord(boxRecordFromManifest(id, manifest)); err != nil {
|
||||||
|
t.Fatalf("UpsertBoxRecord returned error: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func getAccountBoxes(router http.Handler, session metastore.Session, path string) *httptest.ResponseRecorder {
|
||||||
|
request := httptest.NewRequest(http.MethodGet, path, nil)
|
||||||
|
request.AddCookie(&http.Cookie{Name: accountSessionCookie, Value: session.Token})
|
||||||
|
response := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(response, request)
|
||||||
|
return response
|
||||||
|
}
|
||||||
|
|
||||||
|
func postAccountBoxesForm(router http.Handler, session metastore.Session, path string, values url.Values) *httptest.ResponseRecorder {
|
||||||
|
if values == nil {
|
||||||
|
values = url.Values{}
|
||||||
|
}
|
||||||
|
values.Set("csrf_token", session.CSRFToken)
|
||||||
|
request := httptest.NewRequest(http.MethodPost, path, strings.NewReader(values.Encode()))
|
||||||
|
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
request.AddCookie(&http.Cookie{Name: accountSessionCookie, Value: session.Token})
|
||||||
|
response := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(response, request)
|
||||||
|
return response
|
||||||
|
}
|
||||||
61
lib/server/account_nav.go
Normal file
61
lib/server/account_nav.go
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"warpbox/lib/metastore"
|
||||||
|
)
|
||||||
|
|
||||||
|
type AccountNavView struct {
|
||||||
|
Username string
|
||||||
|
IsAdmin bool
|
||||||
|
ActiveSection string
|
||||||
|
AlertCount int
|
||||||
|
AlertSeverity string
|
||||||
|
CanViewBoxes bool
|
||||||
|
CanViewAlerts bool
|
||||||
|
CanViewUsers bool
|
||||||
|
CanViewAPIKeys bool
|
||||||
|
CanViewSettings bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *App) accountNavView(ctx *gin.Context, activeSection string) AccountNavView {
|
||||||
|
perms := currentAccountPermissions(ctx)
|
||||||
|
isAdmin := perms.AdminAccess
|
||||||
|
|
||||||
|
return AccountNavView{
|
||||||
|
Username: app.currentAdminUsername(ctx),
|
||||||
|
IsAdmin: isAdmin,
|
||||||
|
ActiveSection: activeSection,
|
||||||
|
AlertSeverity: "ok",
|
||||||
|
CanViewBoxes: true,
|
||||||
|
CanViewAlerts: true,
|
||||||
|
CanViewUsers: perms.AdminUsersManage,
|
||||||
|
CanViewAPIKeys: true,
|
||||||
|
CanViewSettings: perms.AdminSettingsManage,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func currentAccountPermissions(ctx *gin.Context) metastore.EffectivePermissions {
|
||||||
|
value, ok := ctx.Get("adminPerms")
|
||||||
|
if !ok {
|
||||||
|
return metastore.EffectivePermissions{}
|
||||||
|
}
|
||||||
|
perms, ok := value.(metastore.EffectivePermissions)
|
||||||
|
if !ok {
|
||||||
|
return metastore.EffectivePermissions{}
|
||||||
|
}
|
||||||
|
return perms
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeAlertSeverity(severity string) string {
|
||||||
|
normalized := strings.ToLower(strings.TrimSpace(severity))
|
||||||
|
switch normalized {
|
||||||
|
case "danger", "warning", "info", "ok":
|
||||||
|
return normalized
|
||||||
|
default:
|
||||||
|
return "ok"
|
||||||
|
}
|
||||||
|
}
|
||||||
253
lib/server/account_pages.go
Normal file
253
lib/server/account_pages.go
Normal file
@@ -0,0 +1,253 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"warpbox/lib/boxstore"
|
||||||
|
"warpbox/lib/helpers"
|
||||||
|
"warpbox/lib/metastore"
|
||||||
|
)
|
||||||
|
|
||||||
|
type AccountDashboardView struct {
|
||||||
|
PageTitle string
|
||||||
|
WindowTitle string
|
||||||
|
WindowIcon string
|
||||||
|
PageScripts []string
|
||||||
|
AccountNav AccountNavView
|
||||||
|
CSRFToken string
|
||||||
|
Stats AccountDashboardStats
|
||||||
|
Statuses []accountStatusRow
|
||||||
|
Alerts []accountAlertPreviewRow
|
||||||
|
RecentBoxes []accountDashboardBoxRow
|
||||||
|
RecentActivity []accountActivityRow
|
||||||
|
ShowUsersStat bool
|
||||||
|
CanManageBoxes bool
|
||||||
|
CanManageUsers bool
|
||||||
|
CanViewSettings bool
|
||||||
|
HasAlertsPreview bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type AccountDashboardStats struct {
|
||||||
|
ActiveBoxes int
|
||||||
|
StorageUsedLabel string
|
||||||
|
AlertCount int
|
||||||
|
TotalUsers int
|
||||||
|
ActiveUsers int
|
||||||
|
DisabledUsers int
|
||||||
|
}
|
||||||
|
|
||||||
|
type accountStatusRow struct {
|
||||||
|
Label string
|
||||||
|
Value string
|
||||||
|
Severity string
|
||||||
|
}
|
||||||
|
|
||||||
|
type accountAlertPreviewRow struct {
|
||||||
|
Severity string
|
||||||
|
Title string
|
||||||
|
Detail string
|
||||||
|
}
|
||||||
|
|
||||||
|
type accountDashboardBoxRow struct {
|
||||||
|
ID string
|
||||||
|
FileCount int
|
||||||
|
TotalSizeLabel string
|
||||||
|
CreatedAt string
|
||||||
|
ExpiresAt string
|
||||||
|
Flags string
|
||||||
|
CanManage bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type accountActivityRow struct {
|
||||||
|
Time string
|
||||||
|
Title string
|
||||||
|
Meta string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *App) handleAccountDashboard(ctx *gin.Context) {
|
||||||
|
actor, ok := currentAccountUser(ctx)
|
||||||
|
if !ok {
|
||||||
|
ctx.Redirect(http.StatusSeeOther, "/account/login")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
view, err := app.GetAccountDashboard(ctx, actor)
|
||||||
|
if err != nil {
|
||||||
|
ctx.String(http.StatusInternalServerError, "Could not load account dashboard")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx.HTML(http.StatusOK, "account_dashboard.html", view)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *App) GetAccountDashboard(ctx *gin.Context, actor metastore.User) (AccountDashboardView, error) {
|
||||||
|
perms := currentAccountPermissions(ctx)
|
||||||
|
nav := app.accountNavView(ctx, "dashboard")
|
||||||
|
|
||||||
|
totalSize := int64(0)
|
||||||
|
activeBoxes := 0
|
||||||
|
recentBoxes := []accountDashboardBoxRow{}
|
||||||
|
if perms.AdminBoxesView {
|
||||||
|
summaries, err := boxstore.ListBoxSummaries()
|
||||||
|
if err != nil {
|
||||||
|
return AccountDashboardView{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
recentBoxes = make([]accountDashboardBoxRow, 0, minInt(len(summaries), 10))
|
||||||
|
for _, summary := range summaries {
|
||||||
|
totalSize += summary.TotalSize
|
||||||
|
if !summary.Expired {
|
||||||
|
activeBoxes++
|
||||||
|
}
|
||||||
|
if len(recentBoxes) < 10 {
|
||||||
|
recentBoxes = append(recentBoxes, accountDashboardBoxRow{
|
||||||
|
ID: summary.ID,
|
||||||
|
FileCount: summary.FileCount,
|
||||||
|
TotalSizeLabel: summary.TotalSizeLabel,
|
||||||
|
CreatedAt: formatAdminTime(summary.CreatedAt),
|
||||||
|
ExpiresAt: formatAdminTime(summary.ExpiresAt),
|
||||||
|
Flags: accountBoxFlags(summary.Expired, summary.OneTimeDownload, summary.PasswordProtected),
|
||||||
|
CanManage: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stats := AccountDashboardStats{
|
||||||
|
ActiveBoxes: activeBoxes,
|
||||||
|
StorageUsedLabel: helpers.FormatBytes(totalSize),
|
||||||
|
}
|
||||||
|
alertPreview := []accountAlertPreviewRow{}
|
||||||
|
if perms.AdminAccess {
|
||||||
|
stats.AlertCount, nav.AlertSeverity = app.openAlertSummary()
|
||||||
|
nav.AlertCount = stats.AlertCount
|
||||||
|
alertPreview = app.accountDashboardAlertPreview()
|
||||||
|
}
|
||||||
|
|
||||||
|
showUsersStat := perms.AdminUsersManage
|
||||||
|
if showUsersStat {
|
||||||
|
users, err := app.store.ListUsers()
|
||||||
|
if err != nil {
|
||||||
|
return AccountDashboardView{}, err
|
||||||
|
}
|
||||||
|
stats.TotalUsers = len(users)
|
||||||
|
for _, user := range users {
|
||||||
|
if user.Disabled {
|
||||||
|
stats.DisabledUsers++
|
||||||
|
} else {
|
||||||
|
stats.ActiveUsers++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return AccountDashboardView{
|
||||||
|
PageTitle: "WarpBox Account",
|
||||||
|
WindowTitle: "WarpBox Account Control Panel",
|
||||||
|
WindowIcon: "W",
|
||||||
|
AccountNav: nav,
|
||||||
|
CSRFToken: app.currentCSRFToken(ctx),
|
||||||
|
Stats: stats,
|
||||||
|
Statuses: app.accountDashboardStatuses(),
|
||||||
|
Alerts: alertPreview,
|
||||||
|
RecentBoxes: recentBoxes,
|
||||||
|
RecentActivity: accountPlaceholderActivity(actor, ctx),
|
||||||
|
ShowUsersStat: showUsersStat,
|
||||||
|
CanManageBoxes: perms.AdminBoxesView,
|
||||||
|
CanManageUsers: perms.AdminUsersManage,
|
||||||
|
CanViewSettings: perms.AdminSettingsManage,
|
||||||
|
HasAlertsPreview: true,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *App) accountDashboardStatuses() []accountStatusRow {
|
||||||
|
return []accountStatusRow{
|
||||||
|
{Label: "Guest uploads", Value: enabledLabel(app.config.GuestUploadsEnabled), Severity: boolSeverity(app.config.GuestUploadsEnabled)},
|
||||||
|
{Label: "API", Value: enabledLabel(app.config.APIEnabled), Severity: boolSeverity(app.config.APIEnabled)},
|
||||||
|
{Label: "ZIP downloads", Value: enabledLabel(app.config.ZipDownloadsEnabled), Severity: boolSeverity(app.config.ZipDownloadsEnabled)},
|
||||||
|
{Label: "One-time boxes", Value: enabledLabel(app.config.OneTimeDownloadsEnabled), Severity: boolSeverity(app.config.OneTimeDownloadsEnabled)},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *App) accountDashboardAlertPreview() []accountAlertPreviewRow {
|
||||||
|
alerts, err := app.store.ListAlerts(metastore.AlertFilters{Status: metastore.AlertStatusOpen, Sort: "severity"})
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
rows := make([]accountAlertPreviewRow, 0, minInt(len(alerts), 6))
|
||||||
|
for _, alert := range alerts {
|
||||||
|
if len(rows) == 6 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
rows = append(rows, accountAlertPreviewRow{
|
||||||
|
Severity: alert.Severity,
|
||||||
|
Title: alert.Title,
|
||||||
|
Detail: alert.Description,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return rows
|
||||||
|
}
|
||||||
|
|
||||||
|
func accountPlaceholderActivity(actor metastore.User, ctx *gin.Context) []accountActivityRow {
|
||||||
|
now := time.Now().UTC()
|
||||||
|
if value, ok := ctx.Get("accountSession"); ok {
|
||||||
|
if session, ok := value.(metastore.Session); ok {
|
||||||
|
now = session.CreatedAt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return []accountActivityRow{
|
||||||
|
{
|
||||||
|
Time: formatAdminTime(now),
|
||||||
|
Title: "Signed in",
|
||||||
|
Meta: actor.Username + " opened the account dashboard.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Time: "pending",
|
||||||
|
Title: "Audit log not implemented",
|
||||||
|
Meta: "Recent account activity will use the audit model in a later pass.",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func accountBoxFlags(expired bool, oneTime bool, passwordProtected bool) string {
|
||||||
|
flags := []string{}
|
||||||
|
if expired {
|
||||||
|
flags = append(flags, "expired")
|
||||||
|
}
|
||||||
|
if oneTime {
|
||||||
|
flags = append(flags, "one-time")
|
||||||
|
}
|
||||||
|
if passwordProtected {
|
||||||
|
flags = append(flags, "password")
|
||||||
|
}
|
||||||
|
if len(flags) == 0 {
|
||||||
|
return "normal"
|
||||||
|
}
|
||||||
|
out := flags[0]
|
||||||
|
for _, flag := range flags[1:] {
|
||||||
|
out += ", " + flag
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func enabledLabel(enabled bool) string {
|
||||||
|
if enabled {
|
||||||
|
return "enabled"
|
||||||
|
}
|
||||||
|
return "disabled"
|
||||||
|
}
|
||||||
|
|
||||||
|
func boolSeverity(enabled bool) string {
|
||||||
|
if enabled {
|
||||||
|
return "ok"
|
||||||
|
}
|
||||||
|
return "warn"
|
||||||
|
}
|
||||||
|
|
||||||
|
func minInt(a int, b int) int {
|
||||||
|
if a < b {
|
||||||
|
return a
|
||||||
|
}
|
||||||
|
return b
|
||||||
|
}
|
||||||
506
lib/server/account_settings.go
Normal file
506
lib/server/account_settings.go
Normal file
@@ -0,0 +1,506 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"warpbox/lib/config"
|
||||||
|
"warpbox/lib/metastore"
|
||||||
|
)
|
||||||
|
|
||||||
|
type SettingsView struct {
|
||||||
|
PageTitle string
|
||||||
|
WindowTitle string
|
||||||
|
WindowIcon string
|
||||||
|
PageScripts []string
|
||||||
|
AccountNav AccountNavView
|
||||||
|
CSRFToken string
|
||||||
|
Groups []SettingsGroupView
|
||||||
|
OverridesAllowed bool
|
||||||
|
CanEdit bool
|
||||||
|
Error string
|
||||||
|
Notice string
|
||||||
|
}
|
||||||
|
|
||||||
|
type SettingsGroupView struct {
|
||||||
|
Key string
|
||||||
|
Label string
|
||||||
|
Description string
|
||||||
|
Rows []SettingsRowView
|
||||||
|
}
|
||||||
|
|
||||||
|
type SettingsRowView struct {
|
||||||
|
Key string
|
||||||
|
Label string
|
||||||
|
Description string
|
||||||
|
Type config.SettingType
|
||||||
|
Value string
|
||||||
|
DisplayValue string
|
||||||
|
Source string
|
||||||
|
EnvName string
|
||||||
|
Editable bool
|
||||||
|
LockedReason string
|
||||||
|
Future bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type SettingsBackup struct {
|
||||||
|
Version int `json:"version"`
|
||||||
|
ExportedAt string `json:"exported_at"`
|
||||||
|
Settings map[string]string `json:"settings"`
|
||||||
|
Metadata map[string]string `json:"metadata,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ImportResult struct {
|
||||||
|
Applied int `json:"applied"`
|
||||||
|
Keys []string `json:"keys"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type settingsMeta struct {
|
||||||
|
Group string
|
||||||
|
Description string
|
||||||
|
Units string
|
||||||
|
Future bool
|
||||||
|
}
|
||||||
|
|
||||||
|
var settingsGroups = []SettingsGroupView{
|
||||||
|
{Key: "uploads", Label: "Uploads", Description: "Guest uploads and upload size defaults."},
|
||||||
|
{Key: "downloads", Label: "Downloads", Description: "ZIP and one-time download behavior."},
|
||||||
|
{Key: "retention", Label: "Retention", Description: "Expiry and renewal defaults."},
|
||||||
|
{Key: "accounts", Label: "Accounts", Description: "Session and account defaults."},
|
||||||
|
{Key: "api", Label: "API", Description: "API surface toggles."},
|
||||||
|
{Key: "storage", Label: "Storage", Description: "Storage paths and hard capacity limits."},
|
||||||
|
{Key: "workers", Label: "Workers", Description: "Background worker timing."},
|
||||||
|
{Key: "box_policy", Label: "Box policy", Description: "Defaults for future owner-managed boxes."},
|
||||||
|
}
|
||||||
|
|
||||||
|
var settingsMetadata = map[string]settingsMeta{
|
||||||
|
config.SettingGuestUploadsEnabled: {Group: "uploads", Description: "Allow guests to create upload boxes."},
|
||||||
|
config.SettingDefaultUserMaxFileBytes: {Group: "uploads", Description: "Default per-user file size limit. Zero means unlimited.", Units: "bytes"},
|
||||||
|
config.SettingDefaultUserMaxBoxBytes: {Group: "uploads", Description: "Default per-user total box size limit. Zero means unlimited.", Units: "bytes"},
|
||||||
|
config.SettingZipDownloadsEnabled: {Group: "downloads", Description: "Allow ZIP downloads when a box permits it."},
|
||||||
|
config.SettingOneTimeDownloadsEnabled: {Group: "downloads", Description: "Allow one-time ZIP handoff boxes."},
|
||||||
|
config.SettingOneTimeDownloadExpirySecs: {Group: "downloads", Description: "How long one-time downloads stay retryable or pending.", Units: "duration"},
|
||||||
|
config.SettingOneTimeDownloadRetryFail: {Group: "downloads", Description: "Keep one-time boxes retryable after a ZIP writer failure."},
|
||||||
|
config.SettingDefaultGuestExpirySecs: {Group: "retention", Description: "Default guest box expiry.", Units: "duration"},
|
||||||
|
config.SettingMaxGuestExpirySecs: {Group: "retention", Description: "Maximum guest box expiry.", Units: "duration"},
|
||||||
|
config.SettingRenewOnAccessEnabled: {Group: "retention", Description: "Allow expiry renewal when a box is opened."},
|
||||||
|
config.SettingRenewOnDownloadEnabled: {Group: "retention", Description: "Allow expiry renewal when files are downloaded."},
|
||||||
|
config.SettingSessionTTLSeconds: {Group: "accounts", Description: "Account session lifetime.", Units: "duration"},
|
||||||
|
config.SettingAPIEnabled: {Group: "api", Description: "Expose API-style upload/status endpoints."},
|
||||||
|
config.SettingDataDir: {Group: "storage", Description: "Base data directory. Environment only."},
|
||||||
|
config.SettingGlobalMaxFileSizeBytes: {Group: "storage", Description: "Hard global file size cap. Environment only.", Units: "bytes"},
|
||||||
|
config.SettingGlobalMaxBoxSizeBytes: {Group: "storage", Description: "Hard global box size cap. Environment only.", Units: "bytes"},
|
||||||
|
config.SettingBoxPollIntervalMS: {Group: "workers", Description: "Browser polling cadence for box status.", Units: "milliseconds"},
|
||||||
|
config.SettingThumbnailBatchSize: {Group: "workers", Description: "Thumbnail worker batch size."},
|
||||||
|
config.SettingThumbnailIntervalSeconds: {Group: "workers", Description: "Thumbnail worker interval.", Units: "duration"},
|
||||||
|
config.SettingBoxOwnerEditEnabled: {Group: "box_policy", Description: "Default: owners may edit their boxes."},
|
||||||
|
config.SettingBoxOwnerRefreshEnabled: {Group: "box_policy", Description: "Default: owners may refresh box expiry."},
|
||||||
|
config.SettingBoxOwnerMaxRefreshCount: {Group: "box_policy", Description: "Default maximum number of owner refreshes."},
|
||||||
|
config.SettingBoxOwnerMaxRefreshAmount: {Group: "box_policy", Description: "Default maximum expiry added per owner refresh.", Units: "duration"},
|
||||||
|
config.SettingBoxOwnerMaxTotalExpiry: {Group: "box_policy", Description: "Default maximum total box expiry for owner-managed boxes.", Units: "duration"},
|
||||||
|
config.SettingBoxOwnerPasswordEdit: {Group: "box_policy", Description: "Default: owners may edit box passwords."},
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *App) handleAccountSettings(ctx *gin.Context) {
|
||||||
|
actor, ok := currentAccountUser(ctx)
|
||||||
|
if !ok {
|
||||||
|
ctx.Redirect(http.StatusSeeOther, "/account/login")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
view, err := app.ListSettings(ctx, actor)
|
||||||
|
if err != nil {
|
||||||
|
ctx.String(http.StatusForbidden, "Permission denied")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx.HTML(http.StatusOK, "account_settings.html", view)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *App) handleAccountSettingsPost(ctx *gin.Context) {
|
||||||
|
actor, ok := currentAccountUser(ctx)
|
||||||
|
if !ok {
|
||||||
|
ctx.Redirect(http.StatusSeeOther, "/account/login")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := ctx.Request.ParseForm(); err != nil {
|
||||||
|
app.renderSettingsWithMessage(ctx, actor, "could not parse settings form", "")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
editable := map[string]config.SettingDefinition{}
|
||||||
|
for _, def := range config.EditableDefinitions() {
|
||||||
|
editable[def.Key] = def
|
||||||
|
}
|
||||||
|
for key := range ctx.Request.PostForm {
|
||||||
|
if key == "csrf_token" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, ok := editable[key]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, ok := config.Definition(key); ok {
|
||||||
|
app.renderSettingsWithMessage(ctx, actor, fmt.Sprintf("setting %q is locked", key), "")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
app.renderSettingsWithMessage(ctx, actor, fmt.Sprintf("unknown setting %q", key), "")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
changes := map[string]string{}
|
||||||
|
for _, def := range editable {
|
||||||
|
if def.Type == config.SettingTypeBool {
|
||||||
|
value := "false"
|
||||||
|
if ctx.PostForm(def.Key) == "true" {
|
||||||
|
value = "true"
|
||||||
|
}
|
||||||
|
changes[def.Key] = value
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, exists := ctx.GetPostForm(def.Key); exists {
|
||||||
|
changes[def.Key] = ctx.PostForm(def.Key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := app.UpdateSettings(ctx, actor, changes); err != nil {
|
||||||
|
app.renderSettingsWithMessage(ctx, actor, err.Error(), "")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx.Redirect(http.StatusSeeOther, "/account/settings")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *App) handleAccountSettingsReset(ctx *gin.Context) {
|
||||||
|
actor, ok := currentAccountUser(ctx)
|
||||||
|
if !ok {
|
||||||
|
ctx.Redirect(http.StatusSeeOther, "/account/login")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := app.ResetSettingOverride(ctx, actor, ctx.PostForm("key")); err != nil {
|
||||||
|
app.renderSettingsWithMessage(ctx, actor, err.Error(), "")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx.Redirect(http.StatusSeeOther, "/account/settings")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *App) handleAccountSettingsExport(ctx *gin.Context) {
|
||||||
|
actor, ok := currentAccountUser(ctx)
|
||||||
|
if !ok {
|
||||||
|
ctx.Redirect(http.StatusSeeOther, "/account/login")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
backup, err := app.ExportSettings(ctx, actor)
|
||||||
|
if err != nil {
|
||||||
|
ctx.String(http.StatusForbidden, "Permission denied")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx.Header("Content-Disposition", `attachment; filename="warpbox-settings.json"`)
|
||||||
|
ctx.JSON(http.StatusOK, backup)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *App) handleAccountSettingsImport(ctx *gin.Context) {
|
||||||
|
actor, ok := currentAccountUser(ctx)
|
||||||
|
if !ok {
|
||||||
|
ctx.Redirect(http.StatusSeeOther, "/account/login")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(strings.ToLower(ctx.GetHeader("Content-Type")), "application/json") {
|
||||||
|
ctx.JSON(http.StatusUnsupportedMediaType, gin.H{"error": "settings import requires application/json"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var backup SettingsBackup
|
||||||
|
if err := json.NewDecoder(ctx.Request.Body).Decode(&backup); err != nil {
|
||||||
|
ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid settings JSON"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
result, err := app.ImportSettings(ctx, actor, backup)
|
||||||
|
if err != nil {
|
||||||
|
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx.JSON(http.StatusOK, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *App) ListSettings(ctx *gin.Context, actor metastore.User) (SettingsView, error) {
|
||||||
|
perms := currentAccountPermissions(ctx)
|
||||||
|
if !perms.AdminSettingsManage {
|
||||||
|
return SettingsView{}, fmt.Errorf("permission denied")
|
||||||
|
}
|
||||||
|
|
||||||
|
rows := app.settingsRows(perms.AdminSettingsManage && app.config.AllowAdminSettingsOverride)
|
||||||
|
groups := make([]SettingsGroupView, 0, len(settingsGroups))
|
||||||
|
for _, group := range settingsGroups {
|
||||||
|
copyGroup := group
|
||||||
|
copyGroup.Rows = rows[group.Key]
|
||||||
|
groups = append(groups, copyGroup)
|
||||||
|
}
|
||||||
|
|
||||||
|
return SettingsView{
|
||||||
|
PageTitle: "WarpBox Settings",
|
||||||
|
WindowTitle: "WarpBox Account Settings",
|
||||||
|
WindowIcon: "S",
|
||||||
|
PageScripts: []string{"/static/js/account-settings.js"},
|
||||||
|
AccountNav: app.accountNavView(ctx, "settings"),
|
||||||
|
CSRFToken: app.currentCSRFToken(ctx),
|
||||||
|
Groups: groups,
|
||||||
|
OverridesAllowed: app.config.AllowAdminSettingsOverride,
|
||||||
|
CanEdit: app.config.AllowAdminSettingsOverride,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *App) UpdateSettings(ctx *gin.Context, actor metastore.User, changes map[string]string) error {
|
||||||
|
if err := app.requireSettingsEdit(ctx); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !app.config.AllowAdminSettingsOverride {
|
||||||
|
return fmt.Errorf("admin settings overrides are disabled")
|
||||||
|
}
|
||||||
|
if err := validateSettingChanges(changes); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for key, value := range changes {
|
||||||
|
if err := app.store.SetSetting(key, value); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return app.reloadRuntimeConfig()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *App) ResetSettingOverride(ctx *gin.Context, actor metastore.User, key string) error {
|
||||||
|
if err := app.requireSettingsEdit(ctx); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
def, ok := config.Definition(strings.TrimSpace(key))
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("unknown setting %q", key)
|
||||||
|
}
|
||||||
|
if !def.Editable || def.HardLimit {
|
||||||
|
return fmt.Errorf("setting %q cannot be reset from account settings", key)
|
||||||
|
}
|
||||||
|
if err := app.store.DeleteSetting(def.Key); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return app.reloadRuntimeConfig()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *App) ExportSettings(ctx *gin.Context, actor metastore.User) (SettingsBackup, error) {
|
||||||
|
perms := currentAccountPermissions(ctx)
|
||||||
|
if !perms.AdminSettingsManage {
|
||||||
|
return SettingsBackup{}, fmt.Errorf("permission denied")
|
||||||
|
}
|
||||||
|
settings := map[string]string{}
|
||||||
|
for _, def := range config.EditableDefinitions() {
|
||||||
|
settings[def.Key] = app.config.SettingValue(def.Key)
|
||||||
|
}
|
||||||
|
return SettingsBackup{
|
||||||
|
Version: 1,
|
||||||
|
ExportedAt: time.Now().UTC().Format(time.RFC3339),
|
||||||
|
Settings: settings,
|
||||||
|
Metadata: map[string]string{
|
||||||
|
"app": "WarpBox",
|
||||||
|
},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *App) ImportSettings(ctx *gin.Context, actor metastore.User, backup SettingsBackup) (ImportResult, error) {
|
||||||
|
if err := app.requireSettingsEdit(ctx); err != nil {
|
||||||
|
return ImportResult{}, err
|
||||||
|
}
|
||||||
|
if !app.config.AllowAdminSettingsOverride {
|
||||||
|
return ImportResult{}, fmt.Errorf("admin settings overrides are disabled")
|
||||||
|
}
|
||||||
|
if backup.Settings == nil {
|
||||||
|
return ImportResult{}, fmt.Errorf("settings backup has no settings")
|
||||||
|
}
|
||||||
|
if err := validateSettingChanges(backup.Settings); err != nil {
|
||||||
|
return ImportResult{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
keys := make([]string, 0, len(backup.Settings))
|
||||||
|
for key := range backup.Settings {
|
||||||
|
keys = append(keys, key)
|
||||||
|
}
|
||||||
|
sort.Strings(keys)
|
||||||
|
for _, key := range keys {
|
||||||
|
if err := app.store.SetSetting(key, backup.Settings[key]); err != nil {
|
||||||
|
return ImportResult{}, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := app.reloadRuntimeConfig(); err != nil {
|
||||||
|
return ImportResult{}, err
|
||||||
|
}
|
||||||
|
return ImportResult{Applied: len(keys), Keys: keys}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *App) renderSettingsWithMessage(ctx *gin.Context, actor metastore.User, errorMessage string, notice string) {
|
||||||
|
view, err := app.ListSettings(ctx, actor)
|
||||||
|
if err != nil {
|
||||||
|
ctx.String(http.StatusForbidden, "Permission denied")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
view.Error = errorMessage
|
||||||
|
view.Notice = notice
|
||||||
|
ctx.HTML(http.StatusOK, "account_settings.html", view)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *App) requireSettingsEdit(ctx *gin.Context) error {
|
||||||
|
perms := currentAccountPermissions(ctx)
|
||||||
|
if !perms.AdminSettingsManage {
|
||||||
|
return fmt.Errorf("permission denied")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *App) settingsRows(canEdit bool) map[string][]SettingsRowView {
|
||||||
|
out := map[string][]SettingsRowView{}
|
||||||
|
for _, row := range app.config.SettingRows() {
|
||||||
|
meta := settingsMetadata[row.Definition.Key]
|
||||||
|
group := meta.Group
|
||||||
|
if group == "" {
|
||||||
|
group = "accounts"
|
||||||
|
}
|
||||||
|
editable := canEdit && row.Definition.Editable && !row.Definition.HardLimit
|
||||||
|
out[group] = append(out[group], SettingsRowView{
|
||||||
|
Key: row.Definition.Key,
|
||||||
|
Label: row.Definition.Label,
|
||||||
|
Description: meta.Description,
|
||||||
|
Type: row.Definition.Type,
|
||||||
|
Value: row.Value,
|
||||||
|
DisplayValue: settingDisplayValue(row.Value, meta.Units),
|
||||||
|
Source: settingSourceLabel(row.Source, row.Definition),
|
||||||
|
EnvName: row.Definition.EnvName,
|
||||||
|
Editable: editable,
|
||||||
|
LockedReason: settingLockedReason(row.Definition, canEdit),
|
||||||
|
Future: meta.Future,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateSettingChanges(changes map[string]string) error {
|
||||||
|
if len(changes) == 0 {
|
||||||
|
return fmt.Errorf("no settings provided")
|
||||||
|
}
|
||||||
|
cfg, err := config.Load()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for key, value := range changes {
|
||||||
|
if _, ok := config.Definition(key); !ok {
|
||||||
|
return fmt.Errorf("unknown setting %q", key)
|
||||||
|
}
|
||||||
|
if err := cfg.ApplyOverride(key, value); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *App) reloadRuntimeConfig() error {
|
||||||
|
cfg, err := config.Load()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
overrides, err := app.store.ListSettings()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := cfg.ApplyOverrides(overrides); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
app.config = cfg
|
||||||
|
applyBoxstoreRuntimeConfig(cfg)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func settingSourceLabel(source config.Source, def config.SettingDefinition) string {
|
||||||
|
if def.HardLimit {
|
||||||
|
return "hard env"
|
||||||
|
}
|
||||||
|
if !def.Editable {
|
||||||
|
return "locked"
|
||||||
|
}
|
||||||
|
switch source {
|
||||||
|
case config.SourceDB:
|
||||||
|
return "override"
|
||||||
|
case config.SourceEnv:
|
||||||
|
return "env"
|
||||||
|
default:
|
||||||
|
return "default"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func settingLockedReason(def config.SettingDefinition, canEdit bool) string {
|
||||||
|
if !canEdit {
|
||||||
|
return "settings changes disabled"
|
||||||
|
}
|
||||||
|
if def.HardLimit {
|
||||||
|
return "hard environment limit"
|
||||||
|
}
|
||||||
|
if !def.Editable {
|
||||||
|
return "runtime editing not supported"
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func settingDisplayValue(value string, units string) string {
|
||||||
|
switch units {
|
||||||
|
case "bytes":
|
||||||
|
parsed, ok := parseInt64String(value)
|
||||||
|
if !ok {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
if parsed == 0 {
|
||||||
|
return "unlimited"
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%s (%s bytes)", formatBytesForSettings(parsed), value)
|
||||||
|
case "duration":
|
||||||
|
parsed, ok := parseInt64String(value)
|
||||||
|
if !ok {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%s (%s seconds)", formatDurationForSettings(parsed), value)
|
||||||
|
case "milliseconds":
|
||||||
|
return value + " ms"
|
||||||
|
default:
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseInt64String(value string) (int64, bool) {
|
||||||
|
var parsed int64
|
||||||
|
if _, err := fmt.Sscan(strings.TrimSpace(value), &parsed); err != nil {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
return parsed, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatBytesForSettings(value int64) string {
|
||||||
|
units := []string{"B", "KiB", "MiB", "GiB", "TiB"}
|
||||||
|
size := float64(value)
|
||||||
|
unit := 0
|
||||||
|
for size >= 1024 && unit < len(units)-1 {
|
||||||
|
size /= 1024
|
||||||
|
unit++
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%.1f %s", size, units[unit])
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatDurationForSettings(seconds int64) string {
|
||||||
|
switch {
|
||||||
|
case seconds == 0:
|
||||||
|
return "none"
|
||||||
|
case seconds%86400 == 0:
|
||||||
|
return fmt.Sprintf("%d days", seconds/86400)
|
||||||
|
case seconds%3600 == 0:
|
||||||
|
return fmt.Sprintf("%d hours", seconds/3600)
|
||||||
|
case seconds%60 == 0:
|
||||||
|
return fmt.Sprintf("%d minutes", seconds/60)
|
||||||
|
default:
|
||||||
|
return fmt.Sprintf("%d seconds", seconds)
|
||||||
|
}
|
||||||
|
}
|
||||||
197
lib/server/account_settings_test.go
Normal file
197
lib/server/account_settings_test.go
Normal file
@@ -0,0 +1,197 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"warpbox/lib/config"
|
||||||
|
"warpbox/lib/metastore"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestAccountSettingsPermissionDenied(t *testing.T) {
|
||||||
|
app, _ := setupAccountTestApp(t)
|
||||||
|
user, err := app.store.CreateUserWithPassword("regular", "regular@example.test", "secret", nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateUserWithPassword returned error: %v", err)
|
||||||
|
}
|
||||||
|
router := setupAccountTestRouter(t, app)
|
||||||
|
session := createAccountTestSession(t, app, user)
|
||||||
|
|
||||||
|
request := httptest.NewRequest(http.MethodGet, "/account/settings", nil)
|
||||||
|
request.AddCookie(&http.Cookie{Name: accountSessionCookie, Value: session.Token})
|
||||||
|
response := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(response, request)
|
||||||
|
if response.Code != http.StatusForbidden {
|
||||||
|
t.Fatalf("expected permission denied, got %d", response.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAccountSettingsPageLoadsForAdmin(t *testing.T) {
|
||||||
|
app, user := setupAccountTestApp(t)
|
||||||
|
router := setupAccountTestRouter(t, app)
|
||||||
|
session := createAccountTestSession(t, app, user)
|
||||||
|
|
||||||
|
request := httptest.NewRequest(http.MethodGet, "/account/settings", nil)
|
||||||
|
request.AddCookie(&http.Cookie{Name: accountSessionCookie, Value: session.Token})
|
||||||
|
response := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(response, request)
|
||||||
|
if response.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected settings page, got %d body=%s", response.Code, response.Body.String())
|
||||||
|
}
|
||||||
|
for _, text := range []string{"Uploads", "Downloads", "Box policy", "Save Settings"} {
|
||||||
|
if !strings.Contains(response.Body.String(), text) {
|
||||||
|
t.Fatalf("expected settings page to contain %q", text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAccountSettingsValidUpdate(t *testing.T) {
|
||||||
|
app, user := setupAccountTestApp(t)
|
||||||
|
router := setupAccountTestRouter(t, app)
|
||||||
|
session := createAccountTestSession(t, app, user)
|
||||||
|
|
||||||
|
form := url.Values{}
|
||||||
|
form.Set("csrf_token", session.CSRFToken)
|
||||||
|
form.Set(config.SettingAPIEnabled, "false")
|
||||||
|
response := postAccountSettingsForm(router, session, form)
|
||||||
|
if response.Code != http.StatusSeeOther {
|
||||||
|
t.Fatalf("expected settings redirect, got %d body=%s", response.Code, response.Body.String())
|
||||||
|
}
|
||||||
|
if app.config.APIEnabled {
|
||||||
|
t.Fatal("expected API setting to be disabled")
|
||||||
|
}
|
||||||
|
value, ok, err := app.store.GetSetting(config.SettingAPIEnabled)
|
||||||
|
if err != nil || !ok || value != "false" {
|
||||||
|
t.Fatalf("expected API setting override false, got value=%q ok=%v err=%v", value, ok, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAccountSettingsInvalidUpdate(t *testing.T) {
|
||||||
|
app, user := setupAccountTestApp(t)
|
||||||
|
router := setupAccountTestRouter(t, app)
|
||||||
|
session := createAccountTestSession(t, app, user)
|
||||||
|
|
||||||
|
form := url.Values{}
|
||||||
|
form.Set("csrf_token", session.CSRFToken)
|
||||||
|
form.Set(config.SettingSessionTTLSeconds, "1")
|
||||||
|
response := postAccountSettingsForm(router, session, form)
|
||||||
|
if response.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected settings form render, got %d", response.Code)
|
||||||
|
}
|
||||||
|
if !strings.Contains(response.Body.String(), "must be at least 60") {
|
||||||
|
t.Fatal("expected validation error in response")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAccountSettingsLockedSettingCannotChange(t *testing.T) {
|
||||||
|
app, user := setupAccountTestApp(t)
|
||||||
|
router := setupAccountTestRouter(t, app)
|
||||||
|
session := createAccountTestSession(t, app, user)
|
||||||
|
|
||||||
|
form := url.Values{}
|
||||||
|
form.Set("csrf_token", session.CSRFToken)
|
||||||
|
form.Set(config.SettingGlobalMaxFileSizeBytes, "1")
|
||||||
|
response := postAccountSettingsForm(router, session, form)
|
||||||
|
if response.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected settings form render, got %d", response.Code)
|
||||||
|
}
|
||||||
|
if !strings.Contains(response.Body.String(), "locked") {
|
||||||
|
t.Fatal("expected locked setting error")
|
||||||
|
}
|
||||||
|
if value, ok, err := app.store.GetSetting(config.SettingGlobalMaxFileSizeBytes); err != nil || ok || value != "" {
|
||||||
|
t.Fatalf("expected no locked setting override, got value=%q ok=%v err=%v", value, ok, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAccountSettingsImportRejectsUnknownOrInvalidSettings(t *testing.T) {
|
||||||
|
app, user := setupAccountTestApp(t)
|
||||||
|
router := setupAccountTestRouter(t, app)
|
||||||
|
session := createAccountTestSession(t, app, user)
|
||||||
|
|
||||||
|
for _, body := range []string{
|
||||||
|
`{"version":1,"settings":{"not_real":"true"}}`,
|
||||||
|
`{"version":1,"settings":{"session_ttl_seconds":"1"}}`,
|
||||||
|
} {
|
||||||
|
response := postAccountSettingsJSON(router, session, body)
|
||||||
|
if response.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("expected bad import for %s, got %d", body, response.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAccountSettingsImportAppliesValidSettings(t *testing.T) {
|
||||||
|
app, user := setupAccountTestApp(t)
|
||||||
|
router := setupAccountTestRouter(t, app)
|
||||||
|
session := createAccountTestSession(t, app, user)
|
||||||
|
|
||||||
|
response := postAccountSettingsJSON(router, session, `{"version":1,"settings":{"api_enabled":"false","box_owner_max_refresh_count":"7"}}`)
|
||||||
|
if response.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected import success, got %d body=%s", response.Code, response.Body.String())
|
||||||
|
}
|
||||||
|
if app.config.APIEnabled {
|
||||||
|
t.Fatal("expected imported API setting to be disabled")
|
||||||
|
}
|
||||||
|
if app.config.BoxOwnerMaxRefreshCount != 7 {
|
||||||
|
t.Fatalf("expected imported box owner refresh count 7, got %d", app.config.BoxOwnerMaxRefreshCount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAccountSettingsExportShape(t *testing.T) {
|
||||||
|
app, user := setupAccountTestApp(t)
|
||||||
|
router := setupAccountTestRouter(t, app)
|
||||||
|
session := createAccountTestSession(t, app, user)
|
||||||
|
|
||||||
|
request := httptest.NewRequest(http.MethodGet, "/account/settings/export.json", nil)
|
||||||
|
request.AddCookie(&http.Cookie{Name: accountSessionCookie, Value: session.Token})
|
||||||
|
response := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(response, request)
|
||||||
|
if response.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected export success, got %d", response.Code)
|
||||||
|
}
|
||||||
|
var backup SettingsBackup
|
||||||
|
if err := json.Unmarshal(response.Body.Bytes(), &backup); err != nil {
|
||||||
|
t.Fatalf("Unmarshal returned error: %v", err)
|
||||||
|
}
|
||||||
|
if backup.Version != 1 {
|
||||||
|
t.Fatalf("expected version 1, got %d", backup.Version)
|
||||||
|
}
|
||||||
|
if _, ok := backup.Settings[config.SettingBoxOwnerMaxRefreshCount]; !ok {
|
||||||
|
t.Fatal("expected export to include box owner policy setting")
|
||||||
|
}
|
||||||
|
if _, ok := backup.Settings[config.SettingDataDir]; ok {
|
||||||
|
t.Fatal("did not expect locked data dir in export settings")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func createAccountTestSession(t *testing.T, app *App, user metastore.User) metastore.Session {
|
||||||
|
t.Helper()
|
||||||
|
session, err := app.store.CreateSession(user.ID, time.Hour)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateSession returned error: %v", err)
|
||||||
|
}
|
||||||
|
return session
|
||||||
|
}
|
||||||
|
|
||||||
|
func postAccountSettingsForm(router http.Handler, session metastore.Session, form url.Values) *httptest.ResponseRecorder {
|
||||||
|
request := httptest.NewRequest(http.MethodPost, "/account/settings", strings.NewReader(form.Encode()))
|
||||||
|
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
request.AddCookie(&http.Cookie{Name: accountSessionCookie, Value: session.Token})
|
||||||
|
response := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(response, request)
|
||||||
|
return response
|
||||||
|
}
|
||||||
|
|
||||||
|
func postAccountSettingsJSON(router http.Handler, session metastore.Session, body string) *httptest.ResponseRecorder {
|
||||||
|
request := httptest.NewRequest(http.MethodPost, "/account/settings/import.json", strings.NewReader(body))
|
||||||
|
request.Header.Set("Content-Type", "application/json")
|
||||||
|
request.Header.Set("X-CSRF-Token", session.CSRFToken)
|
||||||
|
request.AddCookie(&http.Cookie{Name: accountSessionCookie, Value: session.Token})
|
||||||
|
response := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(response, request)
|
||||||
|
return response
|
||||||
|
}
|
||||||
858
lib/server/account_test.go
Normal file
858
lib/server/account_test.go
Normal file
@@ -0,0 +1,858 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"html/template"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"warpbox/lib/boxstore"
|
||||||
|
"warpbox/lib/config"
|
||||||
|
"warpbox/lib/metastore"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestAccountLoginSuccess(t *testing.T) {
|
||||||
|
app, _ := setupAccountTestApp(t)
|
||||||
|
router := setupAccountTestRouter(t, app)
|
||||||
|
|
||||||
|
response := postAccountLogin(router, "admin", "secret")
|
||||||
|
if response.Code != http.StatusSeeOther {
|
||||||
|
t.Fatalf("expected login redirect, got %d", response.Code)
|
||||||
|
}
|
||||||
|
if location := response.Header().Get("Location"); location != "/account" {
|
||||||
|
t.Fatalf("expected redirect to /account, got %q", location)
|
||||||
|
}
|
||||||
|
if cookie := findResponseCookie(response, accountSessionCookie); cookie == nil || cookie.Value == "" {
|
||||||
|
t.Fatal("expected account session cookie")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAccountLoginFailure(t *testing.T) {
|
||||||
|
app, _ := setupAccountTestApp(t)
|
||||||
|
router := setupAccountTestRouter(t, app)
|
||||||
|
|
||||||
|
response := postAccountLogin(router, "admin", "wrong")
|
||||||
|
if response.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected failed login to render form, got %d", response.Code)
|
||||||
|
}
|
||||||
|
if cookie := findResponseCookie(response, accountSessionCookie); cookie != nil {
|
||||||
|
t.Fatal("did not expect account session cookie")
|
||||||
|
}
|
||||||
|
if !strings.Contains(response.Body.String(), "not accepted") {
|
||||||
|
t.Fatal("expected login failure message")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAccountDisabledUserLoginFailure(t *testing.T) {
|
||||||
|
app, user := setupAccountTestApp(t)
|
||||||
|
user.Disabled = true
|
||||||
|
if err := app.store.UpdateUser(user); err != nil {
|
||||||
|
t.Fatalf("UpdateUser returned error: %v", err)
|
||||||
|
}
|
||||||
|
router := setupAccountTestRouter(t, app)
|
||||||
|
|
||||||
|
response := postAccountLogin(router, "admin", "secret")
|
||||||
|
if response.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected disabled login to render form, got %d", response.Code)
|
||||||
|
}
|
||||||
|
if cookie := findResponseCookie(response, accountSessionCookie); cookie != nil {
|
||||||
|
t.Fatal("did not expect account session cookie")
|
||||||
|
}
|
||||||
|
if !strings.Contains(response.Body.String(), "not accepted") {
|
||||||
|
t.Fatal("expected login failure message")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAccountLogoutRequiresCSRF(t *testing.T) {
|
||||||
|
app, user := setupAccountTestApp(t)
|
||||||
|
router := setupAccountTestRouter(t, app)
|
||||||
|
session, err := app.store.CreateSession(user.ID, time.Hour)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateSession returned error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
request := httptest.NewRequest(http.MethodPost, "/account/logout", nil)
|
||||||
|
request.AddCookie(&http.Cookie{Name: accountSessionCookie, Value: session.Token})
|
||||||
|
response := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(response, request)
|
||||||
|
if response.Code != http.StatusForbidden {
|
||||||
|
t.Fatalf("expected missing CSRF token to be forbidden, got %d", response.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAccountDashboardRequiresAuth(t *testing.T) {
|
||||||
|
app, _ := setupAccountTestApp(t)
|
||||||
|
router := setupAccountTestRouter(t, app)
|
||||||
|
|
||||||
|
request := httptest.NewRequest(http.MethodGet, "/account", nil)
|
||||||
|
response := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(response, request)
|
||||||
|
if response.Code != http.StatusSeeOther {
|
||||||
|
t.Fatalf("expected dashboard redirect, got %d", response.Code)
|
||||||
|
}
|
||||||
|
if location := response.Header().Get("Location"); location != "/account/login" {
|
||||||
|
t.Fatalf("expected redirect to /account/login, got %q", location)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAccountDashboardLoadsForBootstrapAdmin(t *testing.T) {
|
||||||
|
app, user := setupAccountTestApp(t)
|
||||||
|
router := setupAccountTestRouter(t, app)
|
||||||
|
session, err := app.store.CreateSession(user.ID, time.Hour)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateSession returned error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
request := httptest.NewRequest(http.MethodGet, "/account", nil)
|
||||||
|
request.AddCookie(&http.Cookie{Name: accountSessionCookie, Value: session.Token})
|
||||||
|
response := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(response, request)
|
||||||
|
if response.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected dashboard to load, got %d", response.Code)
|
||||||
|
}
|
||||||
|
body := response.Body.String()
|
||||||
|
for _, text := range []string{"Dashboard", "Recent Boxes", "Users"} {
|
||||||
|
if !strings.Contains(body, text) {
|
||||||
|
t.Fatalf("expected dashboard body to contain %q", text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAccountDashboardHidesAdminOnlyLinksForRegularUser(t *testing.T) {
|
||||||
|
app, _ := setupAccountTestApp(t)
|
||||||
|
user, err := app.store.CreateUserWithPassword("maya", "maya@example.test", "secret", nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateUserWithPassword returned error: %v", err)
|
||||||
|
}
|
||||||
|
router := setupAccountTestRouter(t, app)
|
||||||
|
session, err := app.store.CreateSession(user.ID, time.Hour)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateSession returned error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
request := httptest.NewRequest(http.MethodGet, "/account", nil)
|
||||||
|
request.AddCookie(&http.Cookie{Name: accountSessionCookie, Value: session.Token})
|
||||||
|
response := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(response, request)
|
||||||
|
if response.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected dashboard to load, got %d", response.Code)
|
||||||
|
}
|
||||||
|
body := response.Body.String()
|
||||||
|
for _, text := range []string{">Users<", ">Settings<"} {
|
||||||
|
if strings.Contains(body, text) {
|
||||||
|
t.Fatalf("expected dashboard body to hide %q", text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAdminEntryRedirectsToAccount(t *testing.T) {
|
||||||
|
app, _ := setupAccountTestApp(t)
|
||||||
|
router := setupAccountTestRouter(t, app)
|
||||||
|
|
||||||
|
cases := map[string]string{
|
||||||
|
"/admin/login": "/account/login",
|
||||||
|
"/admin": "/account",
|
||||||
|
}
|
||||||
|
for path, wantLocation := range cases {
|
||||||
|
request := httptest.NewRequest(http.MethodGet, path, nil)
|
||||||
|
response := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(response, request)
|
||||||
|
if response.Code != http.StatusSeeOther {
|
||||||
|
t.Fatalf("expected %s redirect, got %d", path, response.Code)
|
||||||
|
}
|
||||||
|
if location := response.Header().Get("Location"); location != wantLocation {
|
||||||
|
t.Fatalf("expected %s to redirect to %s, got %q", path, wantLocation, location)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func setupAccountTestApp(t *testing.T) (*App, metastore.User) {
|
||||||
|
t.Helper()
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
|
||||||
|
restoreUploadRoot := boxstore.UploadRoot()
|
||||||
|
t.Cleanup(func() { boxstore.SetUploadRoot(restoreUploadRoot) })
|
||||||
|
boxstore.SetUploadRoot(t.TempDir())
|
||||||
|
|
||||||
|
store, err := metastore.Open(t.TempDir())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Open returned error: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = store.Close() })
|
||||||
|
|
||||||
|
cfg, err := config.Load()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Load returned error: %v", err)
|
||||||
|
}
|
||||||
|
cfg.AdminUsername = "admin"
|
||||||
|
cfg.AdminPassword = "secret"
|
||||||
|
cfg.AdminEmail = "admin@example.test"
|
||||||
|
cfg.AdminEnabled = config.AdminEnabledAuto
|
||||||
|
cfg.SessionTTLSeconds = 3600
|
||||||
|
bootstrap, err := metastore.BootstrapAdmin(cfg, store)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("BootstrapAdmin returned error: %v", err)
|
||||||
|
}
|
||||||
|
if bootstrap.AdminUser == nil {
|
||||||
|
t.Fatal("expected bootstrap admin user")
|
||||||
|
}
|
||||||
|
|
||||||
|
app := &App{
|
||||||
|
config: cfg,
|
||||||
|
store: store,
|
||||||
|
adminLoginEnabled: bootstrap.AdminLoginEnabled,
|
||||||
|
}
|
||||||
|
return app, *bootstrap.AdminUser
|
||||||
|
}
|
||||||
|
|
||||||
|
func setupAccountTestRouter(t *testing.T, app *App) *gin.Engine {
|
||||||
|
t.Helper()
|
||||||
|
router := gin.New()
|
||||||
|
templates, err := template.ParseGlob(filepath.Join("..", "..", "templates", "*.html"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ParseGlob returned error: %v", err)
|
||||||
|
}
|
||||||
|
router.SetHTMLTemplate(templates)
|
||||||
|
app.registerAccountRoutes(router)
|
||||||
|
app.registerAdminRoutes(router)
|
||||||
|
return router
|
||||||
|
}
|
||||||
|
|
||||||
|
func postAccountLogin(router *gin.Engine, username string, password string) *httptest.ResponseRecorder {
|
||||||
|
form := url.Values{}
|
||||||
|
form.Set("username", username)
|
||||||
|
form.Set("password", password)
|
||||||
|
request := httptest.NewRequest(http.MethodPost, "/account/login", strings.NewReader(form.Encode()))
|
||||||
|
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
response := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(response, request)
|
||||||
|
return response
|
||||||
|
}
|
||||||
|
|
||||||
|
func findResponseCookie(response *httptest.ResponseRecorder, name string) *http.Cookie {
|
||||||
|
for _, cookie := range response.Result().Cookies() {
|
||||||
|
if cookie.Name == name {
|
||||||
|
return cookie
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUsersPagePermissionDeniedForNoPerms(t *testing.T) {
|
||||||
|
app, _ := setupAccountTestApp(t)
|
||||||
|
user, err := app.store.CreateUserWithPassword("viewer", "viewer@example.test", "secret", nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateUserWithPassword returned error: %v", err)
|
||||||
|
}
|
||||||
|
router := setupAccountTestRouter(t, app)
|
||||||
|
session, err := app.store.CreateSession(user.ID, time.Hour)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateSession returned error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
request := httptest.NewRequest(http.MethodGet, "/account/users", nil)
|
||||||
|
request.AddCookie(&http.Cookie{Name: accountSessionCookie, Value: session.Token})
|
||||||
|
response := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(response, request)
|
||||||
|
if response.Code != http.StatusForbidden {
|
||||||
|
t.Fatalf("expected permission denied, got %d", response.Code)
|
||||||
|
}
|
||||||
|
if !strings.Contains(response.Body.String(), "Permission denied") {
|
||||||
|
t.Fatal("expected permission denied message")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUsersPageLoadsForAdmin(t *testing.T) {
|
||||||
|
app, user := setupAccountTestApp(t)
|
||||||
|
router := setupAccountTestRouter(t, app)
|
||||||
|
session, err := app.store.CreateSession(user.ID, time.Hour)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateSession returned error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
request := httptest.NewRequest(http.MethodGet, "/account/users", nil)
|
||||||
|
request.AddCookie(&http.Cookie{Name: accountSessionCookie, Value: session.Token})
|
||||||
|
response := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(response, request)
|
||||||
|
if response.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected users page to load, got %d", response.Code)
|
||||||
|
}
|
||||||
|
body := response.Body.String()
|
||||||
|
for _, text := range []string{"WarpBox Users", "Create or Invite", "Total users"} {
|
||||||
|
if !strings.Contains(body, text) {
|
||||||
|
t.Fatalf("expected users page body to contain %q", text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUsersPageListFilters(t *testing.T) {
|
||||||
|
app, user := setupAccountTestApp(t)
|
||||||
|
_, err := app.store.CreateUserWithPassword("beta", "beta@example.test", "secret", nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateUserWithPassword returned error: %v", err)
|
||||||
|
}
|
||||||
|
router := setupAccountTestRouter(t, app)
|
||||||
|
session, err := app.store.CreateSession(user.ID, time.Hour)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateSession returned error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
request := httptest.NewRequest(http.MethodGet, "/account/users?q=beta", nil)
|
||||||
|
request.AddCookie(&http.Cookie{Name: accountSessionCookie, Value: session.Token})
|
||||||
|
response := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(response, request)
|
||||||
|
if response.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected users page to load, got %d", response.Code)
|
||||||
|
}
|
||||||
|
body := response.Body.String()
|
||||||
|
if !strings.Contains(body, "beta") {
|
||||||
|
t.Fatal("expected filtered list to contain beta")
|
||||||
|
}
|
||||||
|
if !strings.Contains(body, "1 matching user(s)") {
|
||||||
|
t.Fatalf("expected 1 matching user for beta filter, got body: %s", body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUserCreation(t *testing.T) {
|
||||||
|
app, user := setupAccountTestApp(t)
|
||||||
|
router := setupAccountTestRouter(t, app)
|
||||||
|
session, err := app.store.CreateSession(user.ID, time.Hour)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateSession returned error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
form := url.Values{}
|
||||||
|
form.Set("csrf_token", session.CSRFToken)
|
||||||
|
form.Set("action", "create")
|
||||||
|
form.Set("mode", "create")
|
||||||
|
form.Set("username", "newuser")
|
||||||
|
form.Set("email", "new@example.test")
|
||||||
|
form.Set("password", "password123")
|
||||||
|
|
||||||
|
request := httptest.NewRequest(http.MethodPost, "/account/users", strings.NewReader(form.Encode()))
|
||||||
|
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
request.AddCookie(&http.Cookie{Name: accountSessionCookie, Value: session.Token})
|
||||||
|
response := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(response, request)
|
||||||
|
|
||||||
|
if response.Code != http.StatusSeeOther {
|
||||||
|
t.Fatalf("expected redirect after create, got %d", response.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
created, ok, err := app.store.GetUserByUsername("newuser")
|
||||||
|
if err != nil || !ok {
|
||||||
|
t.Fatal("expected newuser to exist")
|
||||||
|
}
|
||||||
|
if created.Disabled {
|
||||||
|
t.Fatal("expected newuser to be active")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUserInviteCreation(t *testing.T) {
|
||||||
|
app, user := setupAccountTestApp(t)
|
||||||
|
router := setupAccountTestRouter(t, app)
|
||||||
|
session, err := app.store.CreateSession(user.ID, time.Hour)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateSession returned error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
form := url.Values{}
|
||||||
|
form.Set("csrf_token", session.CSRFToken)
|
||||||
|
form.Set("action", "create")
|
||||||
|
form.Set("mode", "invite")
|
||||||
|
form.Set("username", "invited")
|
||||||
|
form.Set("email", "invited@example.test")
|
||||||
|
|
||||||
|
request := httptest.NewRequest(http.MethodPost, "/account/users", strings.NewReader(form.Encode()))
|
||||||
|
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
request.AddCookie(&http.Cookie{Name: accountSessionCookie, Value: session.Token})
|
||||||
|
response := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(response, request)
|
||||||
|
|
||||||
|
if response.Code != http.StatusSeeOther {
|
||||||
|
t.Fatalf("expected redirect after invite, got %d", response.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
created, ok, err := app.store.GetUserByUsername("invited")
|
||||||
|
if err != nil || !ok {
|
||||||
|
t.Fatal("expected invited user to exist")
|
||||||
|
}
|
||||||
|
if !created.Disabled {
|
||||||
|
t.Fatal("expected invited user to be disabled")
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(created.PasswordHash, "invite/") {
|
||||||
|
t.Fatal("expected invited user to have invite prefix")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBulkDisableRejectsSelf(t *testing.T) {
|
||||||
|
app, user := setupAccountTestApp(t)
|
||||||
|
router := setupAccountTestRouter(t, app)
|
||||||
|
session, err := app.store.CreateSession(user.ID, time.Hour)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateSession returned error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
form := url.Values{}
|
||||||
|
form.Set("csrf_token", session.CSRFToken)
|
||||||
|
form.Set("selected_ids", user.ID)
|
||||||
|
|
||||||
|
request := httptest.NewRequest(http.MethodPost, "/account/users/bulk/disable", strings.NewReader(form.Encode()))
|
||||||
|
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
request.AddCookie(&http.Cookie{Name: accountSessionCookie, Value: session.Token})
|
||||||
|
response := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(response, request)
|
||||||
|
|
||||||
|
if response.Code != http.StatusSeeOther {
|
||||||
|
t.Fatalf("expected redirect, got %d", response.Code)
|
||||||
|
}
|
||||||
|
location := response.Header().Get("Location")
|
||||||
|
if !strings.Contains(location, "cannot disable yourself") && !strings.Contains(location, "error=") {
|
||||||
|
t.Fatalf("expected self-disable rejection, got location %q", location)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBulkDisableProtectsFinalAdmin(t *testing.T) {
|
||||||
|
app, user := setupAccountTestApp(t)
|
||||||
|
adminTag, ok, err := app.store.GetTagByName(metastore.AdminTagName)
|
||||||
|
if err != nil || !ok || adminTag.ID == "" {
|
||||||
|
t.Fatal("expected admin tag")
|
||||||
|
}
|
||||||
|
second, err := app.store.CreateUserWithPassword("admin2", "admin2@example.test", "secret", []string{adminTag.ID})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateUserWithPassword returned error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Admin tries to disable the other admin (not self): should work since self remains.
|
||||||
|
router := setupAccountTestRouter(t, app)
|
||||||
|
session, err := app.store.CreateSession(user.ID, time.Hour)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateSession returned error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
form := url.Values{}
|
||||||
|
form.Set("csrf_token", session.CSRFToken)
|
||||||
|
form.Set("selected_ids", second.ID)
|
||||||
|
|
||||||
|
request := httptest.NewRequest(http.MethodPost, "/account/users/bulk/disable", strings.NewReader(form.Encode()))
|
||||||
|
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
request.AddCookie(&http.Cookie{Name: accountSessionCookie, Value: session.Token})
|
||||||
|
response := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(response, request)
|
||||||
|
|
||||||
|
if response.Code != http.StatusSeeOther {
|
||||||
|
t.Fatalf("expected success redirect, got %d", response.Code)
|
||||||
|
}
|
||||||
|
location := response.Header().Get("Location")
|
||||||
|
if !strings.Contains(location, "user(s) disabled") {
|
||||||
|
t.Fatalf("expected success message, got %q", location)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify admin2 is disabled, admin1 still active
|
||||||
|
disabledUser, ok, _ := app.store.GetUserByUsername("admin2")
|
||||||
|
if !ok || !disabledUser.Disabled {
|
||||||
|
t.Fatal("expected admin2 to be disabled")
|
||||||
|
}
|
||||||
|
adminUser, ok, _ := app.store.GetUserByUsername("admin")
|
||||||
|
if !ok || adminUser.Disabled {
|
||||||
|
t.Fatal("expected admin to remain active")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Now try to disable the only remaining admin (self): should be rejected
|
||||||
|
form2 := url.Values{}
|
||||||
|
form2.Set("csrf_token", session.CSRFToken)
|
||||||
|
form2.Set("selected_ids", user.ID)
|
||||||
|
|
||||||
|
req2 := httptest.NewRequest(http.MethodPost, "/account/users/bulk/disable", strings.NewReader(form2.Encode()))
|
||||||
|
req2.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
req2.AddCookie(&http.Cookie{Name: accountSessionCookie, Value: session.Token})
|
||||||
|
resp2 := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(resp2, req2)
|
||||||
|
|
||||||
|
if resp2.Code != http.StatusSeeOther {
|
||||||
|
t.Fatalf("expected redirect for self-disable rejection, got %d", resp2.Code)
|
||||||
|
}
|
||||||
|
loc2 := resp2.Header().Get("Location")
|
||||||
|
if !strings.Contains(loc2, "cannot disable yourself") {
|
||||||
|
t.Fatalf("expected self-disable rejection, got %q", loc2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUserEditPagePermissionDenied(t *testing.T) {
|
||||||
|
app, _ := setupAccountTestApp(t)
|
||||||
|
regular, err := app.store.CreateUserWithPassword("viewer2", "viewer2@example.test", "secret", nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateUserWithPassword: %v", err)
|
||||||
|
}
|
||||||
|
router := setupAccountTestRouter(t, app)
|
||||||
|
session, err := app.store.CreateSession(regular.ID, time.Hour)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateSession: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
request := httptest.NewRequest(http.MethodGet, "/account/users/"+regular.ID, nil)
|
||||||
|
request.AddCookie(&http.Cookie{Name: accountSessionCookie, Value: session.Token})
|
||||||
|
response := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(response, request)
|
||||||
|
if response.Code != http.StatusForbidden {
|
||||||
|
t.Fatalf("expected 403, got %d", response.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUserEditPageLoadsForAdmin(t *testing.T) {
|
||||||
|
app, admin := setupAccountTestApp(t)
|
||||||
|
target, err := app.store.CreateUserWithPassword("edittarget", "edittarget@example.test", "secret", nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateUserWithPassword: %v", err)
|
||||||
|
}
|
||||||
|
router := setupAccountTestRouter(t, app)
|
||||||
|
session, err := app.store.CreateSession(admin.ID, time.Hour)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateSession: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
request := httptest.NewRequest(http.MethodGet, "/account/users/"+target.ID, nil)
|
||||||
|
request.AddCookie(&http.Cookie{Name: accountSessionCookie, Value: session.Token})
|
||||||
|
response := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(response, request)
|
||||||
|
if response.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected 200, got %d", response.Code)
|
||||||
|
}
|
||||||
|
body := response.Body.String()
|
||||||
|
for _, text := range []string{"edittarget", "Access rights", "Limits", "Setting overrides", "Resolved policy"} {
|
||||||
|
if !strings.Contains(body, text) {
|
||||||
|
t.Fatalf("expected body to contain %q", text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUserEditProfileUpdate(t *testing.T) {
|
||||||
|
app, admin := setupAccountTestApp(t)
|
||||||
|
target, err := app.store.CreateUserWithPassword("origname", "orig@example.test", "secret", nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateUserWithPassword: %v", err)
|
||||||
|
}
|
||||||
|
router := setupAccountTestRouter(t, app)
|
||||||
|
session, err := app.store.CreateSession(admin.ID, time.Hour)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateSession: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
form := url.Values{}
|
||||||
|
form.Set("csrf_token", session.CSRFToken)
|
||||||
|
form.Set("username", "newname")
|
||||||
|
form.Set("email", "new@example.test")
|
||||||
|
form.Set("admin_note", "test note")
|
||||||
|
form.Set("state", "active")
|
||||||
|
|
||||||
|
request := httptest.NewRequest(http.MethodPost, "/account/users/"+target.ID, strings.NewReader(form.Encode()))
|
||||||
|
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
request.AddCookie(&http.Cookie{Name: accountSessionCookie, Value: session.Token})
|
||||||
|
response := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(response, request)
|
||||||
|
|
||||||
|
if response.Code != http.StatusSeeOther {
|
||||||
|
t.Fatalf("expected redirect, got %d", response.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
updated, ok, _ := app.store.GetUser(target.ID)
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("user not found after update")
|
||||||
|
}
|
||||||
|
if updated.Username != "newname" {
|
||||||
|
t.Fatalf("expected username newname, got %q", updated.Username)
|
||||||
|
}
|
||||||
|
if updated.AdminNote != "test note" {
|
||||||
|
t.Fatalf("expected admin note, got %q", updated.AdminNote)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUserEditAccessRightsUpdate(t *testing.T) {
|
||||||
|
app, admin := setupAccountTestApp(t)
|
||||||
|
target, err := app.store.CreateUserWithPassword("perm_target", "perm@example.test", "secret", nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateUserWithPassword: %v", err)
|
||||||
|
}
|
||||||
|
router := setupAccountTestRouter(t, app)
|
||||||
|
session, err := app.store.CreateSession(admin.ID, time.Hour)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateSession: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
form := url.Values{}
|
||||||
|
form.Set("csrf_token", session.CSRFToken)
|
||||||
|
form.Set("username", target.Username)
|
||||||
|
form.Set("email", target.Email)
|
||||||
|
form.Set("upload_allowed", "1")
|
||||||
|
form.Set("zip_download_allowed", "1")
|
||||||
|
|
||||||
|
request := httptest.NewRequest(http.MethodPost, "/account/users/"+target.ID, strings.NewReader(form.Encode()))
|
||||||
|
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
request.AddCookie(&http.Cookie{Name: accountSessionCookie, Value: session.Token})
|
||||||
|
response := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(response, request)
|
||||||
|
|
||||||
|
if response.Code != http.StatusSeeOther {
|
||||||
|
t.Fatalf("expected redirect, got %d", response.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
updated, ok, _ := app.store.GetUser(target.ID)
|
||||||
|
if !ok || updated.PermOverrides == nil {
|
||||||
|
t.Fatal("expected perm overrides to be set")
|
||||||
|
}
|
||||||
|
if updated.PermOverrides.UploadAllowed == nil || !*updated.PermOverrides.UploadAllowed {
|
||||||
|
t.Fatal("expected upload_allowed=true")
|
||||||
|
}
|
||||||
|
if updated.PermOverrides.ZipDownloadAllowed == nil || !*updated.PermOverrides.ZipDownloadAllowed {
|
||||||
|
t.Fatal("expected zip_download_allowed=true")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUserEditLimitsUpdate(t *testing.T) {
|
||||||
|
app, admin := setupAccountTestApp(t)
|
||||||
|
target, err := app.store.CreateUserWithPassword("limits_target", "limits@example.test", "secret", nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateUserWithPassword: %v", err)
|
||||||
|
}
|
||||||
|
router := setupAccountTestRouter(t, app)
|
||||||
|
session, err := app.store.CreateSession(admin.ID, time.Hour)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateSession: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
form := url.Values{}
|
||||||
|
form.Set("csrf_token", session.CSRFToken)
|
||||||
|
form.Set("username", target.Username)
|
||||||
|
form.Set("email", target.Email)
|
||||||
|
form.Set("max_file_size_bytes", "1073741824")
|
||||||
|
form.Set("max_expiry_seconds", "86400")
|
||||||
|
|
||||||
|
request := httptest.NewRequest(http.MethodPost, "/account/users/"+target.ID, strings.NewReader(form.Encode()))
|
||||||
|
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
request.AddCookie(&http.Cookie{Name: accountSessionCookie, Value: session.Token})
|
||||||
|
response := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(response, request)
|
||||||
|
|
||||||
|
if response.Code != http.StatusSeeOther {
|
||||||
|
t.Fatalf("expected redirect, got %d", response.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
updated, ok, _ := app.store.GetUser(target.ID)
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("user not found")
|
||||||
|
}
|
||||||
|
if updated.MaxFileSizeBytes == nil || *updated.MaxFileSizeBytes != 1073741824 {
|
||||||
|
t.Fatalf("expected max_file_size_bytes=1073741824, got %v", updated.MaxFileSizeBytes)
|
||||||
|
}
|
||||||
|
if updated.MaxExpirySeconds == nil || *updated.MaxExpirySeconds != 86400 {
|
||||||
|
t.Fatalf("expected max_expiry_seconds=86400, got %v", updated.MaxExpirySeconds)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUserEditInvalidLimitRejected(t *testing.T) {
|
||||||
|
app, admin := setupAccountTestApp(t)
|
||||||
|
target, err := app.store.CreateUserWithPassword("badlimit", "badlimit@example.test", "secret", nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateUserWithPassword: %v", err)
|
||||||
|
}
|
||||||
|
router := setupAccountTestRouter(t, app)
|
||||||
|
session, err := app.store.CreateSession(admin.ID, time.Hour)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateSession: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
form := url.Values{}
|
||||||
|
form.Set("csrf_token", session.CSRFToken)
|
||||||
|
form.Set("username", target.Username)
|
||||||
|
form.Set("email", target.Email)
|
||||||
|
form.Set("max_file_size_bytes", "notanumber")
|
||||||
|
|
||||||
|
request := httptest.NewRequest(http.MethodPost, "/account/users/"+target.ID, strings.NewReader(form.Encode()))
|
||||||
|
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
request.AddCookie(&http.Cookie{Name: accountSessionCookie, Value: session.Token})
|
||||||
|
response := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(response, request)
|
||||||
|
|
||||||
|
if response.Code != http.StatusSeeOther {
|
||||||
|
t.Fatalf("expected redirect, got %d", response.Code)
|
||||||
|
}
|
||||||
|
location := response.Header().Get("Location")
|
||||||
|
if !strings.Contains(location, "error=") {
|
||||||
|
t.Fatalf("expected error redirect, got %q", location)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUserEditSelfDisableRejected(t *testing.T) {
|
||||||
|
app, admin := setupAccountTestApp(t)
|
||||||
|
router := setupAccountTestRouter(t, app)
|
||||||
|
session, err := app.store.CreateSession(admin.ID, time.Hour)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateSession: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
form := url.Values{}
|
||||||
|
form.Set("csrf_token", session.CSRFToken)
|
||||||
|
form.Set("username", admin.Username)
|
||||||
|
form.Set("email", admin.Email)
|
||||||
|
form.Set("state", "disabled")
|
||||||
|
|
||||||
|
request := httptest.NewRequest(http.MethodPost, "/account/users/"+admin.ID, strings.NewReader(form.Encode()))
|
||||||
|
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
request.AddCookie(&http.Cookie{Name: accountSessionCookie, Value: session.Token})
|
||||||
|
response := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(response, request)
|
||||||
|
|
||||||
|
if response.Code != http.StatusSeeOther {
|
||||||
|
t.Fatalf("expected redirect, got %d", response.Code)
|
||||||
|
}
|
||||||
|
location := response.Header().Get("Location")
|
||||||
|
if !strings.Contains(location, "error=") {
|
||||||
|
t.Fatalf("expected error redirect, got %q", location)
|
||||||
|
}
|
||||||
|
|
||||||
|
unchanged, _, _ := app.store.GetUser(admin.ID)
|
||||||
|
if unchanged.Disabled {
|
||||||
|
t.Fatal("admin should not have been disabled")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUserEditLastAdminProtected(t *testing.T) {
|
||||||
|
app, admin := setupAccountTestApp(t)
|
||||||
|
router := setupAccountTestRouter(t, app)
|
||||||
|
session, err := app.store.CreateSession(admin.ID, time.Hour)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateSession: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// try to remove admin tag from the only admin via is_admin=0 (unchecked)
|
||||||
|
form := url.Values{}
|
||||||
|
form.Set("csrf_token", session.CSRFToken)
|
||||||
|
form.Set("username", admin.Username)
|
||||||
|
form.Set("email", admin.Email)
|
||||||
|
// is_admin NOT set → wantsAdmin=false → should be blocked
|
||||||
|
|
||||||
|
request := httptest.NewRequest(http.MethodPost, "/account/users/"+admin.ID, strings.NewReader(form.Encode()))
|
||||||
|
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
request.AddCookie(&http.Cookie{Name: accountSessionCookie, Value: session.Token})
|
||||||
|
response := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(response, request)
|
||||||
|
|
||||||
|
if response.Code != http.StatusSeeOther {
|
||||||
|
t.Fatalf("expected redirect, got %d", response.Code)
|
||||||
|
}
|
||||||
|
location := response.Header().Get("Location")
|
||||||
|
if !strings.Contains(location, "error=") {
|
||||||
|
t.Fatalf("expected error for last-admin removal, got %q", location)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUserEditPasswordReset(t *testing.T) {
|
||||||
|
app, admin := setupAccountTestApp(t)
|
||||||
|
target, err := app.store.CreateUserWithPassword("resetme", "resetme@example.test", "oldpass", nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateUserWithPassword: %v", err)
|
||||||
|
}
|
||||||
|
router := setupAccountTestRouter(t, app)
|
||||||
|
session, err := app.store.CreateSession(admin.ID, time.Hour)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateSession: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
form := url.Values{}
|
||||||
|
form.Set("csrf_token", session.CSRFToken)
|
||||||
|
|
||||||
|
request := httptest.NewRequest(http.MethodPost, "/account/users/"+target.ID+"/password/reset", strings.NewReader(form.Encode()))
|
||||||
|
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
request.AddCookie(&http.Cookie{Name: accountSessionCookie, Value: session.Token})
|
||||||
|
response := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(response, request)
|
||||||
|
|
||||||
|
if response.Code != http.StatusSeeOther {
|
||||||
|
t.Fatalf("expected redirect, got %d", response.Code)
|
||||||
|
}
|
||||||
|
location := response.Header().Get("Location")
|
||||||
|
if !strings.Contains(location, "success=") {
|
||||||
|
t.Fatalf("expected success redirect, got %q", location)
|
||||||
|
}
|
||||||
|
|
||||||
|
updated, _, _ := app.store.GetUser(target.ID)
|
||||||
|
if metastore.VerifyPassword(updated.PasswordHash, "oldpass") {
|
||||||
|
t.Fatal("old password should no longer work after reset")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUserEditRevokeSessions(t *testing.T) {
|
||||||
|
app, admin := setupAccountTestApp(t)
|
||||||
|
target, err := app.store.CreateUserWithPassword("revokeme", "revokeme@example.test", "secret", nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateUserWithPassword: %v", err)
|
||||||
|
}
|
||||||
|
targetSession, err := app.store.CreateSession(target.ID, time.Hour)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateSession: %v", err)
|
||||||
|
}
|
||||||
|
router := setupAccountTestRouter(t, app)
|
||||||
|
adminSession, err := app.store.CreateSession(admin.ID, time.Hour)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateSession: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
form := url.Values{}
|
||||||
|
form.Set("csrf_token", adminSession.CSRFToken)
|
||||||
|
|
||||||
|
request := httptest.NewRequest(http.MethodPost, "/account/users/"+target.ID+"/sessions/revoke", strings.NewReader(form.Encode()))
|
||||||
|
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
request.AddCookie(&http.Cookie{Name: accountSessionCookie, Value: adminSession.Token})
|
||||||
|
response := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(response, request)
|
||||||
|
|
||||||
|
if response.Code != http.StatusSeeOther {
|
||||||
|
t.Fatalf("expected redirect, got %d", response.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, stillValid, _ := app.store.GetSession(targetSession.Token)
|
||||||
|
if stillValid {
|
||||||
|
t.Fatal("target session should have been revoked")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBulkRevokeSessions(t *testing.T) {
|
||||||
|
app, user := setupAccountTestApp(t)
|
||||||
|
router := setupAccountTestRouter(t, app)
|
||||||
|
session, err := app.store.CreateSession(user.ID, time.Hour)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateSession returned error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
other, err := app.store.CreateUserWithPassword("other", "other@example.test", "secret", nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateUserWithPassword returned error: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := app.store.CreateSession(other.ID, time.Hour); err != nil {
|
||||||
|
t.Fatalf("CreateSession returned error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
form := url.Values{}
|
||||||
|
form.Set("csrf_token", session.CSRFToken)
|
||||||
|
form.Set("selected_ids", other.ID)
|
||||||
|
|
||||||
|
request := httptest.NewRequest(http.MethodPost, "/account/users/bulk/revoke-sessions", strings.NewReader(form.Encode()))
|
||||||
|
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
request.AddCookie(&http.Cookie{Name: accountSessionCookie, Value: session.Token})
|
||||||
|
response := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(response, request)
|
||||||
|
|
||||||
|
if response.Code != http.StatusSeeOther {
|
||||||
|
t.Fatalf("expected redirect, got %d", response.Code)
|
||||||
|
}
|
||||||
|
location := response.Header().Get("Location")
|
||||||
|
if !strings.Contains(location, "Sessions revoked") {
|
||||||
|
t.Fatalf("expected success message, got %q", location)
|
||||||
|
}
|
||||||
|
}
|
||||||
557
lib/server/account_user_edit.go
Normal file
557
lib/server/account_user_edit.go
Normal file
@@ -0,0 +1,557 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"warpbox/lib/metastore"
|
||||||
|
)
|
||||||
|
|
||||||
|
type UserEditView struct {
|
||||||
|
PageTitle string
|
||||||
|
WindowTitle string
|
||||||
|
WindowIcon string
|
||||||
|
PageScripts []string
|
||||||
|
AccountNav AccountNavView
|
||||||
|
CSRFToken string
|
||||||
|
Target metastore.User
|
||||||
|
Tags []metastore.Tag
|
||||||
|
AdminTagID string
|
||||||
|
IsAdmin bool
|
||||||
|
IsPending bool
|
||||||
|
Status string
|
||||||
|
Perms metastore.EffectivePermissions
|
||||||
|
PolicyJSON string
|
||||||
|
CanManage bool
|
||||||
|
IsSelf bool
|
||||||
|
Error string
|
||||||
|
Success string
|
||||||
|
// precomputed display values
|
||||||
|
TagNames string
|
||||||
|
CreatedAtStr string
|
||||||
|
UpdatedAtStr string
|
||||||
|
MaxFileSizeStr string
|
||||||
|
MaxBoxSizeStr string
|
||||||
|
MaxExpiryStr string
|
||||||
|
// precomputed perm override checkbox states
|
||||||
|
Check map[string]bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *App) handleAccountUserEdit(ctx *gin.Context) {
|
||||||
|
actor, ok := currentAccountUser(ctx)
|
||||||
|
if !ok {
|
||||||
|
ctx.Redirect(http.StatusSeeOther, "/account/login")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
perms := currentAccountPermissions(ctx)
|
||||||
|
if !perms.AdminUsersView && !perms.AdminUsersManage {
|
||||||
|
ctx.String(http.StatusForbidden, "Permission denied")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
userID := strings.TrimSpace(ctx.Param("id"))
|
||||||
|
view, err := app.buildUserEditView(ctx, actor, userID, perms.AdminUsersManage, "", "")
|
||||||
|
if err != nil {
|
||||||
|
ctx.String(http.StatusNotFound, "User not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx.HTML(http.StatusOK, "account_user_edit.html", view)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *App) handleAccountUserEditPost(ctx *gin.Context) {
|
||||||
|
actor, ok := currentAccountUser(ctx)
|
||||||
|
if !ok {
|
||||||
|
ctx.Redirect(http.StatusSeeOther, "/account/login")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
perms := currentAccountPermissions(ctx)
|
||||||
|
if !perms.AdminUsersManage {
|
||||||
|
ctx.String(http.StatusForbidden, "Permission denied")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
userID := strings.TrimSpace(ctx.Param("id"))
|
||||||
|
target, found, err := app.store.GetUser(userID)
|
||||||
|
if err != nil || !found {
|
||||||
|
redirectUserEdit(ctx, userID, "User not found.", "")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// profile
|
||||||
|
username := strings.TrimSpace(ctx.PostForm("username"))
|
||||||
|
email := strings.TrimSpace(ctx.PostForm("email"))
|
||||||
|
adminNote := strings.TrimSpace(ctx.PostForm("admin_note"))
|
||||||
|
if username == "" {
|
||||||
|
redirectUserEdit(ctx, userID, "Username is required.", "")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// state (cannot change pending via this field)
|
||||||
|
isPending := strings.HasPrefix(target.PasswordHash, "invite/")
|
||||||
|
if !isPending {
|
||||||
|
stateVal := ctx.PostForm("state")
|
||||||
|
switch stateVal {
|
||||||
|
case "disabled":
|
||||||
|
if target.ID == actor.ID {
|
||||||
|
redirectUserEdit(ctx, userID, "You cannot disable yourself.", "")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !target.Disabled {
|
||||||
|
if err := app.checkLastAdminDisable([]string{target.ID}); err != nil {
|
||||||
|
redirectUserEdit(ctx, userID, err.Error(), "")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
target.Disabled = true
|
||||||
|
case "active":
|
||||||
|
target.Disabled = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// admin tag toggle
|
||||||
|
adminTag, adminTagOK, err := app.store.GetTagByName(metastore.AdminTagName)
|
||||||
|
if err != nil {
|
||||||
|
redirectUserEdit(ctx, userID, "Could not verify admin tag.", "")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
wantsAdmin := ctx.PostForm("is_admin") == "1"
|
||||||
|
if adminTagOK {
|
||||||
|
hasAdmin := containsString(target.TagIDs, adminTag.ID)
|
||||||
|
if wantsAdmin && !hasAdmin {
|
||||||
|
target.TagIDs = append(target.TagIDs, adminTag.ID)
|
||||||
|
} else if !wantsAdmin && hasAdmin {
|
||||||
|
if err := app.checkLastAdminDisable([]string{target.ID}); err != nil {
|
||||||
|
redirectUserEdit(ctx, userID, "Cannot remove admin from the last active administrator.", "")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
target.TagIDs = removeString(target.TagIDs, adminTag.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// per-user permission overrides
|
||||||
|
target.PermOverrides = &metastore.UserPermOverrides{
|
||||||
|
UploadAllowed: boolPtr(ctx.PostForm("upload_allowed") == "1"),
|
||||||
|
ManageOwnBoxes: boolPtr(ctx.PostForm("manage_own_boxes") == "1"),
|
||||||
|
ZipDownloadAllowed: boolPtr(ctx.PostForm("zip_download_allowed") == "1"),
|
||||||
|
OneTimeDownloadAllowed: boolPtr(ctx.PostForm("one_time_download_allowed") == "1"),
|
||||||
|
RenewableAllowed: boolPtr(ctx.PostForm("renewable_allowed") == "1"),
|
||||||
|
AllowPasswordProtected: boolPtr(ctx.PostForm("allow_password_protected") == "1"),
|
||||||
|
RenewOnAccess: boolPtr(ctx.PostForm("renew_on_access") == "1"),
|
||||||
|
RenewOnDownload: boolPtr(ctx.PostForm("renew_on_download") == "1"),
|
||||||
|
AllowOwnerBoxEditing: boolPtr(ctx.PostForm("allow_owner_box_editing") == "1"),
|
||||||
|
}
|
||||||
|
|
||||||
|
// limits
|
||||||
|
if raw := ctx.PostForm("max_file_size_bytes"); raw != "" {
|
||||||
|
v, err := parseOptionalInt64(raw)
|
||||||
|
if err != nil {
|
||||||
|
redirectUserEdit(ctx, userID, "Max file size: "+err.Error(), "")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
target.MaxFileSizeBytes = v
|
||||||
|
} else {
|
||||||
|
target.MaxFileSizeBytes = nil
|
||||||
|
}
|
||||||
|
if raw := ctx.PostForm("max_box_size_bytes"); raw != "" {
|
||||||
|
v, err := parseOptionalInt64(raw)
|
||||||
|
if err != nil {
|
||||||
|
redirectUserEdit(ctx, userID, "Max box size: "+err.Error(), "")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
target.MaxBoxSizeBytes = v
|
||||||
|
} else {
|
||||||
|
target.MaxBoxSizeBytes = nil
|
||||||
|
}
|
||||||
|
if raw := ctx.PostForm("max_expiry_seconds"); raw != "" {
|
||||||
|
v, err := parseOptionalInt64(raw)
|
||||||
|
if err != nil {
|
||||||
|
redirectUserEdit(ctx, userID, "Max expiry: "+err.Error(), "")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
target.MaxExpirySeconds = v
|
||||||
|
} else {
|
||||||
|
target.MaxExpirySeconds = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
target.Username = username
|
||||||
|
target.Email = email
|
||||||
|
target.AdminNote = adminNote
|
||||||
|
|
||||||
|
if err := app.store.UpdateUser(target); err != nil {
|
||||||
|
redirectUserEdit(ctx, userID, "Could not save user: "+err.Error(), "")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
redirectUserEdit(ctx, userID, "", "User saved.")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *App) handleAccountUserDisable(ctx *gin.Context) {
|
||||||
|
app.handleAccountUserSetDisabled(ctx, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *App) handleAccountUserEnable(ctx *gin.Context) {
|
||||||
|
app.handleAccountUserSetDisabled(ctx, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *App) handleAccountUserSetDisabled(ctx *gin.Context, disabled bool) {
|
||||||
|
actor, ok := currentAccountUser(ctx)
|
||||||
|
if !ok {
|
||||||
|
ctx.Redirect(http.StatusSeeOther, "/account/login")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
perms := currentAccountPermissions(ctx)
|
||||||
|
if !perms.AdminUsersManage {
|
||||||
|
ctx.String(http.StatusForbidden, "Permission denied")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
userID := strings.TrimSpace(ctx.Param("id"))
|
||||||
|
if userID == actor.ID && disabled {
|
||||||
|
redirectUserEdit(ctx, userID, "You cannot disable yourself.", "")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if disabled {
|
||||||
|
if err := app.checkLastAdminDisable([]string{userID}); err != nil {
|
||||||
|
redirectUserEdit(ctx, userID, err.Error(), "")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
target, found, err := app.store.GetUser(userID)
|
||||||
|
if err != nil || !found {
|
||||||
|
redirectUserEdit(ctx, userID, "User not found.", "")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
target.Disabled = disabled
|
||||||
|
if err := app.store.UpdateUser(target); err != nil {
|
||||||
|
redirectUserEdit(ctx, userID, "Could not update user.", "")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
action := "enabled"
|
||||||
|
if disabled {
|
||||||
|
action = "disabled"
|
||||||
|
}
|
||||||
|
redirectUserEdit(ctx, userID, "", "User "+action+".")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *App) handleAccountUserPasswordReset(ctx *gin.Context) {
|
||||||
|
_, ok := currentAccountUser(ctx)
|
||||||
|
if !ok {
|
||||||
|
ctx.Redirect(http.StatusSeeOther, "/account/login")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
perms := currentAccountPermissions(ctx)
|
||||||
|
if !perms.AdminUsersManage {
|
||||||
|
ctx.String(http.StatusForbidden, "Permission denied")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
userID := strings.TrimSpace(ctx.Param("id"))
|
||||||
|
target, found, err := app.store.GetUser(userID)
|
||||||
|
if err != nil || !found {
|
||||||
|
redirectUserEdit(ctx, userID, "User not found.", "")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
newPassword := randomPassword()
|
||||||
|
hash, err := metastore.HashPassword(newPassword)
|
||||||
|
if err != nil {
|
||||||
|
redirectUserEdit(ctx, userID, "Could not hash password.", "")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
target.PasswordHash = hash
|
||||||
|
target.Disabled = false
|
||||||
|
if err := app.store.UpdateUser(target); err != nil {
|
||||||
|
redirectUserEdit(ctx, userID, "Could not reset password.", "")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
redirectUserEdit(ctx, userID, "", "Password reset. Temporary password: "+newPassword)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *App) handleAccountUserRevokeSessions(ctx *gin.Context) {
|
||||||
|
_, ok := currentAccountUser(ctx)
|
||||||
|
if !ok {
|
||||||
|
ctx.Redirect(http.StatusSeeOther, "/account/login")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
perms := currentAccountPermissions(ctx)
|
||||||
|
if !perms.AdminUsersManage {
|
||||||
|
ctx.String(http.StatusForbidden, "Permission denied")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
userID := strings.TrimSpace(ctx.Param("id"))
|
||||||
|
if err := app.store.RevokeUserSessions(userID); err != nil {
|
||||||
|
redirectUserEdit(ctx, userID, "Could not revoke sessions.", "")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
redirectUserEdit(ctx, userID, "", "All sessions revoked.")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *App) buildUserEditView(ctx *gin.Context, actor metastore.User, userID string, canManage bool, errMsg string, successMsg string) (UserEditView, error) {
|
||||||
|
target, found, err := app.store.GetUser(userID)
|
||||||
|
if err != nil || !found {
|
||||||
|
return UserEditView{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
tags, _ := app.store.ListTags()
|
||||||
|
|
||||||
|
adminTagID := ""
|
||||||
|
for _, t := range tags {
|
||||||
|
if t.Name == metastore.AdminTagName {
|
||||||
|
adminTagID = t.ID
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
isAdmin := containsString(target.TagIDs, adminTagID)
|
||||||
|
isPending := strings.HasPrefix(target.PasswordHash, "invite/")
|
||||||
|
status := "active"
|
||||||
|
if isPending {
|
||||||
|
status = "pending"
|
||||||
|
} else if target.Disabled {
|
||||||
|
status = "disabled"
|
||||||
|
}
|
||||||
|
|
||||||
|
effectivePerms, _ := app.permissionsForUser(target)
|
||||||
|
|
||||||
|
policyJSON := buildPolicyJSON(target.Username, status, effectivePerms, target.PermOverrides)
|
||||||
|
|
||||||
|
// tag names
|
||||||
|
tagNames := make([]string, 0, len(target.TagIDs))
|
||||||
|
for _, tagID := range target.TagIDs {
|
||||||
|
for _, t := range tags {
|
||||||
|
if t.ID == tagID {
|
||||||
|
tagNames = append(tagNames, t.Name)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// perm override checkboxes
|
||||||
|
checks := map[string]bool{
|
||||||
|
"upload_allowed": effectivePerms.UploadAllowed,
|
||||||
|
"manage_own_boxes": false,
|
||||||
|
"zip_download_allowed": effectivePerms.ZipDownloadAllowed,
|
||||||
|
"one_time_download_allowed": effectivePerms.OneTimeDownloadAllowed,
|
||||||
|
"renewable_allowed": effectivePerms.RenewableAllowed,
|
||||||
|
"allow_password_protected": false,
|
||||||
|
"renew_on_access": false,
|
||||||
|
"renew_on_download": false,
|
||||||
|
"allow_owner_box_editing": false,
|
||||||
|
}
|
||||||
|
if o := target.PermOverrides; o != nil {
|
||||||
|
if o.UploadAllowed != nil {
|
||||||
|
checks["upload_allowed"] = *o.UploadAllowed
|
||||||
|
}
|
||||||
|
if o.ManageOwnBoxes != nil {
|
||||||
|
checks["manage_own_boxes"] = *o.ManageOwnBoxes
|
||||||
|
}
|
||||||
|
if o.ZipDownloadAllowed != nil {
|
||||||
|
checks["zip_download_allowed"] = *o.ZipDownloadAllowed
|
||||||
|
}
|
||||||
|
if o.OneTimeDownloadAllowed != nil {
|
||||||
|
checks["one_time_download_allowed"] = *o.OneTimeDownloadAllowed
|
||||||
|
}
|
||||||
|
if o.RenewableAllowed != nil {
|
||||||
|
checks["renewable_allowed"] = *o.RenewableAllowed
|
||||||
|
}
|
||||||
|
if o.AllowPasswordProtected != nil {
|
||||||
|
checks["allow_password_protected"] = *o.AllowPasswordProtected
|
||||||
|
}
|
||||||
|
if o.RenewOnAccess != nil {
|
||||||
|
checks["renew_on_access"] = *o.RenewOnAccess
|
||||||
|
}
|
||||||
|
if o.RenewOnDownload != nil {
|
||||||
|
checks["renew_on_download"] = *o.RenewOnDownload
|
||||||
|
}
|
||||||
|
if o.AllowOwnerBoxEditing != nil {
|
||||||
|
checks["allow_owner_box_editing"] = *o.AllowOwnerBoxEditing
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return UserEditView{
|
||||||
|
PageTitle: "Edit User — " + target.Username,
|
||||||
|
WindowTitle: "User Edit — " + target.Username,
|
||||||
|
WindowIcon: "U",
|
||||||
|
PageScripts: []string{"/static/js/account-user-edit.js"},
|
||||||
|
AccountNav: app.accountNavView(ctx, "users"),
|
||||||
|
CSRFToken: app.currentCSRFToken(ctx),
|
||||||
|
Target: target,
|
||||||
|
Tags: tags,
|
||||||
|
AdminTagID: adminTagID,
|
||||||
|
IsAdmin: isAdmin,
|
||||||
|
IsPending: isPending,
|
||||||
|
Status: status,
|
||||||
|
Perms: effectivePerms,
|
||||||
|
PolicyJSON: policyJSON,
|
||||||
|
CanManage: canManage,
|
||||||
|
IsSelf: actor.ID == target.ID,
|
||||||
|
Error: errMsg,
|
||||||
|
Success: successMsg,
|
||||||
|
TagNames: strings.Join(tagNames, ", "),
|
||||||
|
CreatedAtStr: formatAdminTime(target.CreatedAt),
|
||||||
|
UpdatedAtStr: formatAdminTime(target.UpdatedAt),
|
||||||
|
MaxFileSizeStr: int64PtrStr(target.MaxFileSizeBytes),
|
||||||
|
MaxBoxSizeStr: int64PtrStr(target.MaxBoxSizeBytes),
|
||||||
|
MaxExpiryStr: int64PtrStr(target.MaxExpirySeconds),
|
||||||
|
Check: checks,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildPolicyJSON(username string, status string, perms metastore.EffectivePermissions, overrides *metastore.UserPermOverrides) string {
|
||||||
|
type permMap struct {
|
||||||
|
BoxesCreate bool `json:"boxes.create"`
|
||||||
|
ManageOwn bool `json:"boxes.manage_own"`
|
||||||
|
RefreshOwn bool `json:"boxes.refresh_own"`
|
||||||
|
DownloadsZip bool `json:"downloads.zip"`
|
||||||
|
DownloadsOneTime bool `json:"downloads.one_time"`
|
||||||
|
AdminAccess bool `json:"admin.access"`
|
||||||
|
AdminUsers bool `json:"admin.users.manage"`
|
||||||
|
AdminSettings bool `json:"admin.settings.manage"`
|
||||||
|
}
|
||||||
|
type limitsMap struct {
|
||||||
|
MaxFileSizeBytes int64 `json:"max_file_size_bytes"`
|
||||||
|
MaxBoxSizeBytes int64 `json:"max_box_size_bytes"`
|
||||||
|
MaxExpirySeconds int64 `json:"max_expiry_seconds"`
|
||||||
|
}
|
||||||
|
type overridesMap struct {
|
||||||
|
AllowPassword bool `json:"allow_password_protected"`
|
||||||
|
RenewOnAccess bool `json:"renew_on_access"`
|
||||||
|
RenewOnDownload bool `json:"renew_on_download"`
|
||||||
|
AllowOwnerEdit bool `json:"allow_owner_box_editing"`
|
||||||
|
}
|
||||||
|
type preview struct {
|
||||||
|
User string `json:"user"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
Permissions permMap `json:"permissions"`
|
||||||
|
Limits limitsMap `json:"limits"`
|
||||||
|
Overrides overridesMap `json:"overrides"`
|
||||||
|
}
|
||||||
|
|
||||||
|
manageOwn := false
|
||||||
|
allowPwd := false
|
||||||
|
renewAccess := false
|
||||||
|
renewDownload := false
|
||||||
|
allowOwnerEdit := false
|
||||||
|
if overrides != nil {
|
||||||
|
if overrides.ManageOwnBoxes != nil {
|
||||||
|
manageOwn = *overrides.ManageOwnBoxes
|
||||||
|
}
|
||||||
|
if overrides.AllowPasswordProtected != nil {
|
||||||
|
allowPwd = *overrides.AllowPasswordProtected
|
||||||
|
}
|
||||||
|
if overrides.RenewOnAccess != nil {
|
||||||
|
renewAccess = *overrides.RenewOnAccess
|
||||||
|
}
|
||||||
|
if overrides.RenewOnDownload != nil {
|
||||||
|
renewDownload = *overrides.RenewOnDownload
|
||||||
|
}
|
||||||
|
if overrides.AllowOwnerBoxEditing != nil {
|
||||||
|
allowOwnerEdit = *overrides.AllowOwnerBoxEditing
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
p := preview{
|
||||||
|
User: username,
|
||||||
|
Status: status,
|
||||||
|
Permissions: permMap{
|
||||||
|
BoxesCreate: perms.UploadAllowed,
|
||||||
|
ManageOwn: manageOwn,
|
||||||
|
RefreshOwn: perms.RenewableAllowed,
|
||||||
|
DownloadsZip: perms.ZipDownloadAllowed,
|
||||||
|
DownloadsOneTime: perms.OneTimeDownloadAllowed,
|
||||||
|
AdminAccess: perms.AdminAccess,
|
||||||
|
AdminUsers: perms.AdminUsersManage,
|
||||||
|
AdminSettings: perms.AdminSettingsManage,
|
||||||
|
},
|
||||||
|
Limits: limitsMap{
|
||||||
|
MaxFileSizeBytes: perms.MaxFileSizeBytes,
|
||||||
|
MaxBoxSizeBytes: perms.MaxBoxSizeBytes,
|
||||||
|
MaxExpirySeconds: perms.MaxExpirySeconds,
|
||||||
|
},
|
||||||
|
Overrides: overridesMap{
|
||||||
|
AllowPassword: allowPwd,
|
||||||
|
RenewOnAccess: renewAccess,
|
||||||
|
RenewOnDownload: renewDownload,
|
||||||
|
AllowOwnerEdit: allowOwnerEdit,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
data, err := json.MarshalIndent(p, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return "{}"
|
||||||
|
}
|
||||||
|
return string(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *App) checkLastAdminDisable(ids []string) error {
|
||||||
|
adminTag, ok, err := app.store.GetTagByName(metastore.AdminTagName)
|
||||||
|
if err != nil || !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
adminCount, err := app.store.CountAdminUsers(adminTag.ID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
removing := 0
|
||||||
|
for _, id := range ids {
|
||||||
|
u, found, _ := app.store.GetUser(id)
|
||||||
|
if found && !u.Disabled && containsString(u.TagIDs, adminTag.ID) {
|
||||||
|
removing++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if adminCount-removing < 1 {
|
||||||
|
return fmt.Errorf("cannot remove the last active administrator")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func int64PtrStr(v *int64) string {
|
||||||
|
if v == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%d", *v)
|
||||||
|
}
|
||||||
|
|
||||||
|
func redirectUserEdit(ctx *gin.Context, userID string, errMsg string, successMsg string) {
|
||||||
|
base := "/account/users/" + userID
|
||||||
|
if errMsg != "" {
|
||||||
|
ctx.Redirect(http.StatusSeeOther, base+"?error="+errMsg)
|
||||||
|
} else if successMsg != "" {
|
||||||
|
ctx.Redirect(http.StatusSeeOther, base+"?success="+successMsg)
|
||||||
|
} else {
|
||||||
|
ctx.Redirect(http.StatusSeeOther, base)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func containsString(slice []string, s string) bool {
|
||||||
|
for _, v := range slice {
|
||||||
|
if v == s {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func removeString(slice []string, s string) []string {
|
||||||
|
out := make([]string, 0, len(slice))
|
||||||
|
for _, v := range slice {
|
||||||
|
if v != s {
|
||||||
|
out = append(out, v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func boolPtr(b bool) *bool {
|
||||||
|
return &b
|
||||||
|
}
|
||||||
@@ -181,6 +181,9 @@ func validAdminCSRF(ctx *gin.Context, session metastore.Session) bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
token := ctx.PostForm("csrf_token")
|
token := ctx.PostForm("csrf_token")
|
||||||
|
if token == "" {
|
||||||
|
token = ctx.GetHeader("X-CSRF-Token")
|
||||||
|
}
|
||||||
return token != "" && subtleConstantTimeEqual(token, session.CSRFToken)
|
return token != "" && subtleConstantTimeEqual(token, session.CSRFToken)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,21 +1,35 @@
|
|||||||
package server
|
package server
|
||||||
|
|
||||||
import "github.com/gin-gonic/gin"
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
func (app *App) registerAdminRoutes(router *gin.Engine) {
|
func (app *App) registerAdminRoutes(router *gin.Engine) {
|
||||||
admin := router.Group("/admin")
|
admin := router.Group("/admin")
|
||||||
admin.Use(noStoreAdminHeaders)
|
admin.Use(noStoreAdminHeaders)
|
||||||
admin.GET("/login", app.handleAdminLogin)
|
admin.GET("/login", func(ctx *gin.Context) {
|
||||||
|
ctx.Redirect(http.StatusSeeOther, "/account/login")
|
||||||
|
})
|
||||||
admin.POST("/login", app.handleAdminLoginPost)
|
admin.POST("/login", app.handleAdminLoginPost)
|
||||||
|
admin.GET("", func(ctx *gin.Context) {
|
||||||
|
ctx.Redirect(http.StatusSeeOther, "/account")
|
||||||
|
})
|
||||||
|
admin.GET("/", func(ctx *gin.Context) {
|
||||||
|
ctx.Redirect(http.StatusSeeOther, "/account")
|
||||||
|
})
|
||||||
|
|
||||||
protected := admin.Group("")
|
protected := admin.Group("")
|
||||||
protected.Use(app.requireAdminSession)
|
protected.Use(app.requireAdminSession)
|
||||||
protected.POST("/logout", app.handleAdminLogout)
|
protected.POST("/logout", app.handleAdminLogout)
|
||||||
protected.GET("", app.handleAdminDashboard)
|
|
||||||
protected.GET("/", app.handleAdminDashboard)
|
|
||||||
protected.GET("/boxes", app.handleAdminBoxes)
|
protected.GET("/boxes", app.handleAdminBoxes)
|
||||||
protected.GET("/users", app.handleAdminUsers)
|
protected.GET("/users", func(ctx *gin.Context) {
|
||||||
protected.POST("/users", app.handleAdminUsersPost)
|
ctx.Redirect(http.StatusSeeOther, "/account/users")
|
||||||
|
})
|
||||||
|
protected.POST("/users", func(ctx *gin.Context) {
|
||||||
|
ctx.Redirect(http.StatusSeeOther, "/account/users")
|
||||||
|
})
|
||||||
protected.GET("/tags", app.handleAdminTags)
|
protected.GET("/tags", app.handleAdminTags)
|
||||||
protected.POST("/tags", app.handleAdminTagsPost)
|
protected.POST("/tags", app.handleAdminTagsPost)
|
||||||
protected.GET("/settings", app.handleAdminSettings)
|
protected.GET("/settings", app.handleAdminSettings)
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
package server
|
package server
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"fmt"
|
||||||
|
"math/big"
|
||||||
"net/http"
|
"net/http"
|
||||||
"sort"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
@@ -10,112 +13,381 @@ import (
|
|||||||
"warpbox/lib/metastore"
|
"warpbox/lib/metastore"
|
||||||
)
|
)
|
||||||
|
|
||||||
type adminUserRow struct {
|
const defaultUserPageSize = 12
|
||||||
ID string
|
|
||||||
Username string
|
type UsersIndexView struct {
|
||||||
Email string
|
PageTitle string
|
||||||
Tags string
|
WindowTitle string
|
||||||
CreatedAt string
|
WindowIcon string
|
||||||
Disabled bool
|
PageScripts []string
|
||||||
IsCurrent bool
|
AccountNav AccountNavView
|
||||||
|
CSRFToken string
|
||||||
|
Filters UserFiltersView
|
||||||
|
Rows []metastore.UserRow
|
||||||
|
Stats metastore.UserPageStats
|
||||||
|
Page int
|
||||||
|
PageSize int
|
||||||
|
Total int
|
||||||
|
TotalPages int
|
||||||
|
HasPrev bool
|
||||||
|
HasNext bool
|
||||||
|
CanManage bool
|
||||||
|
Tags []metastore.Tag
|
||||||
|
Error string
|
||||||
|
Success string
|
||||||
}
|
}
|
||||||
|
|
||||||
func (app *App) handleAdminUsers(ctx *gin.Context) {
|
type UserFiltersView struct {
|
||||||
if !app.requireAdminFlag(ctx, func(perms metastore.EffectivePermissions) bool { return perms.AdminUsersManage }) {
|
Query string
|
||||||
return
|
Status string
|
||||||
}
|
Role string
|
||||||
app.renderAdminUsers(ctx, "")
|
Sort string
|
||||||
|
PageSize int
|
||||||
}
|
}
|
||||||
|
|
||||||
func (app *App) handleAdminUsersPost(ctx *gin.Context) {
|
func (app *App) handleAccountUsers(ctx *gin.Context) {
|
||||||
if !app.requireAdminFlag(ctx, func(perms metastore.EffectivePermissions) bool { return perms.AdminUsersManage }) {
|
actor, ok := currentAccountUser(ctx)
|
||||||
|
if !ok {
|
||||||
|
ctx.Redirect(http.StatusSeeOther, "/account/login")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if ctx.PostForm("action") == "toggle_disabled" {
|
perms := currentAccountPermissions(ctx)
|
||||||
userID := strings.TrimSpace(ctx.PostForm("user_id"))
|
if !perms.AdminUsersView && !perms.AdminUsersManage {
|
||||||
user, ok, err := app.store.GetUser(userID)
|
ctx.String(http.StatusForbidden, "Permission denied")
|
||||||
if err != nil || !ok {
|
|
||||||
app.renderAdminUsers(ctx, "User not found.")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if current, ok := ctx.Get("adminUser"); ok {
|
|
||||||
if currentUser, ok := current.(metastore.User); ok && currentUser.ID == user.ID {
|
|
||||||
app.renderAdminUsers(ctx, "You cannot disable the user for the active session.")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
user.Disabled = !user.Disabled
|
|
||||||
if err := app.store.UpdateUser(user); err != nil {
|
|
||||||
app.renderAdminUsers(ctx, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
ctx.Redirect(http.StatusSeeOther, "/admin/users")
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
username := ctx.PostForm("username")
|
filters := userFiltersFromRequest(ctx)
|
||||||
email := ctx.PostForm("email")
|
pageReq := userPageFromRequest(ctx)
|
||||||
password := ctx.PostForm("password")
|
|
||||||
tagIDs := ctx.PostFormArray("tag_ids")
|
|
||||||
if _, err := app.store.CreateUserWithPassword(username, email, password, tagIDs); err != nil {
|
|
||||||
app.renderAdminUsers(ctx, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
ctx.Redirect(http.StatusSeeOther, "/admin/users")
|
|
||||||
}
|
|
||||||
|
|
||||||
func (app *App) renderAdminUsers(ctx *gin.Context, errorMessage string) {
|
userPage, err := app.store.ListUsersPaginated(filters, pageReq)
|
||||||
users, err := app.store.ListUsers()
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
ctx.String(http.StatusInternalServerError, "Could not list users")
|
ctx.String(http.StatusInternalServerError, "Could not list users")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
tags, err := app.store.ListTags()
|
|
||||||
if err != nil {
|
currentID := actor.ID
|
||||||
ctx.String(http.StatusInternalServerError, "Could not list tags")
|
for i := range userPage.Rows {
|
||||||
|
if userPage.Rows[i].ID == currentID {
|
||||||
|
userPage.Rows[i].IsCurrent = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tags, _ := app.store.ListTags()
|
||||||
|
|
||||||
|
view := UsersIndexView{
|
||||||
|
PageTitle: "WarpBox Users",
|
||||||
|
WindowTitle: "WarpBox Users",
|
||||||
|
WindowIcon: "U",
|
||||||
|
PageScripts: []string{"/static/js/account-users.js"},
|
||||||
|
AccountNav: app.accountNavView(ctx, "users"),
|
||||||
|
CSRFToken: app.currentCSRFToken(ctx),
|
||||||
|
Filters: UserFiltersView{
|
||||||
|
Query: filters.Query,
|
||||||
|
Status: filters.Status,
|
||||||
|
Role: filters.Role,
|
||||||
|
Sort: filters.Sort,
|
||||||
|
PageSize: pageReq.PageSize,
|
||||||
|
},
|
||||||
|
Rows: userPage.Rows,
|
||||||
|
Stats: userPage.Stats,
|
||||||
|
Page: userPage.Page,
|
||||||
|
PageSize: userPage.PageSize,
|
||||||
|
Total: userPage.Total,
|
||||||
|
TotalPages: userPage.TotalPages,
|
||||||
|
HasPrev: userPage.HasPrev,
|
||||||
|
HasNext: userPage.HasNext,
|
||||||
|
CanManage: perms.AdminUsersManage,
|
||||||
|
Tags: tags,
|
||||||
|
Error: ctx.Query("error"),
|
||||||
|
Success: ctx.Query("success"),
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.HTML(http.StatusOK, "account_users.html", view)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *App) handleAccountUsersPost(ctx *gin.Context) {
|
||||||
|
actor, ok := currentAccountUser(ctx)
|
||||||
|
if !ok {
|
||||||
|
ctx.Redirect(http.StatusSeeOther, "/account/login")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
tagNames := make(map[string]string, len(tags))
|
|
||||||
for _, tag := range tags {
|
|
||||||
tagNames[tag.ID] = tag.Name
|
|
||||||
}
|
|
||||||
sort.Slice(users, func(i int, j int) bool {
|
|
||||||
return strings.ToLower(users[i].Username) < strings.ToLower(users[j].Username)
|
|
||||||
})
|
|
||||||
|
|
||||||
currentID := ""
|
perms := currentAccountPermissions(ctx)
|
||||||
if current, ok := ctx.Get("adminUser"); ok {
|
if !perms.AdminUsersManage {
|
||||||
if currentUser, ok := current.(metastore.User); ok {
|
ctx.String(http.StatusForbidden, "Permission denied")
|
||||||
currentID = currentUser.ID
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
action := ctx.PostForm("action")
|
||||||
|
switch action {
|
||||||
|
case "create":
|
||||||
|
app.handleAccountUsersCreate(ctx, actor)
|
||||||
|
default:
|
||||||
|
redirectAccountUsers(ctx, "Unknown action", "")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *App) handleAccountUsersCreate(ctx *gin.Context, _ metastore.User) {
|
||||||
|
username := strings.TrimSpace(ctx.PostForm("username"))
|
||||||
|
email := strings.TrimSpace(ctx.PostForm("email"))
|
||||||
|
mode := strings.TrimSpace(ctx.PostForm("mode"))
|
||||||
|
role := strings.TrimSpace(ctx.PostForm("role"))
|
||||||
|
|
||||||
|
if username == "" {
|
||||||
|
redirectAccountUsers(ctx, "Username is required.", "")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if email == "" {
|
||||||
|
redirectAccountUsers(ctx, "Email is required.", "")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var tagIDs []string
|
||||||
|
if role != "" && role != "all" {
|
||||||
|
tag, ok, err := app.store.GetTagByName(role)
|
||||||
|
if err != nil {
|
||||||
|
redirectAccountUsers(ctx, "Could not look up role.", "")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if ok {
|
||||||
|
tagIDs = append(tagIDs, tag.ID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
rows := make([]adminUserRow, 0, len(users))
|
switch mode {
|
||||||
for _, user := range users {
|
case "invite":
|
||||||
names := make([]string, 0, len(user.TagIDs))
|
user, err := app.store.CreateUserWithoutPassword(username, email, tagIDs)
|
||||||
for _, tagID := range user.TagIDs {
|
if err != nil {
|
||||||
if name := tagNames[tagID]; name != "" {
|
redirectAccountUsers(ctx, err.Error(), "")
|
||||||
names = append(names, name)
|
return
|
||||||
|
}
|
||||||
|
inviteLink := fmt.Sprintf("/account/setup?token=%s", strings.TrimPrefix(user.PasswordHash, "invite/"))
|
||||||
|
msg := fmt.Sprintf("Invite user created. Setup link: %s (Email delivery not yet implemented.)", inviteLink)
|
||||||
|
redirectAccountUsers(ctx, "", msg)
|
||||||
|
|
||||||
|
case "create":
|
||||||
|
password := strings.TrimSpace(ctx.PostForm("password"))
|
||||||
|
autoGen := false
|
||||||
|
if password == "" {
|
||||||
|
password = randomPassword()
|
||||||
|
autoGen = true
|
||||||
|
}
|
||||||
|
user, err := app.store.CreateUserWithPassword(username, email, password, tagIDs)
|
||||||
|
if err != nil {
|
||||||
|
redirectAccountUsers(ctx, err.Error(), "")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
msg := fmt.Sprintf("User %s created.", user.Username)
|
||||||
|
if autoGen {
|
||||||
|
msg = fmt.Sprintf("User %s created. Temporary password: %s", user.Username, password)
|
||||||
|
}
|
||||||
|
redirectAccountUsers(ctx, "", msg)
|
||||||
|
|
||||||
|
default:
|
||||||
|
redirectAccountUsers(ctx, "Select create or invite mode.", "")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *App) handleAccountUsersBulkDisable(ctx *gin.Context) {
|
||||||
|
app.handleAccountUsersBulkSetDisabled(ctx, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *App) handleAccountUsersBulkEnable(ctx *gin.Context) {
|
||||||
|
app.handleAccountUsersBulkSetDisabled(ctx, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *App) handleAccountUsersBulkSetDisabled(ctx *gin.Context, disabled bool) {
|
||||||
|
actor, ok := currentAccountUser(ctx)
|
||||||
|
if !ok {
|
||||||
|
ctx.Redirect(http.StatusSeeOther, "/account/login")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
perms := currentAccountPermissions(ctx)
|
||||||
|
if !perms.AdminUsersManage {
|
||||||
|
ctx.String(http.StatusForbidden, "Permission denied")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ids := parseSelectedIDs(ctx)
|
||||||
|
if len(ids) == 0 {
|
||||||
|
redirectAccountUsers(ctx, "No users selected.", "")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, id := range ids {
|
||||||
|
if id == actor.ID {
|
||||||
|
redirectAccountUsers(ctx, "You cannot disable yourself.", "")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if disabled {
|
||||||
|
adminTag, ok, err := app.store.GetTagByName(metastore.AdminTagName)
|
||||||
|
if err != nil || !ok {
|
||||||
|
redirectAccountUsers(ctx, "Could not verify admin protection.", "")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
adminCount, err := app.store.CountAdminUsers(adminTag.ID)
|
||||||
|
if err != nil {
|
||||||
|
redirectAccountUsers(ctx, "Could not verify admin protection.", "")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
disableAdminCount := 0
|
||||||
|
for _, id := range ids {
|
||||||
|
user, ok, err := app.store.GetUser(id)
|
||||||
|
if err != nil || !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !user.Disabled {
|
||||||
|
for _, tagID := range user.TagIDs {
|
||||||
|
if tagID == adminTag.ID {
|
||||||
|
disableAdminCount++
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
rows = append(rows, adminUserRow{
|
if adminCount-disableAdminCount < 1 {
|
||||||
ID: user.ID,
|
redirectAccountUsers(ctx, "Cannot disable the last active administrator.", "")
|
||||||
Username: user.Username,
|
return
|
||||||
Email: user.Email,
|
}
|
||||||
Tags: strings.Join(names, ", "),
|
|
||||||
CreatedAt: formatAdminTime(user.CreatedAt),
|
|
||||||
Disabled: user.Disabled,
|
|
||||||
IsCurrent: user.ID == currentID,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx.HTML(http.StatusOK, "admin_users.html", gin.H{
|
if err := app.store.BulkSetUsersDisabled(ids, disabled); err != nil {
|
||||||
"AdminSection": "users",
|
redirectAccountUsers(ctx, "Could not update users.", "")
|
||||||
"CurrentUser": app.currentAdminUsername(ctx),
|
return
|
||||||
"CSRFToken": app.currentCSRFToken(ctx),
|
}
|
||||||
"Users": rows,
|
|
||||||
"Tags": tags,
|
action := "disabled"
|
||||||
"Error": errorMessage,
|
if !disabled {
|
||||||
})
|
action = "enabled"
|
||||||
|
}
|
||||||
|
redirectAccountUsers(ctx, "", fmt.Sprintf("%d user(s) %s.", len(ids), action))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *App) handleAccountUsersBulkRevokeSessions(ctx *gin.Context) {
|
||||||
|
_, ok := currentAccountUser(ctx)
|
||||||
|
if !ok {
|
||||||
|
ctx.Redirect(http.StatusSeeOther, "/account/login")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
perms := currentAccountPermissions(ctx)
|
||||||
|
if !perms.AdminUsersManage {
|
||||||
|
ctx.String(http.StatusForbidden, "Permission denied")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ids := parseSelectedIDs(ctx)
|
||||||
|
if len(ids) == 0 {
|
||||||
|
redirectAccountUsers(ctx, "No users selected.", "")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := app.store.BulkRevokeUserSessions(ids); err != nil {
|
||||||
|
redirectAccountUsers(ctx, "Could not revoke sessions.", "")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
redirectAccountUsers(ctx, "", fmt.Sprintf("Sessions revoked for %d user(s).", len(ids)))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *App) handleAccountUsersResendInvite(ctx *gin.Context) {
|
||||||
|
_, ok := currentAccountUser(ctx)
|
||||||
|
if !ok {
|
||||||
|
ctx.Redirect(http.StatusSeeOther, "/account/login")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
perms := currentAccountPermissions(ctx)
|
||||||
|
if !perms.AdminUsersManage {
|
||||||
|
ctx.String(http.StatusForbidden, "Permission denied")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
userID := strings.TrimSpace(ctx.Param("id"))
|
||||||
|
if userID == "" {
|
||||||
|
redirectAccountUsers(ctx, "User ID is required.", "")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
user, ok, err := app.store.GetUser(userID)
|
||||||
|
if err != nil || !ok {
|
||||||
|
redirectAccountUsers(ctx, "User not found.", "")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if !strings.HasPrefix(user.PasswordHash, "invite/") {
|
||||||
|
redirectAccountUsers(ctx, "This user is not a pending invite.", "")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
inviteLink := fmt.Sprintf("/account/setup?token=%s", strings.TrimPrefix(user.PasswordHash, "invite/"))
|
||||||
|
redirectAccountUsers(ctx, "", fmt.Sprintf("Invite link: %s (Email delivery not yet implemented.)", inviteLink))
|
||||||
|
}
|
||||||
|
|
||||||
|
func userFiltersFromRequest(ctx *gin.Context) metastore.UserFilters {
|
||||||
|
return metastore.UserFilters{
|
||||||
|
Query: strings.TrimSpace(ctx.Query("q")),
|
||||||
|
Status: strings.TrimSpace(ctx.Query("status")),
|
||||||
|
Role: strings.TrimSpace(ctx.Query("role")),
|
||||||
|
Sort: strings.TrimSpace(ctx.Query("sort")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func userPageFromRequest(ctx *gin.Context) metastore.UserPageRequest {
|
||||||
|
page := 1
|
||||||
|
if p, err := strconv.Atoi(ctx.Query("page")); err == nil && p > 0 {
|
||||||
|
page = p
|
||||||
|
}
|
||||||
|
pageSize := defaultUserPageSize
|
||||||
|
if ps, err := strconv.Atoi(ctx.Query("page_size")); err == nil && ps >= 1 && ps <= 100 {
|
||||||
|
pageSize = ps
|
||||||
|
}
|
||||||
|
return metastore.UserPageRequest{
|
||||||
|
Page: page,
|
||||||
|
PageSize: pageSize,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseSelectedIDs(ctx *gin.Context) []string {
|
||||||
|
raw := ctx.PostForm("selected_ids")
|
||||||
|
if raw == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
parts := strings.Split(raw, ",")
|
||||||
|
ids := make([]string, 0, len(parts))
|
||||||
|
for _, part := range parts {
|
||||||
|
id := strings.TrimSpace(part)
|
||||||
|
if id != "" {
|
||||||
|
ids = append(ids, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ids
|
||||||
|
}
|
||||||
|
|
||||||
|
func redirectAccountUsers(ctx *gin.Context, errorMsg string, successMsg string) {
|
||||||
|
redirectURL := "/account/users"
|
||||||
|
if errorMsg != "" {
|
||||||
|
redirectURL = "/account/users?error=" + errorMsg
|
||||||
|
} else if successMsg != "" {
|
||||||
|
redirectURL = "/account/users?success=" + successMsg
|
||||||
|
}
|
||||||
|
ctx.Redirect(http.StatusSeeOther, redirectURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
func randomPassword() string {
|
||||||
|
const charset = "abcdefghjkmnpqrstuvwxyzABCDEFGHJKMNPQRSTUVWXYZ23456789"
|
||||||
|
result := make([]byte, 16)
|
||||||
|
for i := range result {
|
||||||
|
n, err := rand.Int(rand.Reader, big.NewInt(int64(len(charset))))
|
||||||
|
if err != nil {
|
||||||
|
return "changeme123"
|
||||||
|
}
|
||||||
|
result[i] = charset[n.Int64()]
|
||||||
|
}
|
||||||
|
return string(result)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -74,6 +74,7 @@ func Run(addr string) error {
|
|||||||
DirectBoxUpload: app.handleDirectBoxUpload,
|
DirectBoxUpload: app.handleDirectBoxUpload,
|
||||||
LegacyUpload: app.handleLegacyUpload,
|
LegacyUpload: app.handleLegacyUpload,
|
||||||
})
|
})
|
||||||
|
app.registerAccountRoutes(router)
|
||||||
app.registerAdminRoutes(router)
|
app.registerAdminRoutes(router)
|
||||||
|
|
||||||
compressed := router.Group("/", gzip.Gzip(gzip.DefaultCompression))
|
compressed := router.Group("/", gzip.Gzip(gzip.DefaultCompression))
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ func (app *App) handleCreateBox(ctx *gin.Context) {
|
|||||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
app.indexBoxFromManifest(boxID)
|
||||||
|
|
||||||
ctx.JSON(http.StatusOK, gin.H{"box_id": boxID, "box_url": "/box/" + boxID, "files": files})
|
ctx.JSON(http.StatusOK, gin.H{"box_id": boxID, "box_url": "/box/" + boxID, "files": files})
|
||||||
}
|
}
|
||||||
@@ -80,6 +81,7 @@ func (app *App) handleManifestFileUpload(ctx *gin.Context) {
|
|||||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
app.indexBoxFromManifest(boxID)
|
||||||
|
|
||||||
ctx.JSON(http.StatusOK, gin.H{"box_id": boxID, "box_url": "/box/" + boxID, "file": savedFile})
|
ctx.JSON(http.StatusOK, gin.H{"box_id": boxID, "box_url": "/box/" + boxID, "file": savedFile})
|
||||||
}
|
}
|
||||||
@@ -116,6 +118,7 @@ func (app *App) handleFileStatusUpdate(ctx *gin.Context) {
|
|||||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
app.indexBoxFromManifest(boxID)
|
||||||
|
|
||||||
ctx.JSON(http.StatusOK, gin.H{"file": file})
|
ctx.JSON(http.StatusOK, gin.H{"file": file})
|
||||||
}
|
}
|
||||||
@@ -231,6 +234,7 @@ func (app *App) handleLegacyUpload(ctx *gin.Context) {
|
|||||||
|
|
||||||
savedFiles = append(savedFiles, savedFile)
|
savedFiles = append(savedFiles, savedFile)
|
||||||
}
|
}
|
||||||
|
app.indexBoxFromManifest(boxID)
|
||||||
|
|
||||||
ctx.JSON(http.StatusOK, gin.H{"box_id": boxID, "box_url": "/box/" + boxID, "files": savedFiles})
|
ctx.JSON(http.StatusOK, gin.H{"box_id": boxID, "box_url": "/box/" + boxID, "files": savedFiles})
|
||||||
}
|
}
|
||||||
|
|||||||
2034
static/css/account.css
Normal file
2034
static/css/account.css
Normal file
File diff suppressed because it is too large
Load Diff
16
static/js/account-alerts.js
Normal file
16
static/js/account-alerts.js
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
document.addEventListener("DOMContentLoaded", () => {
|
||||||
|
const title = document.querySelector("[data-alert-detail-title]");
|
||||||
|
const description = document.querySelector("[data-alert-detail-description]");
|
||||||
|
const metadata = document.querySelector("[data-alert-detail-metadata]");
|
||||||
|
|
||||||
|
document.querySelectorAll("[data-alert-row]").forEach((row) => {
|
||||||
|
row.addEventListener("click", (event) => {
|
||||||
|
if (event.target.closest("button, input, a")) return;
|
||||||
|
document.querySelectorAll("[data-alert-row].is-selected").forEach((item) => item.classList.remove("is-selected"));
|
||||||
|
row.classList.add("is-selected");
|
||||||
|
if (title) title.textContent = row.dataset.alertTitle || "";
|
||||||
|
if (description) description.textContent = row.dataset.alertDescription || "";
|
||||||
|
if (metadata) metadata.textContent = row.dataset.alertMetadata || "{}";
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
39
static/js/account-settings.js
Normal file
39
static/js/account-settings.js
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
document.addEventListener("DOMContentLoaded", () => {
|
||||||
|
const panel = document.querySelector("[data-settings-import-panel]");
|
||||||
|
const toggle = document.querySelector("[data-settings-import-toggle]");
|
||||||
|
const submit = document.querySelector("[data-settings-import-submit]");
|
||||||
|
const input = document.querySelector("[data-settings-import-json]");
|
||||||
|
const csrf = document.querySelector('input[name="csrf_token"]')?.value || "";
|
||||||
|
|
||||||
|
toggle?.addEventListener("click", () => {
|
||||||
|
if (!panel) return;
|
||||||
|
panel.hidden = !panel.hidden;
|
||||||
|
if (!panel.hidden) input?.focus();
|
||||||
|
});
|
||||||
|
|
||||||
|
submit?.addEventListener("click", async () => {
|
||||||
|
const body = input?.value.trim() || "";
|
||||||
|
if (!body) {
|
||||||
|
window.WarpBoxAccountUI.toast("Paste settings JSON first.", "warning");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch("/account/settings/import.json", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"X-CSRF-Token": csrf,
|
||||||
|
},
|
||||||
|
body,
|
||||||
|
});
|
||||||
|
|
||||||
|
const payload = await response.json().catch(() => ({}));
|
||||||
|
if (!response.ok) {
|
||||||
|
window.WarpBoxAccountUI.toast(payload.error || "Settings import failed.", "error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.WarpBoxAccountUI.toast(`Imported ${payload.applied || 0} settings.`, "success");
|
||||||
|
window.setTimeout(() => window.location.reload(), 700);
|
||||||
|
});
|
||||||
|
});
|
||||||
258
static/js/account-ui.js
Normal file
258
static/js/account-ui.js
Normal file
@@ -0,0 +1,258 @@
|
|||||||
|
window.WarpBoxAccountUI = (() => {
|
||||||
|
let toastTimer = null;
|
||||||
|
let activeConfirmResolve = null;
|
||||||
|
|
||||||
|
function initStickyTaskbar(options = {}) {
|
||||||
|
const taskbar = options.taskbar || document.querySelector(".top-taskbar");
|
||||||
|
if (!taskbar) return;
|
||||||
|
|
||||||
|
const update = () => {
|
||||||
|
taskbar.classList.toggle("is-scrolled", window.scrollY > 2);
|
||||||
|
};
|
||||||
|
|
||||||
|
update();
|
||||||
|
window.addEventListener("scroll", update, { passive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeMenus(root = document) {
|
||||||
|
root.querySelectorAll(".menu-item.is-open").forEach((item) => {
|
||||||
|
item.classList.remove("is-open");
|
||||||
|
item.querySelector(".menu-button")?.setAttribute("aria-expanded", "false");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function openMenu(item) {
|
||||||
|
if (!item) return;
|
||||||
|
closeMenus(item.closest(".menu-bar") || document);
|
||||||
|
item.classList.add("is-open");
|
||||||
|
item.querySelector(".menu-button")?.setAttribute("aria-expanded", "true");
|
||||||
|
}
|
||||||
|
|
||||||
|
function initMenus(options = {}) {
|
||||||
|
const root = options.root || document;
|
||||||
|
root.addEventListener("click", (event) => {
|
||||||
|
const button = event.target.closest(".menu-button");
|
||||||
|
if (button) {
|
||||||
|
const item = button.closest(".menu-item");
|
||||||
|
const isOpen = item?.classList.contains("is-open");
|
||||||
|
closeMenus(root);
|
||||||
|
if (!isOpen) openMenu(item);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!event.target.closest(".menu-item")) {
|
||||||
|
closeMenus(root);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
root.querySelectorAll(".menu-item").forEach((item) => {
|
||||||
|
item.addEventListener("mouseenter", () => {
|
||||||
|
if (!root.querySelector(".menu-item.is-open")) return;
|
||||||
|
openMenu(item);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener("keydown", (event) => {
|
||||||
|
if (event.key === "Escape") closeMenus(root);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function toast(message, type = "info", options = {}) {
|
||||||
|
if (window.WarpBoxUI?.toast && !options.forceAccountToast) {
|
||||||
|
window.WarpBoxUI.toast(message, type, options);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const target = options.target || document.querySelector("#account-toast") || document.querySelector("#toast");
|
||||||
|
if (!target) return;
|
||||||
|
|
||||||
|
target.textContent = message;
|
||||||
|
target.classList.remove("toast-info", "toast-success", "toast-warning", "toast-error", "is-visible");
|
||||||
|
target.classList.add(`toast-${type}`, "is-visible");
|
||||||
|
clearTimeout(toastTimer);
|
||||||
|
toastTimer = setTimeout(() => target.classList.remove("is-visible"), options.duration || 2600);
|
||||||
|
}
|
||||||
|
|
||||||
|
function modalElements(options = {}) {
|
||||||
|
return {
|
||||||
|
modal: options.modal || document.querySelector("#account-modal"),
|
||||||
|
title: options.title || document.querySelector("#account-modal-title"),
|
||||||
|
body: options.body || document.querySelector("#account-modal-body"),
|
||||||
|
backdrop: options.backdrop || document.querySelector("#account-modal-backdrop") || document.querySelector("#modal-backdrop"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function openModal(titleText, html, options = {}) {
|
||||||
|
const parts = modalElements(options);
|
||||||
|
if (!parts.modal || !parts.title || !parts.body) {
|
||||||
|
if (window.WarpBoxUI?.openPopup) {
|
||||||
|
window.WarpBoxUI.openPopup(titleText, html, options);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
parts.title.textContent = titleText;
|
||||||
|
if (options.text) {
|
||||||
|
parts.body.textContent = html;
|
||||||
|
} else {
|
||||||
|
parts.body.innerHTML = html;
|
||||||
|
}
|
||||||
|
parts.modal.classList.add("is-visible");
|
||||||
|
parts.backdrop?.classList.add("is-visible");
|
||||||
|
parts.modal.querySelector("[data-modal-close]")?.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeModal(options = {}) {
|
||||||
|
const parts = modalElements(options);
|
||||||
|
parts.modal?.classList.remove("is-visible");
|
||||||
|
parts.backdrop?.classList.remove("is-visible");
|
||||||
|
if (window.WarpBoxUI?.closePopup && !parts.modal) {
|
||||||
|
window.WarpBoxUI.closePopup(options);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function confirm(message, options = {}) {
|
||||||
|
const title = options.title || "Confirm action";
|
||||||
|
const confirmLabel = options.confirmLabel || "OK";
|
||||||
|
const cancelLabel = options.cancelLabel || "Cancel";
|
||||||
|
const html = `
|
||||||
|
<p>${htmlEscape(message)}</p>
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button class="win98-button" type="button" data-confirm-cancel>${htmlEscape(cancelLabel)}</button>
|
||||||
|
<button class="win98-button" type="button" data-confirm-ok>${htmlEscape(confirmLabel)}</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
const parts = modalElements(options);
|
||||||
|
if (!parts.modal) {
|
||||||
|
return Promise.resolve(window.confirm(message));
|
||||||
|
}
|
||||||
|
|
||||||
|
openModal(title, html, options);
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
activeConfirmResolve = resolve;
|
||||||
|
parts.modal.querySelector("[data-confirm-ok]")?.focus();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function finishConfirm(result) {
|
||||||
|
if (activeConfirmResolve) {
|
||||||
|
activeConfirmResolve(result);
|
||||||
|
activeConfirmResolve = null;
|
||||||
|
}
|
||||||
|
closeModal();
|
||||||
|
}
|
||||||
|
|
||||||
|
function setDirtyState(isDirty, options = {}) {
|
||||||
|
const target = options.target || document.querySelector("[data-dirty-chip]");
|
||||||
|
if (!target) return;
|
||||||
|
target.classList.toggle("is-dirty", Boolean(isDirty));
|
||||||
|
target.textContent = isDirty ? (options.dirtyText || "unsaved changes") : (options.cleanText || "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function bindFormDirtyState(form, options = {}) {
|
||||||
|
const targetForm = typeof form === "string" ? document.querySelector(form) : form;
|
||||||
|
if (!targetForm) return;
|
||||||
|
|
||||||
|
let baseline = new FormData(targetForm);
|
||||||
|
const serialize = () => new URLSearchParams(new FormData(targetForm)).toString();
|
||||||
|
let baselineValue = new URLSearchParams(baseline).toString();
|
||||||
|
|
||||||
|
const update = () => setDirtyState(serialize() !== baselineValue, options);
|
||||||
|
targetForm.addEventListener("input", update);
|
||||||
|
targetForm.addEventListener("change", update);
|
||||||
|
targetForm.addEventListener("submit", () => {
|
||||||
|
baseline = new FormData(targetForm);
|
||||||
|
baselineValue = new URLSearchParams(baseline).toString();
|
||||||
|
setDirtyState(false, options);
|
||||||
|
});
|
||||||
|
update();
|
||||||
|
}
|
||||||
|
|
||||||
|
function bindConfirmActions(root = document) {
|
||||||
|
root.addEventListener("click", async (event) => {
|
||||||
|
const ok = event.target.closest("[data-confirm-ok]");
|
||||||
|
if (ok) {
|
||||||
|
finishConfirm(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cancel = event.target.closest("[data-confirm-cancel], [data-modal-close]");
|
||||||
|
if (cancel) {
|
||||||
|
finishConfirm(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const action = event.target.closest("[data-confirm]");
|
||||||
|
if (!action) return;
|
||||||
|
if (action.dataset.confirmAccepted === "true") {
|
||||||
|
delete action.dataset.confirmAccepted;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const message = action.getAttribute("data-confirm");
|
||||||
|
if (!message) return;
|
||||||
|
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
|
||||||
|
const accepted = await confirm(message, {
|
||||||
|
title: action.getAttribute("data-confirm-title") || "Confirm action",
|
||||||
|
confirmLabel: action.getAttribute("data-confirm-label") || "OK",
|
||||||
|
cancelLabel: action.getAttribute("data-cancel-label") || "Cancel",
|
||||||
|
});
|
||||||
|
if (!accepted) return;
|
||||||
|
|
||||||
|
if (action instanceof HTMLAnchorElement && action.href) {
|
||||||
|
window.location.href = action.href;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const form = action.closest("form");
|
||||||
|
const type = (action.getAttribute("type") || "").toLowerCase();
|
||||||
|
if (form && (type === "submit" || type === "")) {
|
||||||
|
form.requestSubmit(action);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
action.dataset.confirmAccepted = "true";
|
||||||
|
action.click();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function htmlEscape(value) {
|
||||||
|
return String(value || "")
|
||||||
|
.replaceAll("&", "&")
|
||||||
|
.replaceAll("<", "<")
|
||||||
|
.replaceAll(">", ">")
|
||||||
|
.replaceAll('"', """)
|
||||||
|
.replaceAll("'", "'");
|
||||||
|
}
|
||||||
|
|
||||||
|
function init(root = document) {
|
||||||
|
initStickyTaskbar();
|
||||||
|
initMenus({ root });
|
||||||
|
bindConfirmActions(root);
|
||||||
|
document.querySelector("#account-modal-backdrop")?.addEventListener("click", () => closeModal());
|
||||||
|
document.addEventListener("keydown", (event) => {
|
||||||
|
if (event.key === "Escape") closeModal();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
init,
|
||||||
|
initStickyTaskbar,
|
||||||
|
initMenus,
|
||||||
|
toast,
|
||||||
|
confirm,
|
||||||
|
openModal,
|
||||||
|
closeModal,
|
||||||
|
setDirtyState,
|
||||||
|
bindFormDirtyState,
|
||||||
|
closeMenus,
|
||||||
|
};
|
||||||
|
})();
|
||||||
|
|
||||||
|
document.addEventListener("DOMContentLoaded", () => {
|
||||||
|
window.WarpBoxAccountUI.init();
|
||||||
|
});
|
||||||
101
static/js/account-user-edit.js
Normal file
101
static/js/account-user-edit.js
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
(function () {
|
||||||
|
const form = document.querySelector('[data-ue-form]');
|
||||||
|
const dirtyIndicator = document.querySelector('[data-ue-dirty]');
|
||||||
|
const menuItems = Array.from(document.querySelectorAll('.menu-item'));
|
||||||
|
const toast = document.getElementById('account-toast');
|
||||||
|
let dirty = false;
|
||||||
|
let toastTimer = null;
|
||||||
|
|
||||||
|
function setDirty(next) {
|
||||||
|
dirty = next;
|
||||||
|
if (dirtyIndicator) {
|
||||||
|
dirtyIndicator.textContent = dirty ? 'Unsaved changes' : 'No unsaved changes';
|
||||||
|
}
|
||||||
|
const chip = document.querySelector('[data-dirty-chip]');
|
||||||
|
if (chip) {
|
||||||
|
chip.textContent = dirty ? '● unsaved' : '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function showToast(msg, type) {
|
||||||
|
if (!toast) return;
|
||||||
|
toast.textContent = msg;
|
||||||
|
toast.className = 'toast is-visible' + (type ? ' toast-' + type : '');
|
||||||
|
window.clearTimeout(toastTimer);
|
||||||
|
toastTimer = window.setTimeout(function () {
|
||||||
|
toast.classList.remove('is-visible');
|
||||||
|
}, 2400);
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeMenus() {
|
||||||
|
menuItems.forEach(function (item) { item.classList.remove('is-open'); });
|
||||||
|
}
|
||||||
|
|
||||||
|
menuItems.forEach(function (item) {
|
||||||
|
const btn = item.querySelector('.menu-button');
|
||||||
|
if (!btn) return;
|
||||||
|
btn.addEventListener('click', function (e) {
|
||||||
|
e.stopPropagation();
|
||||||
|
const open = item.classList.contains('is-open');
|
||||||
|
closeMenus();
|
||||||
|
if (!open) item.classList.add('is-open');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener('click', closeMenus);
|
||||||
|
|
||||||
|
document.addEventListener('keydown', function (e) {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
closeMenus();
|
||||||
|
}
|
||||||
|
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 's') {
|
||||||
|
e.preventDefault();
|
||||||
|
if (form) form.requestSubmit ? form.requestSubmit() : form.submit();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (form) {
|
||||||
|
form.addEventListener('change', function () { setDirty(true); });
|
||||||
|
form.addEventListener('input', function () { setDirty(true); });
|
||||||
|
form.addEventListener('submit', function () { setDirty(false); });
|
||||||
|
}
|
||||||
|
|
||||||
|
document.querySelectorAll('[data-ue-command]').forEach(function (el) {
|
||||||
|
el.addEventListener('click', function () {
|
||||||
|
closeMenus();
|
||||||
|
const cmd = el.getAttribute('data-ue-command');
|
||||||
|
switch (cmd) {
|
||||||
|
case 'save':
|
||||||
|
if (form) { form.requestSubmit ? form.requestSubmit() : form.submit(); }
|
||||||
|
break;
|
||||||
|
case 'discard':
|
||||||
|
if (dirty && form) {
|
||||||
|
if (window.confirm('Discard unsaved changes?')) {
|
||||||
|
setDirty(false);
|
||||||
|
form.reset();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'reset-password': {
|
||||||
|
const resetForm = document.querySelector('form[action*="/password/reset"]');
|
||||||
|
if (resetForm && window.confirm('Reset this user\'s password? A temporary password will be generated and shown.')) {
|
||||||
|
resetForm.submit();
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
showToast('Action: ' + cmd);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// sticky scroll shadow on taskbar
|
||||||
|
const header = document.querySelector('.top-taskbar');
|
||||||
|
if (header) {
|
||||||
|
function updateScroll() {
|
||||||
|
header.classList.toggle('is-scrolled', window.scrollY > 4);
|
||||||
|
}
|
||||||
|
updateScroll();
|
||||||
|
window.addEventListener('scroll', updateScroll, { passive: true });
|
||||||
|
}
|
||||||
|
}());
|
||||||
67
static/js/account-users.js
Normal file
67
static/js/account-users.js
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
(function () {
|
||||||
|
const masterCheck = document.getElementById('master-check');
|
||||||
|
const rowChecks = document.querySelectorAll('.row-check');
|
||||||
|
const bulkForm = document.getElementById('users-bulk-form');
|
||||||
|
const selectedIdsInput = document.getElementById('bulk-selected-ids');
|
||||||
|
const selectedCount = document.getElementById('selected-count');
|
||||||
|
const focusCreateBtn = document.querySelector('[data-users-action="focus-create"]');
|
||||||
|
const selectVisibleBtns = document.querySelectorAll('[data-users-action="select-visible"]');
|
||||||
|
|
||||||
|
function updateSelected() {
|
||||||
|
const checked = document.querySelectorAll('.row-check:checked');
|
||||||
|
const ids = Array.from(checked).map(cb => cb.value);
|
||||||
|
selectedIdsInput.value = ids.join(',');
|
||||||
|
if (selectedCount) {
|
||||||
|
selectedCount.textContent = ids.length + ' selected';
|
||||||
|
}
|
||||||
|
if (masterCheck) {
|
||||||
|
const allRowChecks = document.querySelectorAll('.row-check');
|
||||||
|
masterCheck.checked = allRowChecks.length > 0 && checked.length === allRowChecks.length;
|
||||||
|
masterCheck.indeterminate = checked.length > 0 && checked.length < allRowChecks.length;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (masterCheck) {
|
||||||
|
masterCheck.addEventListener('change', function () {
|
||||||
|
document.querySelectorAll('.row-check').forEach(function (cb) {
|
||||||
|
cb.checked = masterCheck.checked;
|
||||||
|
});
|
||||||
|
updateSelected();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('change', function (event) {
|
||||||
|
if (event.target.classList.contains('row-check')) {
|
||||||
|
updateSelected();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
selectVisibleBtns.forEach(function (btn) {
|
||||||
|
btn.addEventListener('click', function () {
|
||||||
|
document.querySelectorAll('.row-check').forEach(function (cb) {
|
||||||
|
cb.checked = true;
|
||||||
|
});
|
||||||
|
updateSelected();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
if (focusCreateBtn) {
|
||||||
|
focusCreateBtn.addEventListener('click', function () {
|
||||||
|
var usernameInput = document.getElementById('users-username');
|
||||||
|
if (usernameInput) {
|
||||||
|
usernameInput.scrollIntoView({ behavior: 'smooth' });
|
||||||
|
setTimeout(function () { usernameInput.focus(); }, 150);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
updateSelected();
|
||||||
|
})();
|
||||||
|
|
||||||
|
function setBulkAction(actionUrl) {
|
||||||
|
var form = document.getElementById('users-bulk-form');
|
||||||
|
if (form) {
|
||||||
|
form.action = actionUrl;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
182
templates/account_alerts.html
Normal file
182
templates/account_alerts.html
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
{{ template "account_shell_start" . }}
|
||||||
|
<main class="account-window" aria-labelledby="account-alerts-title">
|
||||||
|
{{ template "account_window_titlebar" . }}
|
||||||
|
|
||||||
|
<nav class="menu-bar" aria-label="Alerts toolbar">
|
||||||
|
<div class="menu-item">
|
||||||
|
<button class="menu-button" type="button" aria-expanded="false">File</button>
|
||||||
|
<div class="menu-popup" role="menu">
|
||||||
|
<a class="menu-action" href="/account/alerts"><span>R</span><span>Refresh alerts</span><span></span></a>
|
||||||
|
<a class="menu-action" href="/account/alerts/export.json"><span>E</span><span>Export JSON</span><span></span></a>
|
||||||
|
<div class="menu-separator"></div>
|
||||||
|
<form action="/account/logout" method="post">
|
||||||
|
{{ template "account_csrf_field" . }}
|
||||||
|
<button class="menu-action" type="submit"><span>Q</span><span>Log out</span><span></span></button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="menu-item">
|
||||||
|
<button class="menu-button" type="button" aria-expanded="false">View</button>
|
||||||
|
<div class="menu-popup" role="menu">
|
||||||
|
<a class="menu-action" href="/account/alerts?status=open"><span>O</span><span>Open</span><span></span></a>
|
||||||
|
<a class="menu-action" href="/account/alerts?severity=high"><span>H</span><span>High severity</span><span></span></a>
|
||||||
|
<a class="menu-action" href="/account/alerts?sort=severity"><span>S</span><span>Sort by severity</span><span></span></a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div class="alerts-layout account-body-content">
|
||||||
|
<section class="stats-grid" aria-label="Alert statistics">
|
||||||
|
<article class="stat-card sunken-panel is-warning">
|
||||||
|
<p class="stat-label">Open</p>
|
||||||
|
<p class="stat-value">{{ .Stats.Open }}</p>
|
||||||
|
<p class="stat-note"><span class="stat-note-pill">needs attention</span></p>
|
||||||
|
</article>
|
||||||
|
<article class="stat-card sunken-panel is-info">
|
||||||
|
<p class="stat-label">Acknowledged</p>
|
||||||
|
<p class="stat-value">{{ .Stats.Acknowledged }}</p>
|
||||||
|
<p class="stat-note"><span class="stat-note-pill">seen</span></p>
|
||||||
|
</article>
|
||||||
|
<article class="stat-card sunken-panel is-ok">
|
||||||
|
<p class="stat-label">Closed</p>
|
||||||
|
<p class="stat-value">{{ .Stats.Closed }}</p>
|
||||||
|
<p class="stat-note"><span class="stat-note-pill">done</span></p>
|
||||||
|
</article>
|
||||||
|
<article class="stat-card sunken-panel is-danger">
|
||||||
|
<p class="stat-label">High</p>
|
||||||
|
<p class="stat-value">{{ .Stats.High }}</p>
|
||||||
|
<p class="stat-note"><span class="stat-note-pill">{{ .Stats.Medium }} medium</span><span class="stat-note-pill">{{ .Stats.Low }} low</span></p>
|
||||||
|
</article>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<form class="alerts-filterbar raised-panel" action="/account/alerts" method="get">
|
||||||
|
<label class="account-form-row">
|
||||||
|
<span>Search</span>
|
||||||
|
<input class="account-control" name="q" value="{{ .Filters.Query }}" placeholder="title, code, trace">
|
||||||
|
</label>
|
||||||
|
<label class="account-form-row">
|
||||||
|
<span>Severity</span>
|
||||||
|
<select class="account-control" name="severity">
|
||||||
|
<option value="all" {{ if eq .Filters.Severity "all" }}selected{{ end }}>All</option>
|
||||||
|
<option value="low" {{ if eq .Filters.Severity "low" }}selected{{ end }}>Low</option>
|
||||||
|
<option value="medium" {{ if eq .Filters.Severity "medium" }}selected{{ end }}>Medium</option>
|
||||||
|
<option value="high" {{ if eq .Filters.Severity "high" }}selected{{ end }}>High</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label class="account-form-row">
|
||||||
|
<span>Status</span>
|
||||||
|
<select class="account-control" name="status">
|
||||||
|
<option value="all" {{ if eq .Filters.Status "all" }}selected{{ end }}>All</option>
|
||||||
|
<option value="open" {{ if eq .Filters.Status "open" }}selected{{ end }}>Open</option>
|
||||||
|
<option value="acknowledged" {{ if eq .Filters.Status "acknowledged" }}selected{{ end }}>Acknowledged</option>
|
||||||
|
<option value="closed" {{ if eq .Filters.Status "closed" }}selected{{ end }}>Closed</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label class="account-form-row">
|
||||||
|
<span>Group</span>
|
||||||
|
<select class="account-control" name="group">
|
||||||
|
<option value="all" {{ if eq .Filters.Group "all" }}selected{{ end }}>All</option>
|
||||||
|
{{ range .Groups }}
|
||||||
|
<option value="{{ . }}" {{ if eq $.Filters.Group . }}selected{{ end }}>{{ . }}</option>
|
||||||
|
{{ end }}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label class="account-form-row">
|
||||||
|
<span>Sort</span>
|
||||||
|
<select class="account-control" name="sort">
|
||||||
|
<option value="newest" {{ if eq .Filters.Sort "newest" }}selected{{ end }}>Newest</option>
|
||||||
|
<option value="oldest" {{ if eq .Filters.Sort "oldest" }}selected{{ end }}>Oldest</option>
|
||||||
|
<option value="severity" {{ if eq .Filters.Sort "severity" }}selected{{ end }}>Severity</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<button class="win98-button" type="submit">Apply</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<section class="alerts-workspace">
|
||||||
|
<form class="win98-window section-window" action="/account/alerts/bulk/acknowledge" method="post">
|
||||||
|
<div class="win98-titlebar">
|
||||||
|
<div class="win98-titlebar-label">
|
||||||
|
<span class="win98-titlebar-icon">!</span>
|
||||||
|
<h2>Alert List</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="section-body sunken-panel">
|
||||||
|
{{ template "account_csrf_field" . }}
|
||||||
|
<div class="scroll-panel alerts-table-scroll">
|
||||||
|
<table class="account-table alerts-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Select</th>
|
||||||
|
<th>Severity</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Code</th>
|
||||||
|
<th>Title</th>
|
||||||
|
<th>Trace</th>
|
||||||
|
<th>Created</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{{ range .Alerts }}
|
||||||
|
<tr data-alert-row data-alert-id="{{ .ID }}" data-alert-title="{{ .Title }}" data-alert-description="{{ .Description }}" data-alert-metadata="{{ .MetadataPretty }}" class="{{ if eq $.SelectedAlert.ID .ID }}is-selected{{ end }}">
|
||||||
|
<td><input type="checkbox" name="alert_ids" value="{{ .ID }}"></td>
|
||||||
|
<td><span class="badge is-{{ .Severity }}">{{ .Severity }}</span></td>
|
||||||
|
<td><span class="badge">{{ .Status }}</span></td>
|
||||||
|
<td>{{ .Code }}</td>
|
||||||
|
<td>{{ .Title }}</td>
|
||||||
|
<td>{{ .Trace }}</td>
|
||||||
|
<td>{{ .CreatedAt }}</td>
|
||||||
|
<td>
|
||||||
|
<div class="box-actions">
|
||||||
|
{{ if $.CanManageAlerts }}
|
||||||
|
<button class="tiny-button" type="submit" formaction="/account/alerts/{{ .ID }}/acknowledge">Ack</button>
|
||||||
|
<button class="tiny-button" type="submit" formaction="/account/alerts/{{ .ID }}/close">Close</button>
|
||||||
|
{{ end }}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{{ else }}
|
||||||
|
<tr><td colspan="8">No alerts found.</td></tr>
|
||||||
|
{{ end }}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{{ if .CanManageAlerts }}
|
||||||
|
<div class="bulk-actions raised-panel">
|
||||||
|
<button class="win98-button" type="submit">Acknowledge selected</button>
|
||||||
|
<button class="win98-button" type="submit" formaction="/account/alerts/bulk/close">Close selected</button>
|
||||||
|
</div>
|
||||||
|
{{ end }}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<aside class="alerts-detail sunken-panel" aria-label="Alert details">
|
||||||
|
{{ if .SelectedAlert }}
|
||||||
|
<div>
|
||||||
|
<h2 data-alert-detail-title>{{ .SelectedAlert.Title }}</h2>
|
||||||
|
<p data-alert-detail-description>{{ .SelectedAlert.Description }}</p>
|
||||||
|
</div>
|
||||||
|
<pre class="metadata-pre" data-alert-detail-metadata>{{ .SelectedAlert.MetadataPretty }}</pre>
|
||||||
|
<div class="setting-source">
|
||||||
|
<span class="badge is-{{ .SelectedAlert.Severity }}">{{ .SelectedAlert.Severity }}</span>
|
||||||
|
<span class="badge">{{ .SelectedAlert.Status }}</span>
|
||||||
|
<span class="setting-env">{{ .SelectedAlert.Trace }}</span>
|
||||||
|
</div>
|
||||||
|
{{ else }}
|
||||||
|
<div>
|
||||||
|
<h2 data-alert-detail-title>No alert selected</h2>
|
||||||
|
<p data-alert-detail-description>Select an alert row to inspect metadata.</p>
|
||||||
|
</div>
|
||||||
|
<pre class="metadata-pre" data-alert-detail-metadata>{}</pre>
|
||||||
|
{{ end }}
|
||||||
|
</aside>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer class="win98-statusbar" aria-label="Alerts status">
|
||||||
|
<span>alerts</span>
|
||||||
|
<span>{{ .Stats.Open }} open</span>
|
||||||
|
<span>ready</span>
|
||||||
|
</footer>
|
||||||
|
</main>
|
||||||
|
{{ template "account_shell_end" . }}
|
||||||
151
templates/account_box_manager.html
Normal file
151
templates/account_box_manager.html
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
{{ template "account_shell_start" . }}
|
||||||
|
<main class="account-window" aria-labelledby="box-manager-title">
|
||||||
|
{{ template "account_window_titlebar" . }}
|
||||||
|
|
||||||
|
<nav class="menu-bar" aria-label="Box manager toolbar">
|
||||||
|
<div class="menu-item">
|
||||||
|
<button class="menu-button" type="button" aria-expanded="false">File</button>
|
||||||
|
<div class="menu-popup" role="menu">
|
||||||
|
<a class="menu-action" href="/account/boxes"><span>B</span><span>Back to boxes</span><span></span></a>
|
||||||
|
<a class="menu-action" href="{{ .Box.OpenURL }}"><span>O</span><span>Open shared box</span><span></span></a>
|
||||||
|
<div class="menu-separator"></div>
|
||||||
|
<form action="/account/logout" method="post">
|
||||||
|
{{ template "account_csrf_field" . }}
|
||||||
|
<button class="menu-action" type="submit"><span>Q</span><span>Log out</span><span></span></button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div class="box-manager-layout account-body-content">
|
||||||
|
{{ if .Error }}<p class="account-error">{{ .Error }}</p>{{ end }}
|
||||||
|
|
||||||
|
<section class="stats-grid" aria-label="Box summary">
|
||||||
|
<article class="stat-card sunken-panel is-info">
|
||||||
|
<p class="stat-label">Status</p>
|
||||||
|
<p class="stat-value">{{ .Box.Status }}</p>
|
||||||
|
<p class="stat-note"><span class="stat-note-pill">{{ .Box.Flags }}</span></p>
|
||||||
|
</article>
|
||||||
|
<article class="stat-card sunken-panel is-info">
|
||||||
|
<p class="stat-label">Storage</p>
|
||||||
|
<p class="stat-value">{{ .Box.Storage }}</p>
|
||||||
|
<p class="stat-note"><span class="stat-note-pill">{{ len .Files }} files</span></p>
|
||||||
|
</article>
|
||||||
|
<article class="stat-card sunken-panel is-warning">
|
||||||
|
<p class="stat-label">Expiration</p>
|
||||||
|
<p class="stat-note"><span class="stat-note-pill">{{ .Box.ExpiresAt }}</span></p>
|
||||||
|
</article>
|
||||||
|
<article class="stat-card sunken-panel is-ok">
|
||||||
|
<p class="stat-label">Owner policy</p>
|
||||||
|
<p class="stat-note"><span class="stat-note-pill">{{ if .Policy.CanEditMetadata }}editable{{ else }}locked{{ end }}</span><span class="stat-note-pill">{{ if .Policy.CanExtendExpiry }}refreshable{{ else }}no refresh{{ end }}</span></p>
|
||||||
|
</article>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="box-manager-grid">
|
||||||
|
<div class="box-manager-main">
|
||||||
|
<section class="win98-window section-window">
|
||||||
|
<div class="win98-titlebar"><div class="win98-titlebar-label"><span class="win98-titlebar-icon">I</span><h2 id="box-manager-title">Identity</h2></div></div>
|
||||||
|
<div class="section-body sunken-panel">
|
||||||
|
<p><strong>Box:</strong> {{ .Box.ID }}</p>
|
||||||
|
<p><strong>Owner:</strong> {{ .Box.Owner }}</p>
|
||||||
|
<p><strong>Created:</strong> {{ .Box.CreatedAt }}</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="win98-window section-window">
|
||||||
|
<div class="win98-titlebar"><div class="win98-titlebar-label"><span class="win98-titlebar-icon">S</span><h2>Sharing Rules</h2></div></div>
|
||||||
|
<form class="section-body sunken-panel account-form" action="/account/boxes/{{ .Box.ID }}" method="post">
|
||||||
|
{{ template "account_csrf_field" . }}
|
||||||
|
<label><input type="checkbox" name="disable_zip" value="true" {{ if .Box.DisableZip }}checked{{ end }} {{ if not .Policy.CanEditSharingRules }}disabled{{ end }}> Disable ZIP downloads</label>
|
||||||
|
<label><input type="checkbox" name="one_time_download" value="true" {{ if .Box.OneTimeDownload }}checked{{ end }} {{ if not .Policy.CanEditSharingRules }}disabled{{ end }}> One-time download</label>
|
||||||
|
<button class="win98-button" type="submit" {{ if not .Policy.CanEditSharingRules }}disabled{{ end }}>Save Rules</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="win98-window section-window">
|
||||||
|
<div class="win98-titlebar"><div class="win98-titlebar-label"><span class="win98-titlebar-icon">F</span><h2>Files</h2></div></div>
|
||||||
|
<form class="section-body sunken-panel" action="/account/boxes/{{ .Box.ID }}/files/delete" method="post">
|
||||||
|
{{ template "account_csrf_field" . }}
|
||||||
|
<div class="scroll-panel files-scroll">
|
||||||
|
<table class="account-table boxes-table">
|
||||||
|
<thead><tr><th>Select</th><th>Name</th><th>Size</th><th>Status</th><th>Download</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{{ range .Files }}
|
||||||
|
<tr>
|
||||||
|
<td><input type="checkbox" name="file_ids" value="{{ .ID }}"></td>
|
||||||
|
<td>{{ .Name }}</td>
|
||||||
|
<td>{{ .Size }}</td>
|
||||||
|
<td>{{ .Status }}</td>
|
||||||
|
<td><a class="tiny-button" href="{{ .Download }}">Open</a></td>
|
||||||
|
</tr>
|
||||||
|
{{ else }}
|
||||||
|
<tr><td colspan="5">No files.</td></tr>
|
||||||
|
{{ end }}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div class="bulk-actions raised-panel">
|
||||||
|
<button class="win98-button" type="submit" data-confirm="Delete selected files permanently?" {{ if not .Policy.CanDeleteFiles }}disabled{{ end }}>Delete Files</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<aside class="box-manager-side">
|
||||||
|
<section class="sunken-panel section-body">
|
||||||
|
<h2>Expiration</h2>
|
||||||
|
<form class="account-form" action="/account/boxes/{{ .Box.ID }}/extend" method="post">
|
||||||
|
{{ template "account_csrf_field" . }}
|
||||||
|
<label class="account-form-row"><span>Extend seconds</span><input class="account-control" name="extend_seconds" value="{{ .Policy.MaxExtensionSeconds }}" inputmode="numeric"></label>
|
||||||
|
<button class="win98-button" type="submit" {{ if not .Policy.CanExtendExpiry }}disabled{{ end }}>Extend</button>
|
||||||
|
</form>
|
||||||
|
<form action="/account/boxes/{{ .Box.ID }}/expire" method="post">
|
||||||
|
{{ template "account_csrf_field" . }}
|
||||||
|
<button class="win98-button" type="submit" data-confirm="Expire this box now?" {{ if not .Policy.CanEditMetadata }}disabled{{ end }}>Expire Now</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="sunken-panel section-body">
|
||||||
|
<h2>Password</h2>
|
||||||
|
<form class="account-form" action="/account/boxes/{{ .Box.ID }}/password" method="post">
|
||||||
|
{{ template "account_csrf_field" . }}
|
||||||
|
<label class="account-form-row"><span>New password</span><input class="account-control" name="password" type="password" autocomplete="new-password"></label>
|
||||||
|
<button class="win98-button" type="submit" {{ if not .Policy.CanEditPassword }}disabled{{ end }}>Set Password</button>
|
||||||
|
</form>
|
||||||
|
<form action="/account/boxes/{{ .Box.ID }}/password/remove" method="post">
|
||||||
|
{{ template "account_csrf_field" . }}
|
||||||
|
<button class="win98-button" type="submit" data-confirm="Remove box password?" {{ if not .Policy.CanEditPassword }}disabled{{ end }}>Remove Password</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="sunken-panel section-body">
|
||||||
|
<h2>Resolved Policy</h2>
|
||||||
|
<pre class="metadata-pre policy-pre">{{ .PolicyJSON }}</pre>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="sunken-panel section-body">
|
||||||
|
<h2>Box Activity</h2>
|
||||||
|
<div class="activity-list-compact">
|
||||||
|
{{ range .Activity }}
|
||||||
|
<div class="activity-row"><span class="activity-time">{{ .At }}</span><div><p class="activity-title">{{ .Message }}</p><p class="activity-meta">{{ .Actor }}</p></div></div>
|
||||||
|
{{ end }}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="sunken-panel section-body">
|
||||||
|
<form action="/account/boxes/{{ .Box.ID }}/delete" method="post">
|
||||||
|
{{ template "account_csrf_field" . }}
|
||||||
|
<button class="win98-button" type="submit" data-confirm="Delete this box permanently?" {{ if not .Policy.CanDeleteBox }}disabled{{ end }}>Delete Box</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
</aside>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer class="win98-statusbar" aria-label="Box manager status">
|
||||||
|
<span>{{ .Box.ID }}</span>
|
||||||
|
<span>{{ .Box.Status }}</span>
|
||||||
|
<span>ready</span>
|
||||||
|
</footer>
|
||||||
|
</main>
|
||||||
|
{{ template "account_shell_end" . }}
|
||||||
174
templates/account_boxes.html
Normal file
174
templates/account_boxes.html
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
{{ template "account_shell_start" . }}
|
||||||
|
<main class="account-window" aria-labelledby="account-boxes-title">
|
||||||
|
{{ template "account_window_titlebar" . }}
|
||||||
|
|
||||||
|
<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" role="menu">
|
||||||
|
<a class="menu-action" href="/account/boxes"><span>R</span><span>Refresh boxes</span><span></span></a>
|
||||||
|
<a class="menu-action" href="/account/boxes/export.csv"><span>E</span><span>Export visible CSV</span><span></span></a>
|
||||||
|
<div class="menu-separator"></div>
|
||||||
|
<form action="/account/logout" method="post">
|
||||||
|
{{ template "account_csrf_field" . }}
|
||||||
|
<button class="menu-action" type="submit"><span>Q</span><span>Log out</span><span></span></button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="menu-item">
|
||||||
|
<button class="menu-button" type="button" aria-expanded="false">View</button>
|
||||||
|
<div class="menu-popup" role="menu">
|
||||||
|
<a class="menu-action" href="/account/boxes?status=active"><span>A</span><span>Active</span><span></span></a>
|
||||||
|
<a class="menu-action" href="/account/boxes?status=expired"><span>X</span><span>Expired</span><span></span></a>
|
||||||
|
<a class="menu-action" href="/account/boxes?sort=largest"><span>L</span><span>Largest first</span><span></span></a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div class="boxes-layout account-body-content">
|
||||||
|
{{ if .Error }}<p class="account-error">{{ .Error }}</p>{{ end }}
|
||||||
|
|
||||||
|
<section class="stats-grid" aria-label="Box statistics">
|
||||||
|
<article class="stat-card sunken-panel is-info">
|
||||||
|
<p class="stat-label">Visible</p>
|
||||||
|
<p class="stat-value">{{ .Stats.Visible }}</p>
|
||||||
|
<p class="stat-note"><span class="stat-note-pill">{{ .Stats.Total }} matching</span></p>
|
||||||
|
</article>
|
||||||
|
<article class="stat-card sunken-panel is-warning">
|
||||||
|
<p class="stat-label">Expired</p>
|
||||||
|
<p class="stat-value">{{ .Stats.Expired }}</p>
|
||||||
|
<p class="stat-note"><span class="stat-note-pill">visible page</span></p>
|
||||||
|
</article>
|
||||||
|
<article class="stat-card sunken-panel is-info">
|
||||||
|
<p class="stat-label">Storage</p>
|
||||||
|
<p class="stat-value">{{ .Stats.Storage }}</p>
|
||||||
|
<p class="stat-note"><span class="stat-note-pill">visible page</span></p>
|
||||||
|
</article>
|
||||||
|
<article class="stat-card sunken-panel is-ok">
|
||||||
|
<p class="stat-label">Policy</p>
|
||||||
|
<p class="stat-note"><span class="stat-note-pill">{{ .PolicySummary }}</span></p>
|
||||||
|
</article>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<form class="boxes-filterbar raised-panel" action="/account/boxes" method="get">
|
||||||
|
<label class="account-form-row">
|
||||||
|
<span>Search</span>
|
||||||
|
<input class="account-control" name="q" value="{{ .Filters.Query }}" placeholder="id, owner, file">
|
||||||
|
</label>
|
||||||
|
<label class="account-form-row">
|
||||||
|
<span>Owner</span>
|
||||||
|
<input class="account-control" name="owner" value="{{ .Filters.Owner }}" placeholder="all">
|
||||||
|
</label>
|
||||||
|
<label class="account-form-row">
|
||||||
|
<span>Status</span>
|
||||||
|
<select class="account-control" name="status">
|
||||||
|
<option value="all" {{ if eq .Filters.Status "all" }}selected{{ end }}>All</option>
|
||||||
|
<option value="active" {{ if eq .Filters.Status "active" }}selected{{ end }}>Active</option>
|
||||||
|
<option value="pending" {{ if eq .Filters.Status "pending" }}selected{{ end }}>Pending</option>
|
||||||
|
<option value="expired" {{ if eq .Filters.Status "expired" }}selected{{ end }}>Expired</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label class="account-form-row">
|
||||||
|
<span>Flag</span>
|
||||||
|
<select class="account-control" name="flag">
|
||||||
|
<option value="all" {{ if eq .Filters.Flag "all" }}selected{{ end }}>All</option>
|
||||||
|
<option value="password" {{ if eq .Filters.Flag "password" }}selected{{ end }}>Password</option>
|
||||||
|
<option value="one-time" {{ if eq .Filters.Flag "one-time" }}selected{{ end }}>One-time</option>
|
||||||
|
<option value="zip-disabled" {{ if eq .Filters.Flag "zip-disabled" }}selected{{ end }}>ZIP disabled</option>
|
||||||
|
<option value="expired" {{ if eq .Filters.Flag "expired" }}selected{{ end }}>Expired</option>
|
||||||
|
<option value="refreshable" {{ if eq .Filters.Flag "refreshable" }}selected{{ end }}>Refreshable</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label class="account-form-row">
|
||||||
|
<span>Sort</span>
|
||||||
|
<select class="account-control" name="sort">
|
||||||
|
<option value="newest" {{ if eq .Filters.Sort "newest" }}selected{{ end }}>Newest</option>
|
||||||
|
<option value="oldest" {{ if eq .Filters.Sort "oldest" }}selected{{ end }}>Oldest</option>
|
||||||
|
<option value="largest" {{ if eq .Filters.Sort "largest" }}selected{{ end }}>Largest</option>
|
||||||
|
<option value="expires" {{ if eq .Filters.Sort "expires" }}selected{{ end }}>Expires soon</option>
|
||||||
|
<option value="expired" {{ if eq .Filters.Sort "expired" }}selected{{ end }}>Expired first</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<input type="hidden" name="page_size" value="{{ .PageSize }}">
|
||||||
|
<button class="win98-button" type="submit">Apply</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<form class="win98-window section-window" action="/account/boxes/bulk/expire" method="post">
|
||||||
|
{{ template "account_csrf_field" . }}
|
||||||
|
<div class="win98-titlebar">
|
||||||
|
<div class="win98-titlebar-label">
|
||||||
|
<span class="win98-titlebar-icon">B</span>
|
||||||
|
<h2>Box Index</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="section-body sunken-panel">
|
||||||
|
<div class="scroll-panel boxes-table-scroll">
|
||||||
|
<table class="account-table boxes-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Select</th>
|
||||||
|
<th>Box</th>
|
||||||
|
<th>Owner</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Files</th>
|
||||||
|
<th>Size</th>
|
||||||
|
<th>Created</th>
|
||||||
|
<th>Expires</th>
|
||||||
|
<th>Flags</th>
|
||||||
|
<th>Refresh policy</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{{ range .Rows }}
|
||||||
|
<tr>
|
||||||
|
<td><input type="checkbox" name="box_ids" value="{{ .ID }}"></td>
|
||||||
|
<td>{{ .ID }}</td>
|
||||||
|
<td>{{ .Owner }}</td>
|
||||||
|
<td><span class="badge">{{ .Status }}</span></td>
|
||||||
|
<td>{{ .FileCount }}</td>
|
||||||
|
<td>{{ .Size }}</td>
|
||||||
|
<td>{{ .CreatedAt }}</td>
|
||||||
|
<td>{{ .ExpiresAt }}</td>
|
||||||
|
<td>{{ .Flags }}</td>
|
||||||
|
<td>{{ .Policy }}</td>
|
||||||
|
<td>
|
||||||
|
<div class="box-actions">
|
||||||
|
<a class="tiny-button" href="{{ .OpenURL }}">Open</a>
|
||||||
|
{{ if .CanManage }}<a class="tiny-button" href="{{ .ManageURL }}">Manage</a>{{ end }}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{{ else }}
|
||||||
|
<tr><td colspan="11">No indexed boxes found.</td></tr>
|
||||||
|
{{ end }}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div class="bulk-actions raised-panel">
|
||||||
|
<label class="account-form-row">
|
||||||
|
<span>Bump seconds</span>
|
||||||
|
<input class="account-control" name="bump_seconds" value="3600" inputmode="numeric">
|
||||||
|
</label>
|
||||||
|
<button class="win98-button" type="submit" data-confirm="Expire selected boxes?">Expire selected</button>
|
||||||
|
<button class="win98-button" type="submit" formaction="/account/boxes/bulk/bump-expiry">Bump selected</button>
|
||||||
|
<button class="win98-button" type="submit" formaction="/account/boxes/bulk/delete" data-confirm="Delete selected boxes permanently?">Delete selected</button>
|
||||||
|
<button class="win98-button" type="submit" formaction="/account/boxes/delete-largest" data-confirm="Delete 10 biggest matching boxes permanently?">Delete largest 10</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<nav class="pagination-strip raised-panel" aria-label="Pagination">
|
||||||
|
<span class="badge">Page {{ .Page }} / {{ .TotalPages }}</span>
|
||||||
|
{{ if .HasPrev }}<a class="win98-button" href="{{ .PrevURL }}">Prev</a>{{ end }}
|
||||||
|
{{ if .HasNext }}<a class="win98-button" href="{{ .NextURL }}">Next</a>{{ end }}
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer class="win98-statusbar" aria-label="Boxes status">
|
||||||
|
<span>boxes index</span>
|
||||||
|
<span>{{ .Total }} matching</span>
|
||||||
|
<span>ready</span>
|
||||||
|
</footer>
|
||||||
|
</main>
|
||||||
|
{{ template "account_shell_end" . }}
|
||||||
198
templates/account_dashboard.html
Normal file
198
templates/account_dashboard.html
Normal file
@@ -0,0 +1,198 @@
|
|||||||
|
{{ template "account_shell_start" . }}
|
||||||
|
<main class="account-window" aria-labelledby="account-dashboard-title">
|
||||||
|
{{ template "account_window_titlebar" . }}
|
||||||
|
|
||||||
|
<nav class="menu-bar" aria-label="Dashboard toolbar">
|
||||||
|
<div class="menu-item">
|
||||||
|
<button class="menu-button" type="button" aria-expanded="false">File</button>
|
||||||
|
<div class="menu-popup" role="menu">
|
||||||
|
<a class="menu-action" href="/account"><span>R</span><span>Refresh dashboard</span><span class="shortcut">F5</span></a>
|
||||||
|
<div class="menu-separator"></div>
|
||||||
|
<form action="/account/logout" method="post">
|
||||||
|
{{ template "account_csrf_field" . }}
|
||||||
|
<button class="menu-action" type="submit"><span>Q</span><span>Log out</span><span></span></button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="menu-item">
|
||||||
|
<button class="menu-button" type="button" aria-expanded="false">View</button>
|
||||||
|
<div class="menu-popup" role="menu">
|
||||||
|
<a class="menu-action" href="#alerts"><span>!</span><span>Go to alerts</span><span></span></a>
|
||||||
|
<a class="menu-action" href="#recent-boxes"><span>B</span><span>Go to recent boxes</span><span></span></a>
|
||||||
|
<a class="menu-action" href="#recent-activity"><span>T</span><span>Go to recent activity</span><span></span></a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="menu-item">
|
||||||
|
<button class="menu-button" type="button" aria-expanded="false">Tools</button>
|
||||||
|
<div class="menu-popup" role="menu">
|
||||||
|
<a class="menu-action" href="/account/boxes"><span>B</span><span>Boxes</span><span></span></a>
|
||||||
|
<a class="menu-action" href="/account/alerts"><span>!</span><span>Alerts</span><span></span></a>
|
||||||
|
{{ if .CanManageUsers }}
|
||||||
|
<a class="menu-action" href="/account/users"><span>U</span><span>Users</span><span></span></a>
|
||||||
|
{{ end }}
|
||||||
|
{{ if .CanViewSettings }}
|
||||||
|
<a class="menu-action" href="/account/settings"><span>S</span><span>Settings</span><span></span></a>
|
||||||
|
{{ end }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div class="account-body-content">
|
||||||
|
<section class="dashboard-hero raised-panel" aria-labelledby="account-dashboard-title">
|
||||||
|
<div class="hero-copy">
|
||||||
|
<h2 id="account-dashboard-title">Dashboard</h2>
|
||||||
|
<p>Account overview for boxes, alerts, storage, users, and recent activity.</p>
|
||||||
|
</div>
|
||||||
|
<div class="hero-status" aria-label="System summary">
|
||||||
|
{{ range .Statuses }}
|
||||||
|
<div class="hero-status-row"><span>{{ .Label }}</span><strong class="status-{{ .Severity }}">{{ .Value }}</strong></div>
|
||||||
|
{{ end }}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="stats-grid" aria-label="Dashboard statistics">
|
||||||
|
<article class="stat-card sunken-panel is-info">
|
||||||
|
<p class="stat-label">Active boxes</p>
|
||||||
|
<p class="stat-value">{{ .Stats.ActiveBoxes }}</p>
|
||||||
|
<p class="stat-note"><span class="stat-note-pill">live filesystem scan</span></p>
|
||||||
|
</article>
|
||||||
|
<article class="stat-card sunken-panel is-info">
|
||||||
|
<p class="stat-label">Storage used</p>
|
||||||
|
<p class="stat-value">{{ .Stats.StorageUsedLabel }}</p>
|
||||||
|
<p class="stat-note"><span class="stat-note-pill">local backend</span></p>
|
||||||
|
</article>
|
||||||
|
<article class="stat-card sunken-panel is-warning">
|
||||||
|
<p class="stat-label">Alerts</p>
|
||||||
|
<p class="stat-value">{{ .Stats.AlertCount }}</p>
|
||||||
|
<p class="stat-note"><span class="stat-note-pill">alert model pending</span></p>
|
||||||
|
</article>
|
||||||
|
{{ if .ShowUsersStat }}
|
||||||
|
<article class="stat-card sunken-panel is-ok">
|
||||||
|
<p class="stat-label">Users</p>
|
||||||
|
<p class="stat-value">{{ .Stats.TotalUsers }}</p>
|
||||||
|
<p class="stat-note"><span class="stat-note-pill">{{ .Stats.ActiveUsers }} active</span><span class="stat-note-pill">{{ .Stats.DisabledUsers }} disabled</span></p>
|
||||||
|
</article>
|
||||||
|
{{ end }}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="main-grid" aria-label="Dashboard panels">
|
||||||
|
<article id="alerts" class="win98-window section-window">
|
||||||
|
<div class="win98-titlebar">
|
||||||
|
<div class="win98-titlebar-label">
|
||||||
|
<span class="win98-titlebar-icon">!</span>
|
||||||
|
<h2>Alerts Preview</h2>
|
||||||
|
</div>
|
||||||
|
<div class="titlebar-actions">
|
||||||
|
<a class="titlebar-link-button" href="/account/alerts">Show all</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="section-body sunken-panel">
|
||||||
|
<div class="scroll-panel alerts-scroll">
|
||||||
|
<div class="alert-list">
|
||||||
|
{{ range .Alerts }}
|
||||||
|
<div class="alert-row">
|
||||||
|
<span class="badge is-{{ .Severity }}">{{ .Severity }}</span>
|
||||||
|
<div>
|
||||||
|
<p class="alert-title">{{ .Title }}</p>
|
||||||
|
<p class="alert-desc">{{ .Detail }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="alert-actions">
|
||||||
|
<a class="tiny-button" href="/account/alerts">Open</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{ else }}
|
||||||
|
<div class="alert-row">
|
||||||
|
<span class="badge is-ok">ok</span>
|
||||||
|
<div><p class="alert-title">No alerts</p><p class="alert-desc">Nothing needs attention.</p></div>
|
||||||
|
</div>
|
||||||
|
{{ end }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article id="recent-boxes" class="win98-window section-window">
|
||||||
|
<div class="win98-titlebar">
|
||||||
|
<div class="win98-titlebar-label">
|
||||||
|
<span class="win98-titlebar-icon">B</span>
|
||||||
|
<h2>Recent Boxes</h2>
|
||||||
|
</div>
|
||||||
|
<div class="titlebar-actions">
|
||||||
|
<a class="titlebar-link-button" href="/account/boxes">Show all</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="section-body sunken-panel">
|
||||||
|
<div class="scroll-panel boxes-scroll">
|
||||||
|
<table class="account-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Box</th>
|
||||||
|
<th>Files</th>
|
||||||
|
<th>Size</th>
|
||||||
|
<th>Created</th>
|
||||||
|
<th>Expires</th>
|
||||||
|
<th>Flags</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{{ range .RecentBoxes }}
|
||||||
|
<tr>
|
||||||
|
<td>{{ .ID }}</td>
|
||||||
|
<td>{{ .FileCount }}</td>
|
||||||
|
<td>{{ .TotalSizeLabel }}</td>
|
||||||
|
<td>{{ .CreatedAt }}</td>
|
||||||
|
<td>{{ .ExpiresAt }}</td>
|
||||||
|
<td>{{ .Flags }}</td>
|
||||||
|
<td>
|
||||||
|
<div class="box-actions">
|
||||||
|
<a class="tiny-button" href="/box/{{ .ID }}">Open</a>
|
||||||
|
{{ if .CanManage }}
|
||||||
|
<a class="tiny-button" href="/account/boxes/{{ .ID }}">Manage</a>
|
||||||
|
{{ end }}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{{ else }}
|
||||||
|
<tr><td colspan="7">No boxes found.</td></tr>
|
||||||
|
{{ end }}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article id="recent-activity" class="win98-window section-window span-2">
|
||||||
|
<div class="win98-titlebar">
|
||||||
|
<div class="win98-titlebar-label">
|
||||||
|
<span class="win98-titlebar-icon">T</span>
|
||||||
|
<h2>Recent Activity</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="section-body sunken-panel">
|
||||||
|
<div class="scroll-panel activity-scroll">
|
||||||
|
<div class="activity-list">
|
||||||
|
{{ range .RecentActivity }}
|
||||||
|
<div class="activity-row">
|
||||||
|
<span class="activity-time">{{ .Time }}</span>
|
||||||
|
<div>
|
||||||
|
<p class="activity-title">{{ .Title }}</p>
|
||||||
|
<p class="activity-meta">{{ .Meta }}</p>
|
||||||
|
</div>
|
||||||
|
<span class="tag info">account</span>
|
||||||
|
</div>
|
||||||
|
{{ end }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer class="win98-statusbar" aria-label="Dashboard status">
|
||||||
|
<span>signed in: {{ .AccountNav.Username }}</span>
|
||||||
|
<span>{{ if .AccountNav.IsAdmin }}admin{{ else }}account{{ end }}</span>
|
||||||
|
<span>ready</span>
|
||||||
|
</footer>
|
||||||
|
</main>
|
||||||
|
{{ template "account_shell_end" . }}
|
||||||
45
templates/account_login.html
Normal file
45
templates/account_login.html
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>{{ .PageTitle }}</title>
|
||||||
|
{{ template "account_head_assets" . }}
|
||||||
|
</head>
|
||||||
|
<body class="account-body">
|
||||||
|
<div class="app-shell">
|
||||||
|
<div class="app-frame">
|
||||||
|
<main class="account-window" aria-labelledby="account-login-title">
|
||||||
|
<div class="win98-titlebar">
|
||||||
|
<div class="win98-titlebar-label">
|
||||||
|
<span class="win98-titlebar-icon">W</span>
|
||||||
|
<h1 id="account-login-title">WarpBox Account Login</h1>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="account-body-content">
|
||||||
|
{{ if .Error }}
|
||||||
|
<p class="account-error">{{ .Error }}</p>
|
||||||
|
{{ end }}
|
||||||
|
{{ if .AccountLoginEnabled }}
|
||||||
|
<form class="account-form sunken-panel" action="/account/login" method="post">
|
||||||
|
<label class="account-form-row">
|
||||||
|
<span>Username</span>
|
||||||
|
<input name="username" autocomplete="username" required>
|
||||||
|
</label>
|
||||||
|
<label class="account-form-row">
|
||||||
|
<span>Password</span>
|
||||||
|
<input name="password" type="password" autocomplete="current-password" required>
|
||||||
|
</label>
|
||||||
|
<button class="win98-button" type="submit">Login</button>
|
||||||
|
</form>
|
||||||
|
{{ else }}
|
||||||
|
<p class="sunken-panel section-body">Account login is disabled. Set bootstrap admin credentials and restart to enable account access.</p>
|
||||||
|
{{ end }}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{ template "account_toast_modal_containers" . }}
|
||||||
|
<script src="/static/js/account-ui.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
110
templates/account_partials.html
Normal file
110
templates/account_partials.html
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
{{ define "account_head_assets" }}
|
||||||
|
<link rel="icon" type="image/png" href="/static/WarpBoxLogo.png">
|
||||||
|
<link rel="stylesheet" href="/static/css/account.css">
|
||||||
|
{{ end }}
|
||||||
|
|
||||||
|
{{ define "account_shell_start" }}
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>{{ if .PageTitle }}{{ .PageTitle }}{{ else }}WarpBox Account{{ end }}</title>
|
||||||
|
{{ template "account_head_assets" . }}
|
||||||
|
</head>
|
||||||
|
<body class="account-body">
|
||||||
|
<div class="app-shell">
|
||||||
|
<div class="app-frame">
|
||||||
|
{{ template "account_taskbar" . }}
|
||||||
|
{{ end }}
|
||||||
|
|
||||||
|
{{ define "account_shell_end" }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{ template "account_toast_modal_containers" . }}
|
||||||
|
<script src="/static/js/account-ui.js"></script>
|
||||||
|
{{ range .PageScripts }}
|
||||||
|
<script src="{{ . }}"></script>
|
||||||
|
{{ end }}
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
{{ end }}
|
||||||
|
|
||||||
|
{{ define "account_taskbar" }}
|
||||||
|
{{ $nav := .AccountNav }}
|
||||||
|
<header class="top-taskbar" aria-label="Account navigation">
|
||||||
|
<a class="start-button" href="/account">
|
||||||
|
<span class="start-logo">W</span>
|
||||||
|
<span>WarpBox</span>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<nav class="taskbar-nav" aria-label="Primary">
|
||||||
|
<a class="taskbar-button{{ if eq $nav.ActiveSection "dashboard" }} is-active{{ end }}" href="/account">Dashboard</a>
|
||||||
|
{{ if $nav.CanViewBoxes }}
|
||||||
|
<a class="taskbar-button{{ if eq $nav.ActiveSection "boxes" }} is-active{{ end }}" href="/account/boxes">Boxes</a>
|
||||||
|
{{ end }}
|
||||||
|
{{ if $nav.CanViewAlerts }}
|
||||||
|
<a class="taskbar-button{{ if eq $nav.ActiveSection "alerts" }} is-active{{ end }}" href="/account/alerts">Alerts</a>
|
||||||
|
{{ end }}
|
||||||
|
{{ if $nav.CanViewUsers }}
|
||||||
|
<a class="taskbar-button{{ if eq $nav.ActiveSection "users" }} is-active{{ end }}" href="/account/users">Users</a>
|
||||||
|
{{ end }}
|
||||||
|
{{ if $nav.CanViewAPIKeys }}
|
||||||
|
<a class="taskbar-button{{ if eq $nav.ActiveSection "api-keys" }} is-active{{ end }}" href="/account/api-keys">API Keys</a>
|
||||||
|
{{ end }}
|
||||||
|
{{ if $nav.CanViewSettings }}
|
||||||
|
<a class="taskbar-button{{ if eq $nav.ActiveSection "settings" }} is-active{{ end }}" href="/account/settings">Settings</a>
|
||||||
|
{{ end }}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div class="taskbar-session" aria-label="Current session summary">
|
||||||
|
{{ if gt $nav.AlertCount 0 }}
|
||||||
|
<a class="alert-chip is-{{ $nav.AlertSeverity }}" href="/account/alerts">! {{ $nav.AlertCount }} alerts</a>
|
||||||
|
{{ else }}
|
||||||
|
<span class="alert-chip is-ok">0 alerts</span>
|
||||||
|
{{ end }}
|
||||||
|
<span class="session-chip">signed in: {{ $nav.Username }}</span>
|
||||||
|
{{ if $nav.IsAdmin }}
|
||||||
|
<span class="session-chip">admin</span>
|
||||||
|
{{ else }}
|
||||||
|
<span class="session-chip">account</span>
|
||||||
|
{{ end }}
|
||||||
|
<span class="dirty-chip" data-dirty-chip></span>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
{{ end }}
|
||||||
|
|
||||||
|
{{ define "account_window_titlebar" }}
|
||||||
|
<div class="win98-titlebar">
|
||||||
|
<div class="win98-titlebar-label">
|
||||||
|
<span class="win98-titlebar-icon">{{ if .WindowIcon }}{{ .WindowIcon }}{{ else }}W{{ end }}</span>
|
||||||
|
<h1>{{ if .WindowTitle }}{{ .WindowTitle }}{{ else }}WarpBox Account Control Panel{{ end }}</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>
|
||||||
|
{{ end }}
|
||||||
|
|
||||||
|
{{ define "account_csrf_field" }}
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ .CSRFToken }}">
|
||||||
|
{{ end }}
|
||||||
|
|
||||||
|
{{ define "account_toast_modal_containers" }}
|
||||||
|
<div class="toast" id="account-toast" role="status" aria-live="polite"></div>
|
||||||
|
<div class="modal-backdrop" id="account-modal-backdrop" aria-hidden="true"></div>
|
||||||
|
<section class="account-modal win98-window" id="account-modal" role="dialog" aria-modal="true" aria-labelledby="account-modal-title">
|
||||||
|
<div class="win98-titlebar">
|
||||||
|
<div class="win98-titlebar-label">
|
||||||
|
<span class="win98-titlebar-icon">W</span>
|
||||||
|
<h2 id="account-modal-title">WarpBox</h2>
|
||||||
|
</div>
|
||||||
|
<div class="win98-window-controls">
|
||||||
|
<button class="win98-control" type="button" data-modal-close aria-label="Close">x</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body sunken-panel" id="account-modal-body"></div>
|
||||||
|
</section>
|
||||||
|
{{ end }}
|
||||||
134
templates/account_settings.html
Normal file
134
templates/account_settings.html
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
{{ template "account_shell_start" . }}
|
||||||
|
<main class="account-window" aria-labelledby="account-settings-title">
|
||||||
|
{{ template "account_window_titlebar" . }}
|
||||||
|
|
||||||
|
<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" role="menu">
|
||||||
|
<a class="menu-action" href="/account/settings"><span>R</span><span>Refresh settings</span><span></span></a>
|
||||||
|
<a class="menu-action" href="/account/settings/export.json"><span>E</span><span>Export JSON</span><span></span></a>
|
||||||
|
<div class="menu-separator"></div>
|
||||||
|
<form action="/account/logout" method="post">
|
||||||
|
{{ template "account_csrf_field" . }}
|
||||||
|
<button class="menu-action" type="submit"><span>Q</span><span>Log out</span><span></span></button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="menu-item">
|
||||||
|
<button class="menu-button" type="button" aria-expanded="false">View</button>
|
||||||
|
<div class="menu-popup" role="menu">
|
||||||
|
{{ range .Groups }}
|
||||||
|
<a class="menu-action" href="#settings-{{ .Key }}"><span>S</span><span>{{ .Label }}</span><span></span></a>
|
||||||
|
{{ end }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<form class="settings-layout account-body-content" action="/account/settings" method="post">
|
||||||
|
{{ template "account_csrf_field" . }}
|
||||||
|
|
||||||
|
<section class="settings-summary raised-panel" aria-label="Settings status">
|
||||||
|
{{ if .Error }}<span class="badge is-danger">{{ .Error }}</span>{{ end }}
|
||||||
|
{{ if .Notice }}<span class="badge is-ok">{{ .Notice }}</span>{{ end }}
|
||||||
|
{{ if .OverridesAllowed }}
|
||||||
|
<span class="badge is-ok">overrides enabled</span>
|
||||||
|
{{ else }}
|
||||||
|
<span class="badge is-warning">read-only: overrides disabled</span>
|
||||||
|
{{ end }}
|
||||||
|
<a class="tiny-button" href="/account/settings/export.json">Export JSON</a>
|
||||||
|
<button class="tiny-button" type="button" data-settings-import-toggle>Import JSON</button>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="settings-import raised-panel" data-settings-import-panel hidden>
|
||||||
|
<label class="account-form-row">
|
||||||
|
<span>Settings backup JSON</span>
|
||||||
|
<textarea class="account-control" rows="5" data-settings-import-json></textarea>
|
||||||
|
</label>
|
||||||
|
<button class="win98-button" type="button" data-settings-import-submit {{ if not .CanEdit }}disabled{{ end }}>Import</button>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div class="settings-scroll scroll-panel" aria-label="Grouped settings">
|
||||||
|
{{ range .Groups }}
|
||||||
|
<section class="settings-group" id="settings-{{ .Key }}">
|
||||||
|
<header class="settings-group-header">
|
||||||
|
<h2>{{ .Label }}</h2>
|
||||||
|
<p>{{ .Description }}</p>
|
||||||
|
</header>
|
||||||
|
<table class="account-table settings-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Setting</th>
|
||||||
|
<th>Description</th>
|
||||||
|
<th>Value</th>
|
||||||
|
<th>Source</th>
|
||||||
|
<th>Reset</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{{ range .Rows }}
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<strong>{{ .Label }}</strong>
|
||||||
|
<span class="setting-key">{{ .Key }}</span>
|
||||||
|
</td>
|
||||||
|
<td><p class="setting-description">{{ .Description }}</p></td>
|
||||||
|
<td>
|
||||||
|
{{ if .Editable }}
|
||||||
|
{{ if eq .Type "bool" }}
|
||||||
|
<label class="account-checks"><span><input type="checkbox" name="{{ .Key }}" value="true" {{ if eq .Value "true" }}checked{{ end }}> enabled</span></label>
|
||||||
|
{{ else }}
|
||||||
|
<input class="account-control" name="{{ .Key }}" value="{{ .Value }}" inputmode="numeric">
|
||||||
|
<span class="setting-key">{{ .DisplayValue }}</span>
|
||||||
|
{{ end }}
|
||||||
|
{{ else }}
|
||||||
|
<span>{{ .DisplayValue }}</span>
|
||||||
|
{{ if .LockedReason }}<span class="setting-key">{{ .LockedReason }}</span>{{ end }}
|
||||||
|
{{ end }}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span class="setting-source">
|
||||||
|
<span class="badge is-info">{{ .Source }}</span>
|
||||||
|
<span class="setting-env">{{ .EnvName }}</span>
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{{ if .Editable }}
|
||||||
|
<button class="tiny-button" type="submit" form="reset-{{ .Key }}">Reset</button>
|
||||||
|
{{ else }}
|
||||||
|
<span class="badge">locked</span>
|
||||||
|
{{ end }}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{{ else }}
|
||||||
|
<tr><td colspan="5">No settings in this group.</td></tr>
|
||||||
|
{{ end }}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</section>
|
||||||
|
{{ end }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section class="settings-actions raised-panel" aria-label="Settings actions">
|
||||||
|
<button class="win98-button" type="submit" {{ if not .CanEdit }}disabled{{ end }}>Save Settings</button>
|
||||||
|
</section>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{{ range .Groups }}
|
||||||
|
{{ range .Rows }}
|
||||||
|
{{ if .Editable }}
|
||||||
|
<form id="reset-{{ .Key }}" action="/account/settings/reset" method="post" hidden>
|
||||||
|
{{ template "account_csrf_field" $ }}
|
||||||
|
<input type="hidden" name="key" value="{{ .Key }}">
|
||||||
|
</form>
|
||||||
|
{{ end }}
|
||||||
|
{{ end }}
|
||||||
|
{{ end }}
|
||||||
|
|
||||||
|
<footer class="win98-statusbar" aria-label="Settings status">
|
||||||
|
<span>settings</span>
|
||||||
|
<span>{{ if .CanEdit }}editable{{ else }}read-only{{ end }}</span>
|
||||||
|
<span>ready</span>
|
||||||
|
</footer>
|
||||||
|
</main>
|
||||||
|
{{ template "account_shell_end" . }}
|
||||||
323
templates/account_user_edit.html
Normal file
323
templates/account_user_edit.html
Normal file
@@ -0,0 +1,323 @@
|
|||||||
|
{{ template "account_shell_start" . }}
|
||||||
|
<main class="account-window" aria-labelledby="user-edit-title">
|
||||||
|
{{ template "account_window_titlebar" . }}
|
||||||
|
|
||||||
|
<nav class="menu-bar" aria-label="User edit toolbar">
|
||||||
|
<div class="menu-item">
|
||||||
|
<button class="menu-button" type="button" aria-expanded="false">File</button>
|
||||||
|
<div class="menu-popup" role="menu">
|
||||||
|
<button class="menu-action" type="button" data-ue-command="save"><span>💾</span><span>Save user</span><span class="shortcut">Ctrl+S</span></button>
|
||||||
|
<button class="menu-action" type="button" data-ue-command="discard"><span>↩</span><span>Discard changes</span><span class="shortcut">Esc</span></button>
|
||||||
|
{{ if .CanManage }}
|
||||||
|
<div class="menu-separator"></div>
|
||||||
|
{{ if .IsPending }}
|
||||||
|
<form method="post" action="/account/users/{{ .Target.ID }}/invite/resend" style="margin:0">
|
||||||
|
{{ template "account_csrf_field" . }}
|
||||||
|
<button class="menu-action" type="submit"><span>✉</span><span>Send invite again</span><span></span></button>
|
||||||
|
</form>
|
||||||
|
{{ end }}
|
||||||
|
<button class="menu-action" type="button" data-ue-command="reset-password"><span>🔑</span><span>Reset password</span><span></span></button>
|
||||||
|
{{ end }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="menu-item">
|
||||||
|
<button class="menu-button" type="button" aria-expanded="false">User</button>
|
||||||
|
<div class="menu-popup" role="menu">
|
||||||
|
{{ if .CanManage }}
|
||||||
|
{{ if not .IsSelf }}
|
||||||
|
<form method="post" action="/account/users/{{ .Target.ID }}/enable" style="margin:0">
|
||||||
|
{{ template "account_csrf_field" . }}
|
||||||
|
<button class="menu-action" type="submit"><span>✔</span><span>Enable user</span><span></span></button>
|
||||||
|
</form>
|
||||||
|
<form method="post" action="/account/users/{{ .Target.ID }}/disable" style="margin:0">
|
||||||
|
{{ template "account_csrf_field" . }}
|
||||||
|
<button class="menu-action" type="submit"><span>⛔</span><span>Disable user</span><span></span></button>
|
||||||
|
</form>
|
||||||
|
<div class="menu-separator"></div>
|
||||||
|
{{ end }}
|
||||||
|
<form method="post" action="/account/users/{{ .Target.ID }}/sessions/revoke" style="margin:0">
|
||||||
|
{{ template "account_csrf_field" . }}
|
||||||
|
<button class="menu-action" type="submit"><span>◌</span><span>Revoke all sessions</span><span></span></button>
|
||||||
|
</form>
|
||||||
|
{{ end }}
|
||||||
|
<a class="menu-action" href="/account/users"><span>←</span><span>Back to users</span><span></span></a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div class="account-body-content">
|
||||||
|
{{ if .Error }}
|
||||||
|
<div class="account-error-banner">{{ .Error }}</div>
|
||||||
|
{{ end }}
|
||||||
|
{{ if .Success }}
|
||||||
|
<div class="account-success-banner">{{ .Success }}</div>
|
||||||
|
{{ end }}
|
||||||
|
|
||||||
|
<section class="stats-grid" aria-label="User summary">
|
||||||
|
{{ if eq .Status "active" }}
|
||||||
|
<article class="stat-card sunken-panel is-ok">
|
||||||
|
{{ else if eq .Status "pending" }}
|
||||||
|
<article class="stat-card sunken-panel is-warning">
|
||||||
|
{{ else }}
|
||||||
|
<article class="stat-card sunken-panel is-danger">
|
||||||
|
{{ end }}
|
||||||
|
<p class="stat-label">Status</p>
|
||||||
|
<p class="stat-value">{{ .Status }}</p>
|
||||||
|
<p class="stat-note">
|
||||||
|
{{ if eq .Status "active" }}<span class="stat-note-pill">can sign in</span>
|
||||||
|
{{ else if eq .Status "pending" }}<span class="stat-note-pill">invite not accepted</span>
|
||||||
|
{{ else }}<span class="stat-note-pill">blocked</span>{{ end }}
|
||||||
|
</p>
|
||||||
|
</article>
|
||||||
|
{{ if .IsAdmin }}
|
||||||
|
<article class="stat-card sunken-panel is-info">
|
||||||
|
{{ else }}
|
||||||
|
<article class="stat-card sunken-panel">
|
||||||
|
{{ end }}
|
||||||
|
<p class="stat-label">Role</p>
|
||||||
|
<p class="stat-value">{{ if .IsAdmin }}admin{{ else }}user{{ end }}</p>
|
||||||
|
<p class="stat-note">
|
||||||
|
{{ if .TagNames }}<span class="stat-note-pill">{{ .TagNames }}</span>{{ else }}<span class="stat-note-pill">no tags</span>{{ end }}
|
||||||
|
</p>
|
||||||
|
</article>
|
||||||
|
<article class="stat-card sunken-panel">
|
||||||
|
<p class="stat-label">Max file size</p>
|
||||||
|
<p class="stat-value">{{ if .MaxFileSizeStr }}{{ .MaxFileSizeStr }}{{ else }}default{{ end }}</p>
|
||||||
|
<p class="stat-note"><span class="stat-note-pill">bytes</span></p>
|
||||||
|
</article>
|
||||||
|
<article class="stat-card sunken-panel">
|
||||||
|
<p class="stat-label">Max expiry</p>
|
||||||
|
<p class="stat-value">{{ if .MaxExpiryStr }}{{ .MaxExpiryStr }}s{{ else }}default{{ end }}</p>
|
||||||
|
<p class="stat-note"><span class="stat-note-pill">seconds</span></p>
|
||||||
|
</article>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<form method="post" action="/account/users/{{ .Target.ID }}" id="user-edit-form" data-ue-form>
|
||||||
|
{{ template "account_csrf_field" . }}
|
||||||
|
|
||||||
|
<div class="ue-content-grid">
|
||||||
|
<div class="ue-column">
|
||||||
|
|
||||||
|
<section class="win98-window section-window">
|
||||||
|
<div class="win98-titlebar">
|
||||||
|
<div class="win98-titlebar-label">
|
||||||
|
<span class="win98-titlebar-icon">A</span>
|
||||||
|
<h2>Account <span class="ue-panel-sub">identity and basic state</span></h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="section-body sunken-panel ue-panel-body">
|
||||||
|
<div class="ue-form-grid">
|
||||||
|
<div class="ue-field">
|
||||||
|
<label for="ue-username">Username</label>
|
||||||
|
<input class="win98-input" id="ue-username" name="username" type="text" value="{{ .Target.Username }}" {{ if not .CanManage }}disabled{{ end }} autocomplete="off">
|
||||||
|
<span class="ue-help">Visible login name.</span>
|
||||||
|
</div>
|
||||||
|
<div class="ue-field">
|
||||||
|
<label for="ue-email">Email</label>
|
||||||
|
<input class="win98-input" id="ue-email" name="email" type="email" value="{{ .Target.Email }}" {{ if not .CanManage }}disabled{{ end }} autocomplete="off">
|
||||||
|
<span class="ue-help">Account contact and invite destination.</span>
|
||||||
|
</div>
|
||||||
|
{{ if not .IsPending }}
|
||||||
|
<div class="ue-field">
|
||||||
|
<label for="ue-state">State</label>
|
||||||
|
<select class="win98-select" id="ue-state" name="state" {{ if or (not .CanManage) .IsSelf }}disabled{{ end }}>
|
||||||
|
<option value="active" {{ if eq .Status "active" }}selected{{ end }}>Active</option>
|
||||||
|
<option value="disabled" {{ if eq .Status "disabled" }}selected{{ end }}>Disabled</option>
|
||||||
|
</select>
|
||||||
|
<span class="ue-help">{{ if .IsSelf }}Cannot disable yourself.{{ else }}Account state.{{ end }}</span>
|
||||||
|
</div>
|
||||||
|
{{ end }}
|
||||||
|
<div class="ue-field">
|
||||||
|
<label for="ue-admin-note">Admin note</label>
|
||||||
|
<input class="win98-input" id="ue-admin-note" name="admin_note" type="text" value="{{ .Target.AdminNote }}" {{ if not .CanManage }}disabled{{ end }}>
|
||||||
|
<span class="ue-help">Private note. Not shown to the user.</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="win98-window section-window">
|
||||||
|
<div class="win98-titlebar">
|
||||||
|
<div class="win98-titlebar-label">
|
||||||
|
<span class="win98-titlebar-icon">R</span>
|
||||||
|
<h2>Access rights <span class="ue-panel-sub">what this account can do</span></h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="section-body sunken-panel ue-panel-body">
|
||||||
|
<div class="ue-check-grid">
|
||||||
|
<label class="ue-check-card">
|
||||||
|
<input type="checkbox" name="upload_allowed" value="1" {{ if index .Check "upload_allowed" }}checked{{ end }} {{ if not .CanManage }}disabled{{ end }}>
|
||||||
|
<span class="ue-check-copy"><strong>Create boxes</strong><span>Allow browser or API box creation.</span></span>
|
||||||
|
</label>
|
||||||
|
<label class="ue-check-card">
|
||||||
|
<input type="checkbox" name="manage_own_boxes" value="1" {{ if index .Check "manage_own_boxes" }}checked{{ end }} {{ if not .CanManage }}disabled{{ end }}>
|
||||||
|
<span class="ue-check-copy"><strong>Manage own boxes</strong><span>Edit sharing, password, or expiry for owned boxes.</span></span>
|
||||||
|
</label>
|
||||||
|
<label class="ue-check-card">
|
||||||
|
<input type="checkbox" name="renewable_allowed" value="1" {{ if index .Check "renewable_allowed" }}checked{{ end }} {{ if not .CanManage }}disabled{{ end }}>
|
||||||
|
<span class="ue-check-copy"><strong>Refresh own box expiry</strong><span>Permits time extension within limits.</span></span>
|
||||||
|
</label>
|
||||||
|
<label class="ue-check-card">
|
||||||
|
<input type="checkbox" name="zip_download_allowed" value="1" {{ if index .Check "zip_download_allowed" }}checked{{ end }} {{ if not .CanManage }}disabled{{ end }}>
|
||||||
|
<span class="ue-check-copy"><strong>Use ZIP downloads</strong><span>Allow ZIP generation on this user's boxes.</span></span>
|
||||||
|
</label>
|
||||||
|
<label class="ue-check-card">
|
||||||
|
<input type="checkbox" name="one_time_download_allowed" value="1" {{ if index .Check "one_time_download_allowed" }}checked{{ end }} {{ if not .CanManage }}disabled{{ end }}>
|
||||||
|
<span class="ue-check-copy"><strong>Use one-time boxes</strong><span>Permit one-time ZIP handoff boxes.</span></span>
|
||||||
|
</label>
|
||||||
|
<label class="ue-check-card">
|
||||||
|
<input type="checkbox" name="is_admin" value="1" {{ if .IsAdmin }}checked{{ end }} {{ if or (not .CanManage) .IsSelf }}disabled{{ end }}>
|
||||||
|
<span class="ue-check-copy"><strong>Administrator</strong><span>Grants full admin area access. Last admin is protected.</span></span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="win98-window section-window">
|
||||||
|
<div class="win98-titlebar">
|
||||||
|
<div class="win98-titlebar-label">
|
||||||
|
<span class="win98-titlebar-icon">L</span>
|
||||||
|
<h2>Limits <span class="ue-panel-sub">0 = unlimited, empty = system default</span></h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="section-body sunken-panel ue-panel-body">
|
||||||
|
<div class="ue-form-grid">
|
||||||
|
<div class="ue-field">
|
||||||
|
<label for="ue-max-file">Max file size (bytes)</label>
|
||||||
|
<input class="win98-input" id="ue-max-file" name="max_file_size_bytes" type="number" min="0"
|
||||||
|
value="{{ .MaxFileSizeStr }}" {{ if not .CanManage }}disabled{{ end }}>
|
||||||
|
<span class="ue-help">Per-file cap. Empty = system default.</span>
|
||||||
|
</div>
|
||||||
|
<div class="ue-field">
|
||||||
|
<label for="ue-max-box">Max box size (bytes)</label>
|
||||||
|
<input class="win98-input" id="ue-max-box" name="max_box_size_bytes" type="number" min="0"
|
||||||
|
value="{{ .MaxBoxSizeStr }}" {{ if not .CanManage }}disabled{{ end }}>
|
||||||
|
<span class="ue-help">Total size per box. Empty = system default.</span>
|
||||||
|
</div>
|
||||||
|
<div class="ue-field ue-field-full">
|
||||||
|
<label for="ue-max-expiry">Max box expiry (seconds)</label>
|
||||||
|
<input class="win98-input" id="ue-max-expiry" name="max_expiry_seconds" type="number" min="0"
|
||||||
|
value="{{ .MaxExpiryStr }}" {{ if not .CanManage }}disabled{{ end }}>
|
||||||
|
<span class="ue-help">Maximum expiry when creating or editing a box. Empty = system default.</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="ue-column">
|
||||||
|
|
||||||
|
<section class="win98-window section-window">
|
||||||
|
<div class="win98-titlebar">
|
||||||
|
<div class="win98-titlebar-label">
|
||||||
|
<span class="win98-titlebar-icon">O</span>
|
||||||
|
<h2>Setting overrides <span class="ue-panel-sub">account-specific behavior</span></h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="section-body sunken-panel ue-panel-body">
|
||||||
|
<div class="ue-check-grid ue-check-grid-1col">
|
||||||
|
<label class="ue-check-card">
|
||||||
|
<input type="checkbox" name="allow_password_protected" value="1" {{ if index .Check "allow_password_protected" }}checked{{ end }} {{ if not .CanManage }}disabled{{ end }}>
|
||||||
|
<span class="ue-check-copy"><strong>Allow password-protected boxes</strong><span>Overrides system default for this account.</span></span>
|
||||||
|
</label>
|
||||||
|
<label class="ue-check-card">
|
||||||
|
<input type="checkbox" name="renew_on_access" value="1" {{ if index .Check "renew_on_access" }}checked{{ end }} {{ if not .CanManage }}disabled{{ end }}>
|
||||||
|
<span class="ue-check-copy"><strong>Allow renew on access</strong><span>Only applies when the global feature is enabled.</span></span>
|
||||||
|
</label>
|
||||||
|
<label class="ue-check-card">
|
||||||
|
<input type="checkbox" name="renew_on_download" value="1" {{ if index .Check "renew_on_download" }}checked{{ end }} {{ if not .CanManage }}disabled{{ end }}>
|
||||||
|
<span class="ue-check-copy"><strong>Allow renew on download</strong><span>Only applies when the global feature is enabled.</span></span>
|
||||||
|
</label>
|
||||||
|
<label class="ue-check-card">
|
||||||
|
<input type="checkbox" name="allow_owner_box_editing" value="1" {{ if index .Check "allow_owner_box_editing" }}checked{{ end }} {{ if not .CanManage }}disabled{{ end }}>
|
||||||
|
<span class="ue-check-copy"><strong>Allow owner box editing</strong><span>Lets the user open the box edit page for owned boxes.</span></span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="win98-window section-window">
|
||||||
|
<div class="win98-titlebar">
|
||||||
|
<div class="win98-titlebar-label">
|
||||||
|
<span class="win98-titlebar-icon">P</span>
|
||||||
|
<h2>Resolved policy <span class="ue-panel-sub">effective permissions after all overrides</span></h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="section-body sunken-panel ue-panel-body">
|
||||||
|
<pre class="ue-policy-pre">{{ .PolicyJSON }}</pre>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="win98-window section-window">
|
||||||
|
<div class="win98-titlebar">
|
||||||
|
<div class="win98-titlebar-label">
|
||||||
|
<span class="win98-titlebar-icon">I</span>
|
||||||
|
<h2>Account info <span class="ue-panel-sub">read-only details</span></h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="section-body sunken-panel ue-panel-body">
|
||||||
|
<ul class="ue-info-list">
|
||||||
|
<li class="ue-info-item"><strong>User ID</strong><span>{{ .Target.ID }}</span></li>
|
||||||
|
<li class="ue-info-item"><strong>Created</strong><span>{{ .CreatedAtStr }}</span></li>
|
||||||
|
<li class="ue-info-item"><strong>Updated</strong><span>{{ .UpdatedAtStr }}</span></li>
|
||||||
|
<li class="ue-info-item"><strong>Tags</strong><span>{{ if .TagNames }}{{ .TagNames }}{{ else }}none{{ end }}</span></li>
|
||||||
|
<li class="ue-info-item"><strong>Password</strong><span>{{ if .IsPending }}pending invite{{ else }}set{{ end }}</span></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{{ if .CanManage }}
|
||||||
|
<section class="win98-window section-window">
|
||||||
|
<div class="win98-titlebar">
|
||||||
|
<div class="win98-titlebar-label">
|
||||||
|
<span class="win98-titlebar-icon">!</span>
|
||||||
|
<h2>Danger zone</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="section-body sunken-panel ue-panel-body">
|
||||||
|
<div class="ue-danger-row">
|
||||||
|
<form method="post" action="/account/users/{{ .Target.ID }}/password/reset">
|
||||||
|
{{ template "account_csrf_field" . }}
|
||||||
|
<button class="win98-button ue-danger-btn" type="submit">Reset password</button>
|
||||||
|
</form>
|
||||||
|
<form method="post" action="/account/users/{{ .Target.ID }}/sessions/revoke">
|
||||||
|
{{ template "account_csrf_field" . }}
|
||||||
|
<button class="win98-button" type="submit">Revoke sessions</button>
|
||||||
|
</form>
|
||||||
|
{{ if not .IsSelf }}
|
||||||
|
<form method="post" action="/account/users/{{ .Target.ID }}/{{ if .Target.Disabled }}enable{{ else }}disable{{ end }}">
|
||||||
|
{{ template "account_csrf_field" . }}
|
||||||
|
<button class="win98-button ue-danger-btn" type="submit">{{ if .Target.Disabled }}Enable{{ else }}Disable{{ end }} user</button>
|
||||||
|
</form>
|
||||||
|
{{ end }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{{ end }}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="ue-footer">
|
||||||
|
<div class="ue-footer-left">
|
||||||
|
<span class="stat-note-pill" data-ue-dirty>No unsaved changes</span>
|
||||||
|
<a class="stat-note-pill" href="/account/users">← Back to users</a>
|
||||||
|
</div>
|
||||||
|
<div class="ue-footer-right">
|
||||||
|
{{ if .CanManage }}
|
||||||
|
<button class="win98-button" type="button" data-ue-command="discard">Discard</button>
|
||||||
|
<button class="win98-button" type="submit">Save user</button>
|
||||||
|
{{ end }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer class="win98-statusbar" aria-label="User edit status">
|
||||||
|
<span>editing: {{ .Target.Username }}</span>
|
||||||
|
<span>signed in: {{ .AccountNav.Username }}</span>
|
||||||
|
<span>{{ .Status }}</span>
|
||||||
|
</footer>
|
||||||
|
</main>
|
||||||
|
{{ template "account_shell_end" . }}
|
||||||
257
templates/account_users.html
Normal file
257
templates/account_users.html
Normal file
@@ -0,0 +1,257 @@
|
|||||||
|
{{ template "account_shell_start" . }}
|
||||||
|
<main class="account-window" aria-labelledby="account-users-title">
|
||||||
|
{{ template "account_window_titlebar" . }}
|
||||||
|
|
||||||
|
<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" role="menu">
|
||||||
|
<a class="menu-action" href="/account/users"><span>R</span><span>Refresh list</span><span class="shortcut">F5</span></a>
|
||||||
|
<div class="menu-separator"></div>
|
||||||
|
<form action="/account/logout" method="post">
|
||||||
|
{{ template "account_csrf_field" . }}
|
||||||
|
<button class="menu-action" type="submit"><span>Q</span><span>Log out</span><span></span></button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="menu-item">
|
||||||
|
<button class="menu-button" type="button" aria-expanded="false">View</button>
|
||||||
|
<div class="menu-popup" role="menu">
|
||||||
|
<a class="menu-action" href="/account/users?status=active"><span>A</span><span>Show active</span><span></span></a>
|
||||||
|
<a class="menu-action" href="/account/users?status=disabled"><span>D</span><span>Show disabled</span><span></span></a>
|
||||||
|
<a class="menu-action" href="/account/users"><span>X</span><span>Clear filters</span><span></span></a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div class="account-body-content">
|
||||||
|
<section class="dashboard-hero raised-panel" aria-label="Users overview">
|
||||||
|
<div class="hero-copy">
|
||||||
|
<h2 id="account-users-title">WarpBox Users</h2>
|
||||||
|
<p>Accounts, invites, and access. Search, filter, and manage users with safe bulk actions.</p>
|
||||||
|
</div>
|
||||||
|
<div class="hero-actions">
|
||||||
|
<button class="small-action is-primary" type="button" data-users-action="focus-create">Create / Invite</button>
|
||||||
|
<button class="small-action" type="button" data-users-action="select-visible">Select visible</button>
|
||||||
|
<button class="small-action" type="button" onclick="location.href='/account/users'">Refresh</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{{ if .Error }}
|
||||||
|
<div class="account-error-banner">{{ .Error }}</div>
|
||||||
|
{{ end }}
|
||||||
|
{{ if .Success }}
|
||||||
|
<div class="account-success-banner">{{ .Success }}</div>
|
||||||
|
{{ end }}
|
||||||
|
|
||||||
|
<section class="stats-grid" aria-label="User statistics">
|
||||||
|
<article class="stat-card sunken-panel is-info">
|
||||||
|
<p class="stat-label">Total users</p>
|
||||||
|
<p class="stat-value">{{ .Stats.TotalUsers }}</p>
|
||||||
|
<p class="stat-note"><span class="stat-note-pill">all</span></p>
|
||||||
|
</article>
|
||||||
|
<article class="stat-card sunken-panel is-ok">
|
||||||
|
<p class="stat-label">Active</p>
|
||||||
|
<p class="stat-value">{{ .Stats.ActiveUsers }}</p>
|
||||||
|
<p class="stat-note"><span class="stat-note-pill">enabled</span></p>
|
||||||
|
</article>
|
||||||
|
<article class="stat-card sunken-panel is-warning">
|
||||||
|
<p class="stat-label">Pending invites</p>
|
||||||
|
<p class="stat-value">{{ .Stats.PendingInvites }}</p>
|
||||||
|
<p class="stat-note"><span class="stat-note-pill">awaiting setup</span></p>
|
||||||
|
</article>
|
||||||
|
<article class="stat-card sunken-panel is-danger">
|
||||||
|
<p class="stat-label">Disabled</p>
|
||||||
|
<p class="stat-value">{{ .Stats.DisabledUsers }}</p>
|
||||||
|
<p class="stat-note"><span class="stat-note-pill">blocked</span></p>
|
||||||
|
</article>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="main-grid users-grid" aria-label="Users panel and form">
|
||||||
|
<aside class="win98-window section-window users-form-window">
|
||||||
|
<div class="win98-titlebar">
|
||||||
|
<div class="win98-titlebar-label">
|
||||||
|
<span class="win98-titlebar-icon">+</span>
|
||||||
|
<h2>Create or Invite</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="section-body sunken-panel">
|
||||||
|
<form class="form-grid" method="post" action="/account/users">
|
||||||
|
{{ template "account_csrf_field" . }}
|
||||||
|
<input type="hidden" name="action" value="create">
|
||||||
|
|
||||||
|
<div class="field-row">
|
||||||
|
<label for="users-mode">Mode</label>
|
||||||
|
<select class="win98-select" name="mode" id="users-mode">
|
||||||
|
<option value="create">Create local user</option>
|
||||||
|
<option value="invite">Send invite</option>
|
||||||
|
</select>
|
||||||
|
<div class="field-help">Invite creates a disabled account with a setup link. Create makes an active user immediately.</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field-row">
|
||||||
|
<label for="users-username">Username</label>
|
||||||
|
<input class="win98-input" name="username" id="users-username" required placeholder="username" autocomplete="off">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field-row">
|
||||||
|
<label for="users-email">Email</label>
|
||||||
|
<input class="win98-input" name="email" id="users-email" type="email" required placeholder="user@example.test" autocomplete="off">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field-row">
|
||||||
|
<label for="users-password">Password</label>
|
||||||
|
<input class="win98-input" name="password" id="users-password" type="password" autocomplete="new-password" placeholder="Leave empty for auto-generated">
|
||||||
|
<div class="field-help">If empty, a temporary password will be generated. Never prefill passwords.</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field-row">
|
||||||
|
<label for="users-role">Role</label>
|
||||||
|
<select class="win98-select" name="role" id="users-role">
|
||||||
|
<option value="all">No tag (default)</option>
|
||||||
|
{{ range .Tags }}
|
||||||
|
<option value="{{ .Name }}">{{ .Name }}</option>
|
||||||
|
{{ end }}
|
||||||
|
</select>
|
||||||
|
<div class="field-help">Assign an initial role tag. Permissions are resolved from tag settings.</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="button-row">
|
||||||
|
<button class="small-action" type="reset">Clear</button>
|
||||||
|
<button class="small-action is-primary" type="submit">Apply</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<section class="win98-window section-window span-2 users-table-window">
|
||||||
|
<div class="win98-titlebar">
|
||||||
|
<div class="win98-titlebar-label">
|
||||||
|
<span class="win98-titlebar-icon">U</span>
|
||||||
|
<h2>Users</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="users-filters-bar">
|
||||||
|
<form class="users-filters-form" method="get" action="/account/users" id="users-filters-form">
|
||||||
|
<input class="win98-input" name="q" value="{{ .Filters.Query }}" placeholder="Search username or email">
|
||||||
|
<select class="win98-select" name="status" onchange="this.form.submit()">
|
||||||
|
<option value="" {{ if eq .Filters.Status "" }}selected{{ end }}>all statuses</option>
|
||||||
|
<option value="active" {{ if eq .Filters.Status "active" }}selected{{ end }}>active</option>
|
||||||
|
<option value="disabled" {{ if eq .Filters.Status "disabled" }}selected{{ end }}>disabled</option>
|
||||||
|
</select>
|
||||||
|
<select class="win98-select" name="role" onchange="this.form.submit()">
|
||||||
|
<option value="" {{ if eq .Filters.Role "" }}selected{{ end }}>all roles</option>
|
||||||
|
{{ range .Tags }}
|
||||||
|
<option value="{{ .Name }}" {{ if eq $.Filters.Role .Name }}selected{{ end }}>{{ .Name }}</option>
|
||||||
|
{{ end }}
|
||||||
|
</select>
|
||||||
|
<select class="win98-select" name="sort" onchange="this.form.submit()">
|
||||||
|
<option value="username" {{ if eq .Filters.Sort "username" }}selected{{ end }}>sort username</option>
|
||||||
|
<option value="createdDesc" {{ if eq .Filters.Sort "createdDesc" }}selected{{ end }}>newest first</option>
|
||||||
|
</select>
|
||||||
|
<select class="win98-select" name="page_size" onchange="this.form.submit()">
|
||||||
|
<option value="12" {{ if eq .Filters.PageSize 12 }}selected{{ end }}>12 rows</option>
|
||||||
|
<option value="20" {{ if eq .Filters.PageSize 20 }}selected{{ end }}>20 rows</option>
|
||||||
|
<option value="50" {{ if eq .Filters.PageSize 50 }}selected{{ end }}>50 rows</option>
|
||||||
|
</select>
|
||||||
|
<noscript><button class="small-action" type="submit">Filter</button></noscript>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form id="users-bulk-form" method="post" action="/account/users/bulk/disable">
|
||||||
|
{{ template "account_csrf_field" . }}
|
||||||
|
<input type="hidden" name="selected_ids" value="" id="bulk-selected-ids">
|
||||||
|
|
||||||
|
<div class="users-bulk-strip">
|
||||||
|
<button class="small-action" type="button" data-users-action="select-visible">Select visible</button>
|
||||||
|
<button class="small-action" type="submit" data-users-action="bulk-disable" onclick="setBulkAction('/account/users/bulk/disable')">Disable</button>
|
||||||
|
<button class="small-action" type="submit" data-users-action="bulk-enable" onclick="setBulkAction('/account/users/bulk/enable')">Enable</button>
|
||||||
|
<button class="small-action" type="submit" data-users-action="bulk-revoke" onclick="setBulkAction('/account/users/bulk/revoke-sessions')">Revoke sessions</button>
|
||||||
|
<span class="stat-note-pill" id="selected-count">0 selected</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section-body sunken-panel table-body-panel">
|
||||||
|
<div class="table-scroll">
|
||||||
|
<table class="account-table" aria-label="Users">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th class="check-cell"><input type="checkbox" id="master-check" aria-label="Select current page"></th>
|
||||||
|
<th>User</th>
|
||||||
|
<th>Email</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Role</th>
|
||||||
|
<th>Plan</th>
|
||||||
|
<th>Created</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{{ range .Rows }}
|
||||||
|
<tr data-user-id="{{ .ID }}">
|
||||||
|
<td class="check-cell">
|
||||||
|
<input type="checkbox" class="row-check" value="{{ .ID }}" data-user-id="{{ .ID }}" aria-label="Select {{ .Username }}">
|
||||||
|
</td>
|
||||||
|
<td class="user-cell">
|
||||||
|
<div class="user-main">
|
||||||
|
<span class="username">{{ .Username }}{{ if .IsCurrent }} <span class="pill is-info">you</span>{{ end }}</span>
|
||||||
|
<span class="subtle">id: {{ .ID }}</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="email-cell" title="{{ .Email }}">{{ .Email }}</td>
|
||||||
|
<td>
|
||||||
|
{{ if eq .Status "active" }}
|
||||||
|
<span class="pill is-ok">active</span>
|
||||||
|
{{ else }}
|
||||||
|
<span class="pill is-danger">disabled</span>
|
||||||
|
{{ end }}
|
||||||
|
</td>
|
||||||
|
<td><span class="pill is-info">{{ .Role }}</span></td>
|
||||||
|
<td><span class="pill">{{ .Plan }}</span></td>
|
||||||
|
<td>{{ .CreatedAt }}</td>
|
||||||
|
<td class="actions-cell">
|
||||||
|
<a class="tiny-button" href="/account/users/{{ .ID }}">Edit</a>
|
||||||
|
{{ if and .IsInvite (not .IsCurrent) }}
|
||||||
|
<form method="post" action="/account/users/{{ .ID }}/invite/resend" style="display:inline">
|
||||||
|
{{ template "account_csrf_field" $ }}
|
||||||
|
<button class="tiny-button" type="submit">Resend invite</button>
|
||||||
|
</form>
|
||||||
|
{{ end }}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{{ else }}
|
||||||
|
<tr><td colspan="8">No users found.</td></tr>
|
||||||
|
{{ end }}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="pagination">
|
||||||
|
<span class="pagination-info">
|
||||||
|
Page {{ .Page }} of {{ .TotalPages }} — {{ .Total }} matching user(s)
|
||||||
|
</span>
|
||||||
|
<div class="pagination-controls">
|
||||||
|
{{ if .HasPrev }}
|
||||||
|
<a class="small-action" href="?q={{ .Filters.Query }}&status={{ .Filters.Status }}&role={{ .Filters.Role }}&sort={{ .Filters.Sort }}&page_size={{ .PageSize }}&page={{ .PrevPage }}">Prev</a>
|
||||||
|
{{ else }}
|
||||||
|
<button class="small-action" disabled>Prev</button>
|
||||||
|
{{ end }}
|
||||||
|
{{ if .HasNext }}
|
||||||
|
<a class="small-action" href="?q={{ .Filters.Query }}&status={{ .Filters.Status }}&role={{ .Filters.Role }}&sort={{ .Filters.Sort }}&page_size={{ .PageSize }}&page={{ .NextPage }}">Next</a>
|
||||||
|
{{ else }}
|
||||||
|
<button class="small-action" disabled>Next</button>
|
||||||
|
{{ end }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer class="win98-statusbar" aria-label="Users status">
|
||||||
|
<span>signed in: {{ .AccountNav.Username }}</span>
|
||||||
|
<span>{{ if .AccountNav.IsAdmin }}admin{{ else }}account{{ end }}</span>
|
||||||
|
<span>ready</span>
|
||||||
|
</footer>
|
||||||
|
</main>
|
||||||
|
{{ template "account_shell_end" . }}
|
||||||
Reference in New Issue
Block a user