diff --git a/cmd/cmd_env.go b/cmd/cmd_env.go index 345149d..ac06789 100644 --- a/cmd/cmd_env.go +++ b/cmd/cmd_env.go @@ -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 diff --git a/lib/config/config_test.go b/lib/config/config_test.go index aebfaa6..b08f952 100644 --- a/lib/config/config_test.go +++ b/lib/config/config_test.go @@ -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, "") } diff --git a/lib/config/definitions.go b/lib/config/definitions.go index 46f4ff9..ed762ae 100644 --- a/lib/config/definitions.go +++ b/lib/config/definitions.go @@ -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: diff --git a/lib/config/load.go b/lib/config/load.go index 3859d3d..10ee307 100644 --- a/lib/config/load.go +++ b/lib/config/load.go @@ -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 { diff --git a/lib/config/models.go b/lib/config/models.go index dcfb68f..15da109 100644 --- a/lib/config/models.go +++ b/lib/config/models.go @@ -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 diff --git a/lib/config/overrides.go b/lib/config/overrides.go index 5e4395a..b2a5896 100644 --- a/lib/config/overrides.go +++ b/lib/config/overrides.go @@ -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) } diff --git a/lib/server/account_auth.go b/lib/server/account_auth.go new file mode 100644 index 0000000..ad08920 --- /dev/null +++ b/lib/server/account_auth.go @@ -0,0 +1,162 @@ +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) +} + +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 +} diff --git a/lib/server/account_nav.go b/lib/server/account_nav.go new file mode 100644 index 0000000..39d1ef5 --- /dev/null +++ b/lib/server/account_nav.go @@ -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" + } +} diff --git a/lib/server/account_pages.go b/lib/server/account_pages.go new file mode 100644 index 0000000..f6d94cc --- /dev/null +++ b/lib/server/account_pages.go @@ -0,0 +1,238 @@ +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), + } + + 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: accountPlaceholderAlerts(), + 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 accountPlaceholderAlerts() []accountAlertPreviewRow { + return []accountAlertPreviewRow{ + { + Severity: "info", + Title: "Alerts system pending", + Detail: "Dedicated alert storage arrives in the alerts implementation pass.", + }, + } +} + +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 +} diff --git a/lib/server/account_settings.go b/lib/server/account_settings.go new file mode 100644 index 0000000..777dab4 --- /dev/null +++ b/lib/server/account_settings.go @@ -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) + } +} diff --git a/lib/server/account_settings_test.go b/lib/server/account_settings_test.go new file mode 100644 index 0000000..455f20a --- /dev/null +++ b/lib/server/account_settings_test.go @@ -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 +} diff --git a/lib/server/account_test.go b/lib/server/account_test.go new file mode 100644 index 0000000..282c68d --- /dev/null +++ b/lib/server/account_test.go @@ -0,0 +1,245 @@ +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 +} diff --git a/lib/server/admin_auth.go b/lib/server/admin_auth.go index c787d79..09b5709 100644 --- a/lib/server/admin_auth.go +++ b/lib/server/admin_auth.go @@ -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) } diff --git a/lib/server/admin_routes.go b/lib/server/admin_routes.go index 29e31a4..a077f37 100644 --- a/lib/server/admin_routes.go +++ b/lib/server/admin_routes.go @@ -1,18 +1,28 @@ 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) diff --git a/lib/server/server.go b/lib/server/server.go index 26c3876..f253691 100644 --- a/lib/server/server.go +++ b/lib/server/server.go @@ -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)) diff --git a/static/css/account.css b/static/css/account.css new file mode 100644 index 0000000..da0984b --- /dev/null +++ b/static/css/account.css @@ -0,0 +1,1380 @@ +@font-face { + font-family: 'MonoCraft'; + src: url('/static/fonts/Monocraft.ttf'); +} + +:root { + font-family: 'MonoCraft', 'Courier New', monospace; + font-smooth: never; + -webkit-font-smoothing: none; + -moz-osx-font-smoothing: grayscale; + text-rendering: geometricPrecision; + image-rendering: pixelated; + + --base-font-size: 13px; + --account-frame-width: 1320px; + --w98-blue: #000078; + --w98-blue-soft: #0f80cd; + --w98-gray: #c0c0c0; + --w98-gray-dark: #808080; + --w98-panel: #ffffff; + --ok: #008000; + --info: #000078; + --warn: #8a6200; + --danger: #800000; + --shadow-heavy: 5px 6px 0 rgba(0, 0, 0, .5); +} + +* { + box-sizing: border-box; + scrollbar-width: auto; + scrollbar-color: #c0c0c0 #808080; + image-rendering: pixelated; +} + +html { + min-height: 100%; + font-size: var(--base-font-size); + color: #ffffff; + background: #000000; +} + +html, +body { + margin: 0; + padding: 0; +} + +body.account-body { + min-height: 100vh; + overflow-y: auto; + overflow-x: hidden; + background-color: #000000; + background-image: url('/static/img/bg/stars1.gif'); + background-repeat: repeat; + background-size: auto; + font-family: 'MonoCraft', 'Courier New', monospace; +} + +button, +input, +select, +textarea { + font-family: inherit; +} + +button, +a, +.menu-button, +.taskbar-button { + cursor: pointer; +} + +a { + color: inherit; +} + +input[type="text"], +input[type="password"], +input[type="email"], +input[type="number"], +input[type="search"], +textarea { + cursor: text; +} + +:focus-visible { + outline: 2px dotted #000078; + outline-offset: 2px; +} + +::-webkit-scrollbar { + width: 17px; + height: 17px; + background: #c0c0c0; +} + +::-webkit-scrollbar-track { + background: repeating-linear-gradient(45deg, #c0c0c0 0 2px, #b5b5b5 2px 4px); + border-top: 1px solid #808080; + border-left: 1px solid #808080; + border-right: 1px solid #ffffff; + border-bottom: 1px solid #ffffff; +} + +::-webkit-scrollbar-thumb, +::-webkit-scrollbar-button:single-button { + background: #c0c0c0; + border-top: 2px solid #ffffff; + border-left: 2px solid #ffffff; + border-right: 2px solid #000000; + border-bottom: 2px solid #000000; + box-shadow: inset -1px -1px 0 #808080, inset 1px 1px 0 #dfdfdf; +} + +::-webkit-scrollbar-corner { + background: #c0c0c0; + border-top: 1px solid #808080; + border-left: 1px solid #808080; +} + +.app-shell { + width: 100%; + min-height: 100vh; + display: flex; + flex-direction: column; + align-items: center; + justify-content: flex-start; + gap: 10px; + padding: 10px 16px 34px; +} + +.app-frame { + width: min(var(--account-frame-width), 100%); + display: grid; + grid-template-rows: auto auto; + gap: 10px; + align-items: start; +} + +.top-taskbar { + position: sticky; + top: 0; + z-index: 50; + width: 100%; + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto; + align-items: center; + gap: 8px; + padding: 3px; + color: #000000; + background-color: var(--w98-gray); + background-image: + linear-gradient(180deg, rgba(255, 255, 255, .36), rgba(0, 0, 0, .08)), + repeating-linear-gradient(45deg, rgba(255, 255, 255, .12) 0 1px, transparent 1px 5px); + border-top: 2px solid #ffffff; + border-left: 2px solid #ffffff; + border-right: 2px solid #000000; + border-bottom: 2px solid #000000; + box-shadow: inset -1px -1px 0 #808080, inset 1px 1px 0 #dfdfdf, 4px 4px 0 rgba(0, 0, 0, .45); + transition: box-shadow 120ms steps(2, end), filter 120ms steps(2, end); +} + +.top-taskbar.is-scrolled { + filter: brightness(1.02); + box-shadow: inset -1px -1px 0 #808080, inset 1px 1px 0 #dfdfdf, 0 5px 0 rgba(0, 0, 0, .55), 0 11px 0 rgba(0, 0, 0, .18); +} + +.top-taskbar.is-scrolled::after { + content: ""; + position: absolute; + left: 0; + right: 0; + bottom: -10px; + height: 10px; + pointer-events: none; + background: linear-gradient(to bottom, rgba(0, 0, 0, .46), rgba(0, 0, 0, 0)); +} + +.start-button { + min-width: 108px; + height: 24px; + display: inline-grid; + grid-template-columns: 18px 1fr; + align-items: center; + gap: 5px; + padding: 0 8px; + color: #000000; + background: var(--w98-gray); + border-top: 2px solid #ffffff; + border-left: 2px solid #ffffff; + border-right: 2px solid #000000; + border-bottom: 2px solid #000000; + box-shadow: inset -1px -1px 0 #808080, inset 1px 1px 0 #dfdfdf; + font-weight: bold; + text-decoration: none; + white-space: nowrap; +} + +.start-logo, +.win98-titlebar-icon { + width: 16px; + height: 16px; + display: grid; + place-items: center; + flex: 0 0 auto; + color: #ffffff; + background: #000078; + border: 1px solid #ffffff; + box-shadow: inset -5px 0 0 #0f80cd, inset 0 -5px 0 #4c1ca0; + font-size: 10px; + line-height: 10px; +} + +.taskbar-nav { + min-width: 0; + display: flex; + align-items: center; + gap: 4px; + overflow-x: auto; + scrollbar-width: thin; + padding-bottom: 1px; +} + +.taskbar-button { + height: 24px; + min-width: 76px; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 5px; + padding: 0 8px; + color: #000000; + background: var(--w98-gray); + border-top: 1px solid #ffffff; + border-left: 1px solid #ffffff; + border-right: 1px solid #808080; + border-bottom: 1px solid #808080; + text-decoration: none; + white-space: nowrap; +} + +.taskbar-button.is-active, +.taskbar-button:hover { + color: #ffffff; + background: #000078; +} + +.taskbar-button.is-active { + border-top-color: #000000; + border-left-color: #000000; + border-right-color: #ffffff; + border-bottom-color: #ffffff; +} + +.taskbar-session { + min-width: 0; + display: flex; + align-items: center; + justify-content: flex-end; + gap: 5px; + white-space: nowrap; +} + +.session-chip, +.alert-chip, +.dirty-chip { + height: 24px; + display: inline-flex; + align-items: center; + gap: 5px; + padding: 0 8px; + color: #000000; + background: #dfdfdf; + border-top: 1px solid #808080; + border-left: 1px solid #808080; + border-right: 1px solid #ffffff; + border-bottom: 1px solid #ffffff; + text-decoration: none; + white-space: nowrap; +} + +.alert-chip.is-ok { + background: #e8ffe8; + border-color: #008000 #ffffff #ffffff #008000; +} + +.alert-chip.is-info { + background: #d8e5f8; +} + +.alert-chip.is-warning { + background: #ffffcc; + border: 3px solid transparent; + border-image: repeating-linear-gradient(45deg, #111111 0 8px, #ffcc00 8px 16px) 3; +} + +.alert-chip.is-danger { + color: #ffffff; + background: #800000; + border: 3px solid transparent; + border-image: repeating-linear-gradient(45deg, #ffcccc 0 8px, #300000 8px 16px) 3; +} + +.dirty-chip { + display: none; + background: #ffffcc; +} + +.dirty-chip.is-dirty { + display: inline-flex; +} + +.win98-window, +.account-window { + display: flex; + flex-direction: column; + min-height: 0; + color: #000000; + background-color: var(--w98-gray); + background-image: + linear-gradient(180deg, rgba(255, 255, 255, .34), rgba(0, 0, 0, .06)), + repeating-linear-gradient(45deg, rgba(255, 255, 255, .12) 0 1px, transparent 1px 5px); + border-top: 1px solid #ffffff; + border-left: 1px solid #ffffff; + border-right: 1px solid #000000; + border-bottom: 1px solid #000000; + box-shadow: inset -1px -1px 0 #808080, inset 1px 1px 0 #dfdfdf, var(--shadow-heavy); +} + +.account-window { + width: 100%; + overflow: visible; +} + +.win98-titlebar { + min-height: 22px; + display: flex; + align-items: center; + justify-content: space-between; + margin: 2px; + padding: 2px 3px 2px 6px; + color: #ffffff; + background: linear-gradient(90deg, #000078 0%, #000078 30%, #0f80cd 66%, #85c7ff 100%); + background-size: 160% 100%; + box-shadow: inset 0 1px 0 rgba(255, 255, 255, .35), inset 0 -1px 0 rgba(0, 0, 0, .35); + user-select: none; + animation: account-titlebar-drift 32s ease-in-out infinite alternate; +} + +@keyframes account-titlebar-drift { + from { background-position: 0% 50%; } + to { background-position: 100% 50%; } +} + +.win98-titlebar-label { + min-width: 0; + display: flex; + align-items: center; + gap: 5px; +} + +.win98-titlebar h1, +.win98-titlebar h2 { + min-width: 0; + margin: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 14px; + line-height: 14px; + font-weight: bold; +} + +.titlebar-actions, +.win98-window-controls { + display: flex; + flex: 0 0 auto; + align-items: center; + gap: 2px; + margin-left: 8px; +} + +.win98-control { + width: 16px; + height: 14px; + display: grid; + place-items: center; + padding: 0; + color: #000000; + background: var(--w98-gray); + border-top: 1px solid #ffffff; + border-left: 1px solid #ffffff; + border-right: 1px solid #000000; + border-bottom: 1px solid #000000; + box-shadow: inset -1px -1px 0 #808080, inset 1px 1px 0 #dfdfdf; + font-size: 12px; + line-height: 12px; +} + +.win98-button, +.titlebar-link-button, +.tiny-button { + min-width: 84px; + height: 26px; + display: inline-grid; + place-items: center; + margin: 0; + padding: 0 10px; + color: #000000; + background: var(--w98-gray); + border-top: 2px solid #ffffff; + border-left: 2px solid #ffffff; + border-right: 2px solid #000000; + border-bottom: 2px solid #000000; + box-shadow: inset -1px -1px 0 #808080, inset 1px 1px 0 #dfdfdf; + font-size: 13px; + line-height: 13px; + text-align: center; + text-decoration: none; + appearance: none; +} + +.titlebar-link-button, +.tiny-button { + min-width: 56px; + height: 22px; + padding: 0 7px; + border-width: 1px; + font-size: 12px; + line-height: 12px; +} + +.win98-button:disabled, +.win98-button[aria-disabled="true"], +button:disabled, +button[aria-disabled="true"], +input:disabled, +select:disabled, +textarea:disabled { + cursor: not-allowed; +} + +.win98-button:disabled, +.win98-button[aria-disabled="true"] { + color: #808080; + text-shadow: 1px 1px 0 #ffffff; +} + +.win98-button:active:not(:disabled):not([aria-disabled="true"]), +.win98-control:active, +.menu-button[aria-expanded="true"], +.start-button:active, +.taskbar-button:active, +.tiny-button:active:not(:disabled) { + border-top-color: #000000; + border-left-color: #000000; + border-right-color: #ffffff; + border-bottom-color: #ffffff; + box-shadow: inset -1px -1px 0 #dfdfdf, inset 1px 1px 0 #808080; + padding-top: 1px; +} + +.menu-bar { + position: relative; + z-index: 20; + min-height: 24px; + display: flex; + align-items: center; + gap: 2px; + padding: 1px 6px; + font-size: 13px; + line-height: 13px; +} + +.menu-item { + position: relative; +} + +.menu-popup form { + margin: 0; +} + +.menu-button { + height: 20px; + min-width: 54px; + padding: 0 8px; + color: #000000; + background: transparent; + border: 1px solid transparent; + font-size: 13px; + text-align: left; +} + +.menu-button:hover, +.menu-button:focus-visible { + border-top: 1px solid #ffffff; + border-left: 1px solid #ffffff; + border-right: 1px solid #808080; + border-bottom: 1px solid #808080; + outline: none; +} + +.menu-popup { + position: absolute; + top: 22px; + left: 0; + z-index: 60; + min-width: 220px; + display: none; + padding: 2px; + background: var(--w98-gray); + border-top: 2px solid #ffffff; + border-left: 2px solid #ffffff; + border-right: 2px solid #000000; + border-bottom: 2px solid #000000; + box-shadow: 3px 3px 0 rgba(0, 0, 0, .35); +} + +.menu-item.is-open .menu-popup { + display: block; +} + +.menu-action { + width: 100%; + min-height: 22px; + display: grid; + grid-template-columns: 20px minmax(0, 1fr) auto; + align-items: center; + gap: 8px; + padding: 2px 6px; + color: #000000; + background: transparent; + border: 0; + font-size: 12px; + text-align: left; + text-decoration: none; +} + +.menu-action:hover, +.menu-action:focus-visible { + color: #ffffff; + background: #000078; + outline: none; +} + +.menu-separator { + height: 1px; + margin: 3px 2px; + background: #808080; + border-bottom: 1px solid #ffffff; +} + +.shortcut { + color: #555555; +} + +.menu-action:hover .shortcut { + color: #ffffff; +} + +.account-body-content, +.section-body { + display: grid; + gap: 12px; + padding: 10px; +} + +.dashboard-hero { + display: grid; + grid-template-columns: minmax(0, 1fr) 330px; + gap: 10px; + align-items: stretch; + padding: 9px; +} + +.hero-copy h2 { + margin: 0 0 5px; + font-size: 22px; + line-height: 24px; +} + +.hero-copy p { + margin: 0; + color: #333333; + font-size: 13px; + line-height: 15px; +} + +.hero-status { + display: grid; + align-content: center; + gap: 4px; + padding: 7px; + color: #000000; + background: #ffffff; + border-top: 1px solid #808080; + border-left: 1px solid #808080; + border-right: 1px solid #ffffff; + border-bottom: 1px solid #ffffff; + font-size: 12px; + line-height: 13px; +} + +.hero-status-row { + display: flex; + justify-content: space-between; + gap: 8px; +} + +.status-ok { + color: #008000; +} + +.status-warn { + color: #8a6200; +} + +.status-danger { + color: #800000; +} + +.main-grid { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); + gap: 12px; + align-items: start; +} + +.span-2 { + grid-column: 1 / -1; +} + +.section-window { + min-height: 0; + box-shadow: inset -1px -1px 0 #808080, inset 1px 1px 0 #dfdfdf, 3px 4px 0 rgba(0, 0, 0, .38); +} + +.section-window .section-body { + margin: 0 6px 6px; + padding: 8px; +} + +.raised-panel, +.sunken-panel, +.win98-panel { + color: #000000; +} + +.raised-panel { + background: #dfdfdf; + border-top: 1px solid #ffffff; + border-left: 1px solid #ffffff; + border-right: 1px solid #808080; + border-bottom: 1px solid #808080; + box-shadow: inset 1px 1px 0 #f7f7f7, inset -1px -1px 0 #b0b0b0; +} + +.sunken-panel, +.win98-panel { + background-color: #ffffff; + background-image: + linear-gradient(180deg, rgba(255, 255, 255, .9), rgba(238, 238, 238, .58)), + repeating-linear-gradient(0deg, rgba(0, 0, 0, .025) 0 1px, transparent 1px 6px); + border-top: 2px solid #606060; + border-left: 2px solid #606060; + border-right: 2px solid #ffffff; + border-bottom: 2px solid #ffffff; +} + +.stats-grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 10px; +} + +.stat-card { + position: relative; + min-height: 122px; + padding: 10px 11px 10px 14px; + overflow: hidden; +} + +.stat-card::before { + content: ""; + position: absolute; + inset: 0 auto 0 0; + width: 7px; + border-left: 7px solid #000078; + pointer-events: none; +} + +.stat-card.is-ok { + background: linear-gradient(180deg, #eeffee, #ffffff); +} + +.stat-card.is-ok::before { + border-left-color: #008000; +} + +.stat-card.is-info { + background: linear-gradient(180deg, #edf4ff, #ffffff); +} + +.stat-card.is-info::before { + border-left-color: #000078; +} + +.stat-card.is-warning { + background: linear-gradient(180deg, #ffffcc, #ffffff); +} + +.stat-card.is-warning::before { + border-left-color: #ffcc00; +} + +.stat-card.is-danger { + color: #000000; + background: repeating-linear-gradient(45deg, #fff2f2 0 6px, #ffe1e1 6px 12px); +} + +.stat-card.is-danger::before { + border-left-color: #800000; +} + +.stat-label { + margin: 0 0 6px; + color: #333333; + font-size: 13px; + line-height: 13px; + font-weight: bold; +} + +.stat-value { + margin: 0 0 7px; + font-size: 32px; + line-height: 32px; + font-weight: bold; +} + +.stat-note { + display: flex; + flex-wrap: wrap; + gap: 4px; + margin: 0; + color: #222222; + font-size: 12px; + line-height: 14px; +} + +.stat-note-pill, +.badge, +.pill, +.tag { + display: inline-flex; + align-items: center; + min-height: 18px; + padding: 1px 6px; + color: #000000; + background: #dfdfdf; + border-top: 1px solid #ffffff; + border-left: 1px solid #ffffff; + border-right: 1px solid #808080; + border-bottom: 1px solid #808080; + white-space: nowrap; +} + +.badge.is-ok, +.pill.is-ok, +.tag.ok { + color: #008000; + background: #eeffee; +} + +.badge.is-info, +.pill.is-info, +.tag.info { + color: #000078; + background: #edf4ff; +} + +.badge.is-warning, +.pill.is-warning, +.tag.warn { + color: #8a6200; + background: #ffffcc; +} + +.badge.is-danger, +.pill.is-danger, +.tag.danger { + color: #ffffff; + background: #800000; +} + +.account-table { + width: 100%; + min-width: 760px; + border-collapse: collapse; + color: #000000; + background: #ffffff; + font-size: 12px; + line-height: 14px; +} + +.account-table th, +.account-table td { + padding: 6px 7px; + border-bottom: 1px solid #dfdfdf; + text-align: left; + vertical-align: middle; +} + +.account-table th { + position: sticky; + top: 0; + z-index: 5; + background: #dfdfdf; + border-bottom: 1px solid #808080; +} + +.account-table tr:nth-child(even) td { + background: #f5f8ff; +} + +.settings-layout { + display: grid; + grid-template-rows: auto auto minmax(0, 1fr) 42px; + gap: 8px; + min-height: min(900px, calc(100vh - 96px)); +} + +.settings-summary { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 6px; + min-height: 34px; + padding: 6px; +} + +.settings-import { + display: grid; + gap: 8px; + padding: 8px; +} + +.settings-import[hidden] { + display: none; +} + +.settings-scroll { + min-height: 0; + overflow: auto; +} + +.settings-group { + display: grid; + grid-template-rows: 44px minmax(0, auto); + margin: 0 0 8px; +} + +.settings-group-header { + min-height: 44px; + display: grid; + align-content: center; + gap: 2px; + padding: 5px 8px; + background: #dfdfdf; + border-bottom: 1px solid #808080; +} + +.settings-group-header h2 { + margin: 0; + font-size: 14px; + line-height: 15px; +} + +.settings-group-header p { + margin: 0; + color: #333333; + font-size: 12px; + line-height: 13px; +} + +.settings-table { + min-width: 980px; +} + +.settings-table td:nth-child(1) { + width: 210px; +} + +.settings-table td:nth-child(2) { + width: 300px; +} + +.settings-table td:nth-child(3) { + width: 230px; +} + +.setting-key { + display: block; + color: #555555; + font-size: 11px; + line-height: 13px; + overflow-wrap: anywhere; +} + +.setting-description { + margin: 0; + color: #333333; + font-size: 12px; + line-height: 14px; +} + +.setting-source { + display: grid; + gap: 3px; + font-size: 12px; + line-height: 13px; +} + +.setting-env { + color: #555555; + overflow-wrap: anywhere; +} + +.settings-actions { + min-height: 42px; + display: flex; + align-items: center; + justify-content: flex-end; + gap: 8px; + padding: 6px; +} + +.settings-actions form { + margin: 0; +} + +.scroll-panel { + overflow: auto; + color: #000000; + background: #ffffff; + border-top: 2px solid #606060; + border-left: 2px solid #606060; + border-right: 2px solid #ffffff; + border-bottom: 2px solid #ffffff; +} + +.scroll-panel.is-fixed, +.fixed-height-scroller { + height: 352px; +} + +.alerts-scroll, +.boxes-scroll { + height: 352px; +} + +.activity-scroll { + height: 326px; +} + +.alert-list, +.activity-list { + display: grid; + min-width: 0; +} + +.alert-row { + display: grid; + grid-template-columns: 86px minmax(0, 1fr) auto; + gap: 8px; + align-items: start; + min-height: 74px; + padding: 7px; + color: #000000; + background: #ffffff; + border-bottom: 1px solid #dfdfdf; +} + +.alert-row:nth-child(even), +.activity-row:nth-child(even) { + background: #f5f8ff; +} + +.alert-title, +.activity-title { + margin: 0 0 3px; + font-size: 14px; + line-height: 15px; + font-weight: bold; +} + +.alert-desc, +.activity-meta { + margin: 0; + color: #333333; + font-size: 12px; + line-height: 14px; +} + +.alert-actions, +.box-actions { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: 5px; +} + +.activity-row { + display: grid; + grid-template-columns: 76px minmax(0, 1fr) auto; + gap: 9px; + align-items: center; + min-height: 48px; + padding: 6px 8px; + color: #000000; + background: #ffffff; + border-bottom: 1px solid #dfdfdf; +} + +.activity-time { + color: #000078; + font-weight: bold; +} + +.account-form { + display: grid; + gap: 10px; +} + +.account-form.sunken-panel, +.account-form.raised-panel { + padding: 10px; +} + +.account-form-row { + display: grid; + gap: 4px; +} + +.account-form-row input, +.account-form-row textarea, +.account-form-row select, +.account-control { + width: 100%; + min-height: 24px; + color: #000000; + background: #ffffff; + border-top: 1px solid #808080; + border-left: 1px solid #808080; + border-right: 1px solid #ffffff; + border-bottom: 1px solid #ffffff; + font-family: inherit; +} + +.account-checks { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: 8px; +} + +.account-checks label { + display: flex; + align-items: center; + gap: 6px; +} + +.account-error { + margin: 0; + padding: 8px; + color: #000000; + background: #ffdede; + border: 1px solid #800000; +} + +.win98-statusbar { + min-height: 22px; + display: grid; + grid-template-columns: minmax(0, 1fr) auto auto; + gap: 4px; + padding: 0 4px 4px; + font-size: 12px; + line-height: 12px; +} + +.win98-statusbar span { + min-width: 0; + display: flex; + align-items: center; + overflow: hidden; + padding: 0 5px; + border-top: 1px solid #808080; + border-left: 1px solid #808080; + border-right: 1px solid #ffffff; + border-bottom: 1px solid #ffffff; + text-overflow: ellipsis; + white-space: nowrap; +} + +.modal-backdrop { + position: fixed; + inset: 0; + z-index: 70; + display: none; + background: rgba(128, 128, 128, .42); +} + +.modal-backdrop.is-visible { + display: block; +} + +.popup-window, +.account-modal { + position: fixed; + left: 50%; + top: 50%; + z-index: 80; + width: min(760px, calc(100vw - 24px)); + max-height: min(760px, calc(100vh - 24px)); + display: none; + transform: translate(-50%, -50%); +} + +.popup-window.is-visible, +.account-modal.is-visible { + display: flex; + animation: popup-open 160ms steps(5, end); +} + +@keyframes popup-open { + from { transform: translate(-50%, -48%) scale(.97); opacity: .45; } + to { transform: translate(-50%, -50%) scale(1); opacity: 1; } +} + +.popup-body, +.modal-body { + max-height: calc(100vh - 90px); + overflow: auto; + margin: 0 6px 6px; + padding: 10px; + color: #000000; +} + +.modal-actions { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: 6px; + margin-top: 10px; +} + +.toast { + position: fixed; + right: 12px; + bottom: 24px; + z-index: 90; + max-width: min(400px, calc(100vw - 24px)); + display: none; + padding: 8px 10px; + color: #000000; + background: #ffffcc; + border: 4px solid transparent; + border-image: repeating-linear-gradient(45deg, #111111 0 8px, #ffcc00 8px 16px) 4; + box-shadow: 4px 4px 0 rgba(0, 0, 0, .45), inset 1px 1px 0 #fff7a8, inset -1px -1px 0 #a08000; + font-size: 12px; + line-height: 14px; +} + +.toast.is-visible { + display: block; + animation: toast-in 160ms steps(3, end); +} + +.toast.toast-info { + background: #d8e5f8; + border-image: none; + border-color: #000078; +} + +.toast.toast-success { + background: #e8ffe8; + border-image: none; + border-color: #008000; +} + +.toast.toast-error { + color: #ffffff; + background: #800000; + border-image: none; + border-color: #300000; +} + +@keyframes toast-in { + from { transform: translateY(12px); opacity: 0; } + to { transform: translateY(0); opacity: 1; } +} + +@media (max-width: 1180px) { + .top-taskbar { + grid-template-columns: auto minmax(0, 1fr); + } + + .taskbar-session { + grid-column: 1 / -1; + justify-content: flex-start; + overflow-x: auto; + } + + .stats-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .dashboard-hero, + .main-grid { + grid-template-columns: 1fr; + } + + .span-2 { + grid-column: auto; + } +} + +@media (max-width: 760px) { + :root { + --base-font-size: 13px; + } + + .app-shell { + align-items: stretch; + padding: 0 0 18px; + } + + .app-frame { + width: 100%; + gap: 8px; + } + + .top-taskbar { + grid-template-columns: 1fr; + border-left: 0; + border-right: 0; + box-shadow: none; + } + + .top-taskbar.is-scrolled { + box-shadow: 0 4px 0 rgba(0, 0, 0, .55), 0 10px 0 rgba(0, 0, 0, .18); + } + + .start-button { + width: 100%; + justify-content: center; + } + + .taskbar-nav, + .taskbar-session { + width: 100%; + overflow-x: auto; + padding-bottom: 3px; + } + + .taskbar-button { + min-width: 92px; + } + + .session-chip, + .alert-chip, + .dirty-chip { + flex: 0 0 auto; + } + + .account-window { + min-height: 100dvh; + border-left: 0; + border-right: 0; + box-shadow: none; + } + + .account-body-content, + .section-body { + gap: 8px; + padding: 6px; + } + + .menu-bar { + overflow-x: auto; + padding-bottom: 2px; + } + + .menu-popup { + position: fixed; + left: 6px; + right: 6px; + top: 74px; + min-width: 0; + } + + .stats-grid { + grid-template-columns: 1fr; + } + + .stat-card { + min-height: 112px; + } + + .scroll-panel.is-fixed, + .fixed-height-scroller { + height: 320px; + } + + .alerts-scroll, + .boxes-scroll, + .activity-scroll { + height: 320px; + } + + .boxes-scroll { + overflow-x: auto; + } + + .alert-row { + grid-template-columns: 1fr; + min-height: 0; + } + + .alert-actions { + justify-content: flex-start; + } + + .activity-row { + grid-template-columns: 58px minmax(0, 1fr); + } + + .win98-window-controls { + display: none; + } + + .win98-titlebar h1, + .win98-titlebar h2 { + font-size: 13px; + } + + .win98-statusbar { + grid-template-columns: 1fr; + } + + .popup-window, + .account-modal { + left: 0; + top: 0; + width: 100vw; + height: 100dvh; + max-height: none; + border: 0; + box-shadow: none; + transform: none; + } + + .popup-window.is-visible, + .account-modal.is-visible { + animation: popup-open-mobile 150ms steps(5, end); + } + + @keyframes popup-open-mobile { + from { transform: translateY(10px); opacity: .35; } + to { transform: translateY(0); opacity: 1; } + } + + .popup-body, + .modal-body { + max-height: calc(100dvh - 40px); + } + + .toast { + right: 8px; + bottom: 12px; + } +} diff --git a/static/js/account-settings.js b/static/js/account-settings.js new file mode 100644 index 0000000..b7d0f6b --- /dev/null +++ b/static/js/account-settings.js @@ -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); + }); +}); diff --git a/static/js/account-ui.js b/static/js/account-ui.js new file mode 100644 index 0000000..95fa92c --- /dev/null +++ b/static/js/account-ui.js @@ -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 = ` +

${htmlEscape(message)}

+ + `; + + const parts = modalElements(options); + if (!parts.modal) { + return Promise.resolve(window.confirm(message)); + } + + openModal(title, html, options); + return new Promise((resolve) => { + activeConfirmResolve = resolve; + parts.modal.querySelector("[data-confirm-ok]")?.focus(); + }); + } + + function finishConfirm(result) { + if (activeConfirmResolve) { + activeConfirmResolve(result); + activeConfirmResolve = null; + } + closeModal(); + } + + function setDirtyState(isDirty, options = {}) { + const target = options.target || document.querySelector("[data-dirty-chip]"); + if (!target) return; + target.classList.toggle("is-dirty", Boolean(isDirty)); + target.textContent = isDirty ? (options.dirtyText || "unsaved changes") : (options.cleanText || ""); + } + + function bindFormDirtyState(form, options = {}) { + const targetForm = typeof form === "string" ? document.querySelector(form) : form; + if (!targetForm) return; + + let baseline = new FormData(targetForm); + const serialize = () => new URLSearchParams(new FormData(targetForm)).toString(); + let baselineValue = new URLSearchParams(baseline).toString(); + + const update = () => setDirtyState(serialize() !== baselineValue, options); + targetForm.addEventListener("input", update); + targetForm.addEventListener("change", update); + targetForm.addEventListener("submit", () => { + baseline = new FormData(targetForm); + baselineValue = new URLSearchParams(baseline).toString(); + setDirtyState(false, options); + }); + update(); + } + + function bindConfirmActions(root = document) { + root.addEventListener("click", async (event) => { + const ok = event.target.closest("[data-confirm-ok]"); + if (ok) { + finishConfirm(true); + return; + } + + const cancel = event.target.closest("[data-confirm-cancel], [data-modal-close]"); + if (cancel) { + finishConfirm(false); + return; + } + + const action = event.target.closest("[data-confirm]"); + if (!action) return; + if (action.dataset.confirmAccepted === "true") { + delete action.dataset.confirmAccepted; + return; + } + + const message = action.getAttribute("data-confirm"); + if (!message) return; + + event.preventDefault(); + event.stopPropagation(); + + const accepted = await confirm(message, { + title: action.getAttribute("data-confirm-title") || "Confirm action", + confirmLabel: action.getAttribute("data-confirm-label") || "OK", + cancelLabel: action.getAttribute("data-cancel-label") || "Cancel", + }); + if (!accepted) return; + + if (action instanceof HTMLAnchorElement && action.href) { + window.location.href = action.href; + return; + } + + const form = action.closest("form"); + const type = (action.getAttribute("type") || "").toLowerCase(); + if (form && (type === "submit" || type === "")) { + form.requestSubmit(action); + return; + } + + action.dataset.confirmAccepted = "true"; + action.click(); + }); + } + + function htmlEscape(value) { + return String(value || "") + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); + } + + function init(root = document) { + initStickyTaskbar(); + initMenus({ root }); + bindConfirmActions(root); + document.querySelector("#account-modal-backdrop")?.addEventListener("click", () => closeModal()); + document.addEventListener("keydown", (event) => { + if (event.key === "Escape") closeModal(); + }); + } + + return { + init, + initStickyTaskbar, + initMenus, + toast, + confirm, + openModal, + closeModal, + setDirtyState, + bindFormDirtyState, + closeMenus, + }; +})(); + +document.addEventListener("DOMContentLoaded", () => { + window.WarpBoxAccountUI.init(); +}); diff --git a/templates/account_dashboard.html b/templates/account_dashboard.html new file mode 100644 index 0000000..e9e3a36 --- /dev/null +++ b/templates/account_dashboard.html @@ -0,0 +1,198 @@ +{{ template "account_shell_start" . }} +
+ {{ template "account_window_titlebar" . }} + + + +
+
+
+

Dashboard

+

Account overview for boxes, alerts, storage, users, and recent activity.

+
+
+ {{ range .Statuses }} +
{{ .Label }}{{ .Value }}
+ {{ end }} +
+
+ +
+
+

Active boxes

+

{{ .Stats.ActiveBoxes }}

+

live filesystem scan

+
+
+

Storage used

+

{{ .Stats.StorageUsedLabel }}

+

local backend

+
+
+

Alerts

+

{{ .Stats.AlertCount }}

+

alert model pending

+
+ {{ if .ShowUsersStat }} +
+

Users

+

{{ .Stats.TotalUsers }}

+

{{ .Stats.ActiveUsers }} active{{ .Stats.DisabledUsers }} disabled

+
+ {{ end }} +
+ +
+
+
+
+ ! +

Alerts Preview

+
+
+ Show all +
+
+
+
+
+ {{ range .Alerts }} +
+ {{ .Severity }} +
+

{{ .Title }}

+

{{ .Detail }}

+
+
+ Open +
+
+ {{ else }} +
+ ok +

No alerts

Nothing needs attention.

+
+ {{ end }} +
+
+
+
+ +
+
+
+ B +

Recent Boxes

+
+
+ Show all +
+
+
+
+ + + + + + + + + + + + + + {{ range .RecentBoxes }} + + + + + + + + + + {{ else }} + + {{ end }} + + +
+
+
+ +
+
+
+ T +

Recent Activity

+
+
+
+
+
+ {{ range .RecentActivity }} +
+ {{ .Time }} +
+

{{ .Title }}

+

{{ .Meta }}

+
+ account +
+ {{ end }} +
+
+
+
+
+
+ + +
+{{ template "account_shell_end" . }} diff --git a/templates/account_login.html b/templates/account_login.html new file mode 100644 index 0000000..81b28da --- /dev/null +++ b/templates/account_login.html @@ -0,0 +1,45 @@ + + + + + + {{ .PageTitle }} + {{ template "account_head_assets" . }} + + +
+
+
+
+
+ W +

WarpBox Account Login

+
+
+ +
+
+
+ {{ template "account_toast_modal_containers" . }} + + + diff --git a/templates/account_partials.html b/templates/account_partials.html new file mode 100644 index 0000000..a7527ea --- /dev/null +++ b/templates/account_partials.html @@ -0,0 +1,110 @@ +{{ define "account_head_assets" }} + + +{{ end }} + +{{ define "account_shell_start" }} + + + + + + {{ if .PageTitle }}{{ .PageTitle }}{{ else }}WarpBox Account{{ end }} + {{ template "account_head_assets" . }} + + +
+
+ {{ template "account_taskbar" . }} +{{ end }} + +{{ define "account_shell_end" }} +
+
+ {{ template "account_toast_modal_containers" . }} + + {{ range .PageScripts }} + + {{ end }} + + +{{ end }} + +{{ define "account_taskbar" }} +{{ $nav := .AccountNav }} +
+ + + WarpBox + + + + +
+ {{ if gt $nav.AlertCount 0 }} + ! {{ $nav.AlertCount }} alerts + {{ else }} + 0 alerts + {{ end }} + signed in: {{ $nav.Username }} + {{ if $nav.IsAdmin }} + admin + {{ else }} + account + {{ end }} + +
+
+{{ end }} + +{{ define "account_window_titlebar" }} +
+
+ {{ if .WindowIcon }}{{ .WindowIcon }}{{ else }}W{{ end }} +

{{ if .WindowTitle }}{{ .WindowTitle }}{{ else }}WarpBox Account Control Panel{{ end }}

+
+ +
+{{ end }} + +{{ define "account_csrf_field" }} + +{{ end }} + +{{ define "account_toast_modal_containers" }} +
+ + +{{ end }} diff --git a/templates/account_settings.html b/templates/account_settings.html new file mode 100644 index 0000000..556a633 --- /dev/null +++ b/templates/account_settings.html @@ -0,0 +1,134 @@ +{{ template "account_shell_start" . }} +
+ {{ template "account_window_titlebar" . }} + + + +
+ {{ template "account_csrf_field" . }} + +
+ {{ if .Error }}{{ .Error }}{{ end }} + {{ if .Notice }}{{ .Notice }}{{ end }} + {{ if .OverridesAllowed }} + overrides enabled + {{ else }} + read-only: overrides disabled + {{ end }} + Export JSON + +
+ + + +
+ {{ range .Groups }} +
+
+

{{ .Label }}

+

{{ .Description }}

+
+ + + + + + + + + + + + {{ range .Rows }} + + + + + + + + {{ else }} + + {{ end }} + + +
+ {{ end }} +
+ +
+ +
+
+ + {{ range .Groups }} + {{ range .Rows }} + {{ if .Editable }} + + {{ end }} + {{ end }} + {{ end }} + + +
+{{ template "account_shell_end" . }}