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:
@@ -4,6 +4,7 @@ import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"time"
|
||||
|
||||
"warpbox.dev/backend/libs/services"
|
||||
@@ -13,13 +14,16 @@ import (
|
||||
const adminCookieName = "warpbox_admin"
|
||||
|
||||
type adminPageData struct {
|
||||
Stats services.AdminStats
|
||||
Boxes []adminBoxView
|
||||
Error string
|
||||
Stats services.AdminStats
|
||||
Boxes []adminBoxView
|
||||
Users []adminUserView
|
||||
LastInviteURL string
|
||||
Error string
|
||||
}
|
||||
|
||||
type adminBoxView struct {
|
||||
ID string
|
||||
Owner string
|
||||
CreatedAt string
|
||||
ExpiresAt string
|
||||
FileCount int
|
||||
@@ -30,6 +34,15 @@ type adminBoxView struct {
|
||||
Expired bool
|
||||
}
|
||||
|
||||
type adminUserView struct {
|
||||
ID string
|
||||
Username string
|
||||
Email string
|
||||
Role string
|
||||
Status string
|
||||
CreatedAt string
|
||||
}
|
||||
|
||||
func (a *App) AdminLogin(w http.ResponseWriter, r *http.Request) {
|
||||
if a.isAdmin(r) {
|
||||
http.Redirect(w, r, "/admin", http.StatusSeeOther)
|
||||
@@ -63,6 +76,7 @@ func (a *App) AdminLoginPost(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func (a *App) AdminLogout(w http.ResponseWriter, r *http.Request) {
|
||||
a.clearUserSessionCookie(w)
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: adminCookieName,
|
||||
Value: "",
|
||||
@@ -93,6 +107,7 @@ func (a *App) AdminDashboard(w http.ResponseWriter, r *http.Request) {
|
||||
a.renderer.Render(w, http.StatusOK, "admin.html", web.PageData{
|
||||
Title: "Admin overview",
|
||||
Description: "Warpbox admin overview.",
|
||||
CurrentUser: a.currentPublicUser(r),
|
||||
Data: adminPageData{
|
||||
Stats: stats,
|
||||
Boxes: boxes,
|
||||
@@ -119,6 +134,7 @@ func (a *App) AdminFiles(w http.ResponseWriter, r *http.Request) {
|
||||
a.renderer.Render(w, http.StatusOK, "admin.html", web.PageData{
|
||||
Title: "Admin files",
|
||||
Description: "Manage Warpbox uploads.",
|
||||
CurrentUser: a.currentPublicUser(r),
|
||||
Data: adminPageData{
|
||||
Stats: stats,
|
||||
Boxes: boxes,
|
||||
@@ -126,6 +142,86 @@ func (a *App) AdminFiles(w http.ResponseWriter, r *http.Request) {
|
||||
})
|
||||
}
|
||||
|
||||
func (a *App) AdminUsers(w http.ResponseWriter, r *http.Request) {
|
||||
if !a.requireAdmin(w, r) {
|
||||
return
|
||||
}
|
||||
stats, err := a.uploadService.AdminStats()
|
||||
if err != nil {
|
||||
http.Error(w, "unable to load admin stats", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
users, err := a.authService.ListUsers()
|
||||
if err != nil {
|
||||
http.Error(w, "unable to load users", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
rows := make([]adminUserView, 0, len(users))
|
||||
for _, user := range users {
|
||||
rows = append(rows, adminUserView{
|
||||
ID: user.ID,
|
||||
Username: user.Username,
|
||||
Email: user.Email,
|
||||
Role: user.Role,
|
||||
Status: user.Status,
|
||||
CreatedAt: user.CreatedAt.Format("Jan 2 15:04"),
|
||||
})
|
||||
}
|
||||
a.renderer.Render(w, http.StatusOK, "admin_users.html", web.PageData{
|
||||
Title: "Admin users",
|
||||
Description: "Manage Warpbox users and invites.",
|
||||
CurrentUser: a.currentPublicUser(r),
|
||||
Data: adminPageData{
|
||||
Stats: stats,
|
||||
Users: rows,
|
||||
LastInviteURL: r.URL.Query().Get("invite"),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func (a *App) AdminCreateInvite(w http.ResponseWriter, r *http.Request) {
|
||||
admin, ok := a.requireAdminUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Redirect(w, r, "/admin/users", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
result, err := a.authService.CreateInvite(r.FormValue("email"), r.FormValue("role"), admin.ID, 7*24*time.Hour)
|
||||
if err != nil {
|
||||
http.Redirect(w, r, "/admin/users", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
a.logger.Info("invite created", "source", "admin", "severity", "user_activity", "code", 2404, "admin_id", admin.ID)
|
||||
http.Redirect(w, r, "/admin/users?invite="+url.QueryEscape(result.URL), http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (a *App) AdminDisableUser(w http.ResponseWriter, r *http.Request) {
|
||||
if !a.requireAdmin(w, r) {
|
||||
return
|
||||
}
|
||||
disabled := r.URL.Query().Get("disabled") != "false"
|
||||
if err := a.authService.DisableUser(r.PathValue("userID"), disabled); err != nil {
|
||||
http.Error(w, "unable to update user", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/admin/users", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (a *App) AdminResetUser(w http.ResponseWriter, r *http.Request) {
|
||||
admin, ok := a.requireAdminUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
result, err := a.authService.CreatePasswordResetInvite(r.PathValue("userID"), admin.ID)
|
||||
if err != nil {
|
||||
http.Error(w, "unable to create reset link", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/admin/users?invite="+url.QueryEscape(result.URL), http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (a *App) AdminDeleteBox(w http.ResponseWriter, r *http.Request) {
|
||||
if !a.requireAdmin(w, r) {
|
||||
return
|
||||
@@ -185,8 +281,17 @@ func (a *App) adminBoxes(limit int) ([]adminBoxView, error) {
|
||||
|
||||
rows := make([]adminBoxView, 0, len(boxes))
|
||||
for _, box := range boxes {
|
||||
owner := "Anonymous"
|
||||
if box.OwnerID != "" {
|
||||
if user, err := a.authService.UserByID(box.OwnerID); err == nil {
|
||||
owner = user.Email
|
||||
} else {
|
||||
owner = "User"
|
||||
}
|
||||
}
|
||||
rows = append(rows, adminBoxView{
|
||||
ID: box.ID,
|
||||
Owner: owner,
|
||||
CreatedAt: box.CreatedAt.Format("Jan 2 15:04"),
|
||||
ExpiresAt: box.ExpiresAt.Format("Jan 2 15:04"),
|
||||
FileCount: box.FileCount,
|
||||
@@ -209,6 +314,9 @@ func (a *App) requireAdmin(w http.ResponseWriter, r *http.Request) bool {
|
||||
}
|
||||
|
||||
func (a *App) isAdmin(r *http.Request) bool {
|
||||
if user, ok := a.currentUser(r); ok && user.Role == services.UserRoleAdmin {
|
||||
return true
|
||||
}
|
||||
if a.cfg.AdminToken == "" {
|
||||
return false
|
||||
}
|
||||
@@ -219,6 +327,25 @@ func (a *App) isAdmin(r *http.Request) bool {
|
||||
return cookie.Value == adminCookieValue(a.cfg.AdminToken)
|
||||
}
|
||||
|
||||
func (a *App) requireAdminUser(w http.ResponseWriter, r *http.Request) (services.User, bool) {
|
||||
user, ok := a.currentUser(r)
|
||||
if ok && user.Role == services.UserRoleAdmin {
|
||||
return user, true
|
||||
}
|
||||
if a.cfg.AdminToken != "" && a.isAdmin(r) {
|
||||
return services.User{ID: "env-admin", Role: services.UserRoleAdmin, Status: services.UserStatusActive}, true
|
||||
}
|
||||
http.Redirect(w, r, "/login?next="+r.URL.Path, http.StatusSeeOther)
|
||||
return services.User{}, false
|
||||
}
|
||||
|
||||
func (a *App) currentPublicUser(r *http.Request) any {
|
||||
if user, ok := a.currentUser(r); ok {
|
||||
return a.authService.PublicUser(user)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func adminCookieValue(token string) string {
|
||||
sum := sha256.Sum256([]byte("warpbox-admin:" + token))
|
||||
return hex.EncodeToString(sum[:])
|
||||
|
||||
Reference in New Issue
Block a user