Compare commits

...

6 Commits

Author SHA1 Message Date
TeamCity
266cbe7509 ci: bump version to v4.11.0 2025-12-10 10:47:18 +00:00
e8b31b1748 feat: FRO-2 Implement Login Component (#105)
Reviewed-on: #105
Reviewed-by: lq64 <lq@blackhole.local>
Co-authored-by: Janis <janis.e.20@gmx.de>
Co-committed-by: Janis <janis.e.20@gmx.de>
2025-12-10 11:43:51 +01:00
TeamCity
8812b0fad4 ci: bump version to v4.10.0 2025-12-10 10:40:36 +00:00
dd5e8e65e5 feat: BAC-27 Implemented endpoint which returns information about the current state (#103)
Reviewed-on: #103
Reviewed-by: lq64 <lq@blackhole.local>
Co-authored-by: Janis <janis.e.20@gmx.de>
Co-committed-by: Janis <janis.e.20@gmx.de>
2025-12-10 11:37:35 +01:00
TeamCity
bf6ffeadb0 ci: bump version to v4.9.1 2025-12-10 08:46:31 +00:00
fa3d21e303 fix: FRO-29 Websocket Communication (#104)
Reviewed-on: #104
Co-authored-by: Janis <janis.e.20@gmx.de>
Co-committed-by: Janis <janis.e.20@gmx.de>
2025-12-10 09:42:50 +01:00
19 changed files with 181 additions and 51 deletions

1
.gitignore vendored
View File

@@ -134,6 +134,7 @@ target
/.project /.project
/.settings /.settings
/RUNNING_PID /RUNNING_PID
/knockoutwhistwebfrontend/
/knockoutwhist/ /knockoutwhist/
/knockoutwhistweb/.g8/ /knockoutwhistweb/.g8/
/knockoutwhistweb/.bsp/ /knockoutwhistweb/.bsp/

View File

@@ -219,3 +219,18 @@
### Features ### Features
* BAC-30 Implement Jackson Mapping via DTOs ([#102](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/issues/102)) ([8d697fd](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/commit/8d697fd311478cf792b4631377de4522ecbda9f7)) * BAC-30 Implement Jackson Mapping via DTOs ([#102](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/issues/102)) ([8d697fd](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/commit/8d697fd311478cf792b4631377de4522ecbda9f7))
## (2025-12-10)
### Bug Fixes
* FRO-29 Websocket Communication ([#104](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/issues/104)) ([fa3d21e](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/commit/fa3d21e3038eb07369764850a9ad9badd269ac57))
## (2025-12-10)
### Features
* BAC-27 Implemented endpoint which returns information about the current state ([#103](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/issues/103)) ([dd5e8e6](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/commit/dd5e8e65e55f02a7618b3c60e8fc7087774e5106))
## (2025-12-10)
### Features
* FRO-2 Implement Login Component ([#105](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/issues/105)) ([e8b31b1](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/commit/e8b31b174819b5f033034501856c4b1189c4c4ee))

View File

@@ -23,12 +23,12 @@ class AuthAction @Inject()(val sessionManager: SessionManager, val parser: BodyP
case Some(user) => case Some(user) =>
block(new AuthenticatedRequest(user, request)) block(new AuthenticatedRequest(user, request))
case None => case None =>
Future.successful(Results.Redirect(routes.UserController.login())) Future.successful(Results.Unauthorized)
} }
} }
protected def getUserFromSession(request: RequestHeader): Option[User] = { protected def getUserFromSession(request: RequestHeader): Option[User] = {
val session = request.cookies.get("sessionId") val session = request.cookies.get("accessToken")
if (session.isDefined) if (session.isDefined)
return sessionManager.getUserBySession(session.get.value) return sessionManager.getUserBySession(session.get.value)
None None

View File

@@ -0,0 +1,94 @@
package controllers
import auth.AuthAction
import logic.PodManager
import logic.game.GameLobby
import logic.user.SessionManager
import model.users.User
import play.api.libs.json.{JsValue, Json}
import play.api.mvc.*
import util.WebsocketEventMapper
import javax.inject.Inject
class StatusController @Inject()(
val controllerComponents: ControllerComponents,
val sessionManager: SessionManager,
val authAction: AuthAction
) extends BaseController {
def requestStatus(): Action[AnyContent] = {
Action { implicit request =>
val userOpt = getUserFromSession(request)
if (userOpt.isEmpty) {
Ok(
Json.obj(
"status" -> "unauthenticated"
)
)
} else {
val user = userOpt.get
val gameOpt = PodManager.identifyGameOfUser(user)
if (gameOpt.isEmpty) {
Ok(
Json.obj(
"status" -> "authenticated",
"username" -> user.name,
"inGame" -> "false"
)
)
} else {
val game = gameOpt.get
Ok(
Json.obj(
"status" -> "authenticated",
"username" -> user.name,
"inGame" -> "true",
"gameId" -> game.id
)
)
}
}
}
}
def game(gameId: String): Action[AnyContent] = {
Action { implicit request =>
val userOpt = getUserFromSession(request)
if (userOpt.isEmpty) {
Unauthorized("User not authenticated")
} else {
val user = userOpt.get
val gameOpt = PodManager.getGame(gameId)
if (gameOpt.isEmpty) {
NotFound("Game not found")
} else {
val game = gameOpt.get
if (!game.getPlayers.contains(user.id)) {
Forbidden("User not part of this game")
} else {
Ok(
Json.obj(
"gameId" -> game.id,
"state" -> game.logic.getCurrentState.toString,
"data" -> mapGameState(game, user)
)
)
}
}
}
}}
private def getUserFromSession(request: RequestHeader): Option[User] = {
val session = request.cookies.get("sessionId")
if (session.isDefined)
return sessionManager.getUserBySession(session.get.value)
None
}
private def mapGameState(gameLobby: GameLobby, user: User): JsValue = {
val userSession = gameLobby.getUserSession(user.id)
WebsocketEventMapper.stateToJson(userSession)
}
}

View File

@@ -1,10 +1,13 @@
package controllers package controllers
import auth.{AuthAction, AuthenticatedRequest} import auth.{AuthAction, AuthenticatedRequest}
import dto.subDTO.UserDTO
import logic.user.{SessionManager, UserManager} import logic.user.{SessionManager, UserManager}
import model.users.User
import play.api.* import play.api.*
import play.api.libs.json.Json import play.api.libs.json.Json
import play.api.mvc.* import play.api.mvc.*
import play.api.mvc.Cookie.SameSite.{Lax, None, Strict}
import javax.inject.* import javax.inject.*
@@ -21,22 +24,6 @@ class UserController @Inject()(
val authAction: AuthAction val authAction: AuthAction
) extends BaseController { ) extends BaseController {
def login(): Action[AnyContent] = {
Action { implicit request =>
val session = request.cookies.get("sessionId")
if (session.isDefined) {
val possibleUser = sessionManager.getUserBySession(session.get.value)
if (possibleUser.isDefined) {
Redirect(routes.MainMenuController.mainMenu())
} else {
Ok(views.html.main("Login")(views.html.login.login()))
}
} else {
Ok(views.html.main("Login")(views.html.login.login()))
}
}
}
def login_Post(): Action[AnyContent] = { def login_Post(): Action[AnyContent] = {
Action { implicit request => Action { implicit request =>
val jsonBody = request.body.asJson val jsonBody = request.body.asJson
@@ -51,12 +38,17 @@ class UserController @Inject()(
val possibleUser = userManager.authenticate(username.get, password.get) val possibleUser = userManager.authenticate(username.get, password.get)
if (possibleUser.isDefined) { if (possibleUser.isDefined) {
Ok(Json.obj( Ok(Json.obj(
"status" -> "success", "user" -> Json.obj(
"redirectUrl" -> routes.MainMenuController.mainMenu().url, "id" -> possibleUser.get.id,
"content" -> views.html.mainmenu.creategame(possibleUser).toString "username" -> possibleUser.get.name
)).withCookies( )
Cookie("sessionId", sessionManager.createSession(possibleUser.get)) )).withCookies(Cookie(
) name = "accessToken",
value = sessionManager.createSession(possibleUser.get),
httpOnly = true,
secure = false,
sameSite = Some(Lax)
))
} else { } else {
Unauthorized("Invalid username or password") Unauthorized("Invalid username or password")
} }
@@ -65,14 +57,21 @@ class UserController @Inject()(
} }
} }
} }
def getUserInfo(): Action[AnyContent] = authAction { implicit request: AuthenticatedRequest[AnyContent] =>
val user: User = request.user
Ok(Json.obj(
"id" -> user.id,
"username" -> user.name
))
}
// Pass the request-handling function directly to authAction (no nested Action) def logoutPost(): Action[AnyContent] = authAction { implicit request: AuthenticatedRequest[AnyContent] =>
def logout(): Action[AnyContent] = authAction { implicit request: AuthenticatedRequest[AnyContent] => val sessionCookie = request.cookies.get("accessToken")
val sessionCookie = request.cookies.get("sessionId")
if (sessionCookie.isDefined) { if (sessionCookie.isDefined) {
sessionManager.invalidateSession(sessionCookie.get.value) sessionManager.invalidateSession(sessionCookie.get.value)
} }
Redirect(routes.UserController.login()).discardingCookies(DiscardingCookie("sessionId")) NoContent.discardingCookies(DiscardingCookie("accessToken"))
} }
} }

View File

@@ -4,7 +4,7 @@ import dto.subDTO.UserDTO
import logic.game.GameLobby import logic.game.GameLobby
import model.users.User import model.users.User
case class LobbyInfoDTO(users: List[UserDTO], self: UserDTO, maxPlayers: Int) case class LobbyInfoDTO(gameId: String, users: List[UserDTO], self: UserDTO, maxPlayers: Int)
object LobbyInfoDTO { object LobbyInfoDTO {
@@ -12,6 +12,7 @@ object LobbyInfoDTO {
val session = lobby.getUserSession(user.id) val session = lobby.getUserSession(user.id)
LobbyInfoDTO( LobbyInfoDTO(
gameId = lobby.id,
users = lobby.getPlayers.values.map(user => UserDTO(user)).toList, users = lobby.getPlayers.values.map(user => UserDTO(user)).toList,
self = UserDTO(session), self = UserDTO(session),
maxPlayers = lobby.maxPlayers, maxPlayers = lobby.maxPlayers,

View File

@@ -6,7 +6,7 @@ import model.users.User
import scala.util.Try import scala.util.Try
case class TieInfoDTO(currentPlayer: Option[PlayerDTO], self: Option[PlayerDTO], tiedPlayers: Seq[PlayerDTO], highestAmount: Int) case class TieInfoDTO(gameId: String, currentPlayer: Option[PlayerDTO], self: Option[PlayerDTO], tiedPlayers: Seq[PlayerDTO], highestAmount: Int)
object TieInfoDTO { object TieInfoDTO {
@@ -16,6 +16,7 @@ object TieInfoDTO {
}.getOrElse(None) }.getOrElse(None)
TieInfoDTO( TieInfoDTO(
gameId = lobby.id,
currentPlayer = lobby.logic.playerTieLogic.currentTiePlayer().map(PlayerDTO.apply), currentPlayer = lobby.logic.playerTieLogic.currentTiePlayer().map(PlayerDTO.apply),
self = selfPlayer.map(PlayerDTO.apply), self = selfPlayer.map(PlayerDTO.apply),
tiedPlayers = lobby.logic.playerTieLogic.getTiedPlayers.map(PlayerDTO.apply), tiedPlayers = lobby.logic.playerTieLogic.getTiedPlayers.map(PlayerDTO.apply),

View File

@@ -7,6 +7,7 @@ import model.users.User
import scala.util.Try import scala.util.Try
case class TrumpInfoDTO( case class TrumpInfoDTO(
gameId: String,
chooser: Option[PlayerDTO], chooser: Option[PlayerDTO],
self: Option[PlayerDTO], self: Option[PlayerDTO],
selfHand: Option[HandDTO], selfHand: Option[HandDTO],
@@ -20,6 +21,7 @@ object TrumpInfoDTO {
}.getOrElse(None) }.getOrElse(None)
TrumpInfoDTO( TrumpInfoDTO(
gameId = lobby.id,
chooser = lobby.logic.getTrumpPlayer.map(PlayerDTO(_)), chooser = lobby.logic.getTrumpPlayer.map(PlayerDTO(_)),
self = selfPlayer.map(PlayerDTO(_)), self = selfPlayer.map(PlayerDTO(_)),
selfHand = selfPlayer.flatMap(_.currentHand()).map(HandDTO(_)) selfHand = selfPlayer.flatMap(_.currentHand()).map(HandDTO(_))

View File

@@ -5,6 +5,7 @@ import logic.game.GameLobby
import model.users.User import model.users.User
case class WonInfoDTO( case class WonInfoDTO(
gameId: String,
winner: Option[PodiumPlayerDTO], winner: Option[PodiumPlayerDTO],
allPlayers: Seq[PodiumPlayerDTO] allPlayers: Seq[PodiumPlayerDTO]
) )
@@ -24,6 +25,7 @@ object WonInfoDTO {
val winnerDTO = lobby.logic.getWinner val winnerDTO = lobby.logic.getWinner
WonInfoDTO( WonInfoDTO(
gameId = lobby.id,
winner = winnerDTO.map(player => PodiumPlayerDTO(lobby.logic, player)), winner = winnerDTO.map(player => PodiumPlayerDTO(lobby.logic, player)),
allPlayers = allPlayersDTO allPlayers = allPlayersDTO
) )

View File

@@ -3,7 +3,7 @@ package dto.subDTO
import de.knockoutwhist.cards.Card import de.knockoutwhist.cards.Card
import util.WebUIUtils import util.WebUIUtils
case class CardDTO(identifier: String, path: String, idx: Int) { case class CardDTO(identifier: String, path: String, idx: Option[Int]) {
def toCard: Card = { def toCard: Card = {
WebUIUtils.stringToCard(identifier) WebUIUtils.stringToCard(identifier)
@@ -13,11 +13,19 @@ case class CardDTO(identifier: String, path: String, idx: Int) {
object CardDTO { object CardDTO {
def apply(card: Card, index: Int = 0): CardDTO = { def apply(card: Card, index: Int): CardDTO = {
CardDTO( CardDTO(
identifier = WebUIUtils.cardtoString(card), identifier = WebUIUtils.cardtoString(card),
path = WebUIUtils.cardToPath(card), path = WebUIUtils.cardToPath(card),
idx = index idx = Some(index)
)
}
def apply(card: Card): CardDTO = {
CardDTO(
identifier = WebUIUtils.cardtoString(card),
path = WebUIUtils.cardToPath(card),
idx = None
) )
} }
} }

View File

@@ -3,7 +3,7 @@ package dto.subDTO
import de.knockoutwhist.cards.Card import de.knockoutwhist.cards.Card
import de.knockoutwhist.cards.CardValue.Ace import de.knockoutwhist.cards.CardValue.Ace
case class RoundDTO(trumpSuit: CardDTO, firstRound: Boolean, tricklist: List[TrickDTO]) case class RoundDTO(trumpSuit: CardDTO, firstRound: Boolean, trickList: List[TrickDTO])
object RoundDTO { object RoundDTO {
@@ -11,7 +11,7 @@ object RoundDTO {
RoundDTO( RoundDTO(
trumpSuit = CardDTO(Card(Ace, round.trumpSuit)), trumpSuit = CardDTO(Card(Ace, round.trumpSuit)),
firstRound = round.firstRound, firstRound = round.firstRound,
tricklist = round.tricklist.map(trick => TrickDTO(trick)) trickList = round.tricklist.map(trick => TrickDTO(trick))
) )
} }

View File

@@ -2,13 +2,13 @@ package dto.subDTO
import de.knockoutwhist.rounds.Trick import de.knockoutwhist.rounds.Trick
case class TrickDTO(cards: Map[PlayerDTO, CardDTO], firstCard: Option[CardDTO], winner: Option[PlayerDTO]) case class TrickDTO(cards: Map[String, CardDTO], firstCard: Option[CardDTO], winner: Option[PlayerDTO])
object TrickDTO { object TrickDTO {
def apply(trick: Trick): TrickDTO = { def apply(trick: Trick): TrickDTO = {
TrickDTO( TrickDTO(
cards = trick.cards.map { case (card, player) => PlayerDTO(player) -> CardDTO(card) }, cards = trick.cards.map { case (card, player) => player.id.toString -> CardDTO(card) },
firstCard = trick.firstCard.map(card => CardDTO(card)), firstCard = trick.firstCard.map(card => CardDTO(card)),
winner = trick.winner.map(player => PlayerDTO(player)) winner = trick.winner.map(player => PlayerDTO(player))
) )

View File

@@ -9,6 +9,7 @@ trait SessionManager {
def createSession(user: User): String def createSession(user: User): String
def getUserBySession(sessionId: String): Option[User] def getUserBySession(sessionId: String): Option[User]
def invalidateSession(sessionId: String): Unit def invalidateSession(sessionId: String): Unit

View File

@@ -57,12 +57,12 @@ object WebsocketEventMapper {
Json.obj( Json.obj(
"id" -> ("request-" + java.util.UUID.randomUUID().toString), "id" -> ("request-" + java.util.UUID.randomUUID().toString),
"event" -> obj.id, "event" -> obj.id,
"state" -> toJson(session), "state" -> stateToJson(session),
"data" -> data "data" -> data
) )
} }
def toJson(session: UserSession): JsValue = { def stateToJson(session: UserSession): JsValue = {
session.gameLobby.getLogic.getCurrentState match { session.gameLobby.getLogic.getCurrentState match {
case Lobby => Json.toJson(LobbyInfoDTO(session.gameLobby, session.user)) case Lobby => Json.toJson(LobbyInfoDTO(session.gameLobby, session.user))
case InGame => Json.toJson(GameInfoDTO(session.gameLobby, session.user)) case InGame => Json.toJson(GameInfoDTO(session.gameLobby, session.user))

View File

@@ -45,15 +45,9 @@
<li><a class="dropdown-item disabled" href="#" tabindex="-1" aria-disabled="true"> <li><a class="dropdown-item disabled" href="#" tabindex="-1" aria-disabled="true">
Settings</a></li> Settings</a></li>
<li><hr class="dropdown-divider"></li> <li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item" href="@routes.UserController.logout()">Logout</a></li>
</ul> </ul>
</li> </li>
</ul> </ul>
} else {
<div class="d-flex ms-auto">
<a class="btn btn-outline-primary me-2" href="@routes.UserController.login()">Login</a>
<a class="btn btn-primary" href="@routes.UserController.login()">Sign Up</a>
</div>
} }
</div> </div>

View File

@@ -13,3 +13,12 @@ auth {
publicKeyFile = ${?PUBLIC_KEY_FILE} publicKeyFile = ${?PUBLIC_KEY_FILE}
publicKeyPem = ${?PUBLIC_KEY_PEM} publicKeyPem = ${?PUBLIC_KEY_PEM}
} }
play.filters.enabled += "play.filters.cors.CORSFilter"
play.filters.cors {
allowedOrigins = ["http://localhost:5173"]
allowedCredentials = true
allowedHttpMethods = ["GET", "POST", "PUT", "DELETE", "OPTIONS"]
allowedHttpHeaders = ["Accept", "Content-Type", "Origin", "X-Requested-With"]
}

View File

@@ -18,13 +18,16 @@ POST /createGame controllers.MainMenuController.createGame()
POST /joinGame controllers.MainMenuController.joinGame() POST /joinGame controllers.MainMenuController.joinGame()
# User authentication routes # User authentication routes
GET /login controllers.UserController.login()
POST /login controllers.UserController.login_Post() POST /login controllers.UserController.login_Post()
POST /logout controllers.UserController.logoutPost()
GET /logout controllers.UserController.logout() GET /userInfo controllers.UserController.getUserInfo()
# In-game routes # In-game routes
GET /game/:id controllers.IngameController.game(id: String) GET /game/:id controllers.IngameController.game(id: String)
# Websocket # Websocket
GET /websocket controllers.WebsocketController.socket() GET /websocket controllers.WebsocketController.socket()
# Status
GET /status controllers.StatusController.requestStatus()
GET /status/:gameId controllers.StatusController.game(gameId: String)

View File

@@ -1,3 +1,3 @@
MAJOR=4 MAJOR=4
MINOR=9 MINOR=11
PATCH=0 PATCH=0