4 Commits

Author SHA1 Message Date
82d4dc815b feat(users): implement comprehensive user listing and control 2026-04-30 21:45:09 +03:00
89c885f637 feat(models): add box activity tracking
Adds BoxActivity model to track actions taken on a box.
Updates related endpoints and UI for activity feed.
2026-04-30 19:55:32 +03:00
e103829870 feat(models): add alert and box models
Adds comprehensive data structures for Alert and Box functionality across models.
2026-04-30 19:45:22 +03:00
2714907ff4 feat(config): add box owner policy settings
Adds configuration options and environment variables to manage
box owner policies, including settings for refresh counts and expiry.
2026-04-30 19:30:13 +03:00
47 changed files with 10182 additions and 148 deletions

View File

@@ -151,11 +151,6 @@ func buildAllEnvRows(includeHidden bool) []envRow {
}
extra := buildExtraEnvRows(includeHidden)
if loadErr == nil {
for i := range extra {
extra[i].Default = extra[i].Default
}
}
rows = append(rows, extra...)
return rows

View File

@@ -28,6 +28,12 @@ func TestDefaults(t *testing.T) {
if cfg.AdminPassword != "" {
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) {
@@ -39,6 +45,8 @@ func TestEnvironmentOverrides(t *testing.T) {
t.Setenv("WARPBOX_BOX_POLL_INTERVAL_MS", "2000")
t.Setenv("WARPBOX_ADMIN_USERNAME", "root")
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()
if err != nil {
@@ -63,6 +71,9 @@ func TestEnvironmentOverrides(t *testing.T) {
if !cfg.OneTimeDownloadRetryOnFailure {
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 {
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 {
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) {
@@ -181,6 +198,12 @@ func clearConfigEnv(t *testing.T) {
"WARPBOX_BOX_POLL_INTERVAL_MS",
"WARPBOX_THUMBNAIL_BATCH_SIZE",
"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, "")
}

View File

@@ -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: 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: 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 {
@@ -38,6 +44,10 @@ func (cfg *Config) Source(key string) Source {
return cfg.sourceFor(key)
}
func (cfg *Config) SettingValue(key string) string {
return cfg.values[key]
}
func (cfg *Config) AdminLoginEnabled(hasAdminUser bool) bool {
switch cfg.AdminEnabled {
case AdminEnabledFalse:

View File

@@ -11,24 +11,30 @@ import (
func Load() (*Config, error) {
cfg := &Config{
DataDir: "./data",
AdminUsername: "admin",
AdminEnabled: AdminEnabledAuto,
AllowAdminSettingsOverride: true,
GuestUploadsEnabled: true,
APIEnabled: true,
ZipDownloadsEnabled: true,
OneTimeDownloadsEnabled: true,
OneTimeDownloadExpirySeconds: 7 * 24 * 60 * 60,
OneTimeDownloadRetryOnFailure: false,
DefaultGuestExpirySeconds: 10,
MaxGuestExpirySeconds: 48 * 60 * 60,
SessionTTLSeconds: 24 * 60 * 60,
BoxPollIntervalMS: 5000,
ThumbnailBatchSize: 10,
ThumbnailIntervalSeconds: 30,
sources: make(map[string]Source),
values: make(map[string]string),
DataDir: "./data",
AdminUsername: "admin",
AdminEnabled: AdminEnabledAuto,
AllowAdminSettingsOverride: true,
GuestUploadsEnabled: true,
APIEnabled: true,
ZipDownloadsEnabled: true,
OneTimeDownloadsEnabled: true,
OneTimeDownloadExpirySeconds: 7 * 24 * 60 * 60,
OneTimeDownloadRetryOnFailure: false,
DefaultGuestExpirySeconds: 10,
MaxGuestExpirySeconds: 48 * 60 * 60,
SessionTTLSeconds: 24 * 60 * 60,
BoxPollIntervalMS: 5000,
ThumbnailBatchSize: 10,
ThumbnailIntervalSeconds: 30,
BoxOwnerEditEnabled: true,
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.
@@ -73,6 +79,9 @@ func Load() (*Config, error) {
{SettingOneTimeDownloadRetryFail, "WARPBOX_ONE_TIME_DOWNLOAD_RETRY_ON_FAILURE", &cfg.OneTimeDownloadRetryOnFailure},
{SettingRenewOnAccessEnabled, "WARPBOX_RENEW_ON_ACCESS_ENABLED", &cfg.RenewOnAccessEnabled},
{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 {
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},
{SettingOneTimeDownloadExpirySecs, "WARPBOX_ONE_TIME_DOWNLOAD_EXPIRY_SECONDS", 0, &cfg.OneTimeDownloadExpirySeconds},
{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 {
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},
{SettingThumbnailBatchSize, "WARPBOX_THUMBNAIL_BATCH_SIZE", 1, &cfg.ThumbnailBatchSize},
{SettingThumbnailIntervalSeconds, "WARPBOX_THUMBNAIL_INTERVAL_SECONDS", 1, &cfg.ThumbnailIntervalSeconds},
{SettingBoxOwnerMaxRefreshCount, "WARPBOX_BOX_OWNER_MAX_REFRESH_COUNT", 0, &cfg.BoxOwnerMaxRefreshCount},
}
for _, item := range envInts {
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(SettingThumbnailBatchSize, strconv.Itoa(cfg.ThumbnailBatchSize), 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 {

View File

@@ -36,6 +36,12 @@ const (
SettingThumbnailBatchSize = "thumbnail_batch_size"
SettingThumbnailIntervalSeconds = "thumbnail_interval_seconds"
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
@@ -84,16 +90,22 @@ type Config struct {
RenewOnAccessEnabled bool
RenewOnDownloadEnabled bool
DefaultGuestExpirySeconds int64
MaxGuestExpirySeconds int64
GlobalMaxFileSizeBytes int64
GlobalMaxBoxSizeBytes int64
DefaultUserMaxFileSizeBytes int64
DefaultUserMaxBoxSizeBytes int64
SessionTTLSeconds int64
BoxPollIntervalMS int
ThumbnailBatchSize int
ThumbnailIntervalSeconds int
DefaultGuestExpirySeconds int64
MaxGuestExpirySeconds int64
GlobalMaxFileSizeBytes int64
GlobalMaxBoxSizeBytes int64
DefaultUserMaxFileSizeBytes int64
DefaultUserMaxBoxSizeBytes int64
SessionTTLSeconds int64
BoxPollIntervalMS int
ThumbnailBatchSize int
ThumbnailIntervalSeconds int
BoxOwnerEditEnabled bool
BoxOwnerRefreshEnabled bool
BoxOwnerMaxRefreshCount int
BoxOwnerMaxRefreshAmountSeconds int64
BoxOwnerMaxTotalExpirySeconds int64
BoxOwnerPasswordEditEnabled bool
sources map[string]Source
values map[string]string

View File

@@ -64,6 +64,12 @@ func (cfg *Config) assignBool(key string, value bool, source Source) {
cfg.RenewOnAccessEnabled = value
case SettingRenewOnDownloadEnabled:
cfg.RenewOnDownloadEnabled = value
case SettingBoxOwnerEditEnabled:
cfg.BoxOwnerEditEnabled = value
case SettingBoxOwnerRefreshEnabled:
cfg.BoxOwnerRefreshEnabled = value
case SettingBoxOwnerPasswordEdit:
cfg.BoxOwnerPasswordEditEnabled = value
}
cfg.setValue(key, formatBool(value), source)
}
@@ -82,6 +88,10 @@ func (cfg *Config) assignInt64(key string, value int64, source Source) {
cfg.DefaultUserMaxBoxSizeBytes = value
case SettingSessionTTLSeconds:
cfg.SessionTTLSeconds = value
case SettingBoxOwnerMaxRefreshAmount:
cfg.BoxOwnerMaxRefreshAmountSeconds = value
case SettingBoxOwnerMaxTotalExpiry:
cfg.BoxOwnerMaxTotalExpirySeconds = value
}
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
case SettingThumbnailIntervalSeconds:
cfg.ThumbnailIntervalSeconds = value
case SettingBoxOwnerMaxRefreshCount:
cfg.BoxOwnerMaxRefreshCount = value
}
cfg.setValue(key, strconv.Itoa(value), source)
}

247
lib/metastore/alerts.go Normal file
View 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))
}

View 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
View 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
}

View File

@@ -1,21 +1,38 @@
package metastore
import "time"
import (
"encoding/json"
"time"
)
const AdminTagName = "admin"
type User struct {
ID string `json:"id"`
Username string `json:"username"`
Email string `json:"email,omitempty"`
PasswordHash string `json:"password_hash"`
TagIDs []string `json:"tag_ids"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
Disabled bool `json:"disabled"`
MaxFileSizeBytes *int64 `json:"max_file_size_bytes,omitempty"`
MaxBoxSizeBytes *int64 `json:"max_box_size_bytes,omitempty"`
MaxExpirySeconds *int64 `json:"max_expiry_seconds,omitempty"`
ID string `json:"id"`
Username string `json:"username"`
Email string `json:"email,omitempty"`
PasswordHash string `json:"password_hash"`
TagIDs []string `json:"tag_ids"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
Disabled bool `json:"disabled"`
AdminNote string `json:"admin_note,omitempty"`
MaxFileSizeBytes *int64 `json:"max_file_size_bytes,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 {
@@ -39,6 +56,7 @@ type TagPermissions struct {
RenewOnAccessSeconds int64 `json:"renew_on_access_seconds,omitempty"`
RenewOnDownloadSeconds int64 `json:"renew_on_download_seconds,omitempty"`
AdminAccess bool `json:"admin_access"`
AdminUsersView bool `json:"admin_users_view"`
AdminUsersManage bool `json:"admin_users_manage"`
AdminSettingsManage bool `json:"admin_settings_manage"`
AdminBoxesView bool `json:"admin_boxes_view"`
@@ -64,6 +82,7 @@ type EffectivePermissions struct {
RenewOnAccessSeconds int64
RenewOnDownloadSeconds int64
AdminAccess bool
AdminUsersView bool
AdminUsersManage bool
AdminSettingsManage bool
AdminBoxesView bool
@@ -74,3 +93,150 @@ type BootstrapResult struct {
AdminUser *User
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
}

View File

@@ -22,6 +22,7 @@ func ResolveUserPermissions(cfg *config.Config, user User, tags []Tag) Effective
perms.ZipDownloadAllowed = perms.ZipDownloadAllowed || tagPerms.ZipDownloadAllowed
perms.RenewableAllowed = perms.RenewableAllowed || tagPerms.RenewableAllowed
perms.AdminAccess = perms.AdminAccess || tagPerms.AdminAccess
perms.AdminUsersView = perms.AdminUsersView || tagPerms.AdminUsersView
perms.AdminUsersManage = perms.AdminUsersManage || tagPerms.AdminUsersManage
perms.AdminSettingsManage = perms.AdminSettingsManage || tagPerms.AdminSettingsManage
perms.AdminBoxesView = perms.AdminBoxesView || tagPerms.AdminBoxesView
@@ -50,6 +51,21 @@ func ResolveUserPermissions(cfg *config.Config, user User, tags []Tag) Effective
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.MaxBoxSizeBytes = capLimit(perms.MaxBoxSizeBytes, cfg.GlobalMaxBoxSizeBytes)
perms.AllowedExpirySeconds = sortedExpirySet(expirySet)

View File

@@ -4,6 +4,7 @@ import (
"encoding/json"
"errors"
"fmt"
"sort"
"strings"
"time"
@@ -291,6 +292,295 @@ func (store *Store) ListUsers() ([]User, error) {
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) {
var id string
err := store.db.View(func(txn *badger.Txn) error {

View File

@@ -22,6 +22,7 @@ func AdminPermissions() TagPermissions {
ZipDownloadAllowed: true,
RenewableAllowed: true,
AdminAccess: true,
AdminUsersView: true,
AdminUsersManage: true,
AdminSettingsManage: true,
AdminBoxesView: true,

View File

@@ -41,19 +41,28 @@ type BoxFile struct {
}
type BoxManifest struct {
Files []BoxFile `json:"files"`
CreatedAt time.Time `json:"created_at"`
ExpiresAt time.Time `json:"expires_at"`
RetentionKey string `json:"retention_key"`
RetentionLabel string `json:"retention_label"`
RetentionSecs int64 `json:"retention_seconds"`
PasswordSalt string `json:"password_salt,omitempty"`
PasswordHash string `json:"password_hash,omitempty"`
PasswordHashAlg string `json:"password_hash_alg,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"`
Files []BoxFile `json:"files"`
OwnerID string `json:"owner_id,omitempty"`
OwnerUsername string `json:"owner_username,omitempty"`
Activity []BoxActivity `json:"activity,omitempty"`
CreatedAt time.Time `json:"created_at"`
ExpiresAt time.Time `json:"expires_at"`
RetentionKey string `json:"retention_key"`
RetentionLabel string `json:"retention_label"`
RetentionSecs int64 `json:"retention_seconds"`
PasswordSalt string `json:"password_salt,omitempty"`
PasswordHash string `json:"password_hash,omitempty"`
PasswordHashAlg string `json:"password_hash_alg,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 {

View 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
}

View 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
View 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
}

View 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"
}

View 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
View 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
}

View 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
View 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
View 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
}

View 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)
}
}

View 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
View 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)
}
}

View 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
}

View File

@@ -181,6 +181,9 @@ func validAdminCSRF(ctx *gin.Context, session metastore.Session) bool {
}
token := ctx.PostForm("csrf_token")
if token == "" {
token = ctx.GetHeader("X-CSRF-Token")
}
return token != "" && subtleConstantTimeEqual(token, session.CSRFToken)
}

View File

@@ -1,21 +1,35 @@
package server
import "github.com/gin-gonic/gin"
import (
"net/http"
"github.com/gin-gonic/gin"
)
func (app *App) registerAdminRoutes(router *gin.Engine) {
admin := router.Group("/admin")
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.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.Use(app.requireAdminSession)
protected.POST("/logout", app.handleAdminLogout)
protected.GET("", app.handleAdminDashboard)
protected.GET("/", app.handleAdminDashboard)
protected.GET("/boxes", app.handleAdminBoxes)
protected.GET("/users", app.handleAdminUsers)
protected.POST("/users", app.handleAdminUsersPost)
protected.GET("/users", func(ctx *gin.Context) {
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.POST("/tags", app.handleAdminTagsPost)
protected.GET("/settings", app.handleAdminSettings)

View File

@@ -1,8 +1,11 @@
package server
import (
"crypto/rand"
"fmt"
"math/big"
"net/http"
"sort"
"strconv"
"strings"
"github.com/gin-gonic/gin"
@@ -10,112 +13,381 @@ import (
"warpbox/lib/metastore"
)
type adminUserRow struct {
ID string
Username string
Email string
Tags string
CreatedAt string
Disabled bool
IsCurrent bool
const defaultUserPageSize = 12
type UsersIndexView struct {
PageTitle string
WindowTitle string
WindowIcon string
PageScripts []string
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) {
if !app.requireAdminFlag(ctx, func(perms metastore.EffectivePermissions) bool { return perms.AdminUsersManage }) {
return
}
app.renderAdminUsers(ctx, "")
type UserFiltersView struct {
Query string
Status string
Role string
Sort string
PageSize int
}
func (app *App) handleAdminUsersPost(ctx *gin.Context) {
if !app.requireAdminFlag(ctx, func(perms metastore.EffectivePermissions) bool { return perms.AdminUsersManage }) {
func (app *App) handleAccountUsers(ctx *gin.Context) {
actor, ok := currentAccountUser(ctx)
if !ok {
ctx.Redirect(http.StatusSeeOther, "/account/login")
return
}
if ctx.PostForm("action") == "toggle_disabled" {
userID := strings.TrimSpace(ctx.PostForm("user_id"))
user, ok, err := app.store.GetUser(userID)
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")
perms := currentAccountPermissions(ctx)
if !perms.AdminUsersView && !perms.AdminUsersManage {
ctx.String(http.StatusForbidden, "Permission denied")
return
}
username := ctx.PostForm("username")
email := ctx.PostForm("email")
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")
}
filters := userFiltersFromRequest(ctx)
pageReq := userPageFromRequest(ctx)
func (app *App) renderAdminUsers(ctx *gin.Context, errorMessage string) {
users, err := app.store.ListUsers()
userPage, err := app.store.ListUsersPaginated(filters, pageReq)
if err != nil {
ctx.String(http.StatusInternalServerError, "Could not list users")
return
}
tags, err := app.store.ListTags()
if err != nil {
ctx.String(http.StatusInternalServerError, "Could not list tags")
currentID := actor.ID
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
}
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 := ""
if current, ok := ctx.Get("adminUser"); ok {
if currentUser, ok := current.(metastore.User); ok {
currentID = currentUser.ID
perms := currentAccountPermissions(ctx)
if !perms.AdminUsersManage {
ctx.String(http.StatusForbidden, "Permission denied")
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))
for _, user := range users {
names := make([]string, 0, len(user.TagIDs))
for _, tagID := range user.TagIDs {
if name := tagNames[tagID]; name != "" {
names = append(names, name)
switch mode {
case "invite":
user, err := app.store.CreateUserWithoutPassword(username, email, tagIDs)
if err != nil {
redirectAccountUsers(ctx, err.Error(), "")
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{
ID: user.ID,
Username: user.Username,
Email: user.Email,
Tags: strings.Join(names, ", "),
CreatedAt: formatAdminTime(user.CreatedAt),
Disabled: user.Disabled,
IsCurrent: user.ID == currentID,
})
if adminCount-disableAdminCount < 1 {
redirectAccountUsers(ctx, "Cannot disable the last active administrator.", "")
return
}
}
ctx.HTML(http.StatusOK, "admin_users.html", gin.H{
"AdminSection": "users",
"CurrentUser": app.currentAdminUsername(ctx),
"CSRFToken": app.currentCSRFToken(ctx),
"Users": rows,
"Tags": tags,
"Error": errorMessage,
})
if err := app.store.BulkSetUsersDisabled(ids, disabled); err != nil {
redirectAccountUsers(ctx, "Could not update users.", "")
return
}
action := "disabled"
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)
}

View File

@@ -74,6 +74,7 @@ func Run(addr string) error {
DirectBoxUpload: app.handleDirectBoxUpload,
LegacyUpload: app.handleLegacyUpload,
})
app.registerAccountRoutes(router)
app.registerAdminRoutes(router)
compressed := router.Group("/", gzip.Gzip(gzip.DefaultCompression))

View File

@@ -45,6 +45,7 @@ func (app *App) handleCreateBox(ctx *gin.Context) {
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
app.indexBoxFromManifest(boxID)
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()})
return
}
app.indexBoxFromManifest(boxID)
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()})
return
}
app.indexBoxFromManifest(boxID)
ctx.JSON(http.StatusOK, gin.H{"file": file})
}
@@ -231,6 +234,7 @@ func (app *App) handleLegacyUpload(ctx *gin.Context) {
savedFiles = append(savedFiles, savedFile)
}
app.indexBoxFromManifest(boxID)
ctx.JSON(http.StatusOK, gin.H{"box_id": boxID, "box_url": "/box/" + boxID, "files": savedFiles})
}

2034
static/css/account.css Normal file

File diff suppressed because it is too large Load Diff

View 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 || "{}";
});
});
});

View 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
View 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("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#39;");
}
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();
});

View 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 });
}
}());

View 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;
}

View 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" . }}

View 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" . }}

View 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" . }}

View 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" . }}

View 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>

View 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 }}

View 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" . }}

View 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" . }}

View 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 }} &mdash; {{ .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" . }}