From f6d3a1845205318f43eb443601fd257613b7defb Mon Sep 17 00:00:00 2001
From: Janis
Date: Tue, 20 Jan 2026 12:27:59 +0100
Subject: [PATCH] feat: BAC-39 Authentication (#114)
Reviewed-on: https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/pulls/114
Co-authored-by: Janis
Co-committed-by: Janis
---
build.sbt | 8 +
knockoutwhistfrontend | 2 +-
.../app/controllers/IngameController.scala | 444 -----
.../JavaScriptRoutingController.scala | 24 -
.../app/controllers/MainMenuController.scala | 40 +-
.../app/controllers/OpenIDController.scala | 146 ++
.../app/controllers/UserController.scala | 41 +
.../app/di/EntityManagerProvider.scala | 22 +
.../app/di/ProductionModule.scala | 25 +
.../app/logic/user/UserManager.scala | 5 +
.../user/impl/HibernateUserManager.scala | 161 ++
.../app/logic/user/impl/StubUserManager.scala | 41 +-
.../app/model/sessions/UserSession.scala | 3 +
.../app/model/users/UserEntity.scala | 80 +
.../app/services/OpenIDConnectService.scala | 180 ++
knockoutwhistweb/app/util/WebUIUtils.scala | 3 -
.../app/util/WebsocketEventMapper.scala | 5 -
.../util/mapper/GameStateEventMapper.scala | 19 -
.../app/util/mapper/KickEventMapper.scala | 19 -
.../app/util/mapper/LeftEventMapper.scala | 19 -
.../app/util/mapper/SessionClosedMapper.scala | 19 -
.../app/views/ingame/finishedMatch.scala.html | 108 --
.../app/views/ingame/ingame.scala.html | 42 -
.../app/views/ingame/selecttrump.scala.html | 68 -
.../app/views/ingame/tie.scala.html | 114 --
.../app/views/lobby/lobby.scala.html | 38 -
.../app/views/login/login.scala.html | 43 -
knockoutwhistweb/app/views/main.scala.html | 36 -
.../app/views/mainmenu/creategame.scala.html | 33 -
.../app/views/mainmenu/navbar.scala.html | 55 -
.../app/views/mainmenu/rules.scala.html | 180 --
.../app/views/render/card.scala.html | 2 -
.../app/views/render/text.scala.html | 3 -
knockoutwhistweb/conf/application.conf | 25 +
knockoutwhistweb/conf/db.conf | 28 +
knockoutwhistweb/conf/persistence.xml | 38 +
knockoutwhistweb/conf/prod.conf | 24 +-
knockoutwhistweb/conf/routes | 25 +-
knockoutwhistweb/conf/staging.conf | 23 +-
.../public/conf/particlesjs-config.json | 110 --
knockoutwhistweb/public/images/background.png | Bin 2947309 -> 0 bytes
knockoutwhistweb/public/images/cards/1B.png | Bin 4403 -> 0 bytes
knockoutwhistweb/public/images/cards/1J.png | Bin 15878 -> 0 bytes
knockoutwhistweb/public/images/cards/2B.png | Bin 4124 -> 0 bytes
knockoutwhistweb/public/images/cards/2C.png | Bin 8623 -> 0 bytes
knockoutwhistweb/public/images/cards/2D.png | Bin 6378 -> 0 bytes
knockoutwhistweb/public/images/cards/2H.png | Bin 6688 -> 0 bytes
knockoutwhistweb/public/images/cards/2J.png | Bin 16063 -> 0 bytes
knockoutwhistweb/public/images/cards/2S.png | Bin 7852 -> 0 bytes
knockoutwhistweb/public/images/cards/3C.png | Bin 10371 -> 0 bytes
knockoutwhistweb/public/images/cards/3D.png | Bin 7475 -> 0 bytes
knockoutwhistweb/public/images/cards/3H.png | Bin 7818 -> 0 bytes
knockoutwhistweb/public/images/cards/3S.png | Bin 9299 -> 0 bytes
knockoutwhistweb/public/images/cards/4C.png | Bin 9353 -> 0 bytes
knockoutwhistweb/public/images/cards/4D.png | Bin 6711 -> 0 bytes
knockoutwhistweb/public/images/cards/4H.png | Bin 7175 -> 0 bytes
knockoutwhistweb/public/images/cards/4S.png | Bin 8833 -> 0 bytes
knockoutwhistweb/public/images/cards/5C.png | Bin 11384 -> 0 bytes
knockoutwhistweb/public/images/cards/5D.png | Bin 8074 -> 0 bytes
knockoutwhistweb/public/images/cards/5H.png | Bin 8632 -> 0 bytes
knockoutwhistweb/public/images/cards/5S.png | Bin 10558 -> 0 bytes
knockoutwhistweb/public/images/cards/6C.png | Bin 12334 -> 0 bytes
knockoutwhistweb/public/images/cards/6D.png | Bin 8778 -> 0 bytes
knockoutwhistweb/public/images/cards/6H.png | Bin 9454 -> 0 bytes
knockoutwhistweb/public/images/cards/6S.png | Bin 11806 -> 0 bytes
knockoutwhistweb/public/images/cards/7C.png | Bin 13206 -> 0 bytes
knockoutwhistweb/public/images/cards/7D.png | Bin 9176 -> 0 bytes
knockoutwhistweb/public/images/cards/7H.png | Bin 9805 -> 0 bytes
knockoutwhistweb/public/images/cards/7S.png | Bin 12321 -> 0 bytes
knockoutwhistweb/public/images/cards/8C.png | Bin 15775 -> 0 bytes
knockoutwhistweb/public/images/cards/8D.png | Bin 11007 -> 0 bytes
knockoutwhistweb/public/images/cards/8H.png | Bin 11640 -> 0 bytes
knockoutwhistweb/public/images/cards/8S.png | Bin 14667 -> 0 bytes
knockoutwhistweb/public/images/cards/9C.png | Bin 15840 -> 0 bytes
knockoutwhistweb/public/images/cards/9D.png | Bin 10826 -> 0 bytes
knockoutwhistweb/public/images/cards/9H.png | Bin 11795 -> 0 bytes
knockoutwhistweb/public/images/cards/9S.png | Bin 14912 -> 0 bytes
knockoutwhistweb/public/images/cards/AC.png | Bin 6709 -> 0 bytes
knockoutwhistweb/public/images/cards/ACB.png | Bin 3562 -> 0 bytes
knockoutwhistweb/public/images/cards/AD.png | Bin 5288 -> 0 bytes
knockoutwhistweb/public/images/cards/ADB.png | Bin 2822 -> 0 bytes
knockoutwhistweb/public/images/cards/AH.png | Bin 5467 -> 0 bytes
knockoutwhistweb/public/images/cards/AHB.png | Bin 3510 -> 0 bytes
knockoutwhistweb/public/images/cards/AS.png | Bin 12533 -> 0 bytes
knockoutwhistweb/public/images/cards/ASB.png | Bin 11586 -> 0 bytes
knockoutwhistweb/public/images/cards/JC.png | Bin 69643 -> 0 bytes
knockoutwhistweb/public/images/cards/JD.png | Bin 73617 -> 0 bytes
knockoutwhistweb/public/images/cards/JH.png | Bin 67089 -> 0 bytes
knockoutwhistweb/public/images/cards/JS.png | Bin 68386 -> 0 bytes
knockoutwhistweb/public/images/cards/KC.png | Bin 61021 -> 0 bytes
knockoutwhistweb/public/images/cards/KD.png | Bin 62385 -> 0 bytes
knockoutwhistweb/public/images/cards/KH.png | Bin 69463 -> 0 bytes
knockoutwhistweb/public/images/cards/KS.png | Bin 69631 -> 0 bytes
knockoutwhistweb/public/images/cards/QC.png | Bin 65099 -> 0 bytes
knockoutwhistweb/public/images/cards/QD.png | Bin 75063 -> 0 bytes
knockoutwhistweb/public/images/cards/QH.png | Bin 74608 -> 0 bytes
knockoutwhistweb/public/images/cards/QS.png | Bin 73275 -> 0 bytes
knockoutwhistweb/public/images/cards/TC.png | Bin 16475 -> 0 bytes
knockoutwhistweb/public/images/cards/TD.png | Bin 10888 -> 0 bytes
knockoutwhistweb/public/images/cards/TH.png | Bin 11653 -> 0 bytes
knockoutwhistweb/public/images/cards/TS.png | Bin 14698 -> 0 bytes
knockoutwhistweb/public/images/favicon.png | Bin 687 -> 0 bytes
knockoutwhistweb/public/images/img.png | Bin 3439446 -> 0 bytes
knockoutwhistweb/public/images/logo.png | Bin 19770 -> 0 bytes
knockoutwhistweb/public/images/profile.png | Bin 37198 -> 0 bytes
knockoutwhistweb/public/javascripts/events.js | 653 -------
.../public/javascripts/interact.js | 33 -
knockoutwhistweb/public/javascripts/main.js | 222 ---
.../public/javascripts/particles.js | 1524 -----------------
.../public/javascripts/websocket.js | 192 ---
110 files changed, 850 insertions(+), 4075 deletions(-)
delete mode 100644 knockoutwhistweb/app/controllers/IngameController.scala
delete mode 100644 knockoutwhistweb/app/controllers/JavaScriptRoutingController.scala
create mode 100644 knockoutwhistweb/app/controllers/OpenIDController.scala
create mode 100644 knockoutwhistweb/app/di/EntityManagerProvider.scala
create mode 100644 knockoutwhistweb/app/di/ProductionModule.scala
create mode 100644 knockoutwhistweb/app/logic/user/impl/HibernateUserManager.scala
create mode 100644 knockoutwhistweb/app/model/users/UserEntity.scala
create mode 100644 knockoutwhistweb/app/services/OpenIDConnectService.scala
delete mode 100644 knockoutwhistweb/app/util/mapper/GameStateEventMapper.scala
delete mode 100644 knockoutwhistweb/app/util/mapper/KickEventMapper.scala
delete mode 100644 knockoutwhistweb/app/util/mapper/LeftEventMapper.scala
delete mode 100644 knockoutwhistweb/app/util/mapper/SessionClosedMapper.scala
delete mode 100644 knockoutwhistweb/app/views/ingame/finishedMatch.scala.html
delete mode 100644 knockoutwhistweb/app/views/ingame/ingame.scala.html
delete mode 100644 knockoutwhistweb/app/views/ingame/selecttrump.scala.html
delete mode 100644 knockoutwhistweb/app/views/ingame/tie.scala.html
delete mode 100644 knockoutwhistweb/app/views/lobby/lobby.scala.html
delete mode 100644 knockoutwhistweb/app/views/login/login.scala.html
delete mode 100644 knockoutwhistweb/app/views/main.scala.html
delete mode 100644 knockoutwhistweb/app/views/mainmenu/creategame.scala.html
delete mode 100644 knockoutwhistweb/app/views/mainmenu/navbar.scala.html
delete mode 100644 knockoutwhistweb/app/views/mainmenu/rules.scala.html
delete mode 100644 knockoutwhistweb/app/views/render/card.scala.html
delete mode 100644 knockoutwhistweb/app/views/render/text.scala.html
create mode 100644 knockoutwhistweb/conf/db.conf
create mode 100644 knockoutwhistweb/conf/persistence.xml
delete mode 100644 knockoutwhistweb/public/conf/particlesjs-config.json
delete mode 100644 knockoutwhistweb/public/images/background.png
delete mode 100644 knockoutwhistweb/public/images/cards/1B.png
delete mode 100644 knockoutwhistweb/public/images/cards/1J.png
delete mode 100644 knockoutwhistweb/public/images/cards/2B.png
delete mode 100644 knockoutwhistweb/public/images/cards/2C.png
delete mode 100644 knockoutwhistweb/public/images/cards/2D.png
delete mode 100644 knockoutwhistweb/public/images/cards/2H.png
delete mode 100644 knockoutwhistweb/public/images/cards/2J.png
delete mode 100644 knockoutwhistweb/public/images/cards/2S.png
delete mode 100644 knockoutwhistweb/public/images/cards/3C.png
delete mode 100644 knockoutwhistweb/public/images/cards/3D.png
delete mode 100644 knockoutwhistweb/public/images/cards/3H.png
delete mode 100644 knockoutwhistweb/public/images/cards/3S.png
delete mode 100644 knockoutwhistweb/public/images/cards/4C.png
delete mode 100644 knockoutwhistweb/public/images/cards/4D.png
delete mode 100644 knockoutwhistweb/public/images/cards/4H.png
delete mode 100644 knockoutwhistweb/public/images/cards/4S.png
delete mode 100644 knockoutwhistweb/public/images/cards/5C.png
delete mode 100644 knockoutwhistweb/public/images/cards/5D.png
delete mode 100644 knockoutwhistweb/public/images/cards/5H.png
delete mode 100644 knockoutwhistweb/public/images/cards/5S.png
delete mode 100644 knockoutwhistweb/public/images/cards/6C.png
delete mode 100644 knockoutwhistweb/public/images/cards/6D.png
delete mode 100644 knockoutwhistweb/public/images/cards/6H.png
delete mode 100644 knockoutwhistweb/public/images/cards/6S.png
delete mode 100644 knockoutwhistweb/public/images/cards/7C.png
delete mode 100644 knockoutwhistweb/public/images/cards/7D.png
delete mode 100644 knockoutwhistweb/public/images/cards/7H.png
delete mode 100644 knockoutwhistweb/public/images/cards/7S.png
delete mode 100644 knockoutwhistweb/public/images/cards/8C.png
delete mode 100644 knockoutwhistweb/public/images/cards/8D.png
delete mode 100644 knockoutwhistweb/public/images/cards/8H.png
delete mode 100644 knockoutwhistweb/public/images/cards/8S.png
delete mode 100644 knockoutwhistweb/public/images/cards/9C.png
delete mode 100644 knockoutwhistweb/public/images/cards/9D.png
delete mode 100644 knockoutwhistweb/public/images/cards/9H.png
delete mode 100644 knockoutwhistweb/public/images/cards/9S.png
delete mode 100644 knockoutwhistweb/public/images/cards/AC.png
delete mode 100644 knockoutwhistweb/public/images/cards/ACB.png
delete mode 100644 knockoutwhistweb/public/images/cards/AD.png
delete mode 100644 knockoutwhistweb/public/images/cards/ADB.png
delete mode 100644 knockoutwhistweb/public/images/cards/AH.png
delete mode 100644 knockoutwhistweb/public/images/cards/AHB.png
delete mode 100644 knockoutwhistweb/public/images/cards/AS.png
delete mode 100644 knockoutwhistweb/public/images/cards/ASB.png
delete mode 100644 knockoutwhistweb/public/images/cards/JC.png
delete mode 100644 knockoutwhistweb/public/images/cards/JD.png
delete mode 100644 knockoutwhistweb/public/images/cards/JH.png
delete mode 100644 knockoutwhistweb/public/images/cards/JS.png
delete mode 100644 knockoutwhistweb/public/images/cards/KC.png
delete mode 100644 knockoutwhistweb/public/images/cards/KD.png
delete mode 100644 knockoutwhistweb/public/images/cards/KH.png
delete mode 100644 knockoutwhistweb/public/images/cards/KS.png
delete mode 100644 knockoutwhistweb/public/images/cards/QC.png
delete mode 100644 knockoutwhistweb/public/images/cards/QD.png
delete mode 100644 knockoutwhistweb/public/images/cards/QH.png
delete mode 100644 knockoutwhistweb/public/images/cards/QS.png
delete mode 100644 knockoutwhistweb/public/images/cards/TC.png
delete mode 100644 knockoutwhistweb/public/images/cards/TD.png
delete mode 100644 knockoutwhistweb/public/images/cards/TH.png
delete mode 100644 knockoutwhistweb/public/images/cards/TS.png
delete mode 100644 knockoutwhistweb/public/images/favicon.png
delete mode 100644 knockoutwhistweb/public/images/img.png
delete mode 100644 knockoutwhistweb/public/images/logo.png
delete mode 100644 knockoutwhistweb/public/images/profile.png
delete mode 100644 knockoutwhistweb/public/javascripts/events.js
delete mode 100644 knockoutwhistweb/public/javascripts/interact.js
delete mode 100644 knockoutwhistweb/public/javascripts/main.js
delete mode 100644 knockoutwhistweb/public/javascripts/particles.js
delete mode 100644 knockoutwhistweb/public/javascripts/websocket.js
diff --git a/build.sbt b/build.sbt
index 6051547..7f1c07e 100644
--- a/build.sbt
+++ b/build.sbt
@@ -52,6 +52,14 @@ lazy val knockoutwhistweb = project.in(file("knockoutwhistweb"))
libraryDependencies += "com.github.ben-manes.caffeine" % "caffeine" % "3.2.3",
libraryDependencies += "tools.jackson.module" %% "jackson-module-scala" % "3.0.2",
libraryDependencies += "de.janis" % "knockoutwhist-data" % "1.0-SNAPSHOT",
+ libraryDependencies += "org.hibernate.orm" % "hibernate-core" % "6.4.4.Final",
+ libraryDependencies += "jakarta.persistence" % "jakarta.persistence-api" % "3.1.0",
+ libraryDependencies += "org.postgresql" % "postgresql" % "42.7.4",
+ libraryDependencies += "org.playframework" %% "play-jdbc" % "3.0.6",
+ libraryDependencies += "org.playframework" %% "play-java-jpa" % "3.0.6",
+ libraryDependencies += "com.nimbusds" % "oauth2-oidc-sdk" % "11.31.1",
+ libraryDependencies += "org.playframework" %% "play-ws" % "3.0.6",
+ libraryDependencies += ws,
JsEngineKeys.engineType := JsEngineKeys.EngineType.Node
)
diff --git a/knockoutwhistfrontend b/knockoutwhistfrontend
index 6b8488e..86dcc61 160000
--- a/knockoutwhistfrontend
+++ b/knockoutwhistfrontend
@@ -1 +1 @@
-Subproject commit 6b8488e7a4b47c397e8e366412d32f40781e3b8b
+Subproject commit 86dcc6173c2780898803029d6c8da27c3f7590a3
diff --git a/knockoutwhistweb/app/controllers/IngameController.scala b/knockoutwhistweb/app/controllers/IngameController.scala
deleted file mode 100644
index 8752f22..0000000
--- a/knockoutwhistweb/app/controllers/IngameController.scala
+++ /dev/null
@@ -1,444 +0,0 @@
-package controllers
-
-import auth.{AuthAction, AuthenticatedRequest}
-import de.knockoutwhist.control.GameState
-import de.knockoutwhist.control.GameState.*
-import exceptions.*
-import logic.PodManager
-import logic.game.GameLobby
-import model.sessions.UserSession
-import model.users.User
-import play.api.*
-import play.api.libs.json.{JsValue, Json}
-import play.api.mvc.*
-import play.twirl.api.Html
-import util.GameUtil
-
-import java.util.UUID
-import javax.inject.*
-import scala.concurrent.ExecutionContext
-import scala.util.Try
-
-@Singleton
-class IngameController @Inject()(
- val cc: ControllerComponents,
- val authAction: AuthAction,
- implicit val ec: ExecutionContext
- ) extends AbstractController(cc) {
-
- def game(gameId: String): Action[AnyContent] = authAction { implicit request: AuthenticatedRequest[AnyContent] =>
- val game = PodManager.getGame(gameId)
- game match {
- case Some(g) =>
- val results = Try {
- IngameController.returnInnerHTML(g, g.logic.getCurrentState, request.user)
- }
- if (results.isSuccess) {
- Ok(views.html.main("Knockout Whist - " + GameUtil.stateToTitle(g.logic.getCurrentState))(results.get))
- } else {
- InternalServerError(results.failed.get.getMessage)
- }
- case None =>
- Redirect(routes.MainMenuController.mainMenu())
- }
- }
-
- def startGame(gameId: String): Action[AnyContent] = authAction { implicit request: AuthenticatedRequest[AnyContent] =>
- val game = PodManager.getGame(gameId)
- val result = Try {
- game match {
- case Some(g) =>
- g.startGame(request.user)
- case None =>
- NotFound("Game not found")
- }
- }
- if (result.isSuccess) {
- Ok(Json.obj(
- "status" -> "success",
- "redirectUrl" -> routes.IngameController.game(gameId).url,
- "content" -> IngameController.returnInnerHTML(game.get, game.get.logic.getCurrentState, request.user).toString()
- ))
- } else {
- val throwable = result.failed.get
- throwable match {
- case _: NotInThisGameException =>
- BadRequest(Json.obj(
- "status" -> "failure",
- "errorMessage" -> throwable.getMessage
- ))
- case _: NotHostException =>
- Forbidden(Json.obj(
- "status" -> "failure",
- "errorMessage" -> throwable.getMessage
- ))
- case _: NotEnoughPlayersException =>
- BadRequest(Json.obj(
- "status" -> "failure",
- "errorMessage" -> throwable.getMessage
- ))
- case _ =>
- InternalServerError(Json.obj(
- "status" -> "failure",
- "errorMessage" -> throwable.getMessage
- ))
- }
- }
- }
-
- def kickPlayer(gameId: String, playerToKick: String): Action[AnyContent] = authAction { implicit request: AuthenticatedRequest[AnyContent] =>
- val game = PodManager.getGame(gameId)
- val playerToKickUUID = UUID.fromString(playerToKick)
- val result = Try {
- game.get.leaveGame(playerToKickUUID, true)
- }
- if (result.isSuccess) {
- Ok(Json.obj(
- "status" -> "success",
- "redirectUrl" -> routes.IngameController.game(gameId).url
- ))
- } else {
- InternalServerError(Json.obj(
- "status" -> "failure",
- "errorMessage" -> "Something went wrong."
- ))
- }
- }
-
- def leaveGame(gameId: String): Action[AnyContent] = authAction { implicit request: AuthenticatedRequest[AnyContent] =>
- val game = PodManager.getGame(gameId)
- val result = Try {
- game.get.leaveGame(request.user.id, false)
- }
- if (result.isSuccess) {
- Ok(Json.obj(
- "status" -> "success",
- "redirectUrl" -> routes.MainMenuController.mainMenu().url,
- "content" -> views.html.mainmenu.creategame(Some(request.user)).toString
- ))
- } else {
- InternalServerError(Json.obj(
- "status" -> "failure",
- "errorMessage" -> "Something went wrong."
- ))
- }
- }
-
- def playCard(gameId: String): Action[AnyContent] = authAction { implicit request: AuthenticatedRequest[AnyContent] => {
- val game = PodManager.getGame(gameId)
- game match {
- case Some(g) =>
- val jsonBody = request.body.asJson
- val cardIdOpt: Option[String] = jsonBody.flatMap { jsValue =>
- (jsValue \ "cardID").asOpt[String]
- }
- cardIdOpt match {
- case Some(cardId) =>
- var optSession: Option[UserSession] = None
- val result = Try {
- val session = g.getUserSession(request.user.id)
- optSession = Some(session)
- session.lock.lock()
- g.playCard(session, cardId.toInt)
- }
- optSession.foreach(_.lock.unlock())
- if (result.isSuccess) {
- Ok(Json.obj(
- "status" -> "success"
- ))
- } else {
- val throwable = result.failed.get
- throwable match {
- case _: CantPlayCardException =>
- BadRequest(Json.obj(
- "status" -> "failure",
- "errorMessage" -> throwable.getMessage
- ))
- case _: NotInThisGameException =>
- BadRequest(Json.obj(
- "status" -> "failure",
- "errorMessage" -> throwable.getMessage
- ))
- case _: IllegalArgumentException =>
- BadRequest(Json.obj(
- "status" -> "failure",
- "errorMessage" -> throwable.getMessage
- ))
- case _: IllegalStateException =>
- BadRequest(Json.obj(
- "status" -> "failure",
- "errorMessage" -> throwable.getMessage
- ))
- case _: NotInteractableException =>
- BadRequest(Json.obj(
- "status" -> "failure",
- "errorMessage" -> throwable.getMessage
- ))
- case _ =>
- InternalServerError(Json.obj(
- "status" -> "failure",
- "errorMessage" -> throwable.getMessage
- ))
- }
- }
- case None =>
- BadRequest(Json.obj(
- "status" -> "failure",
- "errorMessage" -> "cardId Parameter is missing"
- ))
- }
- case None =>
- NotFound(Json.obj(
- "status" -> "failure",
- "errorMessage" -> "Game not found"
- ))
- }
- }
- }
-
- def playDogCard(gameId: String): Action[AnyContent] = authAction { implicit request: AuthenticatedRequest[AnyContent] => {
- val game = PodManager.getGame(gameId)
- game match {
- case Some(g) => {
- val jsonBody = request.body.asJson
- val cardIdOpt: Option[String] = jsonBody.flatMap { jsValue =>
- (jsValue \ "cardID").asOpt[String]
- }
- var optSession: Option[UserSession] = None
- val result = Try {
- cardIdOpt match {
- case Some(cardId) if cardId == "skip" =>
- val session = g.getUserSession(request.user.id)
- optSession = Some(session)
- session.lock.lock()
- g.playDogCard(session, -1)
- case Some(cardId) =>
- val session = g.getUserSession(request.user.id)
- optSession = Some(session)
- session.lock.lock()
- g.playDogCard(session, cardId.toInt)
- case None =>
- throw new IllegalArgumentException("cardId parameter is missing")
- }
- }
- optSession.foreach(_.lock.unlock())
- if (result.isSuccess) {
- NoContent
- } else {
- val throwable = result.failed.get
- throwable match {
- case _: CantPlayCardException =>
- BadRequest(Json.obj(
- "status" -> "failure",
- "errorMessage" -> throwable.getMessage
- ))
- case _: NotInThisGameException =>
- BadRequest(Json.obj(
- "status" -> "failure",
- "errorMessage" -> throwable.getMessage
- ))
- case _: IllegalArgumentException =>
- BadRequest(Json.obj(
- "status" -> "failure",
- "errorMessage" -> throwable.getMessage
- ))
- case _: IllegalStateException =>
- BadRequest(Json.obj(
- "status" -> "failure",
- "errorMessage" -> throwable.getMessage
- ))
- case _ =>
- InternalServerError(Json.obj(
- "status" -> "failure",
- "errorMessage" -> throwable.getMessage
- ))
- }
- }
- }
- case None =>
- NotFound("Game not found")
- }
- }
- }
-
- def playTrump(gameId: String): Action[AnyContent] = authAction { implicit request: AuthenticatedRequest[AnyContent] =>
- val game = PodManager.getGame(gameId)
- game match {
- case Some(g) =>
- val jsonBody = request.body.asJson
- val trumpOpt: Option[String] = jsonBody.flatMap { jsValue =>
- (jsValue \ "trump").asOpt[String]
- }
- trumpOpt match {
- case Some(trump) =>
- var optSession: Option[UserSession] = None
- val result = Try {
- val session = g.getUserSession(request.user.id)
- optSession = Some(session)
- session.lock.lock()
- g.selectTrump(session, trump.toInt)
- }
- optSession.foreach(_.lock.unlock())
- if (result.isSuccess) {
- NoContent
- } else {
- val throwable = result.failed.get
- throwable match {
- case _: IllegalArgumentException =>
- BadRequest(Json.obj(
- "status" -> "failure",
- "errorMessage" -> throwable.getMessage
- ))
- case _: NotInThisGameException =>
- BadRequest(Json.obj(
- "status" -> "failure",
- "errorMessage" -> throwable.getMessage
- ))
- case _: IllegalStateException =>
- BadRequest(Json.obj(
- "status" -> "failure",
- "errorMessage" -> throwable.getMessage
- ))
- case _ =>
- InternalServerError(Json.obj(
- "status" -> "failure",
- "errorMessage" -> throwable.getMessage
- ))
- }
- }
- case None =>
- BadRequest("trump parameter is missing")
- }
- case None =>
- NotFound("Game not found")
- }
- }
-
- def playTie(gameId: String): Action[AnyContent] = authAction { implicit request: AuthenticatedRequest[AnyContent] =>
- val game = PodManager.getGame(gameId)
- game match {
- case Some(g) =>
- val jsonBody = request.body.asJson
- val tieOpt: Option[String] = jsonBody.flatMap { jsValue =>
- (jsValue \ "tie").asOpt[String]
- }
- tieOpt match {
- case Some(tie) =>
- var optSession: Option[UserSession] = None
- val result = Try {
- val session = g.getUserSession(request.user.id)
- optSession = Some(session)
- session.lock.lock()
- g.selectTie(g.getUserSession(request.user.id), tie.toInt)
- }
- optSession.foreach(_.lock.unlock())
- if (result.isSuccess) {
- NoContent
- } else {
- val throwable = result.failed.get
- throwable match {
- case _: IllegalArgumentException =>
- BadRequest(Json.obj(
- "status" -> "failure",
- "errorMessage" -> throwable.getMessage
- ))
- case _: NotInThisGameException =>
- BadRequest(Json.obj(
- "status" -> "failure",
- "errorMessage" -> throwable.getMessage
- ))
- case _: IllegalStateException =>
- BadRequest(Json.obj(
- "status" -> "failure",
- "errorMessage" -> throwable.getMessage
- ))
- case _ =>
- InternalServerError(Json.obj(
- "status" -> "failure",
- "errorMessage" -> throwable.getMessage
- ))
- }
- }
- case None =>
- BadRequest("tie parameter is missing")
- }
- case None =>
- NotFound("Game not found")
- }
- }
-
-
- def returnToLobby(gameId: String): Action[AnyContent] = authAction { implicit request: AuthenticatedRequest[AnyContent] =>
- val game = PodManager.getGame(gameId)
- game match {
- case Some(g) =>
- val result = Try {
- val session = g.getUserSession(request.user.id)
- g.returnToLobby(session)
- }
- if (result.isSuccess) {
- Ok(Json.obj(
- "status" -> "success",
- "redirectUrl" -> routes.IngameController.game(gameId).url
- ))
- } else {
- val throwable = result.failed.get
- throwable match {
- case _: NotInThisGameException =>
- BadRequest(Json.obj(
- "status" -> "failure",
- "errorMessage" -> throwable.getMessage
- ))
- case _: IllegalStateException =>
- BadRequest(Json.obj(
- "status" -> "failure",
- "errorMessage" -> throwable.getMessage
- ))
- case _ =>
- InternalServerError(Json.obj(
- "status" -> "failure",
- "errorMessage" -> throwable.getMessage
- ))
- }
- }
- case None =>
- NotFound(Json.obj(
- "status" -> "failure",
- "errorMessage" -> "Game not found"
- ))
- }
- }
-
-}
-
-object IngameController {
-
- def returnInnerHTML(gameLobby: GameLobby, gameState: GameState, user: User): Html = {
- gameState match {
- case Lobby => views.html.lobby.lobby(Some(user), gameLobby)
- case InGame =>
- views.html.ingame.ingame(
- gameLobby.getPlayerByUser(user),
- gameLobby
- )
- case SelectTrump =>
- views.html.ingame.selecttrump(
- gameLobby.getPlayerByUser(user),
- gameLobby
- )
- case TieBreak =>
- views.html.ingame.tie(
- gameLobby.getPlayerByUser(user),
- gameLobby
- )
- case FinishedMatch =>
- views.html.ingame.finishedMatch(
- Some(user),
- gameLobby
- )
- case _ =>
- throw new IllegalStateException(s"Invalid game state for in-game view. GameId: ${gameLobby.id}" + s" State: ${gameLobby.logic.getCurrentState}")
- }
- }
-
-}
diff --git a/knockoutwhistweb/app/controllers/JavaScriptRoutingController.scala b/knockoutwhistweb/app/controllers/JavaScriptRoutingController.scala
deleted file mode 100644
index 5b03508..0000000
--- a/knockoutwhistweb/app/controllers/JavaScriptRoutingController.scala
+++ /dev/null
@@ -1,24 +0,0 @@
-package controllers
-
-import auth.AuthAction
-import play.api.mvc.{Action, AnyContent, BaseController, ControllerComponents}
-import play.api.routing.JavaScriptReverseRouter
-
-import javax.inject.Inject
-
-class JavaScriptRoutingController @Inject()(
- val controllerComponents: ControllerComponents,
- val authAction: AuthAction,
- ) extends BaseController {
- def javascriptRoutes(): Action[AnyContent] =
- Action { implicit request =>
- Ok(
- JavaScriptReverseRouter("jsRoutes")(
- routes.javascript.MainMenuController.createGame,
- routes.javascript.MainMenuController.joinGame,
- routes.javascript.MainMenuController.navSPA,
- routes.javascript.UserController.login_Post
- )
- ).as("text/javascript")
- }
-}
diff --git a/knockoutwhistweb/app/controllers/MainMenuController.scala b/knockoutwhistweb/app/controllers/MainMenuController.scala
index 8dd6cfe..e799d9c 100644
--- a/knockoutwhistweb/app/controllers/MainMenuController.scala
+++ b/knockoutwhistweb/app/controllers/MainMenuController.scala
@@ -19,15 +19,6 @@ class MainMenuController @Inject()(
val authAction: AuthAction
) extends BaseController {
- // Pass the request-handling function directly to authAction (no nested Action)
- def mainMenu(): Action[AnyContent] = authAction { implicit request: AuthenticatedRequest[AnyContent] =>
- Ok(views.html.main("Knockout Whist - Create Game")(views.html.mainmenu.creategame(Some(request.user))))
- }
-
- def index(): Action[AnyContent] = authAction { implicit request: AuthenticatedRequest[AnyContent] =>
- Redirect(routes.MainMenuController.mainMenu())
- }
-
def createGame(): Action[AnyContent] = authAction { implicit request: AuthenticatedRequest[AnyContent] =>
val jsonBody = request.body.asJson
if (jsonBody.isDefined) {
@@ -61,9 +52,7 @@ class MainMenuController @Inject()(
case Some(g) =>
g.addUser(request.user)
Ok(Json.obj(
- "status" -> "success",
- "redirectUrl" -> routes.IngameController.game(g.id).url,
- "content" -> IngameController.returnInnerHTML(g, g.logic.getCurrentState, request.user).toString
+ "status" -> "success"
))
case None =>
NotFound(Json.obj(
@@ -72,31 +61,4 @@ class MainMenuController @Inject()(
))
}
}
-
- def rules(): Action[AnyContent] = authAction { implicit request: AuthenticatedRequest[AnyContent] =>
- Ok(views.html.main("Knockout Whist - Rules")(views.html.mainmenu.rules(Some(request.user))))
- }
-
- def navSPA(location: String): Action[AnyContent] = authAction { implicit request: AuthenticatedRequest[AnyContent] =>
- location match {
- case "0" => // Main Menu
- Ok(Json.obj(
- "status" -> "success",
- "redirectUrl" -> routes.MainMenuController.mainMenu().url,
- "content" -> views.html.mainmenu.creategame(Some(request.user)).toString
- ))
- case "1" => // Rules
- Ok(Json.obj(
- "status" -> "success",
- "redirectUrl" -> routes.MainMenuController.rules().url,
- "content" -> views.html.mainmenu.rules(Some(request.user)).toString
- ))
- case _ =>
- BadRequest(Json.obj(
- "status" -> "failure",
- "errorMessage" -> "Invalid form submission"
- ))
- }
- }
-
}
\ No newline at end of file
diff --git a/knockoutwhistweb/app/controllers/OpenIDController.scala b/knockoutwhistweb/app/controllers/OpenIDController.scala
new file mode 100644
index 0000000..589d0d9
--- /dev/null
+++ b/knockoutwhistweb/app/controllers/OpenIDController.scala
@@ -0,0 +1,146 @@
+package controllers
+
+import logic.user.{SessionManager, UserManager}
+import model.users.User
+import play.api.Configuration
+import play.api.libs.json.Json
+import play.api.mvc.*
+import play.api.mvc.Cookie.SameSite.Lax
+import services.{OpenIDConnectService, OpenIDUserInfo}
+
+import javax.inject.*
+import scala.concurrent.{ExecutionContext, Future}
+
+@Singleton
+class OpenIDController @Inject()(
+ val controllerComponents: ControllerComponents,
+ val openIDService: OpenIDConnectService,
+ val sessionManager: SessionManager,
+ val userManager: UserManager,
+ val config: Configuration
+ )(implicit ec: ExecutionContext) extends BaseController {
+
+ def loginWithProvider(provider: String): Action[AnyContent] = Action.async { implicit request =>
+ val state = openIDService.generateState()
+ val nonce = openIDService.generateNonce()
+
+ // Store state and nonce in session
+ openIDService.getAuthorizationUrl(provider, state, nonce) match {
+ case Some(authUrl) =>
+ Future.successful(Redirect(authUrl)
+ .withSession(
+ "oauth_state" -> state,
+ "oauth_nonce" -> nonce,
+ "oauth_provider" -> provider
+ ))
+ case None =>
+ Future.successful(BadRequest(Json.obj("error" -> "Unsupported provider")))
+ }
+ }
+
+ def callback(provider: String): Action[AnyContent] = Action.async { implicit request =>
+ val sessionState = request.session.get("oauth_state")
+ val sessionNonce = request.session.get("oauth_nonce")
+ val sessionProvider = request.session.get("oauth_provider")
+
+ val returnedState = request.getQueryString("state")
+ val code = request.getQueryString("code")
+ val error = request.getQueryString("error")
+
+ error match {
+ case Some(err) =>
+ Future.successful(Redirect("/login").flashing("error" -> s"Authentication failed: $err"))
+ case None =>
+ (for {
+ _ <- Option(sessionState.contains(returnedState.getOrElse("")))
+ _ <- Option(sessionProvider.contains(provider))
+ authCode <- code
+ } yield {
+ openIDService.exchangeCodeForTokens(provider, authCode, sessionState.get).flatMap {
+ case Some(tokenResponse) =>
+ openIDService.getUserInfo(provider, tokenResponse.accessToken).map {
+ case Some(userInfo) =>
+ // Store user info in session for username selection
+ Redirect(config.get[String]("openid.selectUserRoute"))
+ .withSession(
+ "oauth_user_info" -> Json.toJson(userInfo).toString(),
+ "oauth_provider" -> provider,
+ "oauth_access_token" -> tokenResponse.accessToken
+ )
+ case None =>
+ Redirect("/login").flashing("error" -> "Failed to retrieve user information")
+ }
+ case None =>
+ Future.successful(Redirect("/login").flashing("error" -> "Failed to exchange authorization code"))
+ }
+ }).getOrElse {
+ Future.successful(Redirect("/login").flashing("error" -> "Invalid state parameter"))
+ }
+ }
+ }
+
+ def selectUsername(): Action[AnyContent] = Action.async { implicit request =>
+ request.session.get("oauth_user_info") match {
+ case Some(userInfoJson) =>
+ val userInfo = Json.parse(userInfoJson).as[OpenIDUserInfo]
+ Future.successful(Ok(Json.obj(
+ "id" -> userInfo.id,
+ "email" -> userInfo.email,
+ "name" -> userInfo.name,
+ "picture" -> userInfo.picture,
+ "provider" -> userInfo.provider,
+ "providerName" -> userInfo.providerName
+ )))
+ case None =>
+ Future.successful(Redirect("/login").flashing("error" -> "No authentication information found"))
+ }
+ }
+
+ def submitUsername(): Action[AnyContent] = Action.async { implicit request =>
+ val username = request.body.asJson.flatMap(json => (json \ "username").asOpt[String])
+ .orElse(request.body.asFormUrlEncoded.flatMap(_.get("username").flatMap(_.headOption)))
+ val userInfoJson = request.session.get("oauth_user_info")
+ val provider = request.session.get("oauth_provider").getOrElse("unknown")
+
+ (username, userInfoJson) match {
+ case (Some(uname), Some(userInfoJson)) =>
+ val userInfo = Json.parse(userInfoJson).as[OpenIDUserInfo]
+
+ // Check if username already exists
+ val trimmedUsername = uname.trim
+ userManager.userExists(trimmedUsername) match {
+ case Some(_) =>
+ Future.successful(Conflict(Json.obj("error" -> "Username already taken")))
+ case None =>
+ // Create new user with OpenID info (no password needed)
+ val success = userManager.addOpenIDUser(trimmedUsername, userInfo)
+ if (success) {
+ // Get the created user and create session
+ userManager.userExists(trimmedUsername) match {
+ case Some(user) =>
+ val sessionToken = sessionManager.createSession(user)
+ Future.successful(Ok(Json.obj(
+ "message" -> "User created successfully",
+ "user" -> Json.obj(
+ "id" -> user.id,
+ "username" -> user.name
+ )
+ )).withCookies(Cookie(
+ name = "accessToken",
+ value = sessionToken,
+ httpOnly = true,
+ secure = false,
+ sameSite = Some(Lax)
+ )).removingFromSession("oauth_user_info", "oauth_provider", "oauth_access_token"))
+ case None =>
+ Future.successful(InternalServerError(Json.obj("error" -> "Failed to create user session")))
+ }
+ } else {
+ Future.successful(InternalServerError(Json.obj("error" -> "Failed to create user")))
+ }
+ }
+ case _ =>
+ Future.successful(BadRequest(Json.obj("error" -> "Username is required")))
+ }
+ }
+}
diff --git a/knockoutwhistweb/app/controllers/UserController.scala b/knockoutwhistweb/app/controllers/UserController.scala
index 392c7ff..466bf02 100644
--- a/knockoutwhistweb/app/controllers/UserController.scala
+++ b/knockoutwhistweb/app/controllers/UserController.scala
@@ -66,6 +66,47 @@ class UserController @Inject()(
))
}
+ def register(): Action[AnyContent] = {
+ Action { implicit request =>
+ val jsonBody = request.body.asJson
+ val username: Option[String] = jsonBody.flatMap { jsValue =>
+ (jsValue \ "username").asOpt[String]
+ }
+ val password: Option[String] = jsonBody.flatMap { jsValue =>
+ (jsValue \ "password").asOpt[String]
+ }
+
+ if (username.isDefined && password.isDefined) {
+ // Validate input
+ if (username.get.trim.isEmpty || password.get.length < 6) {
+ BadRequest(Json.obj(
+ "error" -> "Invalid input",
+ "message" -> "Username must not be empty and password must be at least 6 characters"
+ ))
+ } else {
+ // Try to register user
+ val registrationSuccess = userManager.addUser(username.get.trim, password.get)
+ if (registrationSuccess) {
+ Created(Json.obj(
+ "message" -> "User registered successfully",
+ "username" -> username.get.trim
+ ))
+ } else {
+ Conflict(Json.obj(
+ "error" -> "User already exists",
+ "message" -> "Username is already taken"
+ ))
+ }
+ }
+ } else {
+ BadRequest(Json.obj(
+ "error" -> "Invalid request",
+ "message" -> "Username and password are required"
+ ))
+ }
+ }
+ }
+
def logoutPost(): Action[AnyContent] = authAction { implicit request: AuthenticatedRequest[AnyContent] =>
val sessionCookie = request.cookies.get("accessToken")
if (sessionCookie.isDefined) {
diff --git a/knockoutwhistweb/app/di/EntityManagerProvider.scala b/knockoutwhistweb/app/di/EntityManagerProvider.scala
new file mode 100644
index 0000000..9b86bb1
--- /dev/null
+++ b/knockoutwhistweb/app/di/EntityManagerProvider.scala
@@ -0,0 +1,22 @@
+package di
+
+import com.google.inject.Provider
+import com.google.inject.Inject
+import jakarta.inject.Singleton
+import jakarta.persistence.{EntityManager, EntityManagerFactory, Persistence}
+
+@Singleton
+class EntityManagerProvider @Inject()() extends Provider[EntityManager] {
+
+ private val emf: EntityManagerFactory = Persistence.createEntityManagerFactory("defaultPersistenceUnit")
+
+ override def get(): EntityManager = {
+ emf.createEntityManager()
+ }
+
+ def close(): Unit = {
+ if (emf.isOpen) {
+ emf.close()
+ }
+ }
+}
diff --git a/knockoutwhistweb/app/di/ProductionModule.scala b/knockoutwhistweb/app/di/ProductionModule.scala
new file mode 100644
index 0000000..5939616
--- /dev/null
+++ b/knockoutwhistweb/app/di/ProductionModule.scala
@@ -0,0 +1,25 @@
+package di
+
+import com.google.inject.AbstractModule
+import com.google.inject.name.Names
+import logic.user.impl.HibernateUserManager
+import play.api.db.DBApi
+import play.api.{Configuration, Environment}
+
+class ProductionModule(
+ environment: Environment,
+ configuration: Configuration
+) extends AbstractModule {
+
+ override def configure(): Unit = {
+ // Bind HibernateUserManager for production
+ bind(classOf[logic.user.UserManager])
+ .to(classOf[logic.user.impl.HibernateUserManager])
+ .asEagerSingleton()
+
+ // Bind EntityManager for JPA
+ bind(classOf[jakarta.persistence.EntityManager])
+ .toProvider(classOf[EntityManagerProvider])
+ .asEagerSingleton()
+ }
+}
diff --git a/knockoutwhistweb/app/logic/user/UserManager.scala b/knockoutwhistweb/app/logic/user/UserManager.scala
index 98e1912..cd76014 100644
--- a/knockoutwhistweb/app/logic/user/UserManager.scala
+++ b/knockoutwhistweb/app/logic/user/UserManager.scala
@@ -3,14 +3,19 @@ package logic.user
import com.google.inject.ImplementedBy
import logic.user.impl.StubUserManager
import model.users.User
+import services.OpenIDUserInfo
@ImplementedBy(classOf[StubUserManager])
trait UserManager {
def addUser(name: String, password: String): Boolean
+ def addOpenIDUser(name: String, userInfo: OpenIDUserInfo): Boolean
+
def authenticate(name: String, password: String): Option[User]
+ def authenticateOpenID(provider: String, providerId: String): Option[User]
+
def userExists(name: String): Option[User]
def userExistsById(id: Long): Option[User]
diff --git a/knockoutwhistweb/app/logic/user/impl/HibernateUserManager.scala b/knockoutwhistweb/app/logic/user/impl/HibernateUserManager.scala
new file mode 100644
index 0000000..e61cfa0
--- /dev/null
+++ b/knockoutwhistweb/app/logic/user/impl/HibernateUserManager.scala
@@ -0,0 +1,161 @@
+package logic.user.impl
+
+import com.typesafe.config.Config
+import jakarta.inject.Inject
+import jakarta.persistence.EntityManager
+import logic.user.UserManager
+import model.users.{User, UserEntity}
+import services.OpenIDUserInfo
+import util.UserHash
+
+import javax.inject.Singleton
+import scala.jdk.CollectionConverters.*
+
+@Singleton
+class HibernateUserManager @Inject()(em: EntityManager, config: Config) extends UserManager {
+
+ override def addUser(name: String, password: String): Boolean = {
+ try {
+ // Check if user already exists
+ val existing = em.createQuery("SELECT u FROM UserEntity u WHERE u.username = :username", classOf[UserEntity])
+ .setParameter("username", name)
+ .getResultList
+
+ if (!existing.isEmpty) {
+ return false
+ }
+
+ // Create new user
+ val userEntity = UserEntity.fromUser(User(
+ internalId = 0L, // Will be set by database
+ id = java.util.UUID.randomUUID(),
+ name = name,
+ passwordHash = UserHash.hashPW(password)
+ ))
+
+ em.persist(userEntity)
+ em.flush()
+ true
+ } catch {
+ case _: Exception => false
+ }
+ }
+
+ override def addOpenIDUser(name: String, userInfo: OpenIDUserInfo): Boolean = {
+ try {
+ // Check if user already exists
+ val existing = em.createQuery("SELECT u FROM UserEntity u WHERE u.username = :username", classOf[UserEntity])
+ .setParameter("username", name)
+ .getResultList
+
+ if (!existing.isEmpty) {
+ return false
+ }
+
+ // Check if OpenID user already exists
+ val existingOpenID = em.createQuery(
+ "SELECT u FROM UserEntity u WHERE u.openidProvider = :provider AND u.openidProviderId = :providerId",
+ classOf[UserEntity]
+ )
+ .setParameter("provider", userInfo.provider)
+ .setParameter("providerId", userInfo.id)
+ .getResultList
+
+ if (!existingOpenID.isEmpty) {
+ return false
+ }
+
+ // Create new OpenID user
+ val userEntity = UserEntity.fromOpenIDUser(name, userInfo)
+
+ em.persist(userEntity)
+ em.flush()
+ true
+ } catch {
+ case _: Exception => false
+ }
+ }
+
+ override def authenticate(name: String, password: String): Option[User] = {
+ try {
+ val users = em.createQuery("SELECT u FROM UserEntity u WHERE u.username = :username", classOf[UserEntity])
+ .setParameter("username", name)
+ .getResultList
+
+ if (users.isEmpty) {
+ return None
+ }
+
+ val userEntity = users.get(0)
+ if (UserHash.verifyUser(password, userEntity.toUser)) {
+ Some(userEntity.toUser)
+ } else {
+ None
+ }
+ } catch {
+ case _: Exception => None
+ }
+ }
+
+ override def authenticateOpenID(provider: String, providerId: String): Option[User] = {
+ try {
+ val users = em.createQuery(
+ "SELECT u FROM UserEntity u WHERE u.openidProvider = :provider AND u.openidProviderId = :providerId",
+ classOf[UserEntity]
+ )
+ .setParameter("provider", provider)
+ .setParameter("providerId", providerId)
+ .getResultList
+
+ if (users.isEmpty) {
+ None
+ } else {
+ Some(users.get(0).toUser)
+ }
+ } catch {
+ case _: Exception => None
+ }
+ }
+
+ override def userExists(name: String): Option[User] = {
+ try {
+ val users = em.createQuery("SELECT u FROM UserEntity u WHERE u.username = :username", classOf[UserEntity])
+ .setParameter("username", name)
+ .getResultList
+
+ if (users.isEmpty) {
+ None
+ } else {
+ Some(users.get(0).toUser)
+ }
+ } catch {
+ case _: Exception => None
+ }
+ }
+
+ override def userExistsById(id: Long): Option[User] = {
+ try {
+ Option(em.find(classOf[UserEntity], id)).map(_.toUser)
+ } catch {
+ case _: Exception => None
+ }
+ }
+
+ override def removeUser(name: String): Boolean = {
+ try {
+ val users = em.createQuery("SELECT u FROM UserEntity u WHERE u.username = :username", classOf[UserEntity])
+ .setParameter("username", name)
+ .getResultList
+
+ if (users.isEmpty) {
+ false
+ } else {
+ em.remove(users.get(0))
+ em.flush()
+ true
+ }
+ } catch {
+ case _: Exception => false
+ }
+ }
+}
diff --git a/knockoutwhistweb/app/logic/user/impl/StubUserManager.scala b/knockoutwhistweb/app/logic/user/impl/StubUserManager.scala
index a50bd58..066c42e 100644
--- a/knockoutwhistweb/app/logic/user/impl/StubUserManager.scala
+++ b/knockoutwhistweb/app/logic/user/impl/StubUserManager.scala
@@ -3,14 +3,16 @@ package logic.user.impl
import com.typesafe.config.Config
import logic.user.UserManager
import model.users.User
+import services.OpenIDUserInfo
import util.UserHash
import javax.inject.{Inject, Singleton}
+import scala.collection.mutable
@Singleton
-class StubUserManager @Inject()(val config: Config) extends UserManager {
+class StubUserManager @Inject()(config: Config) extends UserManager {
- private val user: Map[String, User] = Map(
+ private val user: mutable.Map[String, User] = mutable.Map(
"Janis" -> User(
internalId = 1L,
id = java.util.UUID.fromString("123e4567-e89b-12d3-a456-426614174000"),
@@ -19,8 +21,8 @@ class StubUserManager @Inject()(val config: Config) extends UserManager {
),
"Leon" -> User(
internalId = 2L,
- id = java.util.UUID.fromString("223e4567-e89b-12d3-a456-426614174000"),
- name = "Leon",
+ id = java.util.UUID.randomUUID(),
+ name = "Jakob",
passwordHash = UserHash.hashPW("password123")
),
"Jakob" -> User(
@@ -32,7 +34,26 @@ class StubUserManager @Inject()(val config: Config) extends UserManager {
)
override def addUser(name: String, password: String): Boolean = {
- throw new NotImplementedError("StubUserManager.addUser is not implemented")
+ val newUser = User(
+ internalId = user.size.toLong + 1,
+ id = java.util.UUID.randomUUID(),
+ name = name,
+ passwordHash = UserHash.hashPW(password)
+ )
+ user(name) = newUser
+ true
+ }
+
+ override def addOpenIDUser(name: String, userInfo: OpenIDUserInfo): Boolean = {
+ // For stub implementation, just add a user without password
+ val newUser = User(
+ internalId = user.size.toLong + 1,
+ id = java.util.UUID.randomUUID(),
+ name = name,
+ passwordHash = "" // No password for OpenID users
+ )
+ user(name) = newUser
+ true
}
override def authenticate(name: String, password: String): Option[User] = {
@@ -42,6 +63,13 @@ class StubUserManager @Inject()(val config: Config) extends UserManager {
}
}
+ override def authenticateOpenID(provider: String, providerId: String): Option[User] = {
+ user.values.find { u =>
+ // In a real implementation, this would check stored OpenID provider info
+ u.name.startsWith(s"${provider}_") && u.name.contains(providerId)
+ }
+ }
+
override def userExists(name: String): Option[User] = {
user.get(name)
}
@@ -51,7 +79,6 @@ class StubUserManager @Inject()(val config: Config) extends UserManager {
}
override def removeUser(name: String): Boolean = {
- throw new NotImplementedError("StubUserManager.removeUser is not implemented")
+ user.remove(name).isDefined
}
-
}
diff --git a/knockoutwhistweb/app/model/sessions/UserSession.scala b/knockoutwhistweb/app/model/sessions/UserSession.scala
index 4d89cba..4d1b187 100644
--- a/knockoutwhistweb/app/model/sessions/UserSession.scala
+++ b/knockoutwhistweb/app/model/sessions/UserSession.scala
@@ -26,8 +26,11 @@ class UserSession(val user: User, val host: Boolean, val gameLobby: GameLobby) e
else canInteract = Some(InteractionType.Card)
case _ =>
}
+
+ lock.lock()
websocketActor.foreach(_.solveRequests())
websocketActor.foreach(_.transmitEventToClient(event))
+ lock.unlock()
}
override def id: UUID = user.id
diff --git a/knockoutwhistweb/app/model/users/UserEntity.scala b/knockoutwhistweb/app/model/users/UserEntity.scala
new file mode 100644
index 0000000..b570106
--- /dev/null
+++ b/knockoutwhistweb/app/model/users/UserEntity.scala
@@ -0,0 +1,80 @@
+package model.users
+
+import jakarta.persistence.*
+
+import java.time.LocalDateTime
+import java.util.UUID
+import scala.compiletime.uninitialized
+
+@Entity
+@Table(name = "users")
+class UserEntity {
+
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ var id: Long = uninitialized
+
+ @Column(name = "uuid", nullable = false, unique = true)
+ var uuid: UUID = uninitialized
+
+ @Column(name = "username", nullable = false, unique = true)
+ var username: String = uninitialized
+
+ @Column(name = "password_hash", nullable = false)
+ var passwordHash: String = uninitialized
+
+ @Column(name = "openid_provider")
+ var openidProvider: String = uninitialized
+
+ @Column(name = "openid_provider_id")
+ var openidProviderId: String = uninitialized
+
+ @Column(name = "created_at", nullable = false)
+ var createdAt: LocalDateTime = uninitialized
+
+ @Column(name = "updated_at", nullable = false)
+ var updatedAt: LocalDateTime = uninitialized
+
+ @PrePersist
+ def onCreate(): Unit = {
+ val now = LocalDateTime.now()
+ createdAt = now
+ updatedAt = now
+ if (uuid == null) {
+ uuid = UUID.randomUUID()
+ }
+ }
+
+ @PreUpdate
+ def onUpdate(): Unit = {
+ updatedAt = LocalDateTime.now()
+ }
+
+ def toUser: User = {
+ User(
+ internalId = id,
+ id = uuid,
+ name = username,
+ passwordHash = passwordHash
+ )
+ }
+}
+
+object UserEntity {
+ def fromUser(user: User): UserEntity = {
+ val entity = new UserEntity()
+ entity.uuid = user.id
+ entity.username = user.name
+ entity.passwordHash = user.passwordHash
+ entity
+ }
+
+ def fromOpenIDUser(username: String, userInfo: services.OpenIDUserInfo): UserEntity = {
+ val entity = new UserEntity()
+ entity.username = username
+ entity.passwordHash = "" // No password for OpenID users
+ entity.openidProvider = userInfo.provider
+ entity.openidProviderId = userInfo.id
+ entity
+ }
+}
diff --git a/knockoutwhistweb/app/services/OpenIDConnectService.scala b/knockoutwhistweb/app/services/OpenIDConnectService.scala
new file mode 100644
index 0000000..4c4bdb9
--- /dev/null
+++ b/knockoutwhistweb/app/services/OpenIDConnectService.scala
@@ -0,0 +1,180 @@
+package services
+
+import com.typesafe.config.Config
+import play.api.libs.ws.WSClient
+import play.api.Configuration
+import play.api.libs.json.*
+
+import java.net.URI
+import javax.inject.*
+import scala.concurrent.{ExecutionContext, Future}
+import com.nimbusds.oauth2.sdk.*
+import com.nimbusds.oauth2.sdk.id.*
+import com.nimbusds.openid.connect.sdk.*
+
+import play.api.libs.ws.DefaultBodyWritables.*
+
+case class OpenIDUserInfo(
+ id: String,
+ email: Option[String],
+ name: Option[String],
+ picture: Option[String],
+ provider: String,
+ providerName: String
+)
+
+object OpenIDUserInfo {
+ implicit val writes: Writes[OpenIDUserInfo] = Json.writes[OpenIDUserInfo]
+ implicit val reads: Reads[OpenIDUserInfo] = Json.reads[OpenIDUserInfo]
+}
+
+case class OpenIDProvider(
+ name: String,
+ clientId: String,
+ clientSecret: String,
+ redirectUri: String,
+ authorizationEndpoint: String,
+ tokenEndpoint: String,
+ userInfoEndpoint: String,
+ scopes: Set[String] = Set("openid", "profile", "email")
+)
+
+case class TokenResponse(
+ accessToken: String,
+ tokenType: String,
+ expiresIn: Option[Int],
+ refreshToken: Option[String],
+ idToken: Option[String]
+)
+
+@Singleton
+class OpenIDConnectService@Inject(ws: WSClient, config: Configuration)(implicit ec: ExecutionContext) {
+
+ private val providers = Map(
+ "discord" -> OpenIDProvider(
+ name = "Discord",
+ clientId = config.get[String]("openid.discord.clientId"),
+ clientSecret = config.get[String]("openid.discord.clientSecret"),
+ redirectUri = config.get[String]("openid.discord.redirectUri"),
+ authorizationEndpoint = "https://discord.com/oauth2/authorize",
+ tokenEndpoint = "https://discord.com/api/oauth2/token",
+ userInfoEndpoint = "https://discord.com/api/users/@me",
+ scopes = Set("identify", "email")
+ ),
+ "keycloak" -> OpenIDProvider(
+ name = "Identity",
+ clientId = config.get[String]("openid.keycloak.clientId"),
+ clientSecret = config.get[String]("openid.keycloak.clientSecret"),
+ redirectUri = config.get[String]("openid.keycloak.redirectUri"),
+ authorizationEndpoint = config.get[String]("openid.keycloak.authUrl") + "/protocol/openid-connect/auth",
+ tokenEndpoint = config.get[String]("openid.keycloak.authUrl") + "/protocol/openid-connect/token",
+ userInfoEndpoint = config.get[String]("openid.keycloak.authUrl") + "/protocol/openid-connect/userinfo",
+ scopes = Set("openid", "profile", "email")
+ )
+ )
+
+ def getAuthorizationUrl(providerName: String, state: String, nonce: String): Option[String] = {
+ providers.get(providerName).map { provider =>
+ val authRequest = if (provider.scopes.contains("openid")) {
+ // Use OpenID Connect AuthenticationRequest for OpenID providers
+ new AuthenticationRequest.Builder(
+ new ResponseType(ResponseType.Value.CODE),
+ new com.nimbusds.oauth2.sdk.Scope(provider.scopes.mkString(" ")),
+ new com.nimbusds.oauth2.sdk.id.ClientID(provider.clientId),
+ URI.create(provider.redirectUri)
+ )
+ .state(new com.nimbusds.oauth2.sdk.id.State(state))
+ .nonce(new Nonce(nonce))
+ .endpointURI(URI.create(provider.authorizationEndpoint))
+ .build()
+ } else {
+ // Use standard OAuth2 AuthorizationRequest for non-OpenID providers (like Discord)
+ new AuthorizationRequest.Builder(
+ new ResponseType(ResponseType.Value.CODE),
+ new com.nimbusds.oauth2.sdk.id.ClientID(provider.clientId)
+ )
+ .scope(new com.nimbusds.oauth2.sdk.Scope(provider.scopes.mkString(" ")))
+ .state(new com.nimbusds.oauth2.sdk.id.State(state))
+ .redirectionURI(URI.create(provider.redirectUri))
+ .endpointURI(URI.create(provider.authorizationEndpoint))
+ .build()
+ }
+
+ authRequest.toURI.toString
+ }
+ }
+
+ def exchangeCodeForTokens(providerName: String, code: String, state: String): Future[Option[TokenResponse]] = {
+ providers.get(providerName) match {
+ case Some(provider) =>
+ ws.url(provider.tokenEndpoint)
+ .withHttpHeaders(
+ "Accept" -> "application/json",
+ "Content-Type" -> "application/x-www-form-urlencoded"
+ )
+ .post(
+ Map(
+ "client_id" -> Seq(provider.clientId),
+ "client_secret" -> Seq(provider.clientSecret),
+ "code" -> Seq(code),
+ "grant_type" -> Seq("authorization_code"),
+ "redirect_uri" -> Seq(provider.redirectUri)
+ )
+ )
+ .map { response =>
+ if (response.status == 200) {
+ val json = response.json
+ Some(TokenResponse(
+ accessToken = (json \ "access_token").as[String],
+ tokenType = (json \ "token_type").as[String],
+ expiresIn = (json \ "expires_in").asOpt[Int],
+ refreshToken = (json \ "refresh_token").asOpt[String],
+ idToken = (json \ "id_token").asOpt[String]
+ ))
+ } else {
+ None
+ }
+ }
+ .recover { case _ => None }
+ case None => Future.successful(None)
+ }
+ }
+
+ def getUserInfo(providerName: String, accessToken: String): Future[Option[OpenIDUserInfo]] = {
+ providers.get(providerName) match {
+ case Some(provider) =>
+ ws.url(provider.userInfoEndpoint)
+ .withHttpHeaders("Authorization" -> s"Bearer $accessToken")
+ .get()
+ .map { response =>
+ if (response.status == 200) {
+ val json = response.json
+ Some(OpenIDUserInfo(
+ id = (json \ "id").as[String],
+ email = (json \ "email").asOpt[String],
+ name = (json \ "name").asOpt[String].orElse((json \ "login").asOpt[String]),
+ picture = (json \ "picture").asOpt[String].orElse((json \ "avatar_url").asOpt[String]),
+ provider = providerName,
+ providerName = provider.name
+ ))
+ } else {
+ None
+ }
+ }
+ .recover { case _ => None }
+ case None => Future.successful(None)
+ }
+ }
+
+ def validateState(sessionState: String, returnedState: String): Boolean = {
+ sessionState == returnedState
+ }
+
+ def generateState(): String = {
+ java.util.UUID.randomUUID().toString.replace("-", "")
+ }
+
+ def generateNonce(): String = {
+ java.util.UUID.randomUUID().toString.replace("-", "")
+ }
+}
\ No newline at end of file
diff --git a/knockoutwhistweb/app/util/WebUIUtils.scala b/knockoutwhistweb/app/util/WebUIUtils.scala
index f33be6b..0539267 100644
--- a/knockoutwhistweb/app/util/WebUIUtils.scala
+++ b/knockoutwhistweb/app/util/WebUIUtils.scala
@@ -8,9 +8,6 @@ import play.twirl.api.Html
import scalafx.scene.image.Image
object WebUIUtils {
- def cardtoImage(card: Card): Html = {
- views.html.render.card.apply(cardToPath(card))(card.toString)
- }
def cardToPath(card: Card): String = {
f"images/cards/${cardtoString(card)}.png"
diff --git a/knockoutwhistweb/app/util/WebsocketEventMapper.scala b/knockoutwhistweb/app/util/WebsocketEventMapper.scala
index 8e8c2e8..76df3ba 100644
--- a/knockoutwhistweb/app/util/WebsocketEventMapper.scala
+++ b/knockoutwhistweb/app/util/WebsocketEventMapper.scala
@@ -36,16 +36,12 @@ object WebsocketEventMapper {
// Register all custom mappers here
registerCustomMapper(ReceivedHandEventMapper)
- //registerCustomMapper(GameStateEventMapper)
registerCustomMapper(CardPlayedEventMapper)
registerCustomMapper(NewRoundEventMapper)
registerCustomMapper(NewTrickEventMapper)
registerCustomMapper(TrickEndEventMapper)
registerCustomMapper(RequestCardEventMapper)
registerCustomMapper(LobbyUpdateEventMapper)
- registerCustomMapper(LeftEventMapper)
- registerCustomMapper(KickEventMapper)
- registerCustomMapper(SessionClosedMapper)
registerCustomMapper(TurnEventMapper)
def toJson(obj: SimpleEvent, session: UserSession): JsValue = {
@@ -54,7 +50,6 @@ object WebsocketEventMapper {
}else {
None
}
- //println(s"This is getting sent to client: EVENT: ${obj.id}, STATE: ${session.gameLobby.getLogic.getCurrentState.toString}, STATEDATA: ${stateToJson(session)}, DATA: ${data}")
Json.obj(
"id" -> ("request-" + java.util.UUID.randomUUID().toString),
"event" -> obj.id,
diff --git a/knockoutwhistweb/app/util/mapper/GameStateEventMapper.scala b/knockoutwhistweb/app/util/mapper/GameStateEventMapper.scala
deleted file mode 100644
index d085fa2..0000000
--- a/knockoutwhistweb/app/util/mapper/GameStateEventMapper.scala
+++ /dev/null
@@ -1,19 +0,0 @@
-package util.mapper
-
-import controllers.IngameController
-import de.knockoutwhist.events.global.GameStateChangeEvent
-import model.sessions.UserSession
-import play.api.libs.json.{JsObject, Json}
-import util.GameUtil
-
-object GameStateEventMapper extends SimpleEventMapper[GameStateChangeEvent] {
-
- override def id: String = "GameStateChangeEvent"
-
- override def toJson(event: GameStateChangeEvent, session: UserSession): JsObject = {
- Json.obj(
- "title" -> ("Knockout Whist - " + GameUtil.stateToTitle(event.newState)),
- "content" -> IngameController.returnInnerHTML(session.gameLobby, event.newState, session.user).toString
- )
- }
-}
diff --git a/knockoutwhistweb/app/util/mapper/KickEventMapper.scala b/knockoutwhistweb/app/util/mapper/KickEventMapper.scala
deleted file mode 100644
index 7f73047..0000000
--- a/knockoutwhistweb/app/util/mapper/KickEventMapper.scala
+++ /dev/null
@@ -1,19 +0,0 @@
-package util.mapper
-
-import controllers.routes
-import events.KickEvent
-import model.sessions.UserSession
-import play.api.libs.json.{JsObject, Json}
-
-object KickEventMapper extends SimpleEventMapper[KickEvent] {
-
- override def id: String = "KickEvent"
-
- override def toJson(event: KickEvent, session: UserSession): JsObject = {
- Json.obj(
- "url" -> routes.MainMenuController.mainMenu().url,
- "content" -> views.html.mainmenu.creategame(Some(session.user)).toString,
- )
- }
-
-}
diff --git a/knockoutwhistweb/app/util/mapper/LeftEventMapper.scala b/knockoutwhistweb/app/util/mapper/LeftEventMapper.scala
deleted file mode 100644
index 3455dda..0000000
--- a/knockoutwhistweb/app/util/mapper/LeftEventMapper.scala
+++ /dev/null
@@ -1,19 +0,0 @@
-package util.mapper
-
-import controllers.routes
-import events.{KickEvent, LeftEvent}
-import model.sessions.UserSession
-import play.api.libs.json.{JsObject, Json}
-
-object LeftEventMapper extends SimpleEventMapper[LeftEvent] {
-
- override def id: String = "LeftEvent"
-
- override def toJson(event: LeftEvent, session: UserSession): JsObject = {
- Json.obj(
- "url" -> routes.MainMenuController.mainMenu().url,
- "content" -> views.html.mainmenu.creategame(Some(session.user)).toString
- )
- }
-
-}
diff --git a/knockoutwhistweb/app/util/mapper/SessionClosedMapper.scala b/knockoutwhistweb/app/util/mapper/SessionClosedMapper.scala
deleted file mode 100644
index a247200..0000000
--- a/knockoutwhistweb/app/util/mapper/SessionClosedMapper.scala
+++ /dev/null
@@ -1,19 +0,0 @@
-package util.mapper
-
-import controllers.routes
-import de.knockoutwhist.events.global.SessionClosed
-import model.sessions.UserSession
-import play.api.libs.json.{JsObject, Json}
-
-object SessionClosedMapper extends SimpleEventMapper[SessionClosed] {
-
- override def id: String = "SessionClosed"
-
- override def toJson(event: SessionClosed, session: UserSession): JsObject = {
- Json.obj(
- "url" -> routes.MainMenuController.mainMenu().url,
- "content" -> views.html.mainmenu.creategame(Some(session.user)).toString,
- )
- }
-
-}
diff --git a/knockoutwhistweb/app/views/ingame/finishedMatch.scala.html b/knockoutwhistweb/app/views/ingame/finishedMatch.scala.html
deleted file mode 100644
index 8cf76b9..0000000
--- a/knockoutwhistweb/app/views/ingame/finishedMatch.scala.html
+++ /dev/null
@@ -1,108 +0,0 @@
-@(user: Option[model.users.User], gamelobby: logic.game.GameLobby)
-
-
-
-
-
-
-
-
Match Over!
-
Congratulations to the winner:
-
- @gamelobby.getLogic.getWinner.get.name
-
-
-
-
-
-
-
-
- Player
-
-
- Rounds won
- Tricks won
-
-
-
- @gamelobby.getFinalRanking.zipWithIndex.map { case ((playerName, (wonRounds, tricksWon)), index) =>
- @defining(index + 1) { rank =>
-
-
- #@rank
- @playerName
-
-
- @wonRounds
- @tricksWon
-
-
- }
- }
-
- @if(gamelobby.getFinalRanking.isEmpty) {
-
No final scores available.
- }
-
-
-
- @if(user.isDefined && gamelobby.getUserSession(user.get.id).host) {
-
- } else {
-
-
-
- Loading...
-
-
- Waiting for the Host to continue...
-
-
-
- }
-
-
-
-
-
\ No newline at end of file
diff --git a/knockoutwhistweb/app/views/ingame/ingame.scala.html b/knockoutwhistweb/app/views/ingame/ingame.scala.html
deleted file mode 100644
index b2b39b8..0000000
--- a/knockoutwhistweb/app/views/ingame/ingame.scala.html
+++ /dev/null
@@ -1,42 +0,0 @@
-@import de.knockoutwhist.control.controllerBaseImpl.sublogic.util.TrickUtil
-@import de.knockoutwhist.utils.Implicits.*
-
-@(player: de.knockoutwhist.player.AbstractPlayer, gamelobby: logic.game.GameLobby)
-
-
-
diff --git a/knockoutwhistweb/app/views/ingame/selecttrump.scala.html b/knockoutwhistweb/app/views/ingame/selecttrump.scala.html
deleted file mode 100644
index fe9e43c..0000000
--- a/knockoutwhistweb/app/views/ingame/selecttrump.scala.html
+++ /dev/null
@@ -1,68 +0,0 @@
-@(player: de.knockoutwhist.player.AbstractPlayer, gamelobby: logic.game.GameLobby)
-
-
-
-
-
-
-
-
-
- @if(gamelobby.logic.getCurrentMatch.isDefined) {
- @if(player.equals(gamelobby.logic.getCurrentMatch.get.roundlist.last.winner.get)) {
-
- You (@player.toString) won the last round. Choose the trump suit for the next round.
-
-
-
-
-
- @util.WebUIUtils.cardtoImage(de.knockoutwhist.cards.Card(de.knockoutwhist.cards.CardValue.Ace, de.knockoutwhist.cards.Suit.Spades))
- width="120px" style="border-radius: 6px"/>
-
-
-
-
- @util.WebUIUtils.cardtoImage(de.knockoutwhist.cards.Card(de.knockoutwhist.cards.CardValue.Ace, de.knockoutwhist.cards.Suit.Hearts))
- width="120px" style="border-radius: 6px"/>
-
-
-
-
- @util.WebUIUtils.cardtoImage(de.knockoutwhist.cards.Card(de.knockoutwhist.cards.CardValue.Ace, de.knockoutwhist.cards.Suit.Diamonds))
- width="120px" style="border-radius: 6px"/>
-
-
-
-
- @util.WebUIUtils.cardtoImage(de.knockoutwhist.cards.Card(de.knockoutwhist.cards.CardValue.Ace, de.knockoutwhist.cards.Suit.Clubs))
- width="120px" style="border-radius: 6px"/>
-
-
-
-
- @for(i <- player.currentHand().get.cards.indices) {
-
- @util.WebUIUtils.cardtoImage(player.currentHand().get.cards(i)) width="120px" style="border-radius: 6px"/>
-
- }
-
- } else {
-
- @gamelobby.logic.getCurrentMatch.get.roundlist.last.winner.get.name
- is choosing a trumpsuit. The new round will start once a suit is picked.
-
- }
- }
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/knockoutwhistweb/app/views/ingame/tie.scala.html b/knockoutwhistweb/app/views/ingame/tie.scala.html
deleted file mode 100644
index b224e1d..0000000
--- a/knockoutwhistweb/app/views/ingame/tie.scala.html
+++ /dev/null
@@ -1,114 +0,0 @@
-@(player: de.knockoutwhist.player.AbstractPlayer, gamelobby: logic.game.GameLobby)
-
-
-
-
-
-
-
-
-
-
- The last round was tied between:
-
- @for(players <- gamelobby.logic.playerTieLogic.getTiedPlayers) {
- @players
- }
-
-
-
-
- @if(gamelobby.logic.playerTieLogic.currentTiePlayer().contains(player)) {
- @defining(gamelobby.logic.playerTieLogic.highestAllowedNumber()) { maxNum =>
-
- Pick a number between 1 and @{
- maxNum + 1
- }.
- The resulting card will be your card for the cut.
-
-
-
-
- Your number
-
-
-
-
-
-
- Confirm
-
-
-
Currently Picked Cards
-
-
- @if(gamelobby.logic.playerTieLogic.getSelectedCard.nonEmpty) {
- @for((player, card) <- gamelobby.logic.playerTieLogic.getSelectedCard) {
-
-
-
-
@player
-
- @util.WebUIUtils.cardtoImage(card)
-
-
-
-
- }
- } else {
-
-
-
- No cards have been selected yet.
-
-
- }
-
- }
- } else {
-
- @gamelobby.logic.playerTieLogic.currentTiePlayer().get
- is currently picking a number for the cut.
-
-
-
Currently Picked Cards
-
-
- @if(gamelobby.logic.playerTieLogic.getSelectedCard.nonEmpty) {
- @for((player, card) <- gamelobby.logic.playerTieLogic.getSelectedCard) {
-
-
-
-
@player
-
- @util.WebUIUtils.cardtoImage(card)
-
-
-
-
- }
- } else {
-
-
-
- No cards have been selected yet.
-
-
- }
-
- }
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/knockoutwhistweb/app/views/lobby/lobby.scala.html b/knockoutwhistweb/app/views/lobby/lobby.scala.html
deleted file mode 100644
index e9f90a8..0000000
--- a/knockoutwhistweb/app/views/lobby/lobby.scala.html
+++ /dev/null
@@ -1,38 +0,0 @@
-@(user: Option[model.users.User], gamelobby: logic.game.GameLobby)
-@import play.api.libs.json._
-
-
-
\ No newline at end of file
diff --git a/knockoutwhistweb/app/views/login/login.scala.html b/knockoutwhistweb/app/views/login/login.scala.html
deleted file mode 100644
index 8be5352..0000000
--- a/knockoutwhistweb/app/views/login/login.scala.html
+++ /dev/null
@@ -1,43 +0,0 @@
-@()
-
-
-
-
Login
-
-
-
- Don’t have an account?
- Sign up
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/knockoutwhistweb/app/views/main.scala.html b/knockoutwhistweb/app/views/main.scala.html
deleted file mode 100644
index c7e35ec..0000000
--- a/knockoutwhistweb/app/views/main.scala.html
+++ /dev/null
@@ -1,36 +0,0 @@
-@*
-* This template is called from the `index` template. This template
-* handles the rendering of the page header and body tags. It takes
-* two arguments, a `String` for the title of the page and an `Html`
-* object to insert into the body of the page.
-*@
-@(title: String)(content: Html)
-
-
-
-
-
-
- @* Here's where we render the page title `String`. *@
- @title
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- @* And here's where we render the `Html` object containing
- * the page content. *@
- @content
-
-
diff --git a/knockoutwhistweb/app/views/mainmenu/creategame.scala.html b/knockoutwhistweb/app/views/mainmenu/creategame.scala.html
deleted file mode 100644
index 5ad2504..0000000
--- a/knockoutwhistweb/app/views/mainmenu/creategame.scala.html
+++ /dev/null
@@ -1,33 +0,0 @@
-@(user: Option[model.users.User])
-
-@navbar(user)
-
-
-
- Lobby-Name
-
-
-
-
- public/private
-
-
-
Playeramount:
-
-
- 2
- 3
- 4
- 5
- 6
- 7
-
-
-
-
-
-
\ No newline at end of file
diff --git a/knockoutwhistweb/app/views/mainmenu/navbar.scala.html b/knockoutwhistweb/app/views/mainmenu/navbar.scala.html
deleted file mode 100644
index 8a9ab75..0000000
--- a/knockoutwhistweb/app/views/mainmenu/navbar.scala.html
+++ /dev/null
@@ -1,55 +0,0 @@
-@(user: Option[model.users.User])
-
-
-
-
-
-
-
-
- KnockOutWhist
-
-
-
- @* Right side: profile dropdown if logged in, otherwise Login / Sign Up buttons *@
- @if(user.isDefined) {
-
- }
-
-
-
-
diff --git a/knockoutwhistweb/app/views/mainmenu/rules.scala.html b/knockoutwhistweb/app/views/mainmenu/rules.scala.html
deleted file mode 100644
index e4ff1a4..0000000
--- a/knockoutwhistweb/app/views/mainmenu/rules.scala.html
+++ /dev/null
@@ -1,180 +0,0 @@
-@(user: Option[model.users.User])
-@navbar(user)
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Two to seven players. The aim is to be the last player left in the game.
-
-
-
-
-
-
-
-
- To be the last player left at the end of the game, with the object in each hand being to win a majority of tricks.
-
-
-
-
-
-
-
-
- A standard 52-card pack is used.
-
-
-
-
-
-
-
-
- In each suit, cards rank from highest to lowest: A K Q J 10 9 8 7 6 5 4 3 2.
-
-
-
-
-
-
-
-
- The dealer deals seven cards to each player. One card is turned up to determine the trump suit for the round.
-
-
-
-
-
-
-
-
- The deal rotates clockwise. The player who took the most tricks in the previous hand selects the trump suit. If there's a tie, players cut cards to decide who calls trumps. One fewer card is dealt in each successive hand until the final hand consists of one card each.
-
-
-
-
-
-
-
-
- The player to the dealer's left (eldest hand) leads the first trick. Any card can be led. Other players must follow suit if possible. A player with no cards of the suit led may play any card.
-
-
-
-
-
-
-
-
- The highest card of the suit led wins, unless a trump is played, in which case the highest trump wins. The winner of the trick leads the next.
-
-
-
-
-
-
-
-
- Some rules disallow leading trumps before the suit has been 'broken' (a trump has been played to the lead of another suit). Leading trumps is always permissible if a player holds only trumps.
-
-
-
-
-
-
-
-
- At the end of each hand, any player who took no tricks is knocked out and takes no further part in the game.
-
-
-
-
-
-
-
-
- The game is won when a player takes all the tricks in a round, as all other players are knocked out, leaving only one player remaining.
-
-
-
-
-
-
-
-
- The first player who takes no tricks is awarded a \"dog's life\". In the next hand, that player is dealt one card and can decide which trick to play it to. Each time a trick is played the dog may either play the card or knock on the table and wait to play it later. If the dog wins a trick, the player to the left leads the next and the dog re-enters the game properly in the next hand. If the dog fails, they are knocked out.
-
-
-
-
-
-
-
-
-
-
diff --git a/knockoutwhistweb/app/views/render/card.scala.html b/knockoutwhistweb/app/views/render/card.scala.html
deleted file mode 100644
index a4fca9e..0000000
--- a/knockoutwhistweb/app/views/render/card.scala.html
+++ /dev/null
@@ -1,2 +0,0 @@
-@(src: String)(alt: String)
- @text
-
diff --git a/knockoutwhistweb/conf/application.conf b/knockoutwhistweb/conf/application.conf
index 3d41095..6c8444e 100644
--- a/knockoutwhistweb/conf/application.conf
+++ b/knockoutwhistweb/conf/application.conf
@@ -22,3 +22,28 @@ play.filters.cors {
allowedHttpMethods = ["GET", "POST", "PUT", "DELETE", "OPTIONS"]
allowedHttpHeaders = ["Accept", "Content-Type", "Origin", "X-Requested-With"]
}
+
+# Local Development OpenID Connect Configuration
+openid {
+ selectUserRoute="http://localhost:5173/select-username"
+
+ discord {
+ clientId = ${?DISCORD_CLIENT_ID}
+ clientId = "1462555597118509126"
+ clientSecret = ${?DISCORD_CLIENT_SECRET}
+ clientSecret = "xZZrdd7_tNpfJgnk-6phSG53DSTy-eMK"
+ redirectUri = ${?DISCORD_REDIRECT_URI}
+ redirectUri = "http://localhost:9000/auth/discord/callback"
+ }
+
+ keycloak {
+ clientId = ${?KEYCLOAK_CLIENT_ID}
+ clientId = "your-keycloak-client-id"
+ clientSecret = ${?KEYCLOAK_CLIENT_SECRET}
+ clientSecret = "your-keycloak-client-secret"
+ redirectUri = ${?KEYCLOAK_REDIRECT_URI}
+ redirectUri = "http://localhost:9000/auth/keycloak/callback"
+ authUrl = ${?KEYCLOAK_AUTH_URL}
+ authUrl = "http://localhost:8080/realms/master"
+ }
+}
diff --git a/knockoutwhistweb/conf/db.conf b/knockoutwhistweb/conf/db.conf
new file mode 100644
index 0000000..4e3b094
--- /dev/null
+++ b/knockoutwhistweb/conf/db.conf
@@ -0,0 +1,28 @@
+
+# Database configuration - PostgreSQL with environment variables
+db.default.driver=org.postgresql.Driver
+db.default.url=${?DATABASE_URL}
+db.default.username=${?DB_USER}
+db.default.password=${?DB_PASSWORD}
+db.default.password=""
+
+# JPA/Hibernate configuration
+jpa.default=defaultPersistenceUnit
+
+# Hibernate specific settings
+hibernate.dialect=org.hibernate.dialect.PostgreSQLDialect
+hibernate.hbm2ddl.auto=update
+hibernate.show_sql=false
+hibernate.format_sql=true
+hibernate.use_sql_comments=true
+
+# Connection pool settings
+db.default.hikaricp.maximumPoolSize=20
+db.default.hikaricp.minimumIdle=5
+db.default.hikaricp.connectionTimeout=30000
+db.default.hikaricp.idleTimeout=600000
+db.default.hikaricp.maxLifetime=1800000
+
+# PostgreSQL specific settings
+db.default.hikaricp.connectionTestQuery="SELECT 1"
+db.default.hikaricp.poolName="KnockOutWhistPool"
diff --git a/knockoutwhistweb/conf/persistence.xml b/knockoutwhistweb/conf/persistence.xml
new file mode 100644
index 0000000..7002250
--- /dev/null
+++ b/knockoutwhistweb/conf/persistence.xml
@@ -0,0 +1,38 @@
+
+
+
+
+ org.hibernate.jpa.HibernatePersistenceProvider
+
+ model.users.UserEntity
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/knockoutwhistweb/conf/prod.conf b/knockoutwhistweb/conf/prod.conf
index bec057c..3ec33a9 100644
--- a/knockoutwhistweb/conf/prod.conf
+++ b/knockoutwhistweb/conf/prod.conf
@@ -1,4 +1,5 @@
include "application.conf"
+include "db.conf"
play.http.secret.key="zg8^v0R*:7-m.>^8T2B1q)sE3MV_9=M{K9zx8,<3}"
@@ -7,8 +8,29 @@ play.http.context="/api"
play.modules.enabled += "modules.GatewayModule"
play.filters.cors {
- allowedOrigins = ["https://knockout.janis-eccarius.de"]
+ allowedOrigins = ["http://localhost:5173"]
allowedCredentials = true
allowedHttpMethods = ["GET", "POST", "PUT", "DELETE", "OPTIONS"]
allowedHttpHeaders = ["Accept", "Content-Type", "Origin", "X-Requested-With"]
+}
+
+# OpenID Connect Configuration
+openid {
+
+ selectUserRoute="https://knockout.janis-eccarius.de/select-username"
+
+ discord {
+ clientId = ${?DISCORD_CLIENT_ID}
+ clientSecret = ${?DISCORD_CLIENT_SECRET}
+ redirectUri = ${?DISCORD_REDIRECT_URI}
+ redirectUri = "https://knockout.janis-eccarius.de/auth/discord/callback"
+ }
+
+ keycloak {
+ clientId = "your-keycloak-client-id"
+ clientSecret = "your-keycloak-client-secret"
+ redirectUri = "https://knockout.janis-eccarius.de/api/auth/keycloak/callback"
+ authUrl = ${?KEYCLOAK_AUTH_URL}
+ authUrl = "https://identity.janis-eccarius.de/realms/master"
+ }
}
\ No newline at end of file
diff --git a/knockoutwhistweb/conf/routes b/knockoutwhistweb/conf/routes
index 2fb460e..05c920f 100644
--- a/knockoutwhistweb/conf/routes
+++ b/knockoutwhistweb/conf/routes
@@ -1,29 +1,18 @@
-# Routes
-# This file defines all application routes (Higher priority routes first)
-# https://www.playframework.com/documentation/latest/ScalaRouting
-# ~~~~
-
-# For the javascript routing
-GET /assets/js/routes controllers.JavaScriptRoutingController.javascriptRoutes()
-# Primary routes
-GET / controllers.MainMenuController.index()
-GET /assets/*file controllers.Assets.versioned(path="/public", file: Asset)
-
-# Main menu routes
-GET /mainmenu controllers.MainMenuController.mainMenu()
-GET /rules controllers.MainMenuController.rules()
-GET /navSPA/:pType controllers.MainMenuController.navSPA(pType)
-
+# Create game rounds
POST /createGame controllers.MainMenuController.createGame()
POST /joinGame/:gameId controllers.MainMenuController.joinGame(gameId: String)
# User authentication routes
POST /login controllers.UserController.login_Post()
+POST /register controllers.UserController.register()
POST /logout controllers.UserController.logoutPost()
GET /userInfo controllers.UserController.getUserInfo()
-# In-game routes
-GET /game/:id controllers.IngameController.game(id: String)
+# OpenID Connect routes
+GET /auth/:provider controllers.OpenIDController.loginWithProvider(provider: String)
+GET /auth/:provider/callback controllers.OpenIDController.callback(provider: String)
+GET /select-username controllers.OpenIDController.selectUsername()
+POST /submit-username controllers.OpenIDController.submitUsername()
# Websocket
GET /websocket controllers.WebsocketController.socket()
diff --git a/knockoutwhistweb/conf/staging.conf b/knockoutwhistweb/conf/staging.conf
index 32328d3..b16d2dc 100644
--- a/knockoutwhistweb/conf/staging.conf
+++ b/knockoutwhistweb/conf/staging.conf
@@ -1,4 +1,5 @@
include "application.conf"
+include "db.conf"
play.http.context="/api"
@@ -9,4 +10,24 @@ play.filters.cors {
allowedCredentials = true
allowedHttpMethods = ["GET", "POST", "PUT", "DELETE", "OPTIONS"]
allowedHttpHeaders = ["Accept", "Content-Type", "Origin", "X-Requested-With"]
-}
\ No newline at end of file
+}
+
+openid {
+
+ selectUserRoute="https://st.knockout.janis-eccarius.de/select-username"
+
+ discord {
+ clientId = ${?DISCORD_CLIENT_ID}
+ clientSecret = ${?DISCORD_CLIENT_SECRET}
+ redirectUri = ${?DISCORD_REDIRECT_URI}
+ redirectUri = "https://st.knockout.janis-eccarius.de/auth/discord/callback"
+ }
+
+ keycloak {
+ clientId = "your-keycloak-client-id"
+ clientSecret = "your-keycloak-client-secret"
+ redirectUri = "https://st.knockout.janis-eccarius.de/api/auth/keycloak/callback"
+ authUrl = ${?KEYCLOAK_AUTH_URL}
+ authUrl = "https://identity.janis-eccarius.de/realms/master"
+ }
+}
diff --git a/knockoutwhistweb/public/conf/particlesjs-config.json b/knockoutwhistweb/public/conf/particlesjs-config.json
deleted file mode 100644
index e0215db..0000000
--- a/knockoutwhistweb/public/conf/particlesjs-config.json
+++ /dev/null
@@ -1,110 +0,0 @@
-{
- "particles": {
- "number": {
- "value": 80,
- "density": {
- "enable": true,
- "value_area": 800
- }
- },
- "color": {
- "value": "#ffffff"
- },
- "shape": {
- "type": "circle",
- "stroke": {
- "width": 0,
- "color": "#000000"
- },
- "polygon": {
- "nb_sides": 5
- },
- "image": {
- "src": "img/github.svg",
- "width": 100,
- "height": 100
- }
- },
- "opacity": {
- "value": 0.5,
- "random": false,
- "anim": {
- "enable": false,
- "speed": 1,
- "opacity_min": 0.1,
- "sync": false
- }
- },
- "size": {
- "value": 3,
- "random": true,
- "anim": {
- "enable": false,
- "speed": 40,
- "size_min": 0.1,
- "sync": false
- }
- },
- "line_linked": {
- "enable": true,
- "distance": 150,
- "color": "#ffffff",
- "opacity": 0.4,
- "width": 1
- },
- "move": {
- "enable": true,
- "speed": 1,
- "direction": "none",
- "random": false,
- "straight": false,
- "out_mode": "out",
- "bounce": false,
- "attract": {
- "enable": false,
- "rotateX": 600,
- "rotateY": 1200
- }
- }
- },
- "interactivity": {
- "detect_on": "canvas",
- "events": {
- "onhover": {
- "enable": false,
- "mode": "repulse"
- },
- "onclick": {
- "enable": false,
- "mode": "push"
- },
- "resize": true
- },
- "modes": {
- "grab": {
- "distance": 400,
- "line_linked": {
- "opacity": 1
- }
- },
- "bubble": {
- "distance": 400,
- "size": 40,
- "duration": 2,
- "opacity": 8,
- "speed": 3
- },
- "repulse": {
- "distance": 200,
- "duration": 0.4
- },
- "push": {
- "particles_nb": 4
- },
- "remove": {
- "particles_nb": 2
- }
- }
- },
- "retina_detect": true
-}
\ No newline at end of file
diff --git a/knockoutwhistweb/public/images/background.png b/knockoutwhistweb/public/images/background.png
deleted file mode 100644
index 8a63857eeee95f35de58da8291a11caaff390260..0000000000000000000000000000000000000000
GIT binary patch
literal 0
HcmV?d00001
literal 2947309
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≻ujI=&`?
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
z