feat(accounts): implement user accounts, sessions, and dashboards
All checks were successful
Build and Publish Docker Image / deploy (push) Successful in 1m8s
All checks were successful
Build and Publish Docker Image / deploy (push) Successful in 1m8s
Introduce Stage 4 features to support multi-user accounts, cookie-based web sessions, and personal dashboards. Changes include: - Adding `/register` to bootstrap the first admin account and `/login`/`/logout` for session management. - Creating a personal dashboard (`/app`) to display owned boxes, storage usage, and upload history. - Implementing admin user management (`/admin/users`) for generating invite links and managing user states. - Updating the bbolt database schema to store users, sessions, invites, and collections. - Adding `golang.org/x/crypto` for password hashing and introducing unit tests for account handlers.
This commit is contained in:
179
backend/libs/handlers/dashboard.go
Normal file
179
backend/libs/handlers/dashboard.go
Normal file
@@ -0,0 +1,179 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
|
||||
"warpbox.dev/backend/libs/helpers"
|
||||
"warpbox.dev/backend/libs/services"
|
||||
"warpbox.dev/backend/libs/web"
|
||||
)
|
||||
|
||||
type dashboardData struct {
|
||||
User services.PublicUser
|
||||
Collections []collectionView
|
||||
Boxes []userBoxView
|
||||
StorageUsed string
|
||||
MaxUploadSize string
|
||||
Selected string
|
||||
LastInviteURL string
|
||||
}
|
||||
|
||||
type collectionView struct {
|
||||
ID string
|
||||
Name string
|
||||
}
|
||||
|
||||
type userBoxView struct {
|
||||
ID string
|
||||
Title string
|
||||
CollectionID string
|
||||
CollectionName string
|
||||
FileCount int
|
||||
Size string
|
||||
CreatedAt string
|
||||
ExpiresAt string
|
||||
URL string
|
||||
}
|
||||
|
||||
func (a *App) Dashboard(w http.ResponseWriter, r *http.Request) {
|
||||
user, ok := a.requireUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
collections, err := a.authService.ListCollections(user.ID)
|
||||
if err != nil {
|
||||
http.Error(w, "unable to load collections", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
collectionNames := map[string]string{}
|
||||
collectionViews := make([]collectionView, 0, len(collections))
|
||||
for _, collection := range collections {
|
||||
collectionNames[collection.ID] = collection.Name
|
||||
collectionViews = append(collectionViews, collectionView{ID: collection.ID, Name: collection.Name})
|
||||
}
|
||||
boxes, err := a.uploadService.UserBoxes(user.ID, collectionNames)
|
||||
if err != nil {
|
||||
http.Error(w, "unable to load boxes", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
storageUsed, err := a.uploadService.UserStorageUsed(user.ID)
|
||||
if err != nil {
|
||||
http.Error(w, "unable to load storage usage", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
selected := r.URL.Query().Get("collection")
|
||||
boxViews := make([]userBoxView, 0, len(boxes))
|
||||
for _, row := range boxes {
|
||||
if selected != "" && row.Box.CollectionID != selected {
|
||||
continue
|
||||
}
|
||||
title := row.Box.Title
|
||||
if title == "" {
|
||||
title = fmt.Sprintf("%d file upload", len(row.Box.Files))
|
||||
}
|
||||
boxViews = append(boxViews, userBoxView{
|
||||
ID: row.Box.ID,
|
||||
Title: title,
|
||||
CollectionID: row.Box.CollectionID,
|
||||
CollectionName: row.CollectionName,
|
||||
FileCount: len(row.Box.Files),
|
||||
Size: row.TotalSizeLabel,
|
||||
CreatedAt: row.Box.CreatedAt.Format("Jan 2 15:04"),
|
||||
ExpiresAt: row.Box.ExpiresAt.Format("Jan 2 15:04"),
|
||||
URL: "/d/" + row.Box.ID,
|
||||
})
|
||||
}
|
||||
|
||||
a.renderer.Render(w, http.StatusOK, "dashboard.html", web.PageData{
|
||||
Title: "My files",
|
||||
Description: "Your Warpbox personal file space.",
|
||||
CurrentUser: a.authService.PublicUser(user),
|
||||
Data: dashboardData{
|
||||
User: a.authService.PublicUser(user),
|
||||
Collections: collectionViews,
|
||||
Boxes: boxViews,
|
||||
StorageUsed: helpers.FormatBytes(storageUsed),
|
||||
MaxUploadSize: a.uploadService.MaxUploadSizeLabel(),
|
||||
Selected: selected,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func (a *App) CreateCollection(w http.ResponseWriter, r *http.Request) {
|
||||
user, ok := a.requireUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Redirect(w, r, "/app", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
if _, err := a.authService.CreateCollection(user.ID, r.FormValue("name")); err != nil {
|
||||
a.logger.Warn("collection create failed", "source", "user_activity", "severity", "warn", "code", 4410, "user_id", user.ID, "error", err.Error())
|
||||
}
|
||||
http.Redirect(w, r, "/app", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (a *App) RenameUserBox(w http.ResponseWriter, r *http.Request) {
|
||||
user, ok := a.requireUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Redirect(w, r, "/app", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
if err := a.uploadService.RenameOwnedBox(r.PathValue("boxID"), user.ID, r.FormValue("title")); err != nil {
|
||||
a.handleUserBoxError(w, r, err)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/app", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (a *App) MoveUserBox(w http.ResponseWriter, r *http.Request) {
|
||||
user, ok := a.requireUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Redirect(w, r, "/app", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
collectionID := r.FormValue("collection_id")
|
||||
if !a.authService.CollectionOwnedBy(collectionID, user.ID) {
|
||||
http.Error(w, "collection not found", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
if err := a.uploadService.MoveOwnedBox(r.PathValue("boxID"), user.ID, collectionID); err != nil {
|
||||
a.handleUserBoxError(w, r, err)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/app", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (a *App) DeleteUserBox(w http.ResponseWriter, r *http.Request) {
|
||||
user, ok := a.requireUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if err := a.uploadService.DeleteOwnedBox(r.PathValue("boxID"), user.ID); err != nil {
|
||||
a.handleUserBoxError(w, r, err)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/app", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (a *App) handleUserBoxError(w http.ResponseWriter, r *http.Request, err error) {
|
||||
if os.IsPermission(err) {
|
||||
http.Error(w, "not allowed", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
if os.IsNotExist(err) {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
http.Error(w, "unable to update box", http.StatusInternalServerError)
|
||||
}
|
||||
Reference in New Issue
Block a user