Add setup-flow
This commit is contained in:
@@ -1,6 +0,0 @@
|
|||||||
PORT=8000
|
|
||||||
JWT_SECRET=
|
|
||||||
ADMIN_PASSWORD=
|
|
||||||
|
|
||||||
DOMAIN=http://localhost:8000
|
|
||||||
USE_HTTPS=false
|
|
||||||
@@ -28,6 +28,9 @@ COPY --from=builder /app/.env ./
|
|||||||
|
|
||||||
RUN mkdir -p /app/uploads
|
RUN mkdir -p /app/uploads
|
||||||
|
|
||||||
|
RUN adduser -D appuser
|
||||||
|
USER appuser
|
||||||
|
|
||||||
ENV GIN_MODE=release
|
ENV GIN_MODE=release
|
||||||
|
|
||||||
EXPOSE 8000
|
EXPOSE 8000
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import (
|
|||||||
"ResendIt/internal/user"
|
"ResendIt/internal/user"
|
||||||
"ResendIt/internal/util"
|
"ResendIt/internal/util"
|
||||||
"ResendIt/internal/web"
|
"ResendIt/internal/web"
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/base64"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"html/template"
|
"html/template"
|
||||||
@@ -45,7 +47,7 @@ func main() {
|
|||||||
|
|
||||||
r.LoadHTMLGlob("templates/*.html")
|
r.LoadHTMLGlob("templates/*.html")
|
||||||
//r.LoadHTMLGlob("internal/templates/new/*.html")
|
//r.LoadHTMLGlob("internal/templates/new/*.html")
|
||||||
r.Static("/static", "/static")
|
r.Static("/static", "./static")
|
||||||
|
|
||||||
r.GET("/ping", func(c *gin.Context) {
|
r.GET("/ping", func(c *gin.Context) {
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
@@ -78,36 +80,54 @@ func main() {
|
|||||||
file.RegisterRoutes(apiRoute, fileHandler)
|
file.RegisterRoutes(apiRoute, fileHandler)
|
||||||
|
|
||||||
webHandler := web.NewHandler(fileService)
|
webHandler := web.NewHandler(fileService)
|
||||||
web.RegisterRoutes(r, webHandler)
|
web.RegisterRoutes(r, webHandler, userService)
|
||||||
|
|
||||||
err = r.Run(":" + os.Getenv("PORT"))
|
port := os.Getenv("PORT")
|
||||||
|
if port == "" {
|
||||||
|
port = "8080"
|
||||||
|
}
|
||||||
|
|
||||||
|
err = r.Run(":" + port)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func generateRandomPassword(length int) string {
|
||||||
|
b := make([]byte, length)
|
||||||
|
if _, err := rand.Read(b); err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
return base64.URLEncoding.EncodeToString(b)[:length]
|
||||||
|
}
|
||||||
|
|
||||||
func createAdminUser(service *user.Service) {
|
func createAdminUser(service *user.Service) {
|
||||||
//Check if admin user already exists
|
|
||||||
_, err := service.FindByUsername("admin")
|
_, err := service.FindByUsername("admin")
|
||||||
|
|
||||||
if err == nil {
|
if err == nil {
|
||||||
fmt.Println("Admin user already exists, skipping creation")
|
fmt.Println("Admin user already exists, skipping creation")
|
||||||
return
|
return
|
||||||
} else if !errors.Is(err, user.ErrUserNotFound) {
|
} else if errors.Is(err, user.ErrUserNotFound) {
|
||||||
fmt.Printf("Error checking for admin user: %v\n", err)
|
fmt.Println("Admin user not found, creating new admin user")
|
||||||
|
|
||||||
|
password := generateRandomPassword(16)
|
||||||
|
adminUser, err := service.CreateUser("admin", password, "admin")
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("Error creating admin user: %v\n", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
adminUser.ForceChangePassword = true
|
||||||
|
_, err = service.UpdateUser(adminUser)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("Error creating admin user: %v\n", err)
|
||||||
|
} else {
|
||||||
|
fmt.Printf("Admin user created with random password: %s\n", password)
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
adminPassword, exists := os.LookupEnv("ADMIN_PASSWORD")
|
fmt.Printf("Error checking for admin user: %v\n", err)
|
||||||
if !exists || adminPassword == "" {
|
return
|
||||||
fmt.Println("ADMIN_PASSWORD not set in environment variables")
|
|
||||||
fmt.Println("NO ADMIN ACCOUNT WILL BE CREATED")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err = service.CreateUser("admin", adminPassword, "admin")
|
|
||||||
if err != nil {
|
|
||||||
fmt.Printf("Error creating admin user: %v\n", err)
|
|
||||||
} else {
|
|
||||||
fmt.Println("Admin user created successfully")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,13 +22,11 @@ func AuthMiddleware() gin.HandlerFunc {
|
|||||||
|
|
||||||
var tokenString string
|
var tokenString string
|
||||||
|
|
||||||
// 🔥 1. Try cookie first (NEW)
|
|
||||||
cookie, err := c.Cookie("auth_token")
|
cookie, err := c.Cookie("auth_token")
|
||||||
if err == nil && cookie != "" {
|
if err == nil && cookie != "" {
|
||||||
tokenString = cookie
|
tokenString = cookie
|
||||||
}
|
}
|
||||||
|
|
||||||
// 🔥 2. Fallback to Authorization header (for API tools / future SPA)
|
|
||||||
if tokenString == "" {
|
if tokenString == "" {
|
||||||
authHeader := c.GetHeader("Authorization")
|
authHeader := c.GetHeader("Authorization")
|
||||||
|
|
||||||
@@ -40,13 +38,11 @@ func AuthMiddleware() gin.HandlerFunc {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ❌ No token at all
|
|
||||||
if tokenString == "" {
|
if tokenString == "" {
|
||||||
abortUnauthorized(c)
|
abortUnauthorized(c)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 🔐 Parse JWT
|
|
||||||
claims := &Claims{}
|
claims := &Claims{}
|
||||||
token, err := jwt.ParseWithClaims(tokenString, claims, func(token *jwt.Token) (interface{}, error) {
|
token, err := jwt.ParseWithClaims(tokenString, claims, func(token *jwt.Token) (interface{}, error) {
|
||||||
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
|
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||||
|
|||||||
@@ -13,7 +13,13 @@ import (
|
|||||||
|
|
||||||
func Connect() (*gorm.DB, error) {
|
func Connect() (*gorm.DB, error) {
|
||||||
dbType := os.Getenv("DB_TYPE")
|
dbType := os.Getenv("DB_TYPE")
|
||||||
|
if dbType == "" {
|
||||||
|
dbType = "sqlite"
|
||||||
|
}
|
||||||
dsn := os.Getenv("DATABASE_URL")
|
dsn := os.Getenv("DATABASE_URL")
|
||||||
|
if dbType == "sqlite" && dsn == "" {
|
||||||
|
dsn = "./data/database.db"
|
||||||
|
}
|
||||||
|
|
||||||
switch dbType {
|
switch dbType {
|
||||||
case "sqlite":
|
case "sqlite":
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
type FileRecord struct {
|
type FileRecord struct {
|
||||||
ID string `gorm:"primaryKey" json:"id"`
|
ID string `gorm:"primaryKey" json:"id"`
|
||||||
DeletionID string `json:"deletion_id"`
|
DeletionID string `json:"deletion_id"`
|
||||||
|
ViewID string `json:"view_id"`
|
||||||
Filename string `json:"filename"`
|
Filename string `json:"filename"`
|
||||||
Path string `json:"-"` // file path on disk (not exposed via JSON)
|
Path string `json:"-"` // file path on disk (not exposed via JSON)
|
||||||
ExpiresAt time.Time `json:"expires_at"`
|
ExpiresAt time.Time `json:"expires_at"`
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package file
|
|||||||
import (
|
import (
|
||||||
"io"
|
"io"
|
||||||
"os"
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
@@ -29,7 +30,8 @@ func (s *Service) UploadFile(filename string, data io.Reader, deleteAfterDownloa
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
path := folderPath + "/" + filename
|
safeName := uuid.NewString() + filepath.Ext(filename)
|
||||||
|
path := filepath.Join(folderPath, safeName)
|
||||||
|
|
||||||
out, err := os.Create(path)
|
out, err := os.Create(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -3,3 +3,5 @@ package user
|
|||||||
import "errors"
|
import "errors"
|
||||||
|
|
||||||
var ErrUserNotFound = errors.New("user not found")
|
var ErrUserNotFound = errors.New("user not found")
|
||||||
|
var ErrPasswordsDoNotMatch = errors.New("Incorrect old password")
|
||||||
|
var ErrInvalidPassword = errors.New("invalid password")
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
package user
|
package user
|
||||||
|
|
||||||
import "github.com/gin-gonic/gin"
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
type Handler struct {
|
type Handler struct {
|
||||||
service *Service
|
service *Service
|
||||||
@@ -30,3 +34,55 @@ func (h *Handler) Register(c *gin.Context) {
|
|||||||
|
|
||||||
c.JSON(201, gin.H{"id": user.ID, "username": user.Username, "role": user.Role})
|
c.JSON(201, gin.H{"id": user.ID, "username": user.Username, "role": user.Role})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *Handler) ChangePassword(c *gin.Context) {
|
||||||
|
var req struct {
|
||||||
|
OldPassword string `json:"old_password"`
|
||||||
|
NewPassword string `json:"new_password"`
|
||||||
|
}
|
||||||
|
|
||||||
|
userID, exists := c.Get("user_id")
|
||||||
|
if !exists {
|
||||||
|
fmt.Println("User ID not found in context")
|
||||||
|
c.JSON(401, gin.H{"error": "unauthorized"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(400, gin.H{"error": "invalid request"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
err := h.service.ChangePassword(userID.(string), req.OldPassword, req.NewPassword)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(500, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(200, gin.H{"message": "password changed successfully"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func ForcePasswordChangeMiddleware(userService *Service) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
userID, exists := c.Get("user_id")
|
||||||
|
if !exists {
|
||||||
|
c.Next()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
user, err := userService.FindByID(userID.(string))
|
||||||
|
if err != nil {
|
||||||
|
c.AbortWithStatus(500)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Allow access to change password page itself
|
||||||
|
if user.ForceChangePassword && c.Request.URL.Path != "/change-password" {
|
||||||
|
c.Redirect(302, "/change-password")
|
||||||
|
c.Abort()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.Next()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,7 +4,8 @@ import "gorm.io/gorm"
|
|||||||
|
|
||||||
type User struct {
|
type User struct {
|
||||||
gorm.Model
|
gorm.Model
|
||||||
Username string `gorm:"uniqueIndex;not null"`
|
Username string `gorm:"uniqueIndex;not null"`
|
||||||
PasswordHash string `gorm:"not null"`
|
PasswordHash string `gorm:"not null"`
|
||||||
Role string `gorm:"not null"`
|
Role string `gorm:"not null"`
|
||||||
|
ForceChangePassword bool `gorm:"default:false"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,6 +25,17 @@ func (r *Repository) FindByUsername(username string) (*User, error) {
|
|||||||
return &u, nil
|
return &u, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *Repository) FindByID(id string) (*User, error) {
|
||||||
|
var u User
|
||||||
|
if err := r.db.First(&u, id).Error; err != nil {
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return nil, ErrUserNotFound
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &u, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (r *Repository) Create(u *User) error {
|
func (r *Repository) Create(u *User) error {
|
||||||
return r.db.Create(u).Error
|
return r.db.Create(u).Error
|
||||||
}
|
}
|
||||||
@@ -37,6 +48,10 @@ func (r *Repository) GetAll() ([]User, error) {
|
|||||||
return users, nil
|
return users, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *Repository) Update(u *User) error {
|
||||||
|
return r.db.Save(u).Error
|
||||||
|
}
|
||||||
|
|
||||||
func (r *Repository) Delete(id uint) error {
|
func (r *Repository) Delete(id uint) error {
|
||||||
return r.db.Delete(&User{}, id).Error
|
return r.db.Delete(&User{}, id).Error
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,17 @@
|
|||||||
package user
|
package user
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"ResendIt/internal/api/middleware"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|
||||||
func RegisterRoutes(r *gin.RouterGroup, h *Handler) {
|
func RegisterRoutes(r *gin.RouterGroup, h *Handler) {
|
||||||
//auth := r.Group("/user")
|
auth := r.Group("/user")
|
||||||
|
auth.Use(middleware.AuthMiddleware())
|
||||||
|
auth.Use(middleware.RequireRole("admin"))
|
||||||
|
|
||||||
|
auth.POST("/change-password", h.ChangePassword)
|
||||||
|
|
||||||
//auth.POST("/register", h.Register)
|
//auth.POST("/register", h.Register)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,6 +35,66 @@ func (s *Service) CreateUser(username, password, role string) (*User, error) {
|
|||||||
return u, nil
|
return u, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// UpdateUser updates a user's information
|
||||||
|
func (s *Service) UpdateUser(user *User) (*User, error) {
|
||||||
|
if err := s.repo.Update(user); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return user, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validNewPassword(oldPassword, newPassword string) bool {
|
||||||
|
if oldPassword == newPassword {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if len(newPassword) < 8 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
//Contains 1 uppercase, 1 lowercase, 1 number
|
||||||
|
hasUpper := false
|
||||||
|
hasLower := false
|
||||||
|
hasNumber := false
|
||||||
|
for _, c := range newPassword {
|
||||||
|
switch {
|
||||||
|
case 'A' <= c && c <= 'Z':
|
||||||
|
hasUpper = true
|
||||||
|
case 'a' <= c && c <= 'z':
|
||||||
|
hasLower = true
|
||||||
|
case '0' <= c && c <= '9':
|
||||||
|
hasNumber = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !hasUpper || !hasLower || !hasNumber {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) ChangePassword(userID string, oldPassword string, newPassword string) error {
|
||||||
|
user, err := s.repo.FindByID(userID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if !validNewPassword(oldPassword, newPassword) {
|
||||||
|
return ErrInvalidPassword
|
||||||
|
}
|
||||||
|
|
||||||
|
if !security.CheckPassword(oldPassword, user.PasswordHash) {
|
||||||
|
return ErrPasswordsDoNotMatch
|
||||||
|
}
|
||||||
|
|
||||||
|
newHash, err := security.HashPassword(newPassword)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
user.PasswordHash = newHash
|
||||||
|
user.ForceChangePassword = false
|
||||||
|
|
||||||
|
return s.repo.Update(user)
|
||||||
|
}
|
||||||
|
|
||||||
// GetAllUsers returns all users
|
// GetAllUsers returns all users
|
||||||
func (s *Service) GetAllUsers() ([]User, error) {
|
func (s *Service) GetAllUsers() ([]User, error) {
|
||||||
return s.repo.GetAll()
|
return s.repo.GetAll()
|
||||||
@@ -53,3 +113,8 @@ func (s *Service) DeleteUser(requesterID, targetID uint) error {
|
|||||||
func (s *Service) FindByUsername(username string) (*User, error) {
|
func (s *Service) FindByUsername(username string) (*User, error) {
|
||||||
return s.repo.FindByUsername(username)
|
return s.repo.FindByUsername(username)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// FindByID returns a user by ID
|
||||||
|
func (s *Service) FindByID(id string) (*User, error) {
|
||||||
|
return s.repo.FindByID(id)
|
||||||
|
}
|
||||||
|
|||||||
@@ -64,3 +64,7 @@ func (h *Handler) Logout(c *gin.Context) {
|
|||||||
c.SetCookie("auth_token", "", -1, "/", "", false, true)
|
c.SetCookie("auth_token", "", -1, "/", "", false, true)
|
||||||
c.Redirect(302, "/")
|
c.Redirect(302, "/")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *Handler) ChangePasswordPage(c *gin.Context) {
|
||||||
|
c.HTML(200, "changePassword.html", nil)
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,19 +2,22 @@ package web
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"ResendIt/internal/api/middleware"
|
"ResendIt/internal/api/middleware"
|
||||||
|
"ResendIt/internal/user"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|
||||||
func RegisterRoutes(r *gin.Engine, h *Handler) {
|
func RegisterRoutes(r *gin.Engine, h *Handler, userService *user.Service) {
|
||||||
r.GET("/", h.Index)
|
r.GET("/", h.Index)
|
||||||
r.GET("/upload", h.UploadPage)
|
//r.GET("/upload", h.UploadPage)
|
||||||
r.GET("/login", h.LoginPage)
|
r.GET("/login", h.LoginPage)
|
||||||
|
|
||||||
adminRoutes := r.Group("/")
|
adminRoutes := r.Group("/")
|
||||||
adminRoutes.Use(middleware.AuthMiddleware())
|
adminRoutes.Use(middleware.AuthMiddleware())
|
||||||
adminRoutes.Use(middleware.RequireRole("admin"))
|
adminRoutes.Use(middleware.RequireRole("admin"))
|
||||||
|
adminRoutes.Use(user.ForcePasswordChangeMiddleware(userService))
|
||||||
|
|
||||||
adminRoutes.GET("/admin", h.AdminPage)
|
adminRoutes.GET("/admin", h.AdminPage)
|
||||||
adminRoutes.GET("/logout", h.Logout)
|
adminRoutes.GET("/logout", h.Logout)
|
||||||
|
adminRoutes.GET("/change-password", h.ChangePasswordPage)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -214,6 +214,7 @@
|
|||||||
if (event.target == overlay) closeModal();
|
if (event.target == overlay) closeModal();
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
<a href="/change-password" class="fixed bottom-1 left-1 text-[10px] underline">CHANGE PASSWORD</a>
|
||||||
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
307
templates/changePassword.html
Normal file
307
templates/changePassword.html
Normal file
@@ -0,0 +1,307 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Change Password</title>
|
||||||
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
|
<link rel="icon" type="image/x-icon" href="/static/favicon.ico">
|
||||||
|
<style>
|
||||||
|
* { border-radius: 0 !important; }
|
||||||
|
body { font-family: ui-monospace, 'Cascadia Code', 'Source Code Pro', monospace; background: #fff; color: #000; padding: 20px; }
|
||||||
|
.box { border: 3px solid #000; background: #fff; }
|
||||||
|
|
||||||
|
/* Chunky Buttons */
|
||||||
|
button, .button {
|
||||||
|
border: 2px solid #000;
|
||||||
|
background: #fff;
|
||||||
|
padding: 4px 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 900;
|
||||||
|
text-decoration: none;
|
||||||
|
text-transform: uppercase;
|
||||||
|
box-shadow: 3px 3px 0px #000;
|
||||||
|
}
|
||||||
|
button:hover, .button:hover { background: #000; color: #fff; box-shadow: none; transform: translate(2px, 2px); }
|
||||||
|
button:active { background: #ff0000; color: #fff; }
|
||||||
|
|
||||||
|
.nav-link { font-weight: 900; text-decoration: underline; text-transform: uppercase; font-size: 12px; }
|
||||||
|
.nav-link:hover { background: #000; color: #fff; }
|
||||||
|
|
||||||
|
/* Inputs */
|
||||||
|
.field-group { margin-bottom: 20px; }
|
||||||
|
label {
|
||||||
|
display: block;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 900;
|
||||||
|
text-transform: uppercase;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
input[type="password"] {
|
||||||
|
width: 100%;
|
||||||
|
border: 3px solid #000;
|
||||||
|
padding: 10px 12px;
|
||||||
|
font-family: ui-monospace, 'Cascadia Code', 'Source Code Pro', monospace;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
background: #fff;
|
||||||
|
outline: none;
|
||||||
|
box-sizing: border-box;
|
||||||
|
transition: background 0.1s;
|
||||||
|
}
|
||||||
|
input[type="password"]:focus {
|
||||||
|
background: #ffff00;
|
||||||
|
border-color: #000;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Strength Meter */
|
||||||
|
.strength-bar-wrap {
|
||||||
|
height: 8px;
|
||||||
|
border: 2px solid #000;
|
||||||
|
margin-top: 6px;
|
||||||
|
background: #eee;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.strength-bar {
|
||||||
|
height: 100%;
|
||||||
|
width: 0%;
|
||||||
|
transition: width 0.2s, background 0.2s;
|
||||||
|
}
|
||||||
|
.strength-label {
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 900;
|
||||||
|
text-transform: uppercase;
|
||||||
|
margin-top: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Error / Success */
|
||||||
|
.msg {
|
||||||
|
border: 3px solid #000;
|
||||||
|
padding: 10px 14px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 900;
|
||||||
|
text-transform: uppercase;
|
||||||
|
display: none;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
.msg-error { background: #ff0000; color: #fff; }
|
||||||
|
.msg-success { background: #00ff00; color: #000; }
|
||||||
|
|
||||||
|
/* Match indicator */
|
||||||
|
.match-hint {
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 900;
|
||||||
|
text-transform: uppercase;
|
||||||
|
margin-top: 4px;
|
||||||
|
min-height: 14px;
|
||||||
|
}
|
||||||
|
.match-ok { color: #007700; }
|
||||||
|
.match-bad { color: #ff0000; }
|
||||||
|
|
||||||
|
/* Submit button — big */
|
||||||
|
#submit-btn {
|
||||||
|
width: 100%;
|
||||||
|
padding: 14px;
|
||||||
|
font-size: 16px;
|
||||||
|
border: 4px solid #000;
|
||||||
|
box-shadow: 6px 6px 0px #000;
|
||||||
|
}
|
||||||
|
#submit-btn:hover { box-shadow: none; transform: translate(4px, 4px); }
|
||||||
|
|
||||||
|
/* Requirements list */
|
||||||
|
.req-list { list-style: none; padding: 0; margin: 0; }
|
||||||
|
.req-list li {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
padding: 2px 0;
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.req-list li span.icon { font-style: normal; font-size: 12px; }
|
||||||
|
.req-met { color: #007700; }
|
||||||
|
.req-unmet { color: #aaa; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<div class="max-w-lg mx-auto">
|
||||||
|
<header class="mb-6 border-b-8 border-black pb-4 flex justify-between items-start">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-4xl font-black uppercase tracking-tighter leading-none">Change_Password</h1>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col items-end gap-2">
|
||||||
|
<a href="/admin" class="nav-link">← BACK_TO_ADMIN</a>
|
||||||
|
<a href="/logout" class="nav-link text-red-600">LOGOUT_SESSION</a>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="box p-6">
|
||||||
|
|
||||||
|
<div id="msg-error" class="msg msg-error">⚠ ERROR: Passwords do not match.</div>
|
||||||
|
<div id="msg-success" class="msg msg-success">✓ PASSWORD UPDATED. Credential change committed.</div>
|
||||||
|
|
||||||
|
<div class="field-group">
|
||||||
|
<label for="current-pw">Current_Password</label>
|
||||||
|
<input type="password" id="current-pw" placeholder="••••••••••••" autocomplete="current-password">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field-group">
|
||||||
|
<label for="new-pw">New_Password</label>
|
||||||
|
<input type="password" id="new-pw" placeholder="••••••••••••" autocomplete="new-password" oninput="onNewPwInput()">
|
||||||
|
<div class="strength-bar-wrap"><div class="strength-bar" id="strength-bar"></div></div>
|
||||||
|
<div class="strength-label" id="strength-label" style="color:#aaa">—</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="box p-3 mb-6 bg-gray-50">
|
||||||
|
<div class="text-[10px] font-black uppercase mb-2 tracking-widest">Requirements</div>
|
||||||
|
<ul class="req-list" id="req-list">
|
||||||
|
<li id="req-len" class="req-unmet"><span class="icon">□</span> Min 8 characters</li>
|
||||||
|
<li id="req-upper" class="req-unmet"><span class="icon">□</span> Uppercase letter</li>
|
||||||
|
<li id="req-num" class="req-unmet"><span class="icon">□</span> Number</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field-group">
|
||||||
|
<label for="confirm-pw">Confirm_New_Password</label>
|
||||||
|
<input type="password" id="confirm-pw" placeholder="••••••••••••" autocomplete="new-password" oninput="onConfirmInput()">
|
||||||
|
<div class="match-hint" id="match-hint"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button id="submit-btn" onclick="handleSubmit()">
|
||||||
|
CHANGE PASSWORD
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const requirements = [
|
||||||
|
{ id: 'req-len', test: v => v.length >= 8 },
|
||||||
|
{ id: 'req-upper', test: v => /[A-Z]/.test(v) },
|
||||||
|
{ id: 'req-num', test: v => /[0-9]/.test(v) },
|
||||||
|
];
|
||||||
|
|
||||||
|
function getStrength(v) {
|
||||||
|
const score = requirements.filter(r => r.test(v)).length * 2;
|
||||||
|
if (v.length === 0) return { score: 0, label: '—', color: '#eee', pct: 0 };
|
||||||
|
if (score <= 1) return { score, label: 'WEAK', color: '#ff0000', pct: 20 };
|
||||||
|
if (score === 2) return { score, label: 'POOR', color: '#ff6600', pct: 40 };
|
||||||
|
if (score === 3) return { score, label: 'FAIR', color: '#ffcc00', pct: 60 };
|
||||||
|
if (score === 4) return { score, label: 'STRONG', color: '#88cc00', pct: 80 };
|
||||||
|
return { score, label: 'MAXIMUM', color: '#00cc44', pct: 100 };
|
||||||
|
}
|
||||||
|
|
||||||
|
function onNewPwInput() {
|
||||||
|
const v = document.getElementById('new-pw').value;
|
||||||
|
const { label, color, pct } = getStrength(v);
|
||||||
|
|
||||||
|
const bar = document.getElementById('strength-bar');
|
||||||
|
bar.style.width = pct + '%';
|
||||||
|
bar.style.background = color;
|
||||||
|
|
||||||
|
const lbl = document.getElementById('strength-label');
|
||||||
|
lbl.innerText = label;
|
||||||
|
lbl.style.color = pct === 0 ? '#aaa' : color;
|
||||||
|
|
||||||
|
requirements.forEach(r => {
|
||||||
|
const el = document.getElementById(r.id);
|
||||||
|
const met = r.test(v);
|
||||||
|
el.className = met ? 'req-met' : 'req-unmet';
|
||||||
|
el.querySelector('.icon').innerText = met ? '■' : '□';
|
||||||
|
});
|
||||||
|
|
||||||
|
onConfirmInput();
|
||||||
|
}
|
||||||
|
|
||||||
|
function onConfirmInput() {
|
||||||
|
const nv = document.getElementById('new-pw').value;
|
||||||
|
const cv = document.getElementById('confirm-pw').value;
|
||||||
|
const hint = document.getElementById('match-hint');
|
||||||
|
if (!cv) { hint.innerText = ''; return; }
|
||||||
|
if (nv === cv) {
|
||||||
|
hint.innerText = '✓ PASSWORDS MATCH';
|
||||||
|
hint.className = 'match-hint match-ok';
|
||||||
|
} else {
|
||||||
|
hint.innerText = '✗ MISMATCH';
|
||||||
|
hint.className = 'match-hint match-bad';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function hideMessages() {
|
||||||
|
document.getElementById('msg-error').style.display = 'none';
|
||||||
|
document.getElementById('msg-success').style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmit() {
|
||||||
|
hideMessages();
|
||||||
|
|
||||||
|
const current = document.getElementById('current-pw').value;
|
||||||
|
const nv = document.getElementById('new-pw').value;
|
||||||
|
const cv = document.getElementById('confirm-pw').value;
|
||||||
|
|
||||||
|
if (!current || !nv || !cv) {
|
||||||
|
showError('All fields are required.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (nv !== cv) {
|
||||||
|
showError('Passwords do not match.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { score } = getStrength(nv);
|
||||||
|
if (score < 3) {
|
||||||
|
showError('Password is too weak.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/user/change-password', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
old_password: current,
|
||||||
|
new_password: nv
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
showError(data.error || 'Something went wrong.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
showSuccess('Password updated successfully.');
|
||||||
|
|
||||||
|
document.getElementById('current-pw').value = '';
|
||||||
|
document.getElementById('new-pw').value = '';
|
||||||
|
document.getElementById('confirm-pw').value = '';
|
||||||
|
onNewPwInput();
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
window.location.href = '/login';
|
||||||
|
}, 3000);
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
showError('Network error. Try again.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function showSuccess(msg) {
|
||||||
|
const el = document.getElementById('msg-success');
|
||||||
|
el.innerText = '✓ ' + msg;
|
||||||
|
el.style.display = 'block';
|
||||||
|
}
|
||||||
|
|
||||||
|
function showError(msg) {
|
||||||
|
const el = document.getElementById('msg-error');
|
||||||
|
el.innerText = '⚠ ERROR: ' + msg;
|
||||||
|
el.style.display = 'block';
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Reference in New Issue
Block a user