feat(models): add box activity tracking
Adds BoxActivity model to track actions taken on a box. Updates related endpoints and UI for activity feed.
This commit is contained in:
@@ -41,21 +41,28 @@ type BoxFile struct {
|
||||
}
|
||||
|
||||
type BoxManifest struct {
|
||||
Files []BoxFile `json:"files"`
|
||||
OwnerID string `json:"owner_id,omitempty"`
|
||||
OwnerUsername string `json:"owner_username,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
RetentionKey string `json:"retention_key"`
|
||||
RetentionLabel string `json:"retention_label"`
|
||||
RetentionSecs int64 `json:"retention_seconds"`
|
||||
PasswordSalt string `json:"password_salt,omitempty"`
|
||||
PasswordHash string `json:"password_hash,omitempty"`
|
||||
PasswordHashAlg string `json:"password_hash_alg,omitempty"`
|
||||
AuthToken string `json:"auth_token,omitempty"`
|
||||
DisableZip bool `json:"disable_zip,omitempty"`
|
||||
OneTimeDownload bool `json:"one_time_download,omitempty"`
|
||||
Consumed bool `json:"consumed,omitempty"`
|
||||
Files []BoxFile `json:"files"`
|
||||
OwnerID string `json:"owner_id,omitempty"`
|
||||
OwnerUsername string `json:"owner_username,omitempty"`
|
||||
Activity []BoxActivity `json:"activity,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
RetentionKey string `json:"retention_key"`
|
||||
RetentionLabel string `json:"retention_label"`
|
||||
RetentionSecs int64 `json:"retention_seconds"`
|
||||
PasswordSalt string `json:"password_salt,omitempty"`
|
||||
PasswordHash string `json:"password_hash,omitempty"`
|
||||
PasswordHashAlg string `json:"password_hash_alg,omitempty"`
|
||||
AuthToken string `json:"auth_token,omitempty"`
|
||||
DisableZip bool `json:"disable_zip,omitempty"`
|
||||
OneTimeDownload bool `json:"one_time_download,omitempty"`
|
||||
Consumed bool `json:"consumed,omitempty"`
|
||||
}
|
||||
|
||||
type BoxActivity struct {
|
||||
At time.Time `json:"at"`
|
||||
Message string `json:"message"`
|
||||
Actor string `json:"actor,omitempty"`
|
||||
}
|
||||
|
||||
type BoxSummary struct {
|
||||
|
||||
@@ -40,6 +40,14 @@ func (app *App) registerAccountRoutes(router *gin.Engine) {
|
||||
protected.POST("/boxes/bulk/delete", app.handleAccountBoxesBulkDelete)
|
||||
protected.POST("/boxes/bulk/bump-expiry", app.handleAccountBoxesBulkBumpExpiry)
|
||||
protected.POST("/boxes/delete-largest", app.handleAccountBoxesDeleteLargest)
|
||||
protected.GET("/boxes/:id", app.handleAccountBoxManager)
|
||||
protected.POST("/boxes/:id", app.handleAccountBoxUpdate)
|
||||
protected.POST("/boxes/:id/extend", app.handleAccountBoxExtend)
|
||||
protected.POST("/boxes/:id/expire", app.handleAccountBoxExpire)
|
||||
protected.POST("/boxes/:id/delete", app.handleAccountBoxDelete)
|
||||
protected.POST("/boxes/:id/password", app.handleAccountBoxPassword)
|
||||
protected.POST("/boxes/:id/password/remove", app.handleAccountBoxPasswordRemove)
|
||||
protected.POST("/boxes/:id/files/delete", app.handleAccountBoxFilesDelete)
|
||||
}
|
||||
|
||||
func (app *App) handleAccountLogin(ctx *gin.Context) {
|
||||
|
||||
515
lib/server/account_box_manager.go
Normal file
515
lib/server/account_box_manager.go
Normal file
@@ -0,0 +1,515 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
|
||||
"warpbox/lib/boxstore"
|
||||
"warpbox/lib/helpers"
|
||||
"warpbox/lib/metastore"
|
||||
"warpbox/lib/models"
|
||||
)
|
||||
|
||||
type BoxManagerView struct {
|
||||
PageTitle string
|
||||
WindowTitle string
|
||||
WindowIcon string
|
||||
AccountNav AccountNavView
|
||||
CSRFToken string
|
||||
Box BoxManagerSummary
|
||||
Files []BoxManagerFileRow
|
||||
Policy BoxActionPolicy
|
||||
PolicyJSON string
|
||||
Activity []BoxManagerActivityRow
|
||||
Error string
|
||||
}
|
||||
|
||||
type BoxManagerSummary struct {
|
||||
ID string
|
||||
Owner string
|
||||
Status string
|
||||
Storage string
|
||||
CreatedAt string
|
||||
ExpiresAt string
|
||||
Flags string
|
||||
OpenURL string
|
||||
DisableZip bool
|
||||
OneTimeDownload bool
|
||||
}
|
||||
|
||||
type BoxManagerFileRow struct {
|
||||
ID string
|
||||
Name string
|
||||
Size string
|
||||
Status string
|
||||
Download string
|
||||
}
|
||||
|
||||
type BoxManagerActivityRow struct {
|
||||
At string
|
||||
Message string
|
||||
Actor string
|
||||
}
|
||||
|
||||
type BoxActionPolicy struct {
|
||||
CanViewManager bool `json:"can_view_manager"`
|
||||
CanEditMetadata bool `json:"can_edit_metadata"`
|
||||
CanEditSharingRules bool `json:"can_edit_sharing_rules"`
|
||||
CanEditPassword bool `json:"can_edit_password"`
|
||||
CanDeleteBox bool `json:"can_delete_box"`
|
||||
CanDeleteFiles bool `json:"can_delete_files"`
|
||||
CanExtendExpiry bool `json:"can_extend_expiry"`
|
||||
MaxExtensionSeconds int64 `json:"max_extension_seconds"`
|
||||
MaxRefreshCount int `json:"max_refresh_count"`
|
||||
MaxTotalLifetimeSecs int64 `json:"max_total_lifetime_seconds"`
|
||||
Reasons map[string]string `json:"reasons,omitempty"`
|
||||
}
|
||||
|
||||
type BoxRulesInput struct {
|
||||
DisableZip bool
|
||||
OneTimeDownload bool
|
||||
}
|
||||
|
||||
type BoxPasswordInput struct {
|
||||
Password string
|
||||
}
|
||||
|
||||
func (app *App) handleAccountBoxManager(ctx *gin.Context) {
|
||||
actor, ok := currentAccountUser(ctx)
|
||||
if !ok {
|
||||
ctx.Redirect(http.StatusSeeOther, "/account/login")
|
||||
return
|
||||
}
|
||||
view, err := app.GetBoxManager(ctx, actor, ctx.Param("id"))
|
||||
if err != nil {
|
||||
ctx.String(http.StatusForbidden, err.Error())
|
||||
return
|
||||
}
|
||||
ctx.HTML(http.StatusOK, "account_box_manager.html", view)
|
||||
}
|
||||
|
||||
func (app *App) handleAccountBoxUpdate(ctx *gin.Context) {
|
||||
actor, ok := currentAccountUser(ctx)
|
||||
if !ok {
|
||||
ctx.Redirect(http.StatusSeeOther, "/account/login")
|
||||
return
|
||||
}
|
||||
input := BoxRulesInput{
|
||||
DisableZip: ctx.PostForm("disable_zip") == "true",
|
||||
OneTimeDownload: ctx.PostForm("one_time_download") == "true",
|
||||
}
|
||||
if err := app.UpdateBoxRules(ctx, actor, ctx.Param("id"), input); err != nil {
|
||||
app.renderBoxManagerError(ctx, actor, ctx.Param("id"), err)
|
||||
return
|
||||
}
|
||||
ctx.Redirect(http.StatusSeeOther, "/account/boxes/"+ctx.Param("id"))
|
||||
}
|
||||
|
||||
func (app *App) handleAccountBoxExtend(ctx *gin.Context) {
|
||||
actor, ok := currentAccountUser(ctx)
|
||||
if !ok {
|
||||
ctx.Redirect(http.StatusSeeOther, "/account/login")
|
||||
return
|
||||
}
|
||||
seconds := parsePositiveInt64Default(ctx.PostForm("extend_seconds"), app.config.BoxOwnerMaxRefreshAmountSeconds)
|
||||
if err := app.ExtendBoxExpiry(ctx, actor, ctx.Param("id"), seconds); err != nil {
|
||||
app.renderBoxManagerError(ctx, actor, ctx.Param("id"), err)
|
||||
return
|
||||
}
|
||||
ctx.Redirect(http.StatusSeeOther, "/account/boxes/"+ctx.Param("id"))
|
||||
}
|
||||
|
||||
func (app *App) handleAccountBoxExpire(ctx *gin.Context) {
|
||||
actor, ok := currentAccountUser(ctx)
|
||||
if !ok {
|
||||
ctx.Redirect(http.StatusSeeOther, "/account/login")
|
||||
return
|
||||
}
|
||||
if err := app.ExpireBoxNow(ctx, actor, ctx.Param("id")); err != nil {
|
||||
app.renderBoxManagerError(ctx, actor, ctx.Param("id"), err)
|
||||
return
|
||||
}
|
||||
ctx.Redirect(http.StatusSeeOther, "/account/boxes/"+ctx.Param("id"))
|
||||
}
|
||||
|
||||
func (app *App) handleAccountBoxDelete(ctx *gin.Context) {
|
||||
actor, ok := currentAccountUser(ctx)
|
||||
if !ok {
|
||||
ctx.Redirect(http.StatusSeeOther, "/account/login")
|
||||
return
|
||||
}
|
||||
if err := app.DeleteBox(ctx, actor, ctx.Param("id")); err != nil {
|
||||
app.renderBoxManagerError(ctx, actor, ctx.Param("id"), err)
|
||||
return
|
||||
}
|
||||
ctx.Redirect(http.StatusSeeOther, "/account/boxes")
|
||||
}
|
||||
|
||||
func (app *App) handleAccountBoxPassword(ctx *gin.Context) {
|
||||
actor, ok := currentAccountUser(ctx)
|
||||
if !ok {
|
||||
ctx.Redirect(http.StatusSeeOther, "/account/login")
|
||||
return
|
||||
}
|
||||
if err := app.SetBoxPassword(ctx, actor, ctx.Param("id"), BoxPasswordInput{Password: ctx.PostForm("password")}); err != nil {
|
||||
app.renderBoxManagerError(ctx, actor, ctx.Param("id"), err)
|
||||
return
|
||||
}
|
||||
ctx.Redirect(http.StatusSeeOther, "/account/boxes/"+ctx.Param("id"))
|
||||
}
|
||||
|
||||
func (app *App) handleAccountBoxPasswordRemove(ctx *gin.Context) {
|
||||
actor, ok := currentAccountUser(ctx)
|
||||
if !ok {
|
||||
ctx.Redirect(http.StatusSeeOther, "/account/login")
|
||||
return
|
||||
}
|
||||
if err := app.RemoveBoxPassword(ctx, actor, ctx.Param("id")); err != nil {
|
||||
app.renderBoxManagerError(ctx, actor, ctx.Param("id"), err)
|
||||
return
|
||||
}
|
||||
ctx.Redirect(http.StatusSeeOther, "/account/boxes/"+ctx.Param("id"))
|
||||
}
|
||||
|
||||
func (app *App) handleAccountBoxFilesDelete(ctx *gin.Context) {
|
||||
actor, ok := currentAccountUser(ctx)
|
||||
if !ok {
|
||||
ctx.Redirect(http.StatusSeeOther, "/account/login")
|
||||
return
|
||||
}
|
||||
if err := app.DeleteBoxFiles(ctx, actor, ctx.Param("id"), ctx.PostFormArray("file_ids")); err != nil {
|
||||
app.renderBoxManagerError(ctx, actor, ctx.Param("id"), err)
|
||||
return
|
||||
}
|
||||
ctx.Redirect(http.StatusSeeOther, "/account/boxes/"+ctx.Param("id"))
|
||||
}
|
||||
|
||||
func (app *App) GetBoxManager(ctx *gin.Context, actor metastore.User, boxID string) (BoxManagerView, error) {
|
||||
record, manifest, err := app.loadBoxForManager(boxID)
|
||||
if err != nil {
|
||||
return BoxManagerView{}, err
|
||||
}
|
||||
policy := app.resolveBoxPolicy(ctx, actor, record, manifest)
|
||||
if !policy.CanViewManager {
|
||||
return BoxManagerView{}, fmt.Errorf(policyReason(policy, "view", "permission denied"))
|
||||
}
|
||||
files := make([]BoxManagerFileRow, 0, len(manifest.Files))
|
||||
for _, file := range boxstore.DecorateFiles(boxID, manifest.Files) {
|
||||
files = append(files, BoxManagerFileRow{ID: file.ID, Name: file.Name, Size: file.SizeLabel, Status: file.StatusLabel, Download: file.DownloadPath})
|
||||
}
|
||||
policyJSON, _ := json.MarshalIndent(policy, "", " ")
|
||||
nav := app.accountNavView(ctx, "boxes")
|
||||
nav.AlertCount, nav.AlertSeverity = app.openAlertSummary()
|
||||
return BoxManagerView{
|
||||
PageTitle: "WarpBox Box Manager",
|
||||
WindowTitle: "WarpBox Box Manager",
|
||||
WindowIcon: "B",
|
||||
AccountNav: nav,
|
||||
CSRFToken: app.currentCSRFToken(ctx),
|
||||
Box: BoxManagerSummary{
|
||||
ID: record.ID,
|
||||
Owner: boxOwnerLabel(record),
|
||||
Status: boxStatus(record),
|
||||
Storage: helpers.FormatBytes(record.TotalSize),
|
||||
CreatedAt: formatAdminTime(record.CreatedAt),
|
||||
ExpiresAt: formatAdminTime(record.ExpiresAt),
|
||||
Flags: boxFlags(record),
|
||||
OpenURL: "/box/" + record.ID,
|
||||
DisableZip: record.DisableZip,
|
||||
OneTimeDownload: record.OneTimeDownload,
|
||||
},
|
||||
Files: files,
|
||||
Policy: policy,
|
||||
PolicyJSON: string(policyJSON),
|
||||
Activity: boxActivityRows(manifest.Activity),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (app *App) UpdateBoxRules(ctx *gin.Context, actor metastore.User, boxID string, input BoxRulesInput) error {
|
||||
record, manifest, policy, err := app.boxMutationContext(ctx, actor, boxID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !policy.CanEditSharingRules {
|
||||
return fmt.Errorf(policyReason(policy, "sharing", "sharing edits disabled"))
|
||||
}
|
||||
manifest.DisableZip = input.DisableZip
|
||||
manifest.OneTimeDownload = input.OneTimeDownload
|
||||
appendBoxActivity(&manifest, actor.Username, "sharing rules updated")
|
||||
return app.saveManagedBox(record, manifest)
|
||||
}
|
||||
|
||||
func (app *App) ExtendBoxExpiry(ctx *gin.Context, actor metastore.User, boxID string, amount int64) error {
|
||||
record, manifest, policy, err := app.boxMutationContext(ctx, actor, boxID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !policy.CanExtendExpiry {
|
||||
return fmt.Errorf(policyReason(policy, "extend", "expiry refresh disabled"))
|
||||
}
|
||||
if amount <= 0 {
|
||||
return fmt.Errorf("extension amount must be positive")
|
||||
}
|
||||
if policy.MaxExtensionSeconds > 0 && amount > policy.MaxExtensionSeconds {
|
||||
return fmt.Errorf("extension exceeds maximum single extension")
|
||||
}
|
||||
if policy.MaxRefreshCount > 0 && record.RefreshCount >= policy.MaxRefreshCount {
|
||||
return fmt.Errorf("refresh count limit reached")
|
||||
}
|
||||
base := manifest.ExpiresAt
|
||||
if base.IsZero() || time.Now().UTC().After(base) {
|
||||
base = time.Now().UTC()
|
||||
}
|
||||
next := base.Add(time.Duration(amount) * time.Second)
|
||||
if policy.MaxTotalLifetimeSecs > 0 && next.After(manifest.CreatedAt.Add(time.Duration(policy.MaxTotalLifetimeSecs)*time.Second)) {
|
||||
return fmt.Errorf("extension exceeds maximum total lifetime")
|
||||
}
|
||||
manifest.ExpiresAt = next
|
||||
record.RefreshCount++
|
||||
appendBoxActivity(&manifest, actor.Username, "expiry extended")
|
||||
return app.saveManagedBox(record, manifest)
|
||||
}
|
||||
|
||||
func (app *App) ExpireBoxNow(ctx *gin.Context, actor metastore.User, boxID string) error {
|
||||
record, manifest, policy, err := app.boxMutationContext(ctx, actor, boxID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !policy.CanEditMetadata {
|
||||
return fmt.Errorf(policyReason(policy, "edit", "edit disabled"))
|
||||
}
|
||||
manifest.ExpiresAt = time.Now().UTC().Add(-time.Second)
|
||||
appendBoxActivity(&manifest, actor.Username, "box expired")
|
||||
return app.saveManagedBox(record, manifest)
|
||||
}
|
||||
|
||||
func (app *App) DeleteBox(ctx *gin.Context, actor metastore.User, boxID string) error {
|
||||
record, manifest, policy, err := app.boxMutationContext(ctx, actor, boxID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_ = manifest
|
||||
if !policy.CanDeleteBox {
|
||||
return fmt.Errorf(policyReason(policy, "delete", "delete disabled"))
|
||||
}
|
||||
if err := boxstore.DeleteBox(record.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
return app.store.DeleteBoxRecord(record.ID)
|
||||
}
|
||||
|
||||
func (app *App) SetBoxPassword(ctx *gin.Context, actor metastore.User, boxID string, input BoxPasswordInput) error {
|
||||
record, manifest, policy, err := app.boxMutationContext(ctx, actor, boxID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !policy.CanEditPassword {
|
||||
return fmt.Errorf(policyReason(policy, "password", "password edits disabled"))
|
||||
}
|
||||
password := strings.TrimSpace(input.Password)
|
||||
if password == "" {
|
||||
return fmt.Errorf("password cannot be empty")
|
||||
}
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
token, err := helpers.RandomHexID(16)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
manifest.PasswordHash = string(hash)
|
||||
manifest.PasswordHashAlg = "bcrypt"
|
||||
manifest.AuthToken = token
|
||||
appendBoxActivity(&manifest, actor.Username, "password set")
|
||||
return app.saveManagedBox(record, manifest)
|
||||
}
|
||||
|
||||
func (app *App) RemoveBoxPassword(ctx *gin.Context, actor metastore.User, boxID string) error {
|
||||
record, manifest, policy, err := app.boxMutationContext(ctx, actor, boxID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !policy.CanEditPassword {
|
||||
return fmt.Errorf(policyReason(policy, "password", "password edits disabled"))
|
||||
}
|
||||
manifest.PasswordHash = ""
|
||||
manifest.PasswordHashAlg = ""
|
||||
manifest.PasswordSalt = ""
|
||||
manifest.AuthToken = ""
|
||||
appendBoxActivity(&manifest, actor.Username, "password removed")
|
||||
return app.saveManagedBox(record, manifest)
|
||||
}
|
||||
|
||||
func (app *App) DeleteBoxFiles(ctx *gin.Context, actor metastore.User, boxID string, fileIDs []string) error {
|
||||
record, manifest, policy, err := app.boxMutationContext(ctx, actor, boxID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !policy.CanDeleteFiles {
|
||||
return fmt.Errorf(policyReason(policy, "files", "file deletion disabled"))
|
||||
}
|
||||
fileIDs = uniqueNonEmpty(fileIDs)
|
||||
if len(fileIDs) == 0 {
|
||||
return fmt.Errorf("no files selected")
|
||||
}
|
||||
remove := map[string]bool{}
|
||||
for _, id := range fileIDs {
|
||||
remove[id] = true
|
||||
}
|
||||
kept := make([]models.BoxFile, 0, len(manifest.Files))
|
||||
for _, file := range manifest.Files {
|
||||
if remove[file.ID] {
|
||||
if path, ok := boxstore.SafeBoxFilePath(boxID, file.Name); ok {
|
||||
_ = os.Remove(path)
|
||||
}
|
||||
continue
|
||||
}
|
||||
kept = append(kept, file)
|
||||
}
|
||||
manifest.Files = kept
|
||||
appendBoxActivity(&manifest, actor.Username, "files deleted")
|
||||
return app.saveManagedBox(record, manifest)
|
||||
}
|
||||
|
||||
func (app *App) renderBoxManagerError(ctx *gin.Context, actor metastore.User, boxID string, actionErr error) {
|
||||
view, err := app.GetBoxManager(ctx, actor, boxID)
|
||||
if err != nil {
|
||||
ctx.String(http.StatusForbidden, actionErr.Error())
|
||||
return
|
||||
}
|
||||
view.Error = actionErr.Error()
|
||||
ctx.HTML(http.StatusOK, "account_box_manager.html", view)
|
||||
}
|
||||
|
||||
func (app *App) boxMutationContext(ctx *gin.Context, actor metastore.User, boxID string) (metastore.BoxRecord, models.BoxManifest, BoxActionPolicy, error) {
|
||||
record, manifest, err := app.loadBoxForManager(boxID)
|
||||
if err != nil {
|
||||
return record, manifest, BoxActionPolicy{}, err
|
||||
}
|
||||
policy := app.resolveBoxPolicy(ctx, actor, record, manifest)
|
||||
if !policy.CanViewManager {
|
||||
return record, manifest, policy, fmt.Errorf(policyReason(policy, "view", "permission denied"))
|
||||
}
|
||||
return record, manifest, policy, nil
|
||||
}
|
||||
|
||||
func (app *App) loadBoxForManager(boxID string) (metastore.BoxRecord, models.BoxManifest, error) {
|
||||
if !boxstore.ValidBoxID(boxID) {
|
||||
return metastore.BoxRecord{}, models.BoxManifest{}, fmt.Errorf("invalid box id")
|
||||
}
|
||||
record, ok, err := app.store.GetBoxRecord(boxID)
|
||||
if err != nil {
|
||||
return record, models.BoxManifest{}, err
|
||||
}
|
||||
if !ok {
|
||||
return record, models.BoxManifest{}, fmt.Errorf("box not found")
|
||||
}
|
||||
manifest, err := boxstore.ReadManifest(boxID)
|
||||
if err != nil {
|
||||
return record, manifest, err
|
||||
}
|
||||
return record, manifest, nil
|
||||
}
|
||||
|
||||
func (app *App) resolveBoxPolicy(ctx *gin.Context, actor metastore.User, record metastore.BoxRecord, manifest models.BoxManifest) BoxActionPolicy {
|
||||
perms := currentAccountPermissions(ctx)
|
||||
isAdmin := perms.AdminBoxesView
|
||||
isOwner := record.OwnerID != "" && record.OwnerID == actor.ID
|
||||
policy := BoxActionPolicy{
|
||||
MaxExtensionSeconds: app.config.BoxOwnerMaxRefreshAmountSeconds,
|
||||
MaxRefreshCount: app.config.BoxOwnerMaxRefreshCount,
|
||||
MaxTotalLifetimeSecs: app.config.BoxOwnerMaxTotalExpirySeconds,
|
||||
Reasons: map[string]string{},
|
||||
}
|
||||
if isAdmin {
|
||||
policy.CanViewManager = true
|
||||
policy.CanEditMetadata = true
|
||||
policy.CanEditSharingRules = true
|
||||
policy.CanEditPassword = true
|
||||
policy.CanDeleteBox = true
|
||||
policy.CanDeleteFiles = true
|
||||
policy.CanExtendExpiry = !manifest.OneTimeDownload
|
||||
return policy
|
||||
}
|
||||
if !isOwner {
|
||||
policy.Reasons["view"] = "not box owner"
|
||||
return policy
|
||||
}
|
||||
if !app.config.BoxOwnerEditEnabled {
|
||||
policy.Reasons["view"] = "box owner editing disabled"
|
||||
return policy
|
||||
}
|
||||
policy.CanViewManager = true
|
||||
policy.CanEditMetadata = true
|
||||
policy.CanEditSharingRules = true
|
||||
policy.CanDeleteBox = true
|
||||
policy.CanDeleteFiles = true
|
||||
if app.config.BoxOwnerPasswordEditEnabled {
|
||||
policy.CanEditPassword = true
|
||||
} else {
|
||||
policy.Reasons["password"] = "password editing disabled by policy"
|
||||
}
|
||||
if !app.config.BoxOwnerRefreshEnabled {
|
||||
policy.Reasons["extend"] = "refresh disabled by policy"
|
||||
} else if manifest.OneTimeDownload {
|
||||
policy.Reasons["extend"] = "one-time boxes cannot be refreshed"
|
||||
} else if app.config.BoxOwnerMaxRefreshCount > 0 && record.RefreshCount >= app.config.BoxOwnerMaxRefreshCount {
|
||||
policy.Reasons["extend"] = "refresh count limit reached"
|
||||
} else {
|
||||
policy.CanExtendExpiry = true
|
||||
}
|
||||
return policy
|
||||
}
|
||||
|
||||
func (app *App) saveManagedBox(record metastore.BoxRecord, manifest models.BoxManifest) error {
|
||||
if err := boxstore.WriteManifest(record.ID, manifest); err != nil {
|
||||
return err
|
||||
}
|
||||
next := boxRecordFromManifest(record.ID, manifest)
|
||||
next.RefreshCount = record.RefreshCount
|
||||
return app.store.UpsertBoxRecord(next)
|
||||
}
|
||||
|
||||
func appendBoxActivity(manifest *models.BoxManifest, actor string, message string) {
|
||||
manifest.Activity = append([]models.BoxActivity{{
|
||||
At: time.Now().UTC(),
|
||||
Actor: actor,
|
||||
Message: message,
|
||||
}}, manifest.Activity...)
|
||||
if len(manifest.Activity) > 12 {
|
||||
manifest.Activity = manifest.Activity[:12]
|
||||
}
|
||||
}
|
||||
|
||||
func boxActivityRows(activity []models.BoxActivity) []BoxManagerActivityRow {
|
||||
rows := make([]BoxManagerActivityRow, 0, len(activity))
|
||||
for _, item := range activity {
|
||||
rows = append(rows, BoxManagerActivityRow{At: formatAdminTime(item.At), Message: item.Message, Actor: item.Actor})
|
||||
}
|
||||
if len(rows) == 0 {
|
||||
rows = append(rows, BoxManagerActivityRow{At: "-", Message: "No box activity yet.", Actor: "system"})
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
func policyReason(policy BoxActionPolicy, key string, fallback string) string {
|
||||
if policy.Reasons != nil && policy.Reasons[key] != "" {
|
||||
return policy.Reasons[key]
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
func boxOwnerLabel(record metastore.BoxRecord) string {
|
||||
if record.OwnerUsername != "" {
|
||||
return record.OwnerUsername
|
||||
}
|
||||
return "guest"
|
||||
}
|
||||
219
lib/server/account_box_manager_test.go
Normal file
219
lib/server/account_box_manager_test.go
Normal file
@@ -0,0 +1,219 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"warpbox/lib/boxstore"
|
||||
"warpbox/lib/metastore"
|
||||
)
|
||||
|
||||
func TestAccountBoxManagerAdminCanViewAndEdit(t *testing.T) {
|
||||
app, admin := setupAccountTestApp(t)
|
||||
router := setupAccountTestRouter(t, app)
|
||||
session := createAccountTestSession(t, app, admin)
|
||||
id := "abababababababababababababababab"
|
||||
createIndexedBox(t, app, id, "", "", 10, false)
|
||||
|
||||
response := getAccountBoxManager(router, session, id)
|
||||
if response.Code != http.StatusOK {
|
||||
t.Fatalf("expected manager page, got %d body=%s", response.Code, response.Body.String())
|
||||
}
|
||||
if !strings.Contains(response.Body.String(), "WarpBox Box Manager") {
|
||||
t.Fatal("expected manager UI")
|
||||
}
|
||||
|
||||
form := url.Values{"disable_zip": []string{"true"}}
|
||||
response = postAccountBoxForm(router, session, "/account/boxes/"+id, form)
|
||||
if response.Code != http.StatusSeeOther {
|
||||
t.Fatalf("expected update redirect, got %d", response.Code)
|
||||
}
|
||||
manifest, err := boxstore.ReadManifest(id)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadManifest returned error: %v", err)
|
||||
}
|
||||
if !manifest.DisableZip {
|
||||
t.Fatal("expected sharing rule update")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccountBoxManagerOwnerViewAllowedAndDeniedByPolicy(t *testing.T) {
|
||||
app, _ := setupAccountTestApp(t)
|
||||
user, err := app.store.CreateUserWithPassword("owner-view", "owner-view@example.test", "secret", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateUserWithPassword returned error: %v", err)
|
||||
}
|
||||
router := setupAccountTestRouter(t, app)
|
||||
session := createAccountTestSession(t, app, user)
|
||||
id := "bcbcbcbcbcbcbcbcbcbcbcbcbcbcbcbc"
|
||||
createIndexedBox(t, app, id, user.ID, user.Username, 10, false)
|
||||
|
||||
response := getAccountBoxManager(router, session, id)
|
||||
if response.Code != http.StatusOK {
|
||||
t.Fatalf("expected owner manager page, got %d", response.Code)
|
||||
}
|
||||
|
||||
app.config.BoxOwnerEditEnabled = false
|
||||
response = getAccountBoxManager(router, session, id)
|
||||
if response.Code != http.StatusForbidden {
|
||||
t.Fatalf("expected owner denied by policy, got %d", response.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccountBoxManagerOwnerRefreshLimits(t *testing.T) {
|
||||
app, _ := setupAccountTestApp(t)
|
||||
app.config.BoxOwnerMaxRefreshCount = 1
|
||||
app.config.BoxOwnerMaxRefreshAmountSeconds = 60
|
||||
app.config.BoxOwnerMaxTotalExpirySeconds = 7200
|
||||
user, err := app.store.CreateUserWithPassword("owner-refresh", "owner-refresh@example.test", "secret", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateUserWithPassword returned error: %v", err)
|
||||
}
|
||||
router := setupAccountTestRouter(t, app)
|
||||
session := createAccountTestSession(t, app, user)
|
||||
id := "cdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcd"
|
||||
createIndexedBox(t, app, id, user.ID, user.Username, 10, false)
|
||||
|
||||
response := postAccountBoxForm(router, session, "/account/boxes/"+id+"/extend", url.Values{"extend_seconds": []string{"60"}})
|
||||
if response.Code != http.StatusSeeOther {
|
||||
t.Fatalf("expected owner refresh success, got %d body=%s", response.Code, response.Body.String())
|
||||
}
|
||||
record, ok, err := app.store.GetBoxRecord(id)
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("GetBoxRecord returned ok=%v err=%v", ok, err)
|
||||
}
|
||||
if record.RefreshCount != 1 {
|
||||
t.Fatalf("expected refresh count 1, got %d", record.RefreshCount)
|
||||
}
|
||||
|
||||
response = postAccountBoxForm(router, session, "/account/boxes/"+id+"/extend", url.Values{"extend_seconds": []string{"60"}})
|
||||
if response.Code != http.StatusOK {
|
||||
t.Fatalf("expected refresh count rejection render, got %d", response.Code)
|
||||
}
|
||||
if !strings.Contains(response.Body.String(), "refresh count") {
|
||||
t.Fatal("expected refresh count error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccountBoxManagerOwnerRefreshRejectedOverMaxDuration(t *testing.T) {
|
||||
app, _ := setupAccountTestApp(t)
|
||||
app.config.BoxOwnerMaxRefreshAmountSeconds = 60
|
||||
user, err := app.store.CreateUserWithPassword("owner-duration", "owner-duration@example.test", "secret", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateUserWithPassword returned error: %v", err)
|
||||
}
|
||||
router := setupAccountTestRouter(t, app)
|
||||
session := createAccountTestSession(t, app, user)
|
||||
id := "dededededededededededededededede"
|
||||
createIndexedBox(t, app, id, user.ID, user.Username, 10, false)
|
||||
|
||||
response := postAccountBoxForm(router, session, "/account/boxes/"+id+"/extend", url.Values{"extend_seconds": []string{"120"}})
|
||||
if response.Code != http.StatusOK {
|
||||
t.Fatalf("expected max duration rejection render, got %d", response.Code)
|
||||
}
|
||||
if !strings.Contains(response.Body.String(), "maximum single extension") {
|
||||
t.Fatal("expected max duration error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccountBoxManagerPasswordSetRemovePermissions(t *testing.T) {
|
||||
app, _ := setupAccountTestApp(t)
|
||||
user, err := app.store.CreateUserWithPassword("owner-pass", "owner-pass@example.test", "secret", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateUserWithPassword returned error: %v", err)
|
||||
}
|
||||
router := setupAccountTestRouter(t, app)
|
||||
session := createAccountTestSession(t, app, user)
|
||||
id := "efefefefefefefefefefefefefefefef"
|
||||
createIndexedBox(t, app, id, user.ID, user.Username, 10, false)
|
||||
|
||||
response := postAccountBoxForm(router, session, "/account/boxes/"+id+"/password", url.Values{"password": []string{"new-secret"}})
|
||||
if response.Code != http.StatusSeeOther {
|
||||
t.Fatalf("expected password set redirect, got %d", response.Code)
|
||||
}
|
||||
manifest, err := boxstore.ReadManifest(id)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadManifest returned error: %v", err)
|
||||
}
|
||||
if manifest.PasswordHash == "" || manifest.AuthToken == "" {
|
||||
t.Fatal("expected password set")
|
||||
}
|
||||
|
||||
app.config.BoxOwnerPasswordEditEnabled = false
|
||||
response = postAccountBoxForm(router, session, "/account/boxes/"+id+"/password/remove", nil)
|
||||
if response.Code != http.StatusOK {
|
||||
t.Fatalf("expected password permission render, got %d", response.Code)
|
||||
}
|
||||
if !strings.Contains(response.Body.String(), "password editing disabled") {
|
||||
t.Fatal("expected password permission error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccountBoxManagerFileDeleteAndBoxDeletePermissions(t *testing.T) {
|
||||
app, _ := setupAccountTestApp(t)
|
||||
user, err := app.store.CreateUserWithPassword("owner-delete", "owner-delete@example.test", "secret", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateUserWithPassword returned error: %v", err)
|
||||
}
|
||||
router := setupAccountTestRouter(t, app)
|
||||
session := createAccountTestSession(t, app, user)
|
||||
id := "fafafafafafafafafafafafafafafafa"
|
||||
createIndexedBox(t, app, id, user.ID, user.Username, 10, false)
|
||||
manifest, err := boxstore.ReadManifest(id)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadManifest returned error: %v", err)
|
||||
}
|
||||
fileID := manifest.Files[0].ID
|
||||
|
||||
response := postAccountBoxForm(router, session, "/account/boxes/"+id+"/files/delete", url.Values{"file_ids": []string{fileID}})
|
||||
if response.Code != http.StatusSeeOther {
|
||||
t.Fatalf("expected file delete redirect, got %d", response.Code)
|
||||
}
|
||||
manifest, err = boxstore.ReadManifest(id)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadManifest returned error: %v", err)
|
||||
}
|
||||
if len(manifest.Files) != 0 {
|
||||
t.Fatalf("expected file removed, got %#v", manifest.Files)
|
||||
}
|
||||
|
||||
app.config.BoxOwnerEditEnabled = false
|
||||
response = postAccountBoxForm(router, session, "/account/boxes/"+id+"/delete", nil)
|
||||
if response.Code != http.StatusForbidden {
|
||||
t.Fatalf("expected delete permission denied after policy disabled, got %d", response.Code)
|
||||
}
|
||||
|
||||
app.config.BoxOwnerEditEnabled = true
|
||||
response = postAccountBoxForm(router, session, "/account/boxes/"+id+"/delete", nil)
|
||||
if response.Code != http.StatusSeeOther {
|
||||
t.Fatalf("expected box delete redirect, got %d", response.Code)
|
||||
}
|
||||
if _, err := os.Stat(boxstore.BoxPath(id)); !os.IsNotExist(err) {
|
||||
t.Fatalf("expected box directory deleted, stat err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func getAccountBoxManager(router http.Handler, session metastore.Session, id string) *httptest.ResponseRecorder {
|
||||
request := httptest.NewRequest(http.MethodGet, "/account/boxes/"+id, nil)
|
||||
request.AddCookie(&http.Cookie{Name: accountSessionCookie, Value: session.Token})
|
||||
response := httptest.NewRecorder()
|
||||
router.ServeHTTP(response, request)
|
||||
return response
|
||||
}
|
||||
|
||||
func postAccountBoxForm(router http.Handler, session metastore.Session, path string, values url.Values) *httptest.ResponseRecorder {
|
||||
if values == nil {
|
||||
values = url.Values{}
|
||||
}
|
||||
values.Set("csrf_token", session.CSRFToken)
|
||||
request := httptest.NewRequest(http.MethodPost, path, strings.NewReader(values.Encode()))
|
||||
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
request.AddCookie(&http.Cookie{Name: accountSessionCookie, Value: session.Token})
|
||||
response := httptest.NewRecorder()
|
||||
router.ServeHTTP(response, request)
|
||||
return response
|
||||
}
|
||||
@@ -1040,6 +1040,53 @@ textarea:disabled {
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.box-manager-layout {
|
||||
display: grid;
|
||||
grid-template-rows: auto auto minmax(0, 1fr);
|
||||
gap: 10px;
|
||||
min-height: min(940px, calc(100vh - 96px));
|
||||
}
|
||||
|
||||
.box-manager-grid {
|
||||
min-height: 0;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.35fr) minmax(300px, .65fr);
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.box-manager-main,
|
||||
.box-manager-side {
|
||||
min-height: 0;
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
align-content: start;
|
||||
}
|
||||
|
||||
.files-scroll {
|
||||
height: 430px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.policy-pre {
|
||||
max-height: 220px;
|
||||
min-height: 160px;
|
||||
}
|
||||
|
||||
.activity-list-compact {
|
||||
display: grid;
|
||||
}
|
||||
|
||||
.activity-list-compact .activity-row {
|
||||
grid-template-columns: 132px minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.activity-list-compact .activity-title,
|
||||
.activity-list-compact .activity-meta {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.scroll-panel {
|
||||
overflow: auto;
|
||||
color: #000000;
|
||||
@@ -1321,6 +1368,10 @@ textarea:disabled {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.box-manager-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.alerts-filterbar {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
@@ -1460,6 +1511,10 @@ textarea:disabled {
|
||||
height: 420px;
|
||||
}
|
||||
|
||||
.files-scroll {
|
||||
height: 360px;
|
||||
}
|
||||
|
||||
.win98-window-controls {
|
||||
display: none;
|
||||
}
|
||||
|
||||
151
templates/account_box_manager.html
Normal file
151
templates/account_box_manager.html
Normal file
@@ -0,0 +1,151 @@
|
||||
{{ template "account_shell_start" . }}
|
||||
<main class="account-window" aria-labelledby="box-manager-title">
|
||||
{{ template "account_window_titlebar" . }}
|
||||
|
||||
<nav class="menu-bar" aria-label="Box manager toolbar">
|
||||
<div class="menu-item">
|
||||
<button class="menu-button" type="button" aria-expanded="false">File</button>
|
||||
<div class="menu-popup" role="menu">
|
||||
<a class="menu-action" href="/account/boxes"><span>B</span><span>Back to boxes</span><span></span></a>
|
||||
<a class="menu-action" href="{{ .Box.OpenURL }}"><span>O</span><span>Open shared box</span><span></span></a>
|
||||
<div class="menu-separator"></div>
|
||||
<form action="/account/logout" method="post">
|
||||
{{ template "account_csrf_field" . }}
|
||||
<button class="menu-action" type="submit"><span>Q</span><span>Log out</span><span></span></button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div class="box-manager-layout account-body-content">
|
||||
{{ if .Error }}<p class="account-error">{{ .Error }}</p>{{ end }}
|
||||
|
||||
<section class="stats-grid" aria-label="Box summary">
|
||||
<article class="stat-card sunken-panel is-info">
|
||||
<p class="stat-label">Status</p>
|
||||
<p class="stat-value">{{ .Box.Status }}</p>
|
||||
<p class="stat-note"><span class="stat-note-pill">{{ .Box.Flags }}</span></p>
|
||||
</article>
|
||||
<article class="stat-card sunken-panel is-info">
|
||||
<p class="stat-label">Storage</p>
|
||||
<p class="stat-value">{{ .Box.Storage }}</p>
|
||||
<p class="stat-note"><span class="stat-note-pill">{{ len .Files }} files</span></p>
|
||||
</article>
|
||||
<article class="stat-card sunken-panel is-warning">
|
||||
<p class="stat-label">Expiration</p>
|
||||
<p class="stat-note"><span class="stat-note-pill">{{ .Box.ExpiresAt }}</span></p>
|
||||
</article>
|
||||
<article class="stat-card sunken-panel is-ok">
|
||||
<p class="stat-label">Owner policy</p>
|
||||
<p class="stat-note"><span class="stat-note-pill">{{ if .Policy.CanEditMetadata }}editable{{ else }}locked{{ end }}</span><span class="stat-note-pill">{{ if .Policy.CanExtendExpiry }}refreshable{{ else }}no refresh{{ end }}</span></p>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section class="box-manager-grid">
|
||||
<div class="box-manager-main">
|
||||
<section class="win98-window section-window">
|
||||
<div class="win98-titlebar"><div class="win98-titlebar-label"><span class="win98-titlebar-icon">I</span><h2 id="box-manager-title">Identity</h2></div></div>
|
||||
<div class="section-body sunken-panel">
|
||||
<p><strong>Box:</strong> {{ .Box.ID }}</p>
|
||||
<p><strong>Owner:</strong> {{ .Box.Owner }}</p>
|
||||
<p><strong>Created:</strong> {{ .Box.CreatedAt }}</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="win98-window section-window">
|
||||
<div class="win98-titlebar"><div class="win98-titlebar-label"><span class="win98-titlebar-icon">S</span><h2>Sharing Rules</h2></div></div>
|
||||
<form class="section-body sunken-panel account-form" action="/account/boxes/{{ .Box.ID }}" method="post">
|
||||
{{ template "account_csrf_field" . }}
|
||||
<label><input type="checkbox" name="disable_zip" value="true" {{ if .Box.DisableZip }}checked{{ end }} {{ if not .Policy.CanEditSharingRules }}disabled{{ end }}> Disable ZIP downloads</label>
|
||||
<label><input type="checkbox" name="one_time_download" value="true" {{ if .Box.OneTimeDownload }}checked{{ end }} {{ if not .Policy.CanEditSharingRules }}disabled{{ end }}> One-time download</label>
|
||||
<button class="win98-button" type="submit" {{ if not .Policy.CanEditSharingRules }}disabled{{ end }}>Save Rules</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="win98-window section-window">
|
||||
<div class="win98-titlebar"><div class="win98-titlebar-label"><span class="win98-titlebar-icon">F</span><h2>Files</h2></div></div>
|
||||
<form class="section-body sunken-panel" action="/account/boxes/{{ .Box.ID }}/files/delete" method="post">
|
||||
{{ template "account_csrf_field" . }}
|
||||
<div class="scroll-panel files-scroll">
|
||||
<table class="account-table boxes-table">
|
||||
<thead><tr><th>Select</th><th>Name</th><th>Size</th><th>Status</th><th>Download</th></tr></thead>
|
||||
<tbody>
|
||||
{{ range .Files }}
|
||||
<tr>
|
||||
<td><input type="checkbox" name="file_ids" value="{{ .ID }}"></td>
|
||||
<td>{{ .Name }}</td>
|
||||
<td>{{ .Size }}</td>
|
||||
<td>{{ .Status }}</td>
|
||||
<td><a class="tiny-button" href="{{ .Download }}">Open</a></td>
|
||||
</tr>
|
||||
{{ else }}
|
||||
<tr><td colspan="5">No files.</td></tr>
|
||||
{{ end }}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="bulk-actions raised-panel">
|
||||
<button class="win98-button" type="submit" data-confirm="Delete selected files permanently?" {{ if not .Policy.CanDeleteFiles }}disabled{{ end }}>Delete Files</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<aside class="box-manager-side">
|
||||
<section class="sunken-panel section-body">
|
||||
<h2>Expiration</h2>
|
||||
<form class="account-form" action="/account/boxes/{{ .Box.ID }}/extend" method="post">
|
||||
{{ template "account_csrf_field" . }}
|
||||
<label class="account-form-row"><span>Extend seconds</span><input class="account-control" name="extend_seconds" value="{{ .Policy.MaxExtensionSeconds }}" inputmode="numeric"></label>
|
||||
<button class="win98-button" type="submit" {{ if not .Policy.CanExtendExpiry }}disabled{{ end }}>Extend</button>
|
||||
</form>
|
||||
<form action="/account/boxes/{{ .Box.ID }}/expire" method="post">
|
||||
{{ template "account_csrf_field" . }}
|
||||
<button class="win98-button" type="submit" data-confirm="Expire this box now?" {{ if not .Policy.CanEditMetadata }}disabled{{ end }}>Expire Now</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="sunken-panel section-body">
|
||||
<h2>Password</h2>
|
||||
<form class="account-form" action="/account/boxes/{{ .Box.ID }}/password" method="post">
|
||||
{{ template "account_csrf_field" . }}
|
||||
<label class="account-form-row"><span>New password</span><input class="account-control" name="password" type="password" autocomplete="new-password"></label>
|
||||
<button class="win98-button" type="submit" {{ if not .Policy.CanEditPassword }}disabled{{ end }}>Set Password</button>
|
||||
</form>
|
||||
<form action="/account/boxes/{{ .Box.ID }}/password/remove" method="post">
|
||||
{{ template "account_csrf_field" . }}
|
||||
<button class="win98-button" type="submit" data-confirm="Remove box password?" {{ if not .Policy.CanEditPassword }}disabled{{ end }}>Remove Password</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="sunken-panel section-body">
|
||||
<h2>Resolved Policy</h2>
|
||||
<pre class="metadata-pre policy-pre">{{ .PolicyJSON }}</pre>
|
||||
</section>
|
||||
|
||||
<section class="sunken-panel section-body">
|
||||
<h2>Box Activity</h2>
|
||||
<div class="activity-list-compact">
|
||||
{{ range .Activity }}
|
||||
<div class="activity-row"><span class="activity-time">{{ .At }}</span><div><p class="activity-title">{{ .Message }}</p><p class="activity-meta">{{ .Actor }}</p></div></div>
|
||||
{{ end }}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="sunken-panel section-body">
|
||||
<form action="/account/boxes/{{ .Box.ID }}/delete" method="post">
|
||||
{{ template "account_csrf_field" . }}
|
||||
<button class="win98-button" type="submit" data-confirm="Delete this box permanently?" {{ if not .Policy.CanDeleteBox }}disabled{{ end }}>Delete Box</button>
|
||||
</form>
|
||||
</section>
|
||||
</aside>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<footer class="win98-statusbar" aria-label="Box manager status">
|
||||
<span>{{ .Box.ID }}</span>
|
||||
<span>{{ .Box.Status }}</span>
|
||||
<span>ready</span>
|
||||
</footer>
|
||||
</main>
|
||||
{{ template "account_shell_end" . }}
|
||||
Reference in New Issue
Block a user