~jan0sch/smederee

Showing details for patch 19a937351174b10e6d59e5bc573a9d8dc8d84611.
2022-10-07 (Fri), 6:55 AM - Jens Grassel - 19a937351174b10e6d59e5bc573a9d8dc8d84611

Account Management: First throw at verification and deletion

- BREAKING: add verify_token to database table
- add basic settings page with delete form
- stub routing
- some CSS
- first sketch of AccountManagementRepository
- delete user account from database if requested
- add integration test for AccountManagementRepository
Summary of changes
5 files added
  • modules/hub/src/it/scala/de/smederee/hub/DoobieAccountManagementRepositoryTest.scala
  • modules/hub/src/main/scala/de/smederee/hub/AccountManagementRepository.scala
  • modules/hub/src/main/scala/de/smederee/hub/AccountManagementRoutes.scala
  • modules/hub/src/main/scala/de/smederee/hub/DoobieAccountManagementRepository.scala
  • modules/hub/src/main/twirl/de/smederee/hub/views/account/settings.scala.html
8 files modified with 31 lines added and 9 lines removed
  • modules/hub/src/main/resources/assets/css/main.css with 5 added and 0 removed lines
  • modules/hub/src/main/resources/db/migration/V1__base_tables.sql with 2 added and 0 removed lines
  • modules/hub/src/main/resources/messages_en.properties with 8 added and 0 removed lines
  • modules/hub/src/main/scala/de/smederee/hub/AuthenticationRepository.scala with 2 added and 2 removed lines
  • modules/hub/src/main/scala/de/smederee/hub/HubServer.scala with 9 added and 3 removed lines
  • modules/hub/src/main/scala/de/smederee/hub/SignupRepository.scala with 2 added and 2 removed lines
  • modules/hub/src/main/scala/de/smederee/hub/VcsMetadataRepository.scala with 2 added and 2 removed lines
  • modules/hub/src/main/twirl/de/smederee/hub/views/navbar.scala.html with 1 added and 0 removed lines
diff -rN -u old-smederee/modules/hub/src/it/scala/de/smederee/hub/DoobieAccountManagementRepositoryTest.scala new-smederee/modules/hub/src/it/scala/de/smederee/hub/DoobieAccountManagementRepositoryTest.scala
--- old-smederee/modules/hub/src/it/scala/de/smederee/hub/DoobieAccountManagementRepositoryTest.scala	1970-01-01 00:00:00.000000000 +0000
+++ new-smederee/modules/hub/src/it/scala/de/smederee/hub/DoobieAccountManagementRepositoryTest.scala	2025-02-02 03:57:42.669960040 +0000
@@ -0,0 +1,83 @@
+/*
+ * Copyright (C) 2022  Contributors as noted in the AUTHORS.md file
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ */
+
+package de.smederee.hub
+
+import java.util.UUID
+
+import cats.effect._
+import cats.syntax.all._
+import de.smederee.hub.Generators._
+import de.smederee.hub.config.SmedereeHubConfig
+import doobie._
+import org.flywaydb.core.Flyway
+
+import munit._
+
+final class DoobieAccountManagementRepositoryTest extends BaseSpec {
+  override def beforeEach(context: BeforeEach): Unit = {
+    val dbConfig = configuration.database
+    val flyway: Flyway =
+      Flyway.configure().cleanDisabled(false).dataSource(dbConfig.url, dbConfig.user, dbConfig.pass).load()
+    val _ = flyway.migrate()
+    val _ = flyway.clean()
+    val _ = flyway.migrate()
+  }
+
+  override def afterEach(context: AfterEach): Unit = {
+    val dbConfig = configuration.database
+    val flyway: Flyway =
+      Flyway.configure().cleanDisabled(false).dataSource(dbConfig.url, dbConfig.user, dbConfig.pass).load()
+    val _ = flyway.migrate()
+    val _ = flyway.clean()
+  }
+
+  test("deleteAccount must remove the account from the database") {
+    genValidAccount.sample match {
+      case None => fail("Could not generate data samples!")
+      case Some(account) =>
+        val dbConfig = configuration.database
+        val tx = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass)
+        val repo     = new DoobieAccountManagementRepository[IO](tx)
+        val attempts = scala.util.Random.nextInt(128)
+        val hash     = PasswordHash("Yet another weak password!")
+        val test = for {
+          _ <- createAccount(account, hash, None, Option(attempts))
+          _ <- repo.deleteAccount(account.uid)
+          o <- loadAccount(account.uid)
+        } yield o
+        test.map(result => assert(result === None, "Account not deleted from database!"))
+    }
+  }
+
+  test("findPasswordHash must return correct hash") {
+    genValidAccount.sample match {
+      case None => fail("Could not generate data samples!")
+      case Some(account) =>
+        val dbConfig = configuration.database
+        val tx = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass)
+        val repo     = new DoobieAccountManagementRepository[IO](tx)
+        val attempts = scala.util.Random.nextInt(128)
+        val hash     = PasswordHash("Yet another weak password!")
+        val test = for {
+          _ <- createAccount(account, hash, None, Option(attempts))
+          o <- repo.findPasswordHash(account.uid)
+        } yield o
+        test.map(result => assert(result.exists(_ === hash), "Unexpected result from database!"))
+    }
+  }
+}
diff -rN -u old-smederee/modules/hub/src/main/resources/assets/css/main.css new-smederee/modules/hub/src/main/resources/assets/css/main.css
--- old-smederee/modules/hub/src/main/resources/assets/css/main.css	2025-02-02 03:57:42.669960040 +0000
+++ new-smederee/modules/hub/src/main/resources/assets/css/main.css	2025-02-02 03:57:42.669960040 +0000
@@ -3,6 +3,11 @@
   /*margin-right: 1em;*/
 }
 
+.account-delete-form {
+  border: 1px solid black;
+  padding: 0px 10px;
+}
+
 .pure-button {
   background-color: #1f8dd6;
   color: white;
diff -rN -u old-smederee/modules/hub/src/main/resources/db/migration/V1__base_tables.sql new-smederee/modules/hub/src/main/resources/db/migration/V1__base_tables.sql
--- old-smederee/modules/hub/src/main/resources/db/migration/V1__base_tables.sql	2025-02-02 03:57:42.669960040 +0000
+++ new-smederee/modules/hub/src/main/resources/db/migration/V1__base_tables.sql	2025-02-02 03:57:42.673960045 +0000
@@ -12,6 +12,7 @@
   "created_at"      TIMESTAMP WITH TIME ZONE NOT NULL,
   "updated_at"      TIMESTAMP WITH TIME ZONE NOT NULL,
   "verified_email"  BOOLEAN                  DEFAULT FALSE,
+  "verify_token"    TEXT                     DEFAULT NULL,
   CONSTRAINT "accounts_pk"           PRIMARY KEY ("uid"),
   CONSTRAINT "accounts_unique_name"  UNIQUE ("name"),
   CONSTRAINT "accounts_unique_email" UNIQUE ("email")
@@ -33,6 +34,7 @@
 COMMENT ON COLUMN "accounts"."created_at" IS 'The timestamp of when the account was created.';
 COMMENT ON COLUMN "accounts"."updated_at" IS 'A timestamp when the account was last changed.';
 COMMENT ON COLUMN "accounts"."verified_email" IS 'This flag indicates if the email address of the user has been verified via a verification email.';
+COMMENT ON COLUMN "accounts"."verify_token" IS 'A token used to verify the email address of the user.';
 
 CREATE TABLE "sessions"
 (
diff -rN -u old-smederee/modules/hub/src/main/resources/messages_en.properties new-smederee/modules/hub/src/main/resources/messages_en.properties
--- old-smederee/modules/hub/src/main/resources/messages_en.properties	2025-02-02 03:57:42.669960040 +0000
+++ new-smederee/modules/hub/src/main/resources/messages_en.properties	2025-02-02 03:57:42.669960040 +0000
@@ -14,6 +14,10 @@
 errors.forbidden.title=403 - Forbidden
 
 # Forms
+form.account.delete.button.submit=Delete my account!
+form.account.delete.i-am-sure=Yes, I am sure that I want to delete my account and all related data!
+form.account.delete.notice=If you delete your account then all related data will be permanently removed. This action CANNOT be undone!
+form.account.delete.password=Password
 form.create-repo.button.submit=Create repository
 form.create-repo.name=Name
 form.create-repo.name.placeholder=Please enter a repository name.
@@ -54,6 +58,7 @@
 global.navbar.top.repositories.all=All repositories
 global.navbar.top.repositories.yours=Your repositories
 global.navbar.top.repository.new=New repository
+global.navbar.top.settings=Settings
 global.privacy=Privacy Policy
 global.signup=Sign Up
 global.terms.of.use=Terms of Use
@@ -148,3 +153,6 @@
 repository.overview.latest-changes=Latest changes
 repository.overview.latest-changes.timestamp={0,date,yyyy-MM-dd (E)}, {0,time,short}
 
+# User management / settings
+user.settings.account.delete.title=Delete your account
+
diff -rN -u old-smederee/modules/hub/src/main/scala/de/smederee/hub/AccountManagementRepository.scala new-smederee/modules/hub/src/main/scala/de/smederee/hub/AccountManagementRepository.scala
--- old-smederee/modules/hub/src/main/scala/de/smederee/hub/AccountManagementRepository.scala	1970-01-01 00:00:00.000000000 +0000
+++ new-smederee/modules/hub/src/main/scala/de/smederee/hub/AccountManagementRepository.scala	2025-02-02 03:57:42.673960045 +0000
@@ -0,0 +1,48 @@
+/*
+ * Copyright (C) 2022  Contributors as noted in the AUTHORS.md file
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ */
+
+package de.smederee.hub
+
+/** The base class for database operations related to account management for users.
+  *
+  * @tparam F
+  *   A higher kinded type which wraps the actual return values.
+  */
+abstract class AccountManagementRepository[F[_]] {
+
+  /** Delete the account with the given user id from the database.
+    *
+    * The internal database logic (foreign keys, cascading) SHALL ensure that everything related to the user
+    * is deleted from the database.
+    *
+    * @param uid
+    *   The unique id of the user account.
+    * @return
+    *   The number of affected database rows.
+    */
+  def deleteAccount(uid: UserId): F[Int]
+
+  /** Retrieve the password hash from the database.
+    *
+    * @param uid
+    *   The unique id of the user account.
+    * @return
+    *   An option to the password hash.
+    */
+  def findPasswordHash(uid: UserId): F[Option[PasswordHash]]
+
+}
diff -rN -u old-smederee/modules/hub/src/main/scala/de/smederee/hub/AccountManagementRoutes.scala new-smederee/modules/hub/src/main/scala/de/smederee/hub/AccountManagementRoutes.scala
--- old-smederee/modules/hub/src/main/scala/de/smederee/hub/AccountManagementRoutes.scala	1970-01-01 00:00:00.000000000 +0000
+++ new-smederee/modules/hub/src/main/scala/de/smederee/hub/AccountManagementRoutes.scala	2025-02-02 03:57:42.673960045 +0000
@@ -0,0 +1,117 @@
+/*
+ * Copyright (C) 2022  Contributors as noted in the AUTHORS.md file
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ */
+
+package de.smederee.hub
+
+import cats._
+import cats.data._
+import cats.effect._
+import cats.syntax.all._
+import de.smederee.html.LinkTools._
+import de.smederee.hub.RequestHelpers.instances.given
+import de.smederee.hub.config._
+import de.smederee.security.SignAndValidate
+import org.http4s._
+import org.http4s.dsl._
+import org.http4s.headers.Location
+import org.http4s.implicits._
+import org.http4s.twirl.TwirlInstances._
+import org.slf4j.LoggerFactory
+
+/** Routes for user account self management which should provide every functionality needed by users to manage
+  * their account.
+  *
+  * @param accountManagementRepo
+  *   The repository providing all needed database functionality for account management.
+  * @param configuration
+  *   The hub service configuration.
+  * @param signAndValidate
+  *   A class providing functions to handle session token signing and validation.
+  * @tparam F
+  *   A higher kinded type providing needed functionality, which is usually an IO monad like Async or Sync.
+  */
+final class AccountManagementRoutes[F[_]: Async](
+    accountManagementRepo: AccountManagementRepository[F],
+    configuration: ServiceConfig,
+    signAndValidate: SignAndValidate
+) extends Http4sDsl[F] {
+  private val log = LoggerFactory.getLogger(getClass)
+
+  private val deleteAccount: AuthedRoutes[Account, F] = AuthedRoutes.of {
+    case ar @ POST -> Root / "user" / "settings" / "delete" as user =>
+      ar.req.decodeStrict[F, UrlForm] { urlForm =>
+        for {
+          csrf <- Sync[F].delay(ar.req.getCsrfToken)
+          formData <- Sync[F].delay {
+            urlForm.values.map { t =>
+              val (key, values) = t
+              (
+                key,
+                values.headOption.getOrElse("")
+              ) // Pick the first value (a field might get submitted multiple times)!
+            }
+          }
+          passwordField <- Sync[F].delay(formData.get("password").flatMap(Password.from))
+          userIsSure    <- Sync[F].delay(formData.get("i-am-sure").exists(_ === "yes"))
+          rootUri       <- Sync[F].delay(configuration.external.createFullUri(uri"/"))
+          deleteAction  <- Sync[F].delay(configuration.external.createFullUri(uri"user/settings/delete"))
+          passwordHash  <- accountManagementRepo.findPasswordHash(user.uid)
+          passwordCorrect <- Sync[F].delay(
+            (passwordField, passwordHash)
+              .mapN { case (enteredPassword, hashFromDatabase) =>
+                enteredPassword.matches(hashFromDatabase)
+              }
+              .getOrElse(false)
+          )
+          response <-
+            if (passwordCorrect && userIsSure) {
+              // Delete the user account and redirect the user ot the start page removing the authentication cookie in the process.
+              // FIXME Also delete the repositories on disk!
+              for {
+                _ <- Sync[F].delay(
+                  log.info(s"Going to delete account ${user.name} as requested by the user.")
+                )
+                response <- accountManagementRepo.deleteAccount(user.uid) *> SeeOther(Location(rootUri)).map(
+                  _.removeCookie(Constants.authenticationCookieName.toString)
+                )
+              } yield response
+            } else
+              BadRequest(
+                views.html.account.settings()(csrf, Option(s"Smederee/~${user.name} - Settings"), user)(
+                  deleteAction
+                )
+              )
+        } yield response
+      }
+  }
+
+  private val showAccountSettings: AuthedRoutes[Account, F] = AuthedRoutes.of {
+    case ar @ GET -> Root / "user" / "settings" as user =>
+      for {
+        csrf         <- Sync[F].delay(ar.req.getCsrfToken)
+        deleteAction <- Sync[F].delay(configuration.external.createFullUri(uri"user/settings/delete"))
+        resp <- Ok(
+          views.html.account.settings()(csrf, Option(s"Smederee/~${user.name} - Settings"), user)(
+            deleteAction
+          )
+        )
+      } yield resp
+  }
+
+  val protectedRoutes = deleteAccount <+> showAccountSettings
+
+}
diff -rN -u old-smederee/modules/hub/src/main/scala/de/smederee/hub/AuthenticationRepository.scala new-smederee/modules/hub/src/main/scala/de/smederee/hub/AuthenticationRepository.scala
--- old-smederee/modules/hub/src/main/scala/de/smederee/hub/AuthenticationRepository.scala	2025-02-02 03:57:42.669960040 +0000
+++ new-smederee/modules/hub/src/main/scala/de/smederee/hub/AuthenticationRepository.scala	2025-02-02 03:57:42.673960045 +0000
@@ -28,8 +28,8 @@
   * 3. Also the function `findPasswordHashAndAttempts` must only consider *unlocked* accounts!
   * }}}
   *
-  * @tparm
-  *   F A higher kinded type which wraps the actual return values.
+  * @tparam F
+  *   A higher kinded type which wraps the actual return values.
   */
 abstract class AuthenticationRepository[F[_]] {
 
diff -rN -u old-smederee/modules/hub/src/main/scala/de/smederee/hub/DoobieAccountManagementRepository.scala new-smederee/modules/hub/src/main/scala/de/smederee/hub/DoobieAccountManagementRepository.scala
--- old-smederee/modules/hub/src/main/scala/de/smederee/hub/DoobieAccountManagementRepository.scala	1970-01-01 00:00:00.000000000 +0000
+++ new-smederee/modules/hub/src/main/scala/de/smederee/hub/DoobieAccountManagementRepository.scala	2025-02-02 03:57:42.673960045 +0000
@@ -0,0 +1,42 @@
+/*
+ * Copyright (C) 2022  Contributors as noted in the AUTHORS.md file
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ */
+
+package de.smederee.hub
+
+import java.util.UUID
+
+import cats.effect._
+import cats.syntax.all._
+import de.smederee.hub.VcsMetadataRepositoriesOrdering._
+import doobie._
+import doobie.Fragments._
+import doobie.implicits._
+import doobie.postgres.implicits._
+import fs2.Stream
+import org.http4s.Uri
+
+final class DoobieAccountManagementRepository[F[_]: Sync](tx: Transactor[F])
+    extends AccountManagementRepository[F] {
+  given Meta[PasswordHash] = Meta[String].timap(PasswordHash.apply)(_.toString)
+  given Meta[UserId]       = Meta[UUID].timap(UserId.apply)(_.toUUID)
+
+  override def deleteAccount(uid: UserId): F[Int] =
+    sql"""DELETE FROM "accounts" WHERE uid = $uid""".update.run.transact(tx)
+
+  override def findPasswordHash(uid: UserId): F[Option[PasswordHash]] =
+    sql"""SELECT password FROM "accounts" WHERE uid = $uid LIMIT 1""".query[PasswordHash].option.transact(tx)
+}
diff -rN -u old-smederee/modules/hub/src/main/scala/de/smederee/hub/HubServer.scala new-smederee/modules/hub/src/main/scala/de/smederee/hub/HubServer.scala
--- old-smederee/modules/hub/src/main/scala/de/smederee/hub/HubServer.scala	2025-02-02 03:57:42.669960040 +0000
+++ new-smederee/modules/hub/src/main/scala/de/smederee/hub/HubServer.scala	2025-02-02 03:57:42.673960045 +0000
@@ -102,8 +102,14 @@
           configuration.service.authentication.timeouts
         )
       )
-      darcsWrapper    = new DarcsCommands[IO](configuration.service.darcs.executable)
-      emailMiddleware = new SimpleJavaMailMiddleware(configuration.service.email)
+      darcsWrapper          = new DarcsCommands[IO](configuration.service.darcs.executable)
+      emailMiddleware       = new SimpleJavaMailMiddleware(configuration.service.email)
+      accountManagementRepo = new DoobieAccountManagementRepository[IO](transactor)
+      accountManagementRoutes = new AccountManagementRoutes[IO](
+        accountManagementRepo,
+        configuration.service,
+        signAndValidate
+      )
       authenticationRoutes = new AuthenticationRoutes[IO](
         cryptoClock,
         configuration.service.authentication,
@@ -124,7 +130,7 @@
         vcsMetadataRepo
       )
       protectedRoutesWithFallThrough = authenticationWithFallThrough(
-        authenticationRoutes.protectedRoutes <+> signUpRoutes.protectedRoutes <+> vcsRepoRoutes.protectedRoutes <+> landingPages.protectedRoutes
+        authenticationRoutes.protectedRoutes <+> accountManagementRoutes.protectedRoutes <+> signUpRoutes.protectedRoutes <+> vcsRepoRoutes.protectedRoutes <+> landingPages.protectedRoutes
       )
       globalRoutes = Router(
         Constants.assetsPath.path.toAbsolute.toString -> assetsRoutes,
diff -rN -u old-smederee/modules/hub/src/main/scala/de/smederee/hub/SignupRepository.scala new-smederee/modules/hub/src/main/scala/de/smederee/hub/SignupRepository.scala
--- old-smederee/modules/hub/src/main/scala/de/smederee/hub/SignupRepository.scala	2025-02-02 03:57:42.669960040 +0000
+++ new-smederee/modules/hub/src/main/scala/de/smederee/hub/SignupRepository.scala	2025-02-02 03:57:42.673960045 +0000
@@ -19,8 +19,8 @@
 
 /** A base class for our signup repository which provides the needed database functions.
   *
-  * @tparm
-  *   F A higher kinded type which wraps the actual return values.
+  * @tparam F
+  *   A higher kinded type which wraps the actual return values.
   */
 abstract class SignupRepository[F[_]] {
 
diff -rN -u old-smederee/modules/hub/src/main/scala/de/smederee/hub/VcsMetadataRepository.scala new-smederee/modules/hub/src/main/scala/de/smederee/hub/VcsMetadataRepository.scala
--- old-smederee/modules/hub/src/main/scala/de/smederee/hub/VcsMetadataRepository.scala	2025-02-02 03:57:42.669960040 +0000
+++ new-smederee/modules/hub/src/main/scala/de/smederee/hub/VcsMetadataRepository.scala	2025-02-02 03:57:42.673960045 +0000
@@ -22,8 +22,8 @@
 /** A base class for a database repository that should handle all functionality regarding vcs repositories and
   * their metadata in the database.
   *
-  * @tparm
-  *   F A higher kinded type which wraps the actual return values.
+  * @tparam F
+  *   A higher kinded type which wraps the actual return values.
   */
 abstract class VcsMetadataRepository[F[_]] {
 
diff -rN -u old-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/account/settings.scala.html new-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/account/settings.scala.html
--- old-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/account/settings.scala.html	1970-01-01 00:00:00.000000000 +0000
+++ new-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/account/settings.scala.html	2025-02-02 03:57:42.673960045 +0000
@@ -0,0 +1,31 @@
+@(baseUri: Uri = Uri(path = Uri.Path.Root), lang: LanguageCode = LanguageCode("en"))(csrf: Option[CsrfToken] = None, title: Option[String] = None, user: Account)(deleteAction: Uri)
+@main(baseUri, lang)()(csrf, title, user.some) {
+@defining(lang.toLocale) { implicit locale =>
+<div class="content">
+  <div class="pure-g">
+    <div class="pure-u-1-1 pure-u-md-1-1">
+      <div class="l-box">
+        <div class="account-delete-form">
+          <h4>@Messages("user.settings.account.delete.title")</h4>
+          <form action="@deleteAction" class="pure-form" method="POST" accept-charset="UTF-8">
+            <fieldset>
+              <p class="alert alert-error">
+                <span class="glyphicon glyphicon-exclamation-sign" aria-hidden="true"></span>
+                @Messages("form.account.delete.notice")
+              </p>
+              <div class="pure-control-group">
+                <label for="password">@Messages("form.account.delete.password")</label>
+                <input class="pure-input-1" id="password" name="password" maxlength="128" required="" type="password">
+              </div>
+              <label for="i-am-sure" class="pure-checkbox"><input id="i-am-sure" name="i-am-sure" required="" type="checkbox" value="yes"/> @Messages("form.account.delete.i-am-sure")</label>
+              @csrfToken(csrf)
+              <button type="submit" class="button-warning pure-button">@Messages("form.account.delete.button.submit")</button>
+            </fieldset>
+          </form>
+        </div>
+      </div>
+    </div>
+  </div>
+</div>
+}
+}
diff -rN -u old-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/navbar.scala.html new-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/navbar.scala.html
--- old-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/navbar.scala.html	2025-02-02 03:57:42.669960040 +0000
+++ new-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/navbar.scala.html	2025-02-02 03:57:42.673960045 +0000
@@ -10,6 +10,7 @@
         <li class="pure-menu-item"><a class="pure-menu-link" href="@{baseUri.addPath(s"~${account.name}")}">@Messages("global.navbar.top.repositories.yours")</a></li>
       }
       <li class="pure-menu-item"><a class="pure-menu-link" href="@{baseUri.addPath("repo/create")}">+ @Messages("global.navbar.top.repository.new")</a></li>
+      <li class="pure-menu-item"><a class="pure-menu-link" href="@{baseUri.addPath("user/settings")}">@Messages("global.navbar.top.settings")</a></li>
       <li class="pure-menu-item">
         <form action="@{baseUri.addPath("logout")}" method="POST" accept-charset="UTF-8" class="pure-form">
           @csrfToken(csrf)