diff options
| author | ckrinitsin <101062646+ckrinitsin@users.noreply.github.com> | 2025-04-24 23:54:17 +0200 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2025-04-24 23:54:17 +0200 |
| commit | b75c7c6a9a040af03ef8b498c88c267bfa00aaf4 (patch) | |
| tree | 8b30f8b1269fe985a1fe0d5cca8b771a6e7484ff | |
| parent | 1e974f70a6c262d0b5db8b177ebb02b46446bfb0 (diff) | |
| download | shopping-list-b75c7c6a9a040af03ef8b498c88c267bfa00aaf4.tar.gz shopping-list-b75c7c6a9a040af03ef8b498c88c267bfa00aaf4.zip | |
Authentication (#1)
* add login and register templates * add jwt and gin-contrib dependencies * add List database table * add authentication * add logout
| -rw-r--r-- | authenticate/authenticate.go | 233 | ||||
| -rw-r--r-- | go.mod | 5 | ||||
| -rw-r--r-- | go.sum | 10 | ||||
| -rw-r--r-- | handlers/shopping_list.go | 81 | ||||
| -rw-r--r-- | main.go | 21 | ||||
| -rw-r--r-- | models/database.go | 20 | ||||
| -rw-r--r-- | templates/login.html | 169 | ||||
| -rw-r--r-- | templates/register.html | 173 | ||||
| -rw-r--r-- | templates/template.html | 12 |
9 files changed, 701 insertions, 23 deletions
diff --git a/authenticate/authenticate.go b/authenticate/authenticate.go new file mode 100644 index 0000000..8862313 --- /dev/null +++ b/authenticate/authenticate.go @@ -0,0 +1,233 @@ +package authenticate + +import ( + "fmt" + "net/http" + "os" + "strings" + "time" + + "github.com/ckrinitsin/shopping-list/models" + "github.com/gin-contrib/sessions" + "github.com/gin-gonic/gin" + "github.com/golang-jwt/jwt/v5" + "golang.org/x/crypto/bcrypt" + "gorm.io/gorm" +) + +func CheckAuth(c *gin.Context) { + session := sessions.Default(c) + token_session := session.Get("token") + + if token_session == nil { + c.Redirect(http.StatusFound, "/login") + return + } + + token_string, ok := token_session.(string) + if !ok { + c.Redirect(http.StatusFound, "/login") + return + } + + token, err := jwt.Parse(token_string, func(token *jwt.Token) (any, error) { + if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { + return nil, fmt.Errorf("Unexpected signing method: %v", token.Header["alg"]) + } + return []byte(os.Getenv("SECRET")), nil + }) + + if err != nil || !token.Valid { + c.Redirect(http.StatusFound, "/login") + c.Error(err) + return + } + + claims, ok := token.Claims.(jwt.MapClaims) + if !ok { + c.Redirect(http.StatusFound, "/login") + return + } + + if float64(time.Now().Unix()) > claims["exp"].(float64) { + c.Redirect(http.StatusFound, "/login") + return + } + + var list models.List + err = models.DB. + Model(&models.List{}). + Where("name = ?", claims["username"]). + First(&list). + Error + if err != nil { + c.Redirect(http.StatusFound, "/login") + return + } + + c.Set("current_list", list) + + c.Next() +} + +func LoginGET(c *gin.Context) { + title := "Shopping List" + + c.HTML(http.StatusOK, "login.html", gin.H{ + "name": title, + "error": "", + "base_path": models.BasePath(), + }) +} + +func LoginPOST(c *gin.Context) { + username := strings.TrimSpace(c.PostForm("username")) + password := c.PostForm("password") + + var list models.List + err := models.DB. + Model(&models.List{}). + Where("name = ?", username). + First(&list). + Error + + if err == gorm.ErrRecordNotFound { + c.HTML(http.StatusBadRequest, "login.html", gin.H{ + "error": "User does not exist", + }) + return + } else if err != nil { + c.HTML(http.StatusInternalServerError, "login.html", gin.H{ + "error": "Internal Server Error", + }) + c.Error(err) + return + } + + err = bcrypt.CompareHashAndPassword(list.Password, []byte(password)) + if err == bcrypt.ErrMismatchedHashAndPassword { + c.HTML(http.StatusBadRequest, "login.html", gin.H{ + "error": "Invalid username or password", + }) + return + } else if err != nil { + c.HTML(http.StatusInternalServerError, "login.html", gin.H{ + "error": "Internal Server Error", + }) + c.Error(err) + return + } + + session := sessions.Default(c) + + token, err := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{ + "username": username, + "exp": time.Now().Add(time.Hour * 24 * 30).Unix(), + }).SignedString([]byte(os.Getenv("SECRET"))) + + if err != nil { + c.HTML(http.StatusInternalServerError, "login.html", gin.H{ + "error": "Internal Server Error", + }) + c.Error(err) + return + } + + session.Set("token", token) + session.Save() + + c.Redirect(http.StatusFound, "/") +} + +func RegisterGET(c *gin.Context) { + title := "Shopping List" + + c.HTML(http.StatusOK, "register.html", gin.H{ + "name": title, + "error": "", + "base_path": models.BasePath(), + }) +} + +func RegisterPOST(c *gin.Context) { + username := strings.TrimSpace(c.PostForm("username")) + password := c.PostForm("password") + global_password := strings.TrimSpace(c.PostForm("global_password")) + + if username == "" { + c.HTML(http.StatusBadRequest, "register.html", gin.H{ + "error": "Invalid username", + }) + return + } + + if len(password) <= 0 && len(password) <= 72 { + c.HTML(http.StatusBadRequest, "register.html", gin.H{ + "error": "Invalid password", + }) + return + } + + if global_password != os.Getenv("GLOBAL_PASSWORD") { + c.HTML(http.StatusBadRequest, "register.html", gin.H{ + "error": "Global Password is wrong", + }) + return + } + + var count int64 + err := models.DB. + Model(&models.List{}). + Where("name = ?", username). + Count(&count). + Error + + if count > 0 { + c.HTML(http.StatusBadRequest, "register.html", gin.H{ + "error": "User does exist already", + }) + return + } else if err != gorm.ErrRecordNotFound && err != nil { + c.HTML(http.StatusInternalServerError, "register.html", gin.H{ + "error": "Internal Server Error", + }) + c.Error(err) + return + } + + hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + if err != nil { + c.HTML(http.StatusInternalServerError, "register.html", gin.H{ + "error": "Internal Server Error", + }) + c.Error(err) + return + } + + var list models.List + list = models.List{ + Name: username, + Password: hash, + } + + err = models.DB. + Create(&list). + Error + + if err != nil { + c.HTML(http.StatusInternalServerError, "register.html", gin.H{ + "error": "Internal Server Error", + }) + c.Error(err) + return + } + + c.Redirect(http.StatusFound, "/login") +} + +func Logout(c *gin.Context) { + session := sessions.Default(c) + session.Delete("token") + session.Save() + c.Redirect(http.StatusFound, "/login") +} diff --git a/go.mod b/go.mod index f90c2b2..4af3420 100644 --- a/go.mod +++ b/go.mod @@ -8,12 +8,17 @@ require ( github.com/cloudwego/base64x v0.1.5 // indirect github.com/cloudwego/iasm v0.2.0 // indirect github.com/gabriel-vasile/mimetype v1.4.8 // indirect + github.com/gin-contrib/sessions v1.0.3 // indirect github.com/gin-contrib/sse v1.1.0 // indirect github.com/gin-gonic/gin v1.10.0 // indirect github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-playground/validator/v10 v10.26.0 // indirect github.com/goccy/go-json v0.10.5 // indirect + github.com/golang-jwt/jwt/v5 v5.2.2 // indirect + github.com/gorilla/context v1.1.2 // indirect + github.com/gorilla/securecookie v1.1.2 // indirect + github.com/gorilla/sessions v1.4.0 // indirect github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/now v1.1.5 // indirect github.com/json-iterator/go v1.1.12 // indirect diff --git a/go.sum b/go.sum index 421c093..57e213b 100644 --- a/go.sum +++ b/go.sum @@ -11,6 +11,8 @@ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM= github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8= +github.com/gin-contrib/sessions v1.0.3 h1:AZ4j0AalLsGqdrKNbbrKcXx9OJZqViirvNGsJTxcQps= +github.com/gin-contrib/sessions v1.0.3/go.mod h1:5i4XMx4KPtQihnzxEqG9u1K446lO3G19jAi2GtbfsAI= github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w= github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM= github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU= @@ -23,7 +25,15 @@ github.com/go-playground/validator/v10 v10.26.0 h1:SP05Nqhjcvz81uJaRfEV0YBSSSGMc github.com/go-playground/validator/v10 v10.26.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo= github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= +github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8= +github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/gorilla/context v1.1.2 h1:WRkNAv2uoa03QNIc1A6u4O7DAGMUVoopZhkiXWA2V1o= +github.com/gorilla/context v1.1.2/go.mod h1:KDPwT9i/MeWHiLl90fuTgrt4/wPcv75vFAZLaOOcbxM= +github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA= +github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo= +github.com/gorilla/sessions v1.4.0 h1:kpIYOp/oi6MG/p5PgxApU8srsSw9tuFbt46Lt7auzqQ= +github.com/gorilla/sessions v1.4.0/go.mod h1:FLWm50oby91+hl7p/wRxDth9bWSuk0qVL2emc7lT5ik= github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= diff --git a/handlers/shopping_list.go b/handlers/shopping_list.go index 959c309..a1b90ff 100644 --- a/handlers/shopping_list.go +++ b/handlers/shopping_list.go @@ -2,26 +2,29 @@ package shopping_list import ( "net/http" - "os" "github.com/ckrinitsin/shopping-list/models" "github.com/gin-gonic/gin" ) -func getBasePath() string { - basePath := os.Getenv("BASE_PATH") - if basePath == "" { - basePath = "/" - } - - return basePath -} func LoadElements(c *gin.Context) { - title := "Shopping List" var entries []models.Entry + any_list, ok := c.Get("current_list") + if !ok { + c.String(http.StatusInternalServerError, "Internal Server Error") + return + } + + list, ok := any_list.(models.List) + if !ok { + c.String(http.StatusInternalServerError, "Internal Server Error") + return + } + err := models.DB. + Where("list_name = ?", list.Name). Order("checked asc"). Find(&entries). Error @@ -33,18 +36,31 @@ func LoadElements(c *gin.Context) { } c.HTML(http.StatusOK, "template.html", gin.H{ - "name": title, + "name": list.Name, "entries": entries, - "base_path": getBasePath(), + "base_path": models.BasePath(), }) } func CreateEntry(c *gin.Context) { value := c.PostForm("newItem") + any_list, ok := c.Get("current_list") + if !ok { + c.String(http.StatusInternalServerError, "Internal Server Error") + return + } + + list, ok := any_list.(models.List) + if !ok { + c.String(http.StatusInternalServerError, "Internal Server Error") + return + } + entry := models.Entry{ Text: value, Checked: false, + ListName: list.Name, } err := models.DB. @@ -56,21 +72,54 @@ func CreateEntry(c *gin.Context) { return } - c.Redirect(http.StatusFound, getBasePath() + "/") + c.Redirect(http.StatusFound, models.BasePath() + "/") } func DeleteEntries(c *gin.Context) { - models.DB.Delete(&models.Entry{}, "checked = 1") + any_list, ok := c.Get("current_list") + if !ok { + c.String(http.StatusInternalServerError, "Internal Server Error") + return + } + + list, ok := any_list.(models.List) + if !ok { + c.String(http.StatusInternalServerError, "Internal Server Error") + return + } - c.Redirect(http.StatusFound, getBasePath() + "/") + err := models.DB. + Where("list_name = ?", list.Name). + Delete(&models.Entry{}, "checked = 1"). + Error + + if err != nil { + c.String(http.StatusInternalServerError, "Internal Server Error") + return + } + + c.Redirect(http.StatusFound, models.BasePath() + "/") } func ToggleEntry(c *gin.Context) { id := c.PostForm("id") + any_list, ok := c.Get("current_list") + if !ok { + c.String(http.StatusInternalServerError, "Internal Server Error") + return + } + + list, ok := any_list.(models.List) + if !ok { + c.String(http.StatusInternalServerError, "Internal Server Error") + return + } + var entry models.Entry err := models.DB. + Where("list_name = ?", list.Name). First(&entry, id). Error @@ -89,5 +138,5 @@ func ToggleEntry(c *gin.Context) { return } - c.Redirect(http.StatusFound, getBasePath() + "/") + c.Redirect(http.StatusFound, models.BasePath() + "/") } diff --git a/main.go b/main.go index 0553af0..ef20704 100644 --- a/main.go +++ b/main.go @@ -4,9 +4,13 @@ import ( "embed" "html/template" "net/http" + "os" + "github.com/ckrinitsin/shopping-list/authenticate" "github.com/ckrinitsin/shopping-list/handlers" "github.com/ckrinitsin/shopping-list/models" + "github.com/gin-contrib/sessions" + "github.com/gin-contrib/sessions/cookie" "github.com/gin-gonic/gin" ) @@ -21,16 +25,25 @@ func main() { tmpl := template.Must(template.ParseFS(templatesFS, "templates/*")) r.SetHTMLTemplate(tmpl) + store := cookie.NewStore([]byte(os.Getenv("SECRET"))) + r.Use(sessions.Sessions("session", store)) + r.GET("/health", func(c *gin.Context) { c.JSON(http.StatusOK, gin.H{ "health-check": "passed", }) }) - r.GET("/", shopping_list.LoadElements) - r.POST("/create", shopping_list.CreateEntry) - r.POST("/delete", shopping_list.DeleteEntries) - r.POST("/toggle", shopping_list.ToggleEntry) + r.POST("/login", authenticate.LoginPOST) + r.GET("/login", authenticate.LoginGET) + r.POST("/register", authenticate.RegisterPOST) + r.GET("/register", authenticate.RegisterGET) + r.POST("/logout", authenticate.Logout) + + r.GET("/", authenticate.CheckAuth, shopping_list.LoadElements) + r.POST("/create", authenticate.CheckAuth, shopping_list.CreateEntry) + r.POST("/delete", authenticate.CheckAuth, shopping_list.DeleteEntries) + r.POST("/toggle", authenticate.CheckAuth, shopping_list.ToggleEntry) r.Run() } diff --git a/models/database.go b/models/database.go index 2daaa1e..ffc8a47 100644 --- a/models/database.go +++ b/models/database.go @@ -1,18 +1,28 @@ package models import ( + "os" "time" "gorm.io/driver/sqlite" "gorm.io/gorm" ) +type List struct { + Name string `gorm:"primaryKey"` + Password []byte + CreatedAt time.Time + UpdatedAt time.Time + Entries []Entry +} + type Entry struct { ID uint `gorm:"primaryKey"` Text string Checked bool CreatedAt time.Time UpdatedAt time.Time + ListName string } var DB *gorm.DB @@ -24,7 +34,17 @@ func ConnectDatabase() { panic("Failed to connect to database!") } + db.AutoMigrate(&List{}) db.AutoMigrate(&Entry{}) DB = db } + +func BasePath() string { + basePath := os.Getenv("BASE_PATH") + if basePath == "" { + basePath = "/" + } + + return basePath +} diff --git a/templates/login.html b/templates/login.html new file mode 100644 index 0000000..ab4801d --- /dev/null +++ b/templates/login.html @@ -0,0 +1,169 @@ +<!DOCTYPE html> +<html lang="en"> +<head> + <meta charset="UTF-8"> + <meta name="viewport" content="width=device-width, initial-scale=1.0"> + <title> Login </title> + <style> + html, body { + width: 100%; + height: 100%; + margin: 0; + overflow: hidden; + } + + body { + font-family: Arial, sans-serif; + background-color: #2d353b; + color: #d3c6aa; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + } + + .container { + padding: 2rem; + width: 300px; + max-height: 100%; + } + + .checklist { + max-height: 500px; + overflow-y: auto; + padding: 10px; + } + + h1 { + text-align: center; + } + + ul { + list-style-type: none; + padding: 0; + } + + li { + display: flex; + align-items: center; + justify-content: space-between; + margin: 0.5rem 0; + padding: 0.5rem; + border: 1px solid #d3c6aa; + border-radius: 4px; + height: 25px; + } + + li.selected { + text-decoration: line-through; + color: #475258; + border: 1px solid #475258; + } + + button { + display: block; + padding: 10px 20px; + border: none; + background-color: #d3c6aa; + color: #2d353b; + border-radius: 4px; + cursor: pointer; + width: 100% + } + + .input-container{ + position:relative; + margin-bottom:25px; + margin-top:25px; + } + .input-container label{ + position:absolute; + top:0px; + left:0px; + font-size:16px; + color:#fff; + pointer-event:none; + transition: all 0.5s ease-in-out; + } + .input-container input{ + border:0; + border-bottom:1px solid #555; + background:transparent; + width:100%; + padding:8px 0 5px 0; + font-size:16px; + color:#d3c6aa; + } + .input-container input:focus{ + border:none; + outline:none; + border-bottom:1px solid #d3c6aa; + } + .input-container input:focus ~ label, + .input-container input:valid ~ label{ + top:-12px; + font-size:12px; + + } + + /* settings for mini phones */ + @media (max-width: 280px) { + h1 { + font-size: 0; + height: 0; + margin: 0; + } + li { + height: 18px; + margin: 0.3rem 0; + } + .container { + width: 268px; /* Scale font sizes */ + height: 100%; + margin: 0; + } + .checklist { + height: 300px; + margin: 0; + padding: 3px; + padding-top: 0; + } + .input-container { + margin: 6px; + } + .delete-button { + width: 170px; + } + } + </style> +</head> +<body> + <div class="container"> + <h1>Login</h1> + + <p style="color: #e67e80;"> + {{ .error }} + </p> + + <form action="{{ .base_path }}login" method="POST"> + <div class="input-container"> + Username: + <input type="text" id="newItem" name="username" tabindex="0"/> + + </div> + <div class="input-container"> + Password: + <input type="password" id="newItem" name="password" tabindex="0"/> + </div> + <button class="delete-button"> Login </button> + </form> + + <form action="{{ .base_path }}register" method="GET"> + <div class="input-container"> + <button class="delete-button"> Register </button> + </div> + </form> + + </div> +</body> +</html> diff --git a/templates/register.html b/templates/register.html new file mode 100644 index 0000000..f4b582c --- /dev/null +++ b/templates/register.html @@ -0,0 +1,173 @@ +<!DOCTYPE html> +<html lang="en"> +<head> + <meta charset="UTF-8"> + <meta name="viewport" content="width=device-width, initial-scale=1.0"> + <title> Register</title> + <style> + html, body { + width: 100%; + height: 100%; + margin: 0; + overflow: hidden; + } + + body { + font-family: Arial, sans-serif; + background-color: #2d353b; + color: #d3c6aa; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + } + + .container { + padding: 2rem; + width: 300px; + max-height: 100%; + } + + .checklist { + max-height: 500px; + overflow-y: auto; + padding: 10px; + } + + h1 { + text-align: center; + } + + ul { + list-style-type: none; + padding: 0; + } + + li { + display: flex; + align-items: center; + justify-content: space-between; + margin: 0.5rem 0; + padding: 0.5rem; + border: 1px solid #d3c6aa; + border-radius: 4px; + height: 25px; + } + + li.selected { + text-decoration: line-through; + color: #475258; + border: 1px solid #475258; + } + + button { + display: block; + padding: 10px 20px; + border: none; + background-color: #d3c6aa; + color: #2d353b; + border-radius: 4px; + cursor: pointer; + width: 100% + } + + .input-container{ + position:relative; + margin-bottom:25px; + margin-top:25px; + } + .input-container label{ + position:absolute; + top:0px; + left:0px; + font-size:16px; + color:#fff; + pointer-event:none; + transition: all 0.5s ease-in-out; + } + .input-container input{ + border:0; + border-bottom:1px solid #555; + background:transparent; + width:100%; + padding:8px 0 5px 0; + font-size:16px; + color:#d3c6aa; + } + .input-container input:focus{ + border:none; + outline:none; + border-bottom:1px solid #d3c6aa; + } + .input-container input:focus ~ label, + .input-container input:valid ~ label{ + top:-12px; + font-size:12px; + + } + + /* settings for mini phones */ + @media (max-width: 280px) { + h1 { + font-size: 0; + height: 0; + margin: 0; + } + li { + height: 18px; + margin: 0.3rem 0; + } + .container { + width: 268px; /* Scale font sizes */ + height: 100%; + margin: 0; + } + .checklist { + height: 300px; + margin: 0; + padding: 3px; + padding-top: 0; + } + .input-container { + margin: 6px; + } + .delete-button { + width: 170px; + } + } + </style> +</head> +<body> + <div class="container"> + <h1>Register</h1> + + <p style="color: #e67e80;"> + {{ .error }} + </p> + + <form action="{{ .base_path }}register" method="POST"> + <div class="input-container"> + Username: + <input type="text" id="newItem" name="username" tabindex="0"/> + + </div> + <div class="input-container"> + Password: + <input type="password" id="newItem" name="password" tabindex="0"/> + </div> + <div class="input-container"> + Global Password: + <input type="password" id="newItem" name="global_password" tabindex="0"/> + </div> + <button class="delete-button"> Register </button> + </form> + + <form action="{{ .base_path }}login" method="GET"> + <div class="input-container"> + <button class="delete-button"> Login </button> + </div> + </form> + + </div> +</body> +</html> diff --git a/templates/template.html b/templates/template.html index 7548624..b92f716 100644 --- a/templates/template.html +++ b/templates/template.html @@ -3,7 +3,7 @@ <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> - <title> {{ .name }}</title> + <title>{{ .name }} - List</title> <style> html, body { width: 100%; @@ -166,7 +166,7 @@ </head> <body> <div class="container"> - <h1>{{ .name }}</h1> + <h1>{{ .name }} - List</h1> <ul class="checklist" id="checklist"> {{ range .entries }} {{ if .Checked }} @@ -190,10 +190,16 @@ </div> </form> - <form action="{{ .base_path }}delete" method="POST" onsubmit="return confirm('Are you sure you want to delete all checked entries?')"> + <form action="{{ .base_path }}delete" method="POST" onsubmit="return confirm('Are you sure you want to delete all checked entries?')"> <button type="submit" class="delete-button">Delete Selected Items</button> </form> + <form action="{{ .base_path }}logout" method="POST" onsubmit="return confirm('Are you sure you want to logout?')"> + <div class="input-container"> + <button type="submit" class="delete-button">Logout</button> + </div> + </form> + </div> </body> </html> |