Compare commits

..

No commits in common. "main" and "v1.10.6" have entirely different histories.

86 changed files with 947 additions and 3864 deletions

View file

@ -4,79 +4,34 @@ on:
pull_request:
jobs:
changed_files:
name: Get Changed Files
runs-on: ubuntu-latest
outputs:
backend: ${{ steps.filter.outputs.backend }}
frontend: ${{ steps.filter.outputs.frontend }}
workflow: ${{ steps.filter.outputs.workflow }}
steps:
- uses: actions/checkout@v4
- name: Check for file changes
uses: dorny/paths-filter@v3
id: filter
with:
filters: |
backend:
- 'backend/**'
frontend:
- 'frontend/**'
workflow:
- '.gitea/workflows/**'
checkstyle:
name: "Checkstyle Main"
needs: changed_files
if: ${{ needs.changed_files.outputs.backend == 'true' || needs.changed_files.outputs.workflow == 'true' }}
container:
image: "cimg/openjdk:23.0-node"
steps:
- name: "Checkout"
uses: actions/checkout@v4
- name: "Cache Gradle dependencies"
uses: https://github.com/actions/cache@v4
uses: https://github.com/actions/cache@v3
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
backend/build/reports/checkstyle
key: gradle-${{ runner.os }}-${{ hashFiles('backend/**/*.java', 'backend/config/checkstyle/checkstyle.xml') }}
key: gradle-${{ runner.os }}-common
restore-keys: |
gradle-${{ runner.os }}-
- name: "Check cache for checkstyle reports"
id: check-cache
run: |
if [ -d "backend/build/reports/checkstyle" ] && [ -f "backend/build/reports/checkstyle/main.xml" ]; then
echo "Cache hit! Using cached checkstyle results"
echo "cache-hit=true" >> $GITHUB_OUTPUT
else
echo "Cache miss! Running checkstyle check"
echo "cache-hit=false" >> $GITHUB_OUTPUT
fi
- name: "Run checkstyle"
if: steps.check-cache.outputs.cache-hit != 'true'
- name: "Prepare Gradle"
working-directory: ./backend
run: |
gradle checkstyleMain
- name: "Cache checkstyle results"
if: steps.check-cache.outputs.cache-hit != 'true'
uses: actions/upload-artifact@v4
with:
name: checkstyle-results
path: backend/build/reports/checkstyle
run: gradle clean
- name: "Check"
working-directory: ./backend
run: gradle checkstyleMain
- name: "Stop Gradle"
if: steps.check-cache.outputs.cache-hit != 'true'
working-directory: ./backend
run: gradle --stop
eslint:
name: eslint
needs: changed_files
if: ${{ needs.changed_files.outputs.frontend == 'true' || needs.changed_files.outputs.workflow == 'true' }}
container:
image: catthehacker/ubuntu:act-latest
steps:
@ -103,8 +58,6 @@ jobs:
prettier:
name: prettier
needs: changed_files
if: ${{ needs.changed_files.outputs.frontend == 'true' || needs.changed_files.outputs.workflow == 'true' }}
container:
image: catthehacker/ubuntu:act-latest
steps:
@ -131,8 +84,6 @@ jobs:
test-build:
name: test-build
needs: changed_files
if: ${{ needs.changed_files.outputs.frontend == 'true' || needs.changed_files.outputs.workflow == 'true' }}
container:
image: catthehacker/ubuntu:act-latest
steps:

View file

@ -3,11 +3,6 @@ on:
push:
branches:
- "main"
paths:
- 'backend/**'
- 'frontend/**'
- '.gitea/workflows/release.yml'
- 'release.config.cjs'
env:
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}

21
LICENSE
View file

@ -1,21 +0,0 @@
MIT License
Copyright (c) 2025 Casino Gaming Platform
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View file

@ -2,7 +2,7 @@
An online gaming platform offering various casino-style games with virtual currency support. This project features a modern tech stack with Angular frontend, Spring Boot backend, and complete user authentication.
Please refer to our [Style Guide](https://git.kjan.de/SZUT/casino/wiki/Frontend#design-system) for design guidelines and component standards.
![Casino Gaming Platform](/frontend/public/blackjack.webp)
## Features
@ -67,11 +67,6 @@ cd backend
./gradlew bootRun
```
You may optionally install [watchexec](https://github.com/watchexec/watchexec?tab=readme-ov-file) and use this command to autorecompile the backend on file changes:
```bash
watchexec -r -e java ./gradlew :bootRun
```
The backend will be available at:
- API endpoint: http://localhost:8080
- Swagger documentation: http://localhost:8080/swagger
@ -95,20 +90,6 @@ npm run dev
The frontend will be available at http://localhost:4200
### Local Stripe integration
1. Install the Stripe CLI
https://stripe.com/docs/stripe-cli
2. Login to the casino stripe account
```
stripe login --api-key <casino-stripe-secret-key>
```
3. Start webhook forwarding
```
stripe listen --forward-to localhost:8080/webhook
```
## Database Management
### Postgres Management
@ -169,4 +150,4 @@ References:
## License
This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
[License information here]

View file

@ -1,7 +1,7 @@
plugins {
java
id("org.springframework.boot") version "3.4.4"
id("io.spring.dependency-management") version "1.1.7"
id("org.springframework.boot") version "3.3.3"
id("io.spring.dependency-management") version "1.1.6"
id("checkstyle")
}
@ -39,7 +39,7 @@ repositories {
}
dependencies {
implementation("com.stripe:stripe-java:29.0.0")
implementation("com.stripe:stripe-java:20.79.0")
implementation("org.springframework.boot:spring-boot-starter-data-jpa")
implementation("org.springframework.boot:spring-boot-starter-web")
compileOnly("org.projectlombok:lombok")
@ -47,10 +47,10 @@ dependencies {
testImplementation("org.springframework.boot:spring-boot-starter-test")
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
implementation("org.springframework.boot:spring-boot-starter-security")
implementation("org.springframework.boot:spring-boot-starter-oauth2-resource-server:3.4.4")
implementation("org.springframework.boot:spring-boot-starter-oauth2-client:3.4.4")
implementation("org.springframework.boot:spring-boot-starter-oauth2-resource-server:3.3.3")
implementation("org.springframework.boot:spring-boot-starter-oauth2-client:3.3.3")
runtimeOnly("org.postgresql:postgresql")
implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.6")
implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:2.6.0")
}
tasks.withType<Test> {

View file

@ -1,6 +1,6 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-8.8-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME

View file

@ -1,20 +0,0 @@
POST http://localhost:8080/blackjack/start
Authorization: Bearer {{token}}
Content-Type: application/json
{
"betAmount": 1.01
}
###
POST http://localhost:8080/blackjack/54/hit
Authorization: Bearer {{token}}
###
POST http://localhost:8080/blackjack/202/stand
Authorization: Bearer {{token}}
###
GET http://localhost:8080/blackjack/202
Authorization: Bearer {{token}}

View file

@ -1,6 +1,6 @@
POST http://localhost:9090/realms/LF12/protocol/openid-connect/token
Content-Type: application/x-www-form-urlencoded
grant_type=password&client_id=lf12&username=lf12_test_user&password=secret&scope=openid
grant_type=password&client_id=lf12&username=lf12_test_user&password=secret
> {% client.global.set("token", response.body.access_token); %}

View file

@ -1,9 +0,0 @@
GET http://localhost:8080/lootboxes
Authorization: Bearer {{token}}
Content-Type: application/json
###
POST http://localhost:8080/lootboxes/2
Authorization: Bearer {{token}}
Content-Type: application/json

View file

@ -12,7 +12,7 @@ Content-Type: application/json
Authorization: Bearer {{token}}
{
"authentikId": "52cc0208-a3bd-4367-94c5-0404b016a003",
"keycloakId": "52cc0208-a3bd-4367-94c5-0404b016a003",
"username": "john.doe"
}

View file

@ -1 +0,0 @@
POST localhost:8080/webhook

View file

@ -1,20 +1,10 @@
package de.szut.casino;
import de.szut.casino.lootboxes.LootBoxEntity;
import de.szut.casino.lootboxes.LootBoxRepository;
import de.szut.casino.lootboxes.RewardEntity;
import de.szut.casino.lootboxes.RewardRepository;
import org.springframework.boot.CommandLineRunner;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.Bean;
import org.springframework.web.client.RestTemplate;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
@SpringBootApplication
public class CasinoApplication {
@ -26,65 +16,4 @@ public class CasinoApplication {
public static RestTemplate restTemplate() {
return new RestTemplate();
}
@Bean
public CommandLineRunner initData(LootBoxRepository lootBoxRepository, RewardRepository rewardRepository) {
return _ -> {
if (lootBoxRepository.count() == 0) {
LootBoxEntity basicLootBox = new LootBoxEntity();
basicLootBox.setName("Basic LootBox");
basicLootBox.setPrice(new BigDecimal("2"));
basicLootBox.setRewards(new ArrayList<>()); // Initialize the list
LootBoxEntity premiumLootBox = new LootBoxEntity();
premiumLootBox.setName("Premium LootBox");
premiumLootBox.setPrice(new BigDecimal("5"));
premiumLootBox.setRewards(new ArrayList<>()); // Initialize the list
lootBoxRepository.saveAll(Arrays.asList(basicLootBox, premiumLootBox));
RewardEntity commonReward = new RewardEntity();
commonReward.setValue(new BigDecimal("0.50"));
commonReward.setProbability(new BigDecimal("0.7"));
RewardEntity rareReward = new RewardEntity();
rareReward.setValue(new BigDecimal("2.00"));
rareReward.setProbability(new BigDecimal("0.25"));
RewardEntity epicReward = new RewardEntity();
epicReward.setValue(new BigDecimal("5.00"));
epicReward.setProbability(new BigDecimal("0.5"));
RewardEntity premiumCommon = new RewardEntity();
premiumCommon.setValue(new BigDecimal("2.00"));
premiumCommon.setProbability(new BigDecimal("0.6"));
RewardEntity premiumRare = new RewardEntity();
premiumRare.setValue(new BigDecimal("5.00"));
premiumRare.setProbability(new BigDecimal("0.3"));
RewardEntity legendaryReward = new RewardEntity();
legendaryReward.setValue(new BigDecimal("15.00"));
legendaryReward.setProbability(new BigDecimal("0.10"));
rewardRepository.saveAll(Arrays.asList(
commonReward, rareReward, epicReward,
premiumCommon, premiumRare, legendaryReward
));
basicLootBox.getRewards().add(commonReward);
basicLootBox.getRewards().add(premiumRare);
premiumLootBox.getRewards().add(premiumCommon);
premiumLootBox.getRewards().add(premiumRare);
premiumLootBox.getRewards().add(legendaryReward);
lootBoxRepository.saveAll(Arrays.asList(basicLootBox, premiumLootBox));
System.out.println("Initial LootBoxes and rewards created successfully");
} else {
System.out.println("LootBoxes already exist, skipping initialization");
}
};
}
}

View file

@ -1,140 +0,0 @@
package de.szut.casino.blackjack;
import de.szut.casino.blackjack.dto.CreateBlackJackGameDto;
import de.szut.casino.user.UserEntity;
import de.szut.casino.user.UserService;
import jakarta.validation.Valid;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.math.BigDecimal;
import java.util.HashMap;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
@Slf4j
@RestController
public class BlackJackGameController {
private final UserService userService;
private final BlackJackService blackJackService;
public BlackJackGameController(UserService userService, BlackJackService blackJackService) {
this.blackJackService = blackJackService;
this.userService = userService;
}
@GetMapping("/blackjack/{id}")
public ResponseEntity<Object> getGame(@PathVariable Long id, @RequestHeader("Authorization") String token) {
Optional<UserEntity> optionalUser = userService.getCurrentUser(token);
if (optionalUser.isEmpty()) {
return ResponseEntity.notFound().build();
}
UserEntity user = optionalUser.get();
BlackJackGameEntity game = blackJackService.getBlackJackGame(id);
if (game == null || !Objects.equals(game.getUserId(), user.getId())) {
return ResponseEntity.notFound().build();
}
return ResponseEntity.ok(game);
}
@PostMapping("/blackjack/{id}/hit")
public ResponseEntity<Object> hit(@PathVariable Long id, @RequestHeader("Authorization") String token) {
Optional<UserEntity> optionalUser = userService.getCurrentUser(token);
if (optionalUser.isEmpty()) {
return ResponseEntity.notFound().build();
}
UserEntity user = optionalUser.get();
BlackJackGameEntity game = blackJackService.getBlackJackGame(id);
if (game == null || !Objects.equals(game.getUserId(), user.getId())) {
return ResponseEntity.notFound().build();
}
return ResponseEntity.ok(blackJackService.hit(game));
}
@PostMapping("/blackjack/{id}/stand")
public ResponseEntity<Object> stand(@PathVariable Long id, @RequestHeader("Authorization") String token) {
Optional<UserEntity> optionalUser = userService.getCurrentUser(token);
if (optionalUser.isEmpty()) {
return ResponseEntity.notFound().build();
}
UserEntity user = optionalUser.get();
BlackJackGameEntity game = blackJackService.getBlackJackGame(id);
if (game == null || !Objects.equals(game.getUserId(), user.getId())) {
return ResponseEntity.notFound().build();
}
return ResponseEntity.ok(blackJackService.stand(game));
}
@PostMapping("/blackjack/{id}/doubleDown")
public ResponseEntity<Object> doubleDown(@PathVariable Long id, @RequestHeader("Authorization") String token) {
Optional<UserEntity> optionalUser = userService.getCurrentUser(token);
if (optionalUser.isEmpty()) {
return ResponseEntity.notFound().build();
}
UserEntity user = optionalUser.get();
BlackJackGameEntity game = blackJackService.getBlackJackGame(id);
if (game == null || !Objects.equals(game.getUserId(), user.getId())) {
return ResponseEntity.notFound().build();
}
return ResponseEntity.ok(blackJackService.doubleDown(game));
}
@PostMapping("/blackjack/{id}/split")
public ResponseEntity<Object> split(@PathVariable Long id, @RequestHeader("Authorization") String token) {
Optional<UserEntity> optionalUser = userService.getCurrentUser(token);
if (optionalUser.isEmpty()) {
return ResponseEntity.notFound().build();
}
UserEntity user = optionalUser.get();
BlackJackGameEntity game = blackJackService.getBlackJackGame(id);
if (game == null || !Objects.equals(game.getUserId(), user.getId())) {
return ResponseEntity.notFound().build();
}
return ResponseEntity.ok(blackJackService.split(game));
}
@PostMapping("/blackjack/start")
public ResponseEntity<Object> createBlackJackGame(@RequestBody @Valid CreateBlackJackGameDto createBlackJackGameDto, @RequestHeader("Authorization") String token) {
Optional<UserEntity> optionalUser = userService.getCurrentUser(token);
if (optionalUser.isEmpty()) {
return ResponseEntity.notFound().build();
}
UserEntity user = optionalUser.get();
BigDecimal balance = user.getBalance();
BigDecimal betAmount = createBlackJackGameDto.getBetAmount();
if (betAmount.compareTo(BigDecimal.ZERO) <= 0) {
Map<String, String> errorResponse = new HashMap<>();
errorResponse.put("error", "Invalid bet amount");
return ResponseEntity.badRequest().body(errorResponse);
}
if (betAmount.compareTo(balance) > 0) {
Map<String, String> errorResponse = new HashMap<>();
errorResponse.put("error", "Insufficient funds");
return ResponseEntity.badRequest().body(errorResponse);
}
return ResponseEntity.ok(blackJackService.createBlackJackGame(user, betAmount));
}
}

View file

@ -1,65 +0,0 @@
package de.szut.casino.blackjack;
import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.annotation.JsonManagedReference;
import de.szut.casino.user.UserEntity;
import jakarta.persistence.*;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import org.hibernate.annotations.SQLRestriction;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.List;
@Entity
@Getter
@Setter
@AllArgsConstructor
@NoArgsConstructor
public class BlackJackGameEntity {
@Id
@GeneratedValue
private Long id;
@ManyToOne
@JoinColumn(name = "user_id", nullable = false)
@JsonIgnore
private UserEntity user;
public Long getUserId() {
return user != null ? user.getId() : null;
}
@Enumerated(EnumType.STRING)
private BlackJackState state;
private BigDecimal bet;
@OneToMany(mappedBy = "game", cascade = CascadeType.ALL, orphanRemoval = true)
@JsonIgnore
@SQLRestriction("card_type = 'DECK'")
private List<CardEntity> deck = new ArrayList<>();
@OneToMany(mappedBy = "game", cascade = CascadeType.ALL, orphanRemoval = true)
@JsonManagedReference
@SQLRestriction("card_type = 'PLAYER'")
private List<CardEntity> playerCards = new ArrayList<>();
@OneToMany(mappedBy = "game", cascade = CascadeType.ALL, orphanRemoval = true)
@JsonManagedReference
@SQLRestriction("card_type = 'DEALER'")
private List<CardEntity> dealerCards = new ArrayList<>();
@OneToMany(mappedBy = "game", cascade = CascadeType.ALL, orphanRemoval = true)
@JsonManagedReference
@SQLRestriction("card_type = 'PLAYER_SPLIT'")
private List<CardEntity> playerSplitCards = new ArrayList<>();
@Column(name = "split_bet")
private BigDecimal splitBet;
@Column(name = "is_split")
private boolean isSplit;
}

View file

@ -1,12 +0,0 @@
package de.szut.casino.blackjack;
import de.szut.casino.user.UserEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.stereotype.Service;
import java.util.Optional;
@Service
public interface BlackJackGameRepository extends JpaRepository<BlackJackGameEntity, Long> {
}

View file

@ -1,310 +0,0 @@
package de.szut.casino.blackjack;
import de.szut.casino.user.UserEntity;
import de.szut.casino.user.UserRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
import java.util.List;
import java.util.Optional;
import java.util.Random;
@Service
public class BlackJackService {
private final BlackJackGameRepository blackJackGameRepository;
private final UserRepository userRepository;
private final Random random = new Random();
public BlackJackService(BlackJackGameRepository blackJackGameRepository, UserRepository userRepository) {
this.blackJackGameRepository = blackJackGameRepository;
this.userRepository = userRepository;
}
public BlackJackGameEntity getBlackJackGame(Long id) {
return blackJackGameRepository.findById(id).orElse(null);
}
@Transactional
public BlackJackGameEntity createBlackJackGame(UserEntity user, BigDecimal betAmount) {
BlackJackGameEntity game = new BlackJackGameEntity();
game.setUser(user);
game.setBet(betAmount);
initializeDeck(game);
dealInitialCards(game);
game.setState(getState(game));
deductBetFromBalance(user, betAmount);
return blackJackGameRepository.save(game);
}
@Transactional
public BlackJackGameEntity hit(BlackJackGameEntity game) {
if (game.getState() != BlackJackState.IN_PROGRESS) {
return game;
}
dealCardToPlayer(game);
updateGameStateAndBalance(game);
return blackJackGameRepository.save(game);
}
@Transactional
public BlackJackGameEntity stand(BlackJackGameEntity game) {
if (game.getState() != BlackJackState.IN_PROGRESS) {
return game;
}
dealCardsToDealerUntilMinimumScore(game);
determineWinnerAndUpdateBalance(game);
return blackJackGameRepository.save(game);
}
@Transactional
public BlackJackGameEntity doubleDown(BlackJackGameEntity game) {
if (game.getState() != BlackJackState.IN_PROGRESS || game.getPlayerCards().size() != 2) {
return game;
}
UserEntity user = getUserWithFreshData(game.getUser());
BigDecimal additionalBet = game.getBet();
deductBetFromBalance(user, additionalBet);
game.setBet(game.getBet().add(additionalBet));
dealCardToPlayer(game);
updateGameStateAndBalance(game);
if (game.getState() == BlackJackState.IN_PROGRESS) {
return stand(game);
}
return game;
}
@Transactional
public BlackJackGameEntity split(BlackJackGameEntity game) {
if (game.getState() != BlackJackState.IN_PROGRESS ||
game.getPlayerCards().size() != 2 ||
game.isSplit() ||
!game.getPlayerCards().get(0).getRank().equals(game.getPlayerCards().get(1).getRank())) {
return game;
}
UserEntity user = getUserWithFreshData(game.getUser());
BigDecimal splitBet = game.getBet();
if (user.getBalance().compareTo(splitBet) < 0) {
return game;
}
deductBetFromBalance(user, splitBet);
game.setSplitBet(splitBet);
game.setSplit(true);
CardEntity card = game.getPlayerCards().remove(1);
card.setCardType(CardType.PLAYER_SPLIT);
game.getPlayerSplitCards().add(card);
dealCardToPlayer(game);
dealCardToSplitHand(game);
return blackJackGameRepository.save(game);
}
private BlackJackGameEntity refreshGameState(BlackJackGameEntity game) {
return blackJackGameRepository.findById(game.getId()).orElse(game);
}
private UserEntity getUserWithFreshData(UserEntity user) {
return userRepository.findById(user.getId()).orElse(user);
}
private void dealInitialCards(BlackJackGameEntity game) {
for (int i = 0; i < 2; i++) {
dealCardToPlayer(game);
}
dealCardToDealer(game);
}
private void dealCardToPlayer(BlackJackGameEntity game) {
CardEntity card = drawCardFromDeck(game);
card.setCardType(CardType.PLAYER);
game.getPlayerCards().add(card);
}
private void dealCardToDealer(BlackJackGameEntity game) {
CardEntity card = drawCardFromDeck(game);
card.setCardType(CardType.DEALER);
game.getDealerCards().add(card);
}
private void dealCardsToDealerUntilMinimumScore(BlackJackGameEntity game) {
while (calculateHandValue(game.getDealerCards()) < 17) {
dealCardToDealer(game);
}
}
private void dealCardToSplitHand(BlackJackGameEntity game) {
CardEntity card = drawCardFromDeck(game);
card.setCardType(CardType.PLAYER_SPLIT);
game.getPlayerSplitCards().add(card);
}
private void updateGameStateAndBalance(BlackJackGameEntity game) {
if (game.isSplit()) {
int mainHandValue = calculateHandValue(game.getPlayerCards());
int splitHandValue = calculateHandValue(game.getPlayerSplitCards());
if (mainHandValue > 21 && splitHandValue > 21) {
game.setState(BlackJackState.PLAYER_LOST);
updateUserBalance(game, false);
} else if (mainHandValue <= 21 && splitHandValue <= 21) {
game.setState(BlackJackState.IN_PROGRESS);
} else {
game.setState(BlackJackState.IN_PROGRESS);
}
} else {
game.setState(getState(game));
if (game.getState() == BlackJackState.PLAYER_WON) {
updateUserBalance(game, true);
} else if (game.getState() == BlackJackState.PLAYER_LOST) {
updateUserBalance(game, false);
}
}
}
private void determineWinnerAndUpdateBalance(BlackJackGameEntity game) {
int playerValue = calculateHandValue(game.getPlayerCards());
int dealerValue = calculateHandValue(game.getDealerCards());
if (dealerValue > 21 || playerValue > dealerValue) {
game.setState(BlackJackState.PLAYER_WON);
updateUserBalance(game, true);
} else if (playerValue < dealerValue) {
game.setState(BlackJackState.PLAYER_LOST);
updateUserBalance(game, false);
} else {
game.setState(BlackJackState.DRAW);
updateUserBalance(game, false);
}
}
private void deductBetFromBalance(UserEntity user, BigDecimal betAmount) {
user.setBalance(user.getBalance().subtract(betAmount));
userRepository.save(user);
}
@Transactional
private void updateUserBalance(BlackJackGameEntity game, boolean isWin) {
UserEntity user = getUserWithFreshData(game.getUser());
BigDecimal totalBet = game.getBet();
BigDecimal balance = user.getBalance();
if (game.isSplit()) {
totalBet = totalBet.add(game.getSplitBet());
if (isWin) {
int mainHandValue = calculateHandValue(game.getPlayerCards());
int splitHandValue = calculateHandValue(game.getPlayerSplitCards());
int dealerValue = calculateHandValue(game.getDealerCards());
if (mainHandValue <= 21 && (dealerValue > 21 || mainHandValue > dealerValue)) {
balance = balance.add(game.getBet().multiply(BigDecimal.valueOf(2)));
} else if (mainHandValue == dealerValue) {
balance = balance.add(game.getBet());
}
if (splitHandValue <= 21 && (dealerValue > 21 || splitHandValue > dealerValue)) {
balance = balance.add(game.getSplitBet().multiply(BigDecimal.valueOf(2)));
} else if (splitHandValue == dealerValue) {
balance = balance.add(game.getSplitBet());
}
} else if (game.getState() == BlackJackState.DRAW) {
balance = balance.add(totalBet);
}
} else {
if (isWin) {
balance = balance.add(totalBet.multiply(BigDecimal.valueOf(2)));
} else if (game.getState() == BlackJackState.DRAW) {
balance = balance.add(totalBet);
}
}
user.setBalance(balance);
userRepository.save(user);
}
private void initializeDeck(BlackJackGameEntity game) {
for (Suit suit : Suit.values()) {
for (Rank rank : Rank.values()) {
CardEntity card = new CardEntity();
card.setGame(game);
card.setSuit(suit);
card.setRank(rank);
card.setCardType(CardType.DECK);
game.getDeck().add(card);
}
}
java.util.Collections.shuffle(game.getDeck(), random);
}
private CardEntity drawCardFromDeck(BlackJackGameEntity game) {
if (game.getDeck().isEmpty()) {
throw new IllegalStateException("Deck is empty");
}
return game.getDeck().removeFirst();
}
private BlackJackState getState(BlackJackGameEntity game) {
int playerHandValue = calculateHandValue(game.getPlayerCards());
if (playerHandValue == 21) {
CardEntity hole = drawCardFromDeck(game);
hole.setCardType(CardType.DEALER);
game.getDealerCards().add(hole);
int dealerHandValue = calculateHandValue(game.getDealerCards());
if (dealerHandValue == 21) {
return BlackJackState.DRAW;
} else {
BigDecimal blackjackWinnings = game.getBet().multiply(new BigDecimal("1.5"));
UserEntity user = getUserWithFreshData(game.getUser());
user.setBalance(user.getBalance().add(blackjackWinnings));
return BlackJackState.PLAYER_BLACKJACK;
}
} else if (playerHandValue > 21) {
return BlackJackState.PLAYER_LOST;
}
return BlackJackState.IN_PROGRESS;
}
private int calculateHandValue(List<CardEntity> hand) {
int sum = 0;
int aceCount = 0;
for (CardEntity card : hand) {
sum += card.getRank().getValue();
if (card.getRank() == Rank.ACE) {
aceCount++;
}
}
while (sum > 21 && aceCount > 0) {
sum -= 10;
aceCount--;
}
return sum;
}
}

View file

@ -1,9 +0,0 @@
package de.szut.casino.blackjack;
public enum BlackJackState {
IN_PROGRESS,
PLAYER_BLACKJACK,
PLAYER_LOST,
PLAYER_WON,
DRAW,
}

View file

@ -1,40 +0,0 @@
package de.szut.casino.blackjack;
import com.fasterxml.jackson.annotation.JsonBackReference;
import com.fasterxml.jackson.annotation.JsonIgnore;
import jakarta.persistence.*;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
@Entity
@Getter
@Setter
@AllArgsConstructor
@NoArgsConstructor
public class CardEntity {
@Id
@GeneratedValue
@JsonIgnore
private Long id;
@ManyToOne
@JoinColumn(name = "game_id", nullable = false)
@JsonBackReference
private BlackJackGameEntity game;
@Enumerated(EnumType.STRING)
private Suit suit;
@Enumerated(EnumType.STRING)
private Rank rank;
@Enumerated(EnumType.STRING)
@JsonIgnore
private CardType cardType;
}
enum CardType {
DECK, PLAYER, DEALER, PLAYER_SPLIT
}

View file

@ -1,31 +0,0 @@
package de.szut.casino.blackjack;
import lombok.Getter;
@Getter
public enum Rank {
TWO("2", "Two", 2),
THREE("3", "Three", 3),
FOUR("4", "Four", 4),
FIVE("5", "Five", 5),
SIX("6", "Six", 6),
SEVEN("7", "Seven", 7),
EIGHT("8", "Eight", 8),
NINE("9", "Nine", 9),
TEN("10", "Ten", 10),
JACK("J", "Jack", 10),
QUEEN("Q", "Queen", 10),
KING("K", "King", 10),
ACE("A", "Ace", 11);
private final String symbol;
private final String displayName;
private final int value;
Rank(String symbol, String displayName, int value) {
this.symbol = symbol;
this.displayName = displayName;
this.value = value;
}
}

View file

@ -1,20 +0,0 @@
package de.szut.casino.blackjack;
import lombok.Getter;
@Getter
public enum Suit {
HEARTS("H", "Hearts"),
DIAMONDS("D", "Diamonds"),
CLUBS("C", "Clubs"),
SPADES("S", "Spades");
private final String symbol;
private final String displayName;
Suit(String symbol, String displayName) {
this.symbol = symbol;
this.displayName = displayName;
}
}

View file

@ -1,16 +0,0 @@
package de.szut.casino.blackjack.dto;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import java.math.BigDecimal;
@Getter
@Setter
@AllArgsConstructor
@NoArgsConstructor
public class CreateBlackJackGameDto {
private BigDecimal betAmount;
}

View file

@ -6,20 +6,10 @@ import com.stripe.model.checkout.Session;
import com.stripe.param.checkout.SessionCreateParams;
import de.szut.casino.deposit.dto.AmountDto;
import de.szut.casino.deposit.dto.SessionIdDto;
import de.szut.casino.user.UserEntity;
import de.szut.casino.user.UserRepository;
import de.szut.casino.user.UserService;
import de.szut.casino.user.dto.KeycloakUserDto;
import jakarta.validation.Valid;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.client.RestTemplate;
import java.util.Optional;
@RestController
public class DepositController {
@ -30,36 +20,16 @@ public class DepositController {
@Value("${app.frontend-host}")
private String frontendHost;
private final TransactionService transactionService;
private final RestTemplate restTemplate;
private final UserRepository userRepository;
public DepositController(TransactionService transactionService, RestTemplate restTemplate, UserRepository userRepository) {
this.transactionService = transactionService;
this.restTemplate = restTemplate;
this.userRepository = userRepository;
}
@PostMapping("/deposit/checkout")
public ResponseEntity<SessionIdDto> checkout(@RequestBody @Valid AmountDto amountDto, @RequestHeader("Authorization") String token) throws StripeException {
public ResponseEntity<SessionIdDto> checkout(@RequestBody @Valid AmountDto amountDto) throws StripeException {
Stripe.apiKey = stripeKey;
KeycloakUserDto userData = getAuthentikUserInfo(token);
Optional<UserEntity> optionalUserEntity = this.userRepository.findOneByAuthentikId(userData.getSub());
SessionCreateParams params = SessionCreateParams.builder()
.addLineItem(SessionCreateParams.LineItem.builder()
.setPriceData(SessionCreateParams.LineItem.PriceData.builder()
.setCurrency("EUR")
.setUnitAmount((long) amountDto.getAmount() * 100)
.setProductData(SessionCreateParams.LineItem.PriceData.ProductData.builder()
.setName("Einzahlung")
.build())
.build())
.setAmount((long) amountDto.getAmount() * 100)
.setCurrency("EUR")
.setQuantity(1L)
.setName("Einzahlung")
.build())
.setSuccessUrl(frontendHost+"/home?success=true")
.setCancelUrl(frontendHost+"/home?success=false")
@ -68,21 +38,7 @@ public class DepositController {
Session session = Session.create(params);
if (optionalUserEntity.isEmpty()) {
throw new RuntimeException("User doesnt exist");
}
transactionService.createTransaction(optionalUserEntity.get(), session.getId(), amountDto.getAmount());
return ResponseEntity.ok(new SessionIdDto(session.getId()));
}
private KeycloakUserDto getAuthentikUserInfo(String token) {
HttpHeaders headers = new HttpHeaders();
headers.set("Authorization", token);
ResponseEntity<KeycloakUserDto> response = this.restTemplate.exchange("https://oauth.simonis.lol/application/o/userinfo/", HttpMethod.GET, new HttpEntity<>(headers), KeycloakUserDto.class);
return response.getBody();
}
}

View file

@ -1,29 +0,0 @@
package de.szut.casino.deposit;
import de.szut.casino.user.UserEntity;
import jakarta.persistence.*;
import lombok.Getter;
import lombok.Setter;
import java.math.BigDecimal;
@Setter
@Getter
@Entity
public class TransactionEntity {
@Id
@GeneratedValue
private Long id;
@ManyToOne
@JoinColumn(name = "user_id", nullable = false)
private UserEntity user;
@Column(unique = true)
private String sessionId = null;
private double amount = 0;
@Enumerated(EnumType.STRING)
private TransactionStatus status = TransactionStatus.PROCESSING;
}

View file

@ -1,14 +0,0 @@
package de.szut.casino.deposit;
import de.szut.casino.user.UserEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.stereotype.Service;
import java.util.Optional;
@Service
public interface TransactionRepository extends JpaRepository<TransactionEntity, Long> {
@Query("SELECT t FROM TransactionEntity t WHERE t.sessionId = ?1")
Optional<TransactionEntity> findOneBySessionID(String sessionId);
}

View file

@ -1,64 +0,0 @@
package de.szut.casino.deposit;
import com.stripe.exception.StripeException;
import com.stripe.model.checkout.Session;
import com.stripe.param.checkout.SessionRetrieveParams;
import de.szut.casino.user.UserEntity;
import de.szut.casino.user.UserRepository;
import org.springframework.stereotype.Service;
import java.util.Objects;
import java.util.Optional;
@Service
public class TransactionService {
private final TransactionRepository transactionRepository;
private final UserRepository userRepository;
public TransactionService(TransactionRepository transactionRepository, UserRepository userRepository) {
this.transactionRepository = transactionRepository;
this.userRepository = userRepository;
}
public void createTransaction(
UserEntity user,
String sessionID,
Double amount
) {
TransactionEntity transaction = new TransactionEntity();
transaction.setUser(user);
transaction.setSessionId(sessionID);
transaction.setAmount(amount);
transactionRepository.save(transaction);
}
public void fulfillCheckout(String sessionID) throws StripeException {
SessionRetrieveParams params = SessionRetrieveParams.builder()
.addExpand("line_items")
.build();
Session checkoutSession = Session.retrieve(sessionID, params, null);
if (!"paid".equals(checkoutSession.getPaymentStatus())) {
return;
}
Optional<TransactionEntity> optionalTransaction = transactionRepository.findOneBySessionID(sessionID);
if (optionalTransaction.isEmpty()) {
throw new RuntimeException("Transaction not found");
}
TransactionEntity transaction = optionalTransaction.get();
transaction.setStatus(TransactionStatus.SUCCEEDED);
UserEntity user = transaction.getUser();
Long amountTotal = checkoutSession.getAmountTotal();
if (amountTotal != null) {
user.addBalance(amountTotal);
}
userRepository.save(user);
transactionRepository.save(transaction);
}
}

View file

@ -1,6 +0,0 @@
package de.szut.casino.deposit;
public enum TransactionStatus {
PROCESSING,
SUCCEEDED,
}

View file

@ -1,70 +0,0 @@
package de.szut.casino.deposit;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.stripe.Stripe;
import com.stripe.exception.SignatureVerificationException;
import com.stripe.exception.StripeException;
import com.stripe.model.*;
import com.stripe.model.checkout.Session;
import com.stripe.net.Webhook;
import com.stripe.param.checkout.SessionRetrieveParams;
import de.szut.casino.user.UserEntity;
import de.szut.casino.user.UserRepository;
import jakarta.annotation.PostConstruct;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.math.BigDecimal;
import java.util.Objects;
import java.util.Optional;
@RestController
public class WebhookController {
private static final Logger logger = LoggerFactory.getLogger(WebhookController.class);
@Value("${stripe.secret.key}")
private String stripeSecretKey;
@Value("${stripe.webhook.secret}")
private String webhookSecret;
private final TransactionService transactionService;
public WebhookController(TransactionService transactionService) {
this.transactionService = transactionService;
}
@PostConstruct
public void init() {
Stripe.apiKey = stripeSecretKey;
}
@PostMapping("/webhook")
public ResponseEntity<String> webhook(@RequestBody String payload, @RequestHeader("Stripe-Signature") String sigHeader) throws StripeException {
Event event = Webhook.constructEvent(payload, sigHeader, webhookSecret);
System.out.println(event.getType());
switch (event.getType()) {
case "checkout.session.completed":
case "checkout.session.async_payment_succeeded":
EventDataObjectDeserializer dataObjectDeserializer = event.getDataObjectDeserializer();
if (dataObjectDeserializer.getObject().isPresent()) {
Session session = (Session) dataObjectDeserializer.getObject().get();
this.transactionService.fulfillCheckout(session.getId());
} else {
logger.error("Failed to deserialize webhook event data");
}
break;
default:
// No action needed for other event types
break;
}
return ResponseEntity.ok().body(null);
}
}

View file

@ -1,58 +0,0 @@
package de.szut.casino.lootboxes;
import de.szut.casino.user.UserEntity;
import de.szut.casino.user.UserRepository;
import de.szut.casino.user.UserService;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
@RestController
public class LootBoxController {
private final LootBoxRepository lootBoxRepository;
private final UserService userService;
private final LootBoxService lootBoxService;
public LootBoxController(LootBoxRepository lootBoxRepository, UserRepository userRepository, UserService userService, LootBoxService lootBoxService) {
this.lootBoxRepository = lootBoxRepository;
this.userService = userService;
this.lootBoxService = lootBoxService;
}
@GetMapping("/lootboxes")
public List<LootBoxEntity> getAllLootBoxes() {
return lootBoxRepository.findAll();
}
@PostMapping("/lootboxes/{id}")
public ResponseEntity<Object> purchaseLootBox(@PathVariable Long id, @RequestHeader("Authorization") String token) {
Optional<LootBoxEntity> optionalLootBox = lootBoxRepository.findById(id);
if (optionalLootBox.isEmpty()) {
return ResponseEntity.notFound().build();
}
LootBoxEntity lootBox = optionalLootBox.get();
Optional<UserEntity> optionalUser = userService.getCurrentUser(token);
if (optionalUser.isEmpty()) {
return ResponseEntity.notFound().build();
}
UserEntity user = optionalUser.get();
if (lootBoxService.hasSufficientBalance(user, lootBox.getPrice())) {
Map<String, String> errorResponse = new HashMap<>();
errorResponse.put("error", "Insufficient balance");
return ResponseEntity.badRequest().body(errorResponse);
}
RewardEntity reward = lootBoxService.determineReward(lootBox);
lootBoxService.handleBalance(user, lootBox, reward);
return ResponseEntity.ok(reward);
}
}

View file

@ -1,40 +0,0 @@
package de.szut.casino.lootboxes;
import com.fasterxml.jackson.annotation.JsonBackReference;
import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.annotation.JsonManagedReference;
import de.szut.casino.blackjack.CardEntity;
import jakarta.persistence.*;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import org.hibernate.annotations.SQLRestriction;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.List;
@Entity
@Getter
@Setter
@AllArgsConstructor
@NoArgsConstructor
public class LootBoxEntity {
@Id
@GeneratedValue
private Long id;
private String name;
@Column(precision = 19, scale = 2)
private BigDecimal price;
@ManyToMany(cascade = {CascadeType.PERSIST, CascadeType.MERGE})
@JoinTable(
name = "lootbox_reward",
joinColumns = @JoinColumn(name = "lootbox_id"),
inverseJoinColumns = @JoinColumn(name = "reward_id")
)
private List<RewardEntity> rewards = new ArrayList<>();
}

View file

@ -1,8 +0,0 @@
package de.szut.casino.lootboxes;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Service;
@Service
public interface LootBoxRepository extends JpaRepository<LootBoxEntity, Long> {
}

View file

@ -1,40 +0,0 @@
package de.szut.casino.lootboxes;
import de.szut.casino.user.UserEntity;
import de.szut.casino.user.UserRepository;
import org.springframework.stereotype.Service;
import java.math.BigDecimal;
@Service
public class LootBoxService {
private final UserRepository userRepository;
public LootBoxService(UserRepository userRepository) {
this.userRepository = userRepository;
}
public boolean hasSufficientBalance(UserEntity user, BigDecimal price) {
return user.getBalance().compareTo(price) < 0;
}
public RewardEntity determineReward(LootBoxEntity lootBox) {
double randomValue = Math.random();
BigDecimal cumulativeProbability = BigDecimal.ZERO;
for (RewardEntity reward : lootBox.getRewards()) {
cumulativeProbability = cumulativeProbability.add(reward.getProbability());
if (randomValue <= cumulativeProbability.doubleValue()) {
return reward;
}
}
return lootBox.getRewards().getLast();
}
public void handleBalance(UserEntity user, LootBoxEntity lootBox, RewardEntity reward) {
user.setBalance(user.getBalance().subtract(lootBox.getPrice()));
user.setBalance(user.getBalance().add(reward.getValue()));
userRepository.save(user);
}
}

View file

@ -1,30 +0,0 @@
package de.szut.casino.lootboxes;
import com.fasterxml.jackson.annotation.JsonBackReference;
import com.fasterxml.jackson.annotation.JsonManagedReference;
import jakarta.persistence.*;
import lombok.Getter;
import lombok.Setter;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.List;
@Getter
@Setter
@Entity
public class RewardEntity {
@Id
@GeneratedValue
private Long id;
@Column(precision = 19, scale = 2)
private BigDecimal value;
@Column(precision = 5, scale = 2)
private BigDecimal probability;
@ManyToMany(mappedBy = "rewards")
@JsonBackReference
private List<LootBoxEntity> lootBoxes = new ArrayList<>();
}

View file

@ -1,8 +0,0 @@
package de.szut.casino.lootboxes;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Service;
@Service
public interface RewardRepository extends JpaRepository<RewardEntity, Long> {
}

View file

@ -1,24 +0,0 @@
package de.szut.casino.security;
import org.springframework.core.convert.converter.Converter;
import org.springframework.security.authentication.AbstractAuthenticationToken;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter;
import org.springframework.security.oauth2.server.resource.authentication.JwtGrantedAuthoritiesConverter;
public class CustomJwtAuthenticationConverter implements Converter<Jwt, AbstractAuthenticationToken> {
@Override
public AbstractAuthenticationToken convert(Jwt source) {
JwtGrantedAuthoritiesConverter authoritiesConverter = new JwtGrantedAuthoritiesConverter();
JwtAuthenticationConverter converter = new JwtAuthenticationConverter();
converter.setJwtGrantedAuthoritiesConverter(authoritiesConverter);
return converter.convert(source);
}
public <U> Converter<Jwt, U> andThen(Converter<? super AbstractAuthenticationToken, ? extends U> after) {
return Converter.super.andThen(after);
}
}

View file

@ -0,0 +1,48 @@
package de.szut.casino.security;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.Authentication;
import org.springframework.security.oauth2.core.oidc.user.OidcUser;
import org.springframework.security.web.authentication.logout.LogoutHandler;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestTemplate;
import org.springframework.web.util.UriComponentsBuilder;
@Slf4j
@Component
public class KeycloakLogoutHandler implements LogoutHandler {
private final RestTemplate restTemplate;
public KeycloakLogoutHandler(RestTemplate restTemplate) {
this.restTemplate = restTemplate;
}
@Override
public void logout(HttpServletRequest request, HttpServletResponse response, Authentication auth) {
logout(request, auth);
}
public void logout(HttpServletRequest request, Authentication auth) {
logoutFromKeycloak((OidcUser) auth.getPrincipal());
}
private void logoutFromKeycloak(OidcUser user) {
String endSessionEndpoint = user.getIssuer() + "/protocol/openid-connect/logout";
UriComponentsBuilder builder = UriComponentsBuilder
.fromUriString(endSessionEndpoint)
.queryParam("id_token_hint", user.getIdToken().getTokenValue());
ResponseEntity<String> logoutResponse = restTemplate.getForEntity(builder.toUriString(), String.class);
if (logoutResponse.getStatusCode().is2xxSuccessful()) {
log.info("Successfulley logged out from Keycloak");
} else {
log.error("Could not propagate logout to Keycloak");
}
}
}

View file

@ -0,0 +1,77 @@
package de.szut.casino.security;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.session.SessionRegistry;
import org.springframework.security.core.session.SessionRegistryImpl;
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.session.RegisterSessionAuthenticationStrategy;
import org.springframework.security.web.authentication.session.SessionAuthenticationStrategy;
import org.springframework.security.web.session.HttpSessionEventPublisher;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
@Configuration
@EnableWebSecurity
class KeycloakSecurityConfig {
private final KeycloakLogoutHandler keycloakLogoutHandler;
KeycloakSecurityConfig(KeycloakLogoutHandler keycloakLogoutHandler) {
this.keycloakLogoutHandler = keycloakLogoutHandler;
}
@Bean
public SessionRegistry sessionRegistry() {
return new SessionRegistryImpl();
}
@Bean
protected SessionAuthenticationStrategy sessionAuthenticationStrategy() {
return new RegisterSessionAuthenticationStrategy(sessionRegistry());
}
@Bean
public HttpSessionEventPublisher httpSessionEventPublisher() {
return new HttpSessionEventPublisher();
}
@Bean
public SecurityFilterChain resourceServerFilterChain(HttpSecurity http) throws Exception {
http.authorizeHttpRequests(auth -> auth
.requestMatchers("/swagger", "/swagger-ui/**", "/v3/api-docs/**", "/health").permitAll()
.anyRequest().authenticated()
)
.oauth2ResourceServer(spec -> spec.jwt(Customizer.withDefaults()));
return http.build();
}
@Bean
public JwtAuthenticationConverter jwtAuthenticationConverter() {
JwtAuthenticationConverter jwtAuthenticationConverter = new JwtAuthenticationConverter();
jwtAuthenticationConverter.setJwtGrantedAuthoritiesConverter(jwt -> {
List<GrantedAuthority> grantedAuthorities = new ArrayList<>();
Map<String, Object> realmAccess = jwt.getClaim("realm_access");
if (realmAccess != null && realmAccess.containsKey("roles")) {
List<String> roles = (List<String>) realmAccess.get("roles");
for (String role : roles) {
grantedAuthorities.add(new SimpleGrantedAuthority("ROLE_" + role));
}
}
return grantedAuthorities;
});
return jwtAuthenticationConverter;
}
}

View file

@ -1,49 +0,0 @@
package de.szut.casino.security;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import java.util.Arrays;
import java.util.List;
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.cors(Customizer.withDefaults())
.csrf(csrf -> csrf.disable())
.authorizeHttpRequests(auth -> {
auth.requestMatchers("/swagger/**", "/swagger-ui/**", "/health").permitAll()
.anyRequest().authenticated();
})
.oauth2ResourceServer(oauth2 -> oauth2.jwt(jwt ->
jwt.jwtAuthenticationConverter(new CustomJwtAuthenticationConverter())
));
return http.build();
}
@Bean
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration configuration = new CorsConfiguration();
configuration.setAllowedOrigins(List.of("http://localhost:4200"));
configuration.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"));
configuration.setAllowedHeaders(Arrays.asList("authorization", "content-type", "x-auth-token", "Access-Control-Allow-Origin"));
configuration.setExposedHeaders(List.of("x-auth-token"));
configuration.setAllowCredentials(true);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", configuration);
return source;
}
}

View file

@ -5,6 +5,7 @@ import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
@ -22,13 +23,20 @@ public class UserController {
@Autowired
private UserService userService;
@GetMapping("/user/{id}")
public ResponseEntity<?> getUser(@PathVariable String id) {
if (id == null || !userService.exists(id)) {
return ResponseEntity.notFound().build();
}
return ResponseEntity.ok(userService.getUser(id));
}
@PostMapping("/user")
public ResponseEntity<?> createUser(@RequestBody @Valid CreateUserDto userData) {
if (userService.exists(userData.getAuthentikId())) {
HttpHeaders headers = new HttpHeaders();
headers.add("Location", "/user");
if (userService.exists(userData.getKeycloakId())) {
return new ResponseEntity<>(headers, HttpStatus.FOUND);
return this.redirect("/user/" + userData.getKeycloakId());
}
return ResponseEntity.ok(userService.createUser(userData));
@ -36,7 +44,7 @@ public class UserController {
@GetMapping("/user")
public ResponseEntity<GetUserDto> getCurrentUser(@RequestHeader("Authorization") String token) {
GetUserDto userData = userService.getCurrentUserAsDto(token);
GetUserDto userData = userService.getCurrentUser(token);
if (userData == null) {
return ResponseEntity.notFound().build();
@ -44,4 +52,11 @@ public class UserController {
return ResponseEntity.ok(userData);
}
private ResponseEntity<Object> redirect(String route) {
HttpHeaders headers = new HttpHeaders();
headers.add("Location", route);
return new ResponseEntity<>(headers, HttpStatus.FOUND);
}
}

View file

@ -7,7 +7,6 @@ import jakarta.persistence.Id;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import java.math.BigDecimal;
@Setter
@ -19,25 +18,15 @@ public class UserEntity {
@GeneratedValue
private Long id;
@Column(unique = true)
private String authentikId;
private String keycloakId;
private String username;
@Column(precision = 19, scale = 2)
private BigDecimal balance;
public UserEntity(String authentikId, String username, BigDecimal balance) {
this.authentikId = authentikId;
public UserEntity(String keycloakId, String username, BigDecimal balance) {
this.keycloakId = keycloakId;
this.username = username;
this.balance = balance;
}
public void addBalance(long amountInCents) {
BigDecimal amountToAdd = BigDecimal.valueOf(amountInCents).movePointLeft(2);
if (this.balance == null) {
this.balance = amountToAdd;
} else {
this.balance = this.balance.add(amountToAdd);
}
}
}

View file

@ -9,11 +9,10 @@ import java.math.BigDecimal;
@Service
public class UserMappingService {
public GetUserDto mapToGetUserDto(UserEntity user) {
return new GetUserDto(user.getAuthentikId(), user.getUsername(), user.getBalance());
return new GetUserDto(user.getKeycloakId(), user.getUsername(), user.getBalance());
}
public UserEntity mapToUserEntity(CreateUserDto createUserDto) {
return new UserEntity(createUserDto.getAuthentikId(), createUserDto.getUsername(), BigDecimal.ZERO);
}
return new UserEntity(createUserDto.getKeycloakId(), createUserDto.getUsername(), BigDecimal.ZERO); }
}

View file

@ -8,8 +8,8 @@ import java.util.Optional;
@Service
public interface UserRepository extends JpaRepository<UserEntity, Long> {
@Query("SELECT u FROM UserEntity u WHERE u.authentikId = ?1")
Optional<UserEntity> findOneByAuthentikId(String authentikId);
@Query("SELECT u FROM UserEntity u WHERE u.keycloakId = ?1")
Optional<UserEntity> findOneByKeycloakId(String keycloakId);
boolean existsByAuthentikId(String authentikId);
boolean existsByKeycloakId(String keycloakId);
}

View file

@ -1,8 +1,7 @@
package de.szut.casino.user;
import de.szut.casino.user.dto.CreateUserDto;
import de.szut.casino.user.dto.GetUserDto;
import de.szut.casino.user.dto.KeycloakUserDto;
import java.util.Optional;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
@ -11,7 +10,9 @@ import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestTemplate;
import java.util.Optional;
import de.szut.casino.user.dto.CreateUserDto;
import de.szut.casino.user.dto.GetUserDto;
import de.szut.casino.user.dto.KeycloakUserDto;
@Service
public class UserService {
@ -31,51 +32,33 @@ public class UserService {
return user;
}
public GetUserDto getUser(String authentikId) {
Optional<UserEntity> user = this.userRepository.findOneByAuthentikId(authentikId);
public GetUserDto getUser(String keycloakId) {
Optional<UserEntity> user = this.userRepository.findOneByKeycloakId(keycloakId);
return user.map(userEntity -> mappingService.mapToGetUserDto(userEntity)).orElse(null);
}
public GetUserDto getCurrentUserAsDto(String token) {
KeycloakUserDto userData = getAuthentikUserInfo(token);
public GetUserDto getCurrentUser(String token) {
KeycloakUserDto userData = getKeycloakUserInfo(token);
if (userData == null) {
return null;
}
Optional<UserEntity> user = this.userRepository.findOneByAuthentikId(userData.getSub());
Optional<UserEntity> user = this.userRepository.findOneByKeycloakId(userData.getSub());
return user.map(userEntity -> mappingService.mapToGetUserDto(userEntity)).orElse(null);
}
public Optional<UserEntity> getCurrentUser(String token) {
KeycloakUserDto userData = getAuthentikUserInfo(token);
private KeycloakUserDto getKeycloakUserInfo(String token) {
HttpHeaders headers = new HttpHeaders();
headers.set("Authorization", token);
ResponseEntity<KeycloakUserDto> response = this.http.exchange("http://localhost:9090/realms/LF12/protocol/openid-connect/userinfo", HttpMethod.GET, new HttpEntity<>(headers), KeycloakUserDto.class);
if (userData == null) {
return Optional.empty();
}
return this.userRepository.findOneByAuthentikId(userData.getSub());
return response.getBody();
}
private KeycloakUserDto getAuthentikUserInfo(String token) {
try {
HttpHeaders headers = new HttpHeaders();
headers.set("Authorization", token);
ResponseEntity<KeycloakUserDto> response = this.http.exchange(
"https://oauth.simonis.lol/application/o/userinfo/",
HttpMethod.GET,
new HttpEntity<>(headers),
KeycloakUserDto.class
);
return response.getBody();
} catch (Exception e) {
System.err.println("Error fetching user info from Authentik: " + e.getMessage());
return null;
}
}
public boolean exists(String authentikId) {
return userRepository.existsByAuthentikId(authentikId);
public boolean exists(String keycloakId) {
return userRepository.existsByKeycloakId(keycloakId);
}
}

View file

@ -10,6 +10,6 @@ import lombok.Setter;
@AllArgsConstructor
@NoArgsConstructor
public class CreateUserDto {
private String authentikId;
private String keycloakId;
private String username;
}

View file

@ -12,7 +12,7 @@ import java.math.BigDecimal;
@AllArgsConstructor
@NoArgsConstructor
public class GetUserDto {
private String authentikId;
private String keycloakId;
private String username;
private BigDecimal balance;
}

View file

@ -2,38 +2,22 @@ spring.datasource.url=jdbc:postgresql://${DB_HOST:localhost}:5432/postgresdb
spring.datasource.username=postgres_user
spring.datasource.password=postgres_pass
server.port=8080
spring.jpa.hibernate.ddl-auto=update
spring.jpa.hibernate.ddl-auto=create-drop
stripe.secret.key=${STRIPE_SECRET_KEY:sk_test_51QrePYIvCfqz7ANgqam8rEwWcMeKiLOof3j6SCMgu2sl4sESP45DJxca16mWcYo1sQaiBv32CMR6Z4AAAGQPCJo300ubuZKO8I}
stripe.webhook.secret=whsec_746b6a488665f6057118bdb4a2b32f4916f16c277109eeaed5e8f8e8b81b8c15
app.frontend-host=http://localhost:4200
spring.application.name=lf12_starter
#client registration configuration
spring.security.oauth2.client.registration.authentik.client-id=MDqjm1kcWKuZfqHJXjxwAV20i44aT7m4VhhTL3Nm
spring.security.oauth2.client.registration.authentik.client-secret=GY2F8te6iAVYt1TNAUVLzWZEXb6JoMNp6chbjqaXNq4gS5xTDL54HqBiAlV1jFKarN28LQ7FUsYX4SbwjfEhZhgeoKuBnZKjR9eiu7RawnGgxIK9ffvUfMkjRxnmiGI5
spring.security.oauth2.client.registration.authentik.provider=authentik
spring.security.oauth2.client.registration.authentik.client-name=Authentik
spring.security.oauth2.client.registration.authentik.scope=openid,email,profile
spring.security.oauth2.client.registration.authentik.client-authentication-method=client_secret_basic
spring.security.oauth2.client.registration.authentik.authorization-grant-type=authorization_code
spring.security.oauth2.client.registration.authentik.redirect-uri={baseUrl}/login/oauth2/code/{registrationId}
# Provider settings
spring.security.oauth2.client.provider.authentik.issuer-uri=https://oauth.simonis.lol/application/o/casino-dev/
spring.security.oauth2.client.provider.authentik.authorization-uri=https://oauth.simonis.lol/application/o/authorize/
spring.security.oauth2.client.provider.authentik.token-uri=https://oauth.simonis.lol/application/o/token/
spring.security.oauth2.client.provider.authentik.user-info-uri=https://oauth.simonis.lol/application/o/userinfo/
spring.security.oauth2.client.provider.authentik.jwk-set-uri=https://oauth.simonis.lol/application/o/casino-dev/jwks/
spring.security.oauth2.client.provider.authentik.user-name-attribute=preferred_username
# Resource server config
spring.security.oauth2.resourceserver.jwt.issuer-uri=https://oauth.simonis.lol/application/o/casino-dev/
spring.security.oauth2.resourceserver.jwt.jwk-set-uri=https://oauth.simonis.lol/application/o/casino-dev/jwks/
spring.security.oauth2.client.registration.keycloak.client-id=lf12
spring.security.oauth2.client.registration.keycloak.authorization-grant-type=authorization_code
spring.security.oauth2.client.registration.keycloak.scope=openid
#OIDC provider configuration:
spring.security.oauth2.client.provider.keycloak.issuer-uri=http://localhost:9090/realms/LF12
spring.security.oauth2.client.provider.keycloak.user-name-attribute=preferred_username
logging.level.org.springframework.security=DEBUG
#validating JWT token against our Authentik server
#validating JWT token against our Keycloak server
spring.security.oauth2.resourceserver.jwt.issuer-uri=http://localhost:9090/realms/LF12
springdoc.swagger-ui.path=swagger
springdoc.swagger-ui.try-it-out-enabled=true

View file

@ -45,15 +45,15 @@ public class UserControllerTest {
@BeforeEach
void setUp() {
getUserDto = new GetUserDto();
getUserDto.setAuthentikId(TEST_ID);
getUserDto.setKeycloakId(TEST_ID);
getUserDto.setUsername("testuser");
testUser = new UserEntity();
testUser.setAuthentikId(TEST_ID);
testUser.setKeycloakId(TEST_ID);
testUser.setUsername("testuser");
createUserDto = new CreateUserDto();
createUserDto.setAuthentikId(TEST_ID);
createUserDto.setKeycloakId(TEST_ID);
createUserDto.setUsername("testuser");
}
@ -64,7 +64,7 @@ public class UserControllerTest {
mockMvc.perform(get("/user/" + TEST_ID))
.andExpect(status().isOk())
.andExpect(jsonPath("$.authentikId").value(TEST_ID))
.andExpect(jsonPath("$.keycloakId").value(TEST_ID))
.andExpect(jsonPath("$.username").value("testuser"));
}
@ -85,7 +85,7 @@ public class UserControllerTest {
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(createUserDto)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.authentikId").value(TEST_ID))
.andExpect(jsonPath("$.keycloakId").value(TEST_ID))
.andExpect(jsonPath("$.username").value("testuser"));
}
@ -107,7 +107,7 @@ public class UserControllerTest {
mockMvc.perform(get("/user")
.header("Authorization", AUTH_TOKEN))
.andExpect(status().isOk())
.andExpect(jsonPath("$.authentikId").value(TEST_ID))
.andExpect(jsonPath("$.keycloakId").value(TEST_ID))
.andExpect(jsonPath("$.username").value("testuser"));
}

View file

@ -28,7 +28,7 @@ services:
condition: service_healthy
keycloakdb_svr:
image: postgres:17.4
image: postgres:14.2
volumes:
- postgres_data_keycloak_db:/var/lib/postgresql/data
environment:
@ -41,7 +41,7 @@ services:
- "9433:5432"
postgres_db:
image: postgres:17.4
image: postgres:16.4
volumes:
- postgres_data:/var/lib/postgresql/data
environment:

View file

@ -1,136 +1,3 @@
# Casino Gaming Platform - Frontend
# Lf12Starter 2024
This is the frontend application for the Casino Gaming Platform. It's built with Angular 18 and TailwindCSS, providing a responsive and modern UI for the casino gaming experience.
## Development
### Commands
- **Build**: `bun run build` or `bunx @angular/cli build`
- **Start Dev Server**: `bun run start` or `bunx @angular/cli serve --proxy-config src/proxy.conf.json`
- **Format Code**: `bun run format` or `prettier --write "src/**/*.{ts,html,css,scss}"`
- **Lint**: `bun run lint` or `ng lint`
- **Test**: `bun run test` or `bunx @angular/cli test`
- **Test Single File**: `bunx @angular/cli test --include=path/to/test.spec.ts`
## Style Guide
### Color Palette
#### Primary Colors
- Deep Blue: `#0a1219` (background)
- Deep Blue Light: `#121e27` (secondary background)
- Deep Blue Contrast: `#1a2835` (cards, elements)
#### Accent Colors
- Emerald: `#10b981` (primary buttons)
- Emerald Dark: `#059669` (button hover)
- Emerald Light: `#34d399` (highlights)
#### Text Colors
- Primary Text: `#ffffff` (white)
- Secondary Text: `#94a3b8` (light gray)
- Tertiary Text: `#64748b` (darker gray)
#### Additional Accents
- Yellow: `#fbbf24`
- Red: `#ef4444`
- Purple: `#8b5cf6`
### Typography
#### Font Sizes
- Extra Small: Text-xs (footer disclaimers)
- Small: Text-sm (navigation links, footer links)
- Base: Text-base (general text)
- Large: Text-lg (section headings)
- Extra Large: Text-xl (stat numbers, game headings)
- Display: Text-4xl/5xl/7xl (welcome bonus text)
#### Font Weights
- Normal: General text
- Medium: Labels
- Semibold: Navigation brand
- Bold: Headings, stats
- Extrabold: Welcome bonus text
### Components
#### Buttons
- Primary: Emerald background with hover state
- Secondary: Deep blue light background with hover state
- All buttons have active scale effect (95%)
- Transition duration: 200-300ms
#### Cards
- Background: Deep blue contrast
- Rounded corners (lg)
- Shadow effects with hover transition
- Consistent padding (p-4)
#### Navigation
- Desktop: Horizontal links with hover effects
- Mobile: Collapsible menu with toggle
- Links have color and background transitions
#### Modals
- Backdrop blur with dark overlay
- Card-style container with emerald focus rings
- Consistent form styling with transitions
### Forms
#### Inputs
- Dark background with border
- Focus states with emerald accent
- Consistent padding and rounded corners
- Clear label positioning
### Layout
#### Grid System
- Mobile-first responsive grid
- Breakpoints: sm, md, lg
- Grid columns: 1 (mobile), 2 (tablet), 3 (desktop)
- Consistent gap spacing (gap-4)
#### Spacing
- Consistent margin/padding scale
- Mobile-responsive spacing adjustments
### Animation
- Transitions: 200-500ms duration
- Hover/active state animations
- Scale transformations (95-110%)
- Opacity transitions for navigation elements
### Components & Classes
#### Common UI Elements
- `.card` - Base card container
- `.button-primary` - Main CTA buttons
- `.button-secondary` - Alternative action buttons
- `.section-heading` - Section titles
- `.nav-link` - Navigation links
- `.modal-card` - Modal container
#### Game Elements
- `.game-card-content` - Game information container
- `.game-heading-sm` - Small game titles
- `.game-heading-xl` - Large game titles
- `.game-text` - Game descriptions
- `.slider-container` - Game carousel container
### Responsive Design
- Mobile-first approach
- Tailwind breakpoints (sm, md, lg)
- Different layouts based on screen size
- Responsive text sizing and spacing
- Hidden/visible elements using responsive classes
### CSS Framework
- Tailwind CSS for utility classes
- Custom utility classes with @apply directive
- CSS variables for theming
- Component-based styling approach

File diff suppressed because it is too large Load diff

View file

@ -7,7 +7,7 @@ volumes:
services:
postgres-employee:
container_name: postgres_employee
image: postgres:17.4
image: postgres:13.3
volumes:
- employee_postgres_data:/var/lib/postgresql/data
environment:
@ -19,7 +19,7 @@ services:
employee:
container_name: employee
image: berndheidemann/employee-management-service:1.1.3
image: berndheidemann/employee-management-service:1.0.4
# image: berndheidemann/employee-management-service_without_keycloak:1.1
environment:
spring.datasource.url: jdbc:postgresql://postgres-employee:5432/employee_db

View file

@ -9,52 +9,48 @@
"test": "bunx @angular/cli test",
"format": "prettier --write \"src/**/*.{ts,html,css,scss}\"",
"format:check": "prettier --check \"src/**/*.{ts,html,css,scss}\"",
"lint": "bunx @angular/cli lint"
"lint": "ng lint"
},
"private": true,
"dependencies": {
"@angular/animations": "^19.0.0",
"@angular/cdk": "~19.2.0",
"@angular/common": "^19.0.0",
"@angular/compiler": "^19.2.4",
"@angular/core": "^19.0.0",
"@angular/forms": "^19.0.0",
"@angular/platform-browser": "^19.0.0",
"@angular/platform-browser-dynamic": "^19.0.0",
"@angular/router": "^19.0.0",
"@angular/animations": "^18.2.0",
"@angular/cdk": "~18.2.14",
"@angular/common": "^18.2.0",
"@angular/compiler": "^18.2.0",
"@angular/core": "^18.2.0",
"@angular/forms": "^18.2.0",
"@angular/platform-browser": "^18.2.0",
"@angular/platform-browser-dynamic": "^18.2.0",
"@angular/router": "^18.2.0",
"@fortawesome/angular-fontawesome": "^1.0.0",
"@fortawesome/fontawesome-svg-core": "^6.7.2",
"@fortawesome/free-brands-svg-icons": "^6.7.2",
"@fortawesome/free-solid-svg-icons": "^6.7.2",
"@stripe/stripe-js": "^7.0.0",
"@stripe/stripe-js": "^5.6.0",
"@tailwindcss/postcss": "^4.0.3",
"ajv": "8.17.1",
"ajv-formats": "3.0.1",
"countup.js": "^2.8.0",
"gsap": "^3.12.7",
"angular-oauth2-oidc": "^19.0.0",
"keycloak-angular": "^19.0.0",
"keycloak-js": "^26.0.0",
"keycloak-angular": "^16.0.1",
"keycloak-js": "^25.0.5",
"postcss": "^8.5.1",
"rxjs": "~7.8.2",
"rxjs": "~7.8.0",
"tailwindcss": "^4.0.3",
"tslib": "^2.3.0"
},
"devDependencies": {
"@angular-devkit/build-angular": "^19.0.0",
"@angular/cli": "^19.2.5",
"@angular/compiler-cli": "^19.0.0",
"@angular-devkit/build-angular": "^18.2.2",
"@angular/cli": "^18.2.2",
"@angular/compiler-cli": "^18.2.0",
"@types/jasmine": "~5.1.0",
"angular-eslint": "19.3.0",
"angular-eslint": "19.1.0",
"eslint": "^9.20.0",
"jasmine-core": "~5.6.0",
"jasmine-core": "~5.2.0",
"karma": "~6.4.0",
"karma-chrome-launcher": "~3.2.0",
"karma-coverage": "~2.2.0",
"karma-jasmine": "~5.1.0",
"karma-jasmine-html-reporter": "~2.1.0",
"prettier": "^3.4.2",
"typescript": "~5.8.0",
"typescript-eslint": "8.29.1"
"typescript": "~5.5.2",
"typescript-eslint": "8.23.0"
}
}

View file

@ -1,12 +1,13 @@
import { ChangeDetectionStrategy, Component } from '@angular/core';
import { Component, ChangeDetectionStrategy } from '@angular/core';
import { CommonModule } from '@angular/common';
import { RouterOutlet } from '@angular/router';
import { KeycloakAngularModule } from 'keycloak-angular';
import { FooterComponent } from './shared/components/footer/footer.component';
@Component({
selector: 'app-root',
standalone: true,
imports: [CommonModule, RouterOutlet, FooterComponent],
imports: [CommonModule, RouterOutlet, KeycloakAngularModule, FooterComponent],
providers: [],
templateUrl: './app.component.html',
styleUrl: './app.component.css',

View file

@ -1,24 +1,59 @@
import { ApplicationConfig, provideExperimentalZonelessChangeDetection } from '@angular/core';
import {
APP_INITIALIZER,
ApplicationConfig,
provideExperimentalZonelessChangeDetection,
} from '@angular/core';
import { provideRouter } from '@angular/router';
import { FontAwesomeModule } from '@fortawesome/angular-fontawesome';
import { routes } from './app.routes';
import { provideHttpClient, withInterceptors } from '@angular/common/http';
import {
KeycloakAngularModule,
KeycloakBearerInterceptor,
KeycloakService,
} from 'keycloak-angular';
import { HTTP_INTERCEPTORS, provideHttpClient, withInterceptorsFromDi } from '@angular/common/http';
import { provideAnimationsAsync } from '@angular/platform-browser/animations/async';
import { OAuthStorage, provideOAuthClient } from 'angular-oauth2-oidc';
import { httpInterceptor } from './shared/interceptor/http.interceptor';
export const initializeKeycloak = (keycloak: KeycloakService) => async () =>
keycloak.init({
config: {
url: 'http://localhost:9090',
realm: 'LF12',
clientId: 'lf12',
},
loadUserProfileAtStartUp: true,
initOptions: {
onLoad: 'check-sso',
silentCheckSsoRedirectUri: window.location.origin + '/silent-check-sso.html',
checkLoginIframe: false,
redirectUri: window.location.origin + '/',
},
});
function initializeApp(keycloak: KeycloakService): () => Promise<boolean> {
return () => initializeKeycloak(keycloak)();
}
export const appConfig: ApplicationConfig = {
providers: [
provideRouter(routes),
KeycloakAngularModule,
FontAwesomeModule,
provideHttpClient(withInterceptors([httpInterceptor])),
provideExperimentalZonelessChangeDetection(),
provideAnimationsAsync(),
provideOAuthClient(),
{
provide: OAuthStorage,
useFactory: () => localStorage,
provide: APP_INITIALIZER,
useFactory: initializeApp,
multi: true,
deps: [KeycloakService],
},
KeycloakService,
provideHttpClient(withInterceptorsFromDi()),
provideExperimentalZonelessChangeDetection(),
{
provide: HTTP_INTERCEPTORS,
useClass: KeycloakBearerInterceptor,
multi: true,
},
provideAnimationsAsync(),
],
};

View file

@ -8,7 +8,7 @@ export const routes: Routes = [
component: LandingComponent,
},
{
path: 'auth/callback',
path: 'login/success',
loadComponent: () => import('./feature/login-success/login-success.component'),
},
{
@ -16,9 +16,4 @@ export const routes: Routes = [
loadComponent: () => import('./feature/home/home.component'),
canActivate: [authGuard],
},
{
path: 'game/blackjack',
loadComponent: () => import('./feature/game/blackjack/blackjack.component'),
canActivate: [authGuard],
},
];

View file

@ -1,12 +1,12 @@
import { CanActivateFn, Router } from '@angular/router';
import { inject } from '@angular/core';
import { AuthService } from './service/auth.service';
import { KeycloakService } from 'keycloak-angular';
export const authGuard: CanActivateFn = async () => {
const authService = inject(AuthService);
const keycloakService = inject(KeycloakService);
const router = inject(Router);
if (authService.isLoggedIn()) {
if (keycloakService.isLoggedIn()) {
return true;
}

View file

@ -16,23 +16,23 @@ import {
} from '@angular/core';
import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms';
import { loadStripe, Stripe } from '@stripe/stripe-js';
import { DepositService } from '../../service/deposit.service';
import { debounceTime } from 'rxjs';
import { CommonModule } from '@angular/common';
import { environment } from '../../../environments/environment';
import { NgIf } from '@angular/common';
import { ModalAnimationService } from '../../shared/services/modal-animation.service';
import gsap from 'gsap';
import { DepositService } from '@service/deposit.service';
import { environment } from '@environments/environment';
import { ModalAnimationService } from '@shared/services/modal-animation.service';
@Component({
selector: 'app-deposit',
standalone: true,
imports: [ReactiveFormsModule, CommonModule],
imports: [ReactiveFormsModule, NgIf],
templateUrl: './deposit.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class DepositComponent implements OnInit, AfterViewInit, OnDestroy, OnChanges {
@Input() isOpen = false;
@Output() closeModalEmitter = new EventEmitter<void>();
@Output() close = new EventEmitter<void>();
@ViewChild('modalBg') modalBg!: ElementRef;
@ViewChild('modalCard') modalCard!: ElementRef;
protected form!: FormGroup;
@ -110,7 +110,7 @@ export class DepositComponent implements OnInit, AfterViewInit, OnDestroy, OnCha
this.modalAnimationService.closeModal(
this.modalCard.nativeElement,
this.modalBg.nativeElement,
() => this.closeModalEmitter.emit()
() => this.close.emit()
);
}
}

View file

@ -1,46 +0,0 @@
<app-navbar></app-navbar>
<div class="container mx-auto px-4 py-6 space-y-8">
<div class="grid grid-cols-1 lg:grid-cols-4 gap-6">
<div class="lg:col-span-3 space-y-6 flex flex-col gap-4">
<app-dealer-hand [cards]="dealerCards()"></app-dealer-hand>
<app-player-hand [cards]="playerCards()"></app-player-hand>
@if (gameInProgress()) {
<app-game-controls
[playerCards]="playerCards()"
[gameState]="gameState()"
[isActionInProgress]="isActionInProgress()"
(hit)="onHit()"
(stand)="onStand()"
(doubleDown)="onDoubleDown()"
(leave)="leaveGame()"
></app-game-controls>
}
</div>
<div class="lg:col-span-1 space-y-6">
<app-game-info
[balance]="balance()"
[currentBet]="currentBet()"
[gameInProgress]="gameInProgress()"
[isActionInProgress]="isActionInProgress()"
(newGame)="onNewGame($event)"
></app-game-info>
</div>
</div>
</div>
<app-game-result
[gameState]="gameState()"
[amount]="currentBet()"
[balance]="balance()"
[show]="showGameResult()"
(gameResultClosed)="onCloseGameResult()"
></app-game-result>
<app-debt-dialog
[amount]="debtAmount()"
[show]="showDebtDialog()"
(dialogClosed)="onCloseDebtDialog()"
></app-debt-dialog>

View file

@ -1,226 +0,0 @@
import { ChangeDetectionStrategy, Component, inject, signal, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { Router } from '@angular/router';
import { PlayingCardComponent } from './components/playing-card/playing-card.component';
import { DealerHandComponent } from './components/dealer-hand/dealer-hand.component';
import { PlayerHandComponent } from './components/player-hand/player-hand.component';
import { GameControlsComponent } from './components/game-controls/game-controls.component';
import { GameInfoComponent } from './components/game-info/game-info.component';
import { Card, BlackjackGame } from '@blackjack/models/blackjack.model';
import { BlackjackService } from '@blackjack/services/blackjack.service';
import { HttpErrorResponse } from '@angular/common/http';
import { GameResultComponent } from '@blackjack/components/game-result/game-result.component';
import { GameState } from '@blackjack/enum/gameState';
import { NavbarComponent } from '@shared/components/navbar/navbar.component';
import { UserService } from '@service/user.service';
import { timer } from 'rxjs';
import { DebtDialogComponent } from '@shared/components/debt-dialog/debt-dialog.component';
@Component({
selector: 'app-blackjack',
standalone: true,
imports: [
CommonModule,
NavbarComponent,
PlayingCardComponent,
DealerHandComponent,
PlayerHandComponent,
GameControlsComponent,
GameInfoComponent,
GameResultComponent,
DebtDialogComponent,
],
templateUrl: './blackjack.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export default class BlackjackComponent implements OnInit {
private router = inject(Router);
private userService = inject(UserService);
private blackjackService = inject(BlackjackService);
dealerCards = signal<Card[]>([]);
playerCards = signal<Card[]>([]);
currentBet = signal(0);
balance = signal(0);
currentGameId = signal<number | undefined>(undefined);
gameInProgress = signal(false);
gameState = signal<GameState>(GameState.IN_PROGRESS);
showGameResult = signal(false);
isActionInProgress = signal(false);
showDebtDialog = signal(false);
debtAmount = signal(0);
ngOnInit(): void {
this.userService.currentUser$.subscribe((user) => {
if (user) {
this.balance.set(user.balance);
}
});
}
private updateGameState(game: BlackjackGame) {
console.log('Game state update:', game);
this.currentGameId.set(game.id);
this.currentBet.set(game.bet);
this.gameInProgress.set(game.state === GameState.IN_PROGRESS);
this.gameState.set(game.state as GameState);
const isGameOver = game.state !== GameState.IN_PROGRESS;
this.dealerCards.set(
game.dealerCards.map((card, index) => ({
...card,
hidden: !isGameOver && index === 1 && game.state === GameState.IN_PROGRESS,
}))
);
this.playerCards.set(
game.playerCards.map((card) => ({
...card,
hidden: false,
}))
);
if (isGameOver) {
console.log('Game is over, state:', game.state);
this.userService.refreshCurrentUser();
timer(1500).subscribe(() => {
this.showGameResult.set(true);
console.log('Game result dialog shown after delay');
});
}
}
onNewGame(bet: number): void {
this.isActionInProgress.set(true);
this.blackjackService.startGame(bet).subscribe({
next: (game) => {
this.updateGameState(game);
this.userService.refreshCurrentUser();
this.isActionInProgress.set(false);
},
error: (error) => {
console.error('Failed to start game:', error);
this.isActionInProgress.set(false);
},
});
}
onHit(): void {
if (!this.currentGameId() || this.isActionInProgress()) return;
this.isActionInProgress.set(true);
this.blackjackService.hit(this.currentGameId()!).subscribe({
next: (game) => {
this.updateGameState(game);
if (game.state !== 'IN_PROGRESS') {
this.userService.refreshCurrentUser();
}
this.isActionInProgress.set(false);
},
error: (error) => {
console.error('Failed to hit:', error);
this.handleGameError(error);
this.isActionInProgress.set(false);
},
});
}
onStand(): void {
if (!this.currentGameId() || this.isActionInProgress()) return;
if (this.gameState() !== GameState.IN_PROGRESS) {
console.log('Cannot stand: game is not in progress');
return;
}
this.isActionInProgress.set(true);
this.blackjackService.stand(this.currentGameId()!).subscribe({
next: (game) => {
this.updateGameState(game);
this.userService.refreshCurrentUser();
this.isActionInProgress.set(false);
},
error: (error) => {
console.error('Failed to stand:', error);
this.handleGameError(error);
this.isActionInProgress.set(false);
},
});
}
onDoubleDown(): void {
if (!this.currentGameId() || this.isActionInProgress()) return;
if (this.gameState() !== GameState.IN_PROGRESS || this.playerCards().length !== 2) {
console.log('Cannot double down: game is not in progress or more than 2 cards');
return;
}
this.isActionInProgress.set(true);
this.blackjackService.doubleDown(this.currentGameId()!).subscribe({
next: (game) => {
this.updateGameState(game);
this.userService.getCurrentUser().subscribe((user) => {
if (user && user.balance < 0) {
this.debtAmount.set(Math.abs(user.balance));
this.showDebtDialog.set(true);
}
});
this.isActionInProgress.set(false);
},
error: (error) => {
console.error('Failed to double down:', error);
this.handleGameError(error);
this.isActionInProgress.set(false);
},
});
}
onCloseGameResult(): void {
console.log('Closing game result dialog');
this.showGameResult.set(false);
this.userService.refreshCurrentUser();
}
onCloseDebtDialog(): void {
this.showDebtDialog.set(false);
}
private handleGameError(error: HttpErrorResponse): void {
if (error instanceof HttpErrorResponse) {
if (error.status === 400 && error.error?.error === 'Invalid state') {
this.gameInProgress.set(false);
this.userService.refreshCurrentUser();
} else if (error.status === 500) {
console.log('Server error occurred. The game may have been updated in another session.');
this.gameInProgress.set(false);
this.userService.refreshCurrentUser();
if (this.currentGameId()) {
this.refreshGameState(this.currentGameId()!);
}
}
}
}
private refreshGameState(gameId: number): void {
this.blackjackService.getGame(gameId).subscribe({
next: (game) => {
this.updateGameState(game);
},
error: (err) => {
console.error('Failed to refresh game state:', err);
},
});
}
leaveGame(): void {
this.router.navigate(['/home']);
}
}

View file

@ -1,83 +0,0 @@
import {
ChangeDetectionStrategy,
Component,
Input,
OnChanges,
SimpleChanges,
ElementRef,
ViewChild,
AfterViewInit,
} from '@angular/core';
import { CommonModule, CurrencyPipe } from '@angular/common';
import { CountUp } from 'countup.js';
@Component({
selector: 'app-animated-number',
standalone: true,
imports: [CommonModule, CurrencyPipe],
template: ` <span #numberElement>{{ formattedValue }}</span> `,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class AnimatedNumberComponent implements OnChanges, AfterViewInit {
@Input() value = 0;
@Input() duration = 1;
@Input() ease = 'power1.out';
@ViewChild('numberElement') numberElement!: ElementRef;
private countUp: CountUp | null = null;
private previousValue = 0;
formattedValue = '0,00 €';
ngAfterViewInit(): void {
this.initializeCountUp();
if (this.countUp && this.value !== 0) {
this.countUp.start(() => {
this.previousValue = this.value;
});
}
}
ngOnChanges(changes: SimpleChanges): void {
if (changes['value']) {
if (this.countUp) {
const endVal = this.value;
this.countUp.update(endVal);
this.previousValue = endVal;
} else {
this.formattedValue = new Intl.NumberFormat('de-DE', {
style: 'currency',
currency: 'EUR',
minimumFractionDigits: 2,
maximumFractionDigits: 2,
}).format(this.value);
}
}
}
private initializeCountUp(): void {
if (this.numberElement) {
this.countUp = new CountUp(this.numberElement.nativeElement, this.value, {
startVal: this.previousValue,
duration: this.duration,
easingFn: (t, b, c, d) => {
if (this.ease === 'power1.out') {
return c * (1 - Math.pow(1 - t / d, 1)) + b;
}
return c * (t / d) + b;
},
formattingFn: (value) => {
const formatted = new Intl.NumberFormat('de-DE', {
style: 'currency',
currency: 'EUR',
minimumFractionDigits: 2,
maximumFractionDigits: 2,
}).format(value);
this.formattedValue = formatted;
return formatted;
},
});
}
}
}

View file

@ -1,73 +0,0 @@
import { ChangeDetectionStrategy, Component, Input, OnChanges, SimpleChanges } from '@angular/core';
import { CommonModule } from '@angular/common';
import { Card } from '@blackjack/models/blackjack.model';
import { PlayingCardComponent } from '../playing-card/playing-card.component';
import { GameControlsService } from '@blackjack/services/game-controls.service';
@Component({
selector: 'app-dealer-hand',
standalone: true,
imports: [CommonModule, PlayingCardComponent],
template: `
<div class="space-y-4">
<div class="flex justify-between items-center">
<h3 class="section-heading text-2xl">Dealer's Karten</h3>
<div class="flex items-center gap-2">
<div class="text-text-secondary">Punkte:</div>
<div class="text-xl font-bold text-accent-red">
{{ gameControlsService.calculateHandValue(cards) }}
</div>
</div>
</div>
<div class="card p-6 !bg-accent-red">
<div class="flex justify-center gap-4 min-h-[160px] p-4 border-2 border-red-400 rounded-lg">
@if (cards.length > 0) {
@for (card of cardsWithState; track card.id) {
<app-playing-card
[rank]="card.rank"
[suit]="card.suit"
[hidden]="card.hidden"
[isNew]="card.isNew"
></app-playing-card>
}
} @else {
<div class="flex items-center justify-center text-white/70 text-lg font-medium">
Warte auf Spielstart...
</div>
}
</div>
</div>
</div>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class DealerHandComponent implements OnChanges {
@Input() cards: Card[] = [];
cardsWithState: (Card & { isNew: boolean; id: string })[] = [];
private lastCardCount = 0;
constructor(protected gameControlsService: GameControlsService) {}
ngOnChanges(changes: SimpleChanges): void {
if (changes['cards']) {
this.updateCardsWithState();
}
}
private updateCardsWithState(): void {
const newCards = this.cards.length > this.lastCardCount;
this.cardsWithState = this.cards.map((card, index) => {
const isNew = newCards && index >= this.lastCardCount;
return {
...card,
isNew,
id: `${card.suit}-${card.rank}-${index}`,
};
});
this.lastCardCount = this.cards.length;
}
}

View file

@ -1,81 +0,0 @@
import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core';
import { CommonModule } from '@angular/common';
import { GameState } from '@blackjack/enum/gameState';
import { Card } from '@blackjack/models/blackjack.model';
import { GameControlsService } from '@blackjack/services/game-controls.service';
@Component({
selector: 'app-game-controls',
standalone: true,
imports: [CommonModule],
template: `
<div class="flex flex-col gap-4">
<div class="flex justify-center text-lg mb-5">
<div class="card p-4">
<div class="text-emerald font-bold mb-1">
Deine Punkte: {{ gameControlsService.calculateHandValue(playerCards) }}
</div>
<div class="text-text-secondary">
Status:
<span [class]="gameControlsService.getStatusClass(gameState)">{{
gameControlsService.getStatusText(gameState)
}}</span>
</div>
</div>
</div>
<div class="flex justify-center gap-4">
<button
(click)="hit.emit()"
class="button-primary px-8 py-4 text-lg font-medium min-w-[120px] relative"
[disabled]="gameState !== GameState.IN_PROGRESS || isActionInProgress"
>
<span>Ziehen</span>
</button>
<button
(click)="stand.emit()"
class="button-primary px-8 py-4 text-lg font-medium min-w-[120px] relative"
[disabled]="gameState !== GameState.IN_PROGRESS || isActionInProgress"
>
<span>Halten</span>
</button>
<button
(click)="doubleDown.emit()"
class="button-primary px-8 py-4 text-lg font-medium min-w-[120px] relative"
[disabled]="!canDoubleDown || isActionInProgress"
>
<span>Verdoppeln</span>
</button>
<button
(click)="leave.emit()"
class="bg-accent-red hover:bg-accent-red/80 px-8 py-4 rounded text-lg font-medium min-w-[120px] transition-all duration-300"
[disabled]="isActionInProgress"
>
Abbrechen
</button>
</div>
</div>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class GameControlsComponent {
@Input() playerCards: Card[] = [];
@Input() gameState: GameState = GameState.IN_PROGRESS;
@Input() isActionInProgress = false;
@Output() hit = new EventEmitter<void>();
@Output() stand = new EventEmitter<void>();
@Output() doubleDown = new EventEmitter<void>();
@Output() leave = new EventEmitter<void>();
protected readonly GameState = GameState;
constructor(protected gameControlsService: GameControlsService) {}
get canDoubleDown(): boolean {
return (
this.gameState === GameState.IN_PROGRESS &&
this.playerCards.length === 2 &&
!this.isActionInProgress
);
}
}

View file

@ -1,150 +0,0 @@
import {
ChangeDetectionStrategy,
Component,
EventEmitter,
Input,
OnChanges,
Output,
SimpleChanges,
signal,
} from '@angular/core';
import { CommonModule, CurrencyPipe } from '@angular/common';
import { FormGroup, ReactiveFormsModule } from '@angular/forms';
import { BettingService } from '@blackjack/services/betting.service';
import { AnimatedNumberComponent } from '../animated-number/animated-number.component';
@Component({
selector: 'app-game-info',
standalone: true,
imports: [CommonModule, CurrencyPipe, ReactiveFormsModule, AnimatedNumberComponent],
template: `
<div class="card p-4">
<h3 class="section-heading text-xl mb-4">Spiel Informationen</h3>
<div class="space-y-4">
<div class="flex justify-between items-center">
<span class="text-text-secondary">Aktuelle Wette:</span>
<span [class]="currentBet > 0 ? 'text-accent-red' : 'text-text-secondary'">
<app-animated-number [value]="currentBet" [duration]="0.5"></app-animated-number>
</span>
</div>
@if (!gameInProgress) {
<div class="grid grid-cols-2 gap-2 mb-4">
<button
(click)="setBetAmount(0.1)"
class="button-primary py-2 text-sm"
[disabled]="gameInProgress"
>
10%
</button>
<button
(click)="setBetAmount(0.25)"
class="button-primary py-2 text-sm"
[disabled]="gameInProgress"
>
25%
</button>
<button
(click)="setBetAmount(0.5)"
class="button-primary py-2 text-sm"
[disabled]="gameInProgress"
>
50%
</button>
<button
(click)="setBetAmount(1)"
class="button-primary py-2 text-sm"
[disabled]="gameInProgress"
>
100%
</button>
</div>
}
<form [formGroup]="betForm" (ngSubmit)="onSubmit()" class="space-y-2">
<div class="space-y-1">
<label for="bet" class="text-sm text-text-secondary">Einsatz</label>
<input
type="number"
id="bet"
formControlName="bet"
class="w-full px-3 py-2 bg-deep-blue-light text-white rounded focus:outline-none focus:ring-2 focus:ring-emerald disabled:opacity-50"
[min]="1"
[max]="balance"
step="0.01"
[disabled]="gameInProgress || isActionInProgress"
[placeholder]="balance | currency: 'EUR'"
/>
@if (betForm.get('bet')?.errors?.['required'] && betForm.get('bet')?.touched) {
<span class="text-xs text-accent-red">Bitte geben Sie einen Einsatz ein</span>
}
@if (betForm.get('bet')?.errors?.['min'] && betForm.get('bet')?.touched) {
<span class="text-xs text-accent-red">Mindestens 1 setzen</span>
}
@if (betForm.get('bet')?.errors?.['max'] && betForm.get('bet')?.touched) {
<span class="text-xs text-accent-red">Nicht genügend Guthaben</span>
}
</div>
<button
type="submit"
class="button-primary w-full py-2 relative"
[disabled]="!betForm.valid || gameInProgress || isActionInProgress"
>
<span [class.invisible]="isActionInProgress">Neues Spiel</span>
@if (isActionInProgress) {
<div class="absolute inset-0 flex items-center justify-center">
<div
class="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin"
></div>
</div>
}
</button>
</form>
</div>
</div>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class GameInfoComponent implements OnChanges {
@Input() set balance(value: number) {
this._balance.set(value);
}
get balance() {
return this._balance();
}
private _balance = signal(0);
@Input() currentBet = 0;
@Input() gameInProgress = false;
@Input() isActionInProgress = false;
@Output() newGame = new EventEmitter<number>();
betForm: FormGroup;
constructor(private bettingService: BettingService) {
this.betForm = this.bettingService.createBetForm();
}
ngOnChanges(changes: SimpleChanges): void {
if (changes['balance']) {
this.bettingService.updateBetFormValidators(this.betForm, this.balance);
}
}
setBetAmount(percentage: number) {
const betAmount = this.bettingService.calculateBetAmount(this.balance, percentage);
if (this.bettingService.isValidBet(betAmount, this.balance)) {
this.betForm.patchValue({ bet: betAmount });
}
}
onSubmit() {
if (this.betForm.valid) {
const betAmount = parseFloat(this.betForm.value.bet);
if (this.bettingService.isValidBet(betAmount, this.balance)) {
this.newGame.emit(betAmount);
this.betForm.reset();
}
}
}
}

View file

@ -1,135 +0,0 @@
import { ChangeDetectionStrategy, Component, Input, Output, EventEmitter } from '@angular/core';
import { CommonModule, CurrencyPipe } from '@angular/common';
import { animate, style, transition, trigger } from '@angular/animations';
import { GameState } from '../../enum/gameState';
import { AnimatedNumberComponent } from '../animated-number/animated-number.component';
@Component({
selector: 'app-game-result',
standalone: true,
imports: [CommonModule, CurrencyPipe, AnimatedNumberComponent],
template: `
<div *ngIf="visible" [@fadeInOut] class="modal-bg" style="z-index: 1000; position: fixed;">
<div class="modal-card" [@cardAnimation]>
<h2 class="modal-heading" [class]="getResultClass()">{{ getResultTitle() }}</h2>
<p class="py-2 text-text-secondary mb-4">{{ getResultMessage() }}</p>
<div
class="bg-deep-blue-light/50 rounded-lg p-5 mb-6 shadow-inner border border-deep-blue-light/30"
>
<div class="grid grid-cols-2 gap-4">
<div class="text-text-secondary">Einsatz:</div>
<div class="font-medium text-right">
<app-animated-number [value]="amount" [duration]="0.5"></app-animated-number>
</div>
<div class="text-text-secondary">
{{ isDraw ? 'Zurückgegeben:' : isWin ? 'Gewonnen:' : 'Verloren:' }}
</div>
<div
class="font-medium text-right"
[ngClass]="{
'text-emerald': isWin,
'text-accent-red': isLoss,
'text-yellow-400': isDraw,
}"
>
{{ isLoss ? '-' : '+' }}
<app-animated-number
[value]="isWin ? amount * 2 : amount"
[duration]="0.5"
></app-animated-number>
<div *ngIf="isWin" class="text-xs text-text-secondary">
(Einsatz
<app-animated-number [value]="amount" [duration]="0.5"></app-animated-number> × 2)
</div>
</div>
<div class="text-text-secondary border-t border-text-secondary/20 pt-3 font-medium">
Kontostand:
</div>
<div class="font-medium text-right border-t border-text-secondary/20 pt-3">
<app-animated-number [value]="balance" [duration]="0.5"></app-animated-number>
</div>
</div>
</div>
<button type="button" (click)="closeDialog()" class="button-primary w-full py-2">
Verstanden
</button>
</div>
</div>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
animations: [
trigger('fadeInOut', [
transition(':enter', [
style({ opacity: 0 }),
animate('150ms ease-out', style({ opacity: 1 })),
]),
transition(':leave', [animate('150ms ease-in', style({ opacity: 0 }))]),
]),
trigger('cardAnimation', [
transition(':enter', [
style({ opacity: 0, transform: 'scale(0.95)' }),
animate('200ms ease-out', style({ opacity: 1, transform: 'scale(1)' })),
]),
]),
],
})
export class GameResultComponent {
@Input() gameState: GameState = GameState.IN_PROGRESS;
@Input() amount = 0;
@Input() balance = 0;
@Input() set show(value: boolean) {
console.log('GameResultComponent show input changed:', value, 'gameState:', this.gameState);
this.visible = value;
}
@Output() gameResultClosed = new EventEmitter<void>();
visible = false;
get isWin(): boolean {
return this.gameState === GameState.PLAYER_WON || this.gameState === GameState.PLAYER_BLACKJACK;
}
get isLoss(): boolean {
return this.gameState === GameState.PLAYER_LOST;
}
get isDraw(): boolean {
return this.gameState === GameState.DRAW;
}
getResultTitle(): string {
if (this.gameState === GameState.PLAYER_BLACKJACK) return 'Blackjack!';
if (this.isWin) return 'Gewonnen!';
if (this.isLoss) return 'Verloren!';
if (this.isDraw) return 'Unentschieden!';
return '';
}
getResultMessage(): string {
if (this.gameState === GameState.PLAYER_BLACKJACK)
return 'Glückwunsch! Du hast mit einem Blackjack gewonnen!';
if (this.isWin) return 'Glückwunsch! Du hast diese Runde gewonnen.';
if (this.isLoss) return 'Schade! Du hast diese Runde verloren.';
if (this.isDraw) return 'Diese Runde endet unentschieden. Dein Einsatz wurde zurückgegeben.';
return '';
}
getResultClass(): string {
if (this.gameState === GameState.PLAYER_BLACKJACK) return 'text-emerald font-bold';
if (this.isWin) return 'text-emerald';
if (this.isLoss) return 'text-accent-red';
if (this.isDraw) return 'text-yellow-400';
return '';
}
closeDialog(): void {
this.visible = false;
this.gameResultClosed.emit();
console.log('Dialog closed by user');
}
}

View file

@ -1,75 +0,0 @@
import { ChangeDetectionStrategy, Component, Input, OnChanges, SimpleChanges } from '@angular/core';
import { CommonModule } from '@angular/common';
import { PlayingCardComponent } from '../playing-card/playing-card.component';
import { Card } from '@blackjack/models/blackjack.model';
import { GameControlsService } from '@blackjack/services/game-controls.service';
@Component({
selector: 'app-player-hand',
standalone: true,
imports: [CommonModule, PlayingCardComponent],
template: `
<div class="space-y-4">
<div class="flex justify-between items-center">
<h3 class="section-heading text-2xl">Deine Karten</h3>
<div class="flex items-center gap-2">
<div class="text-text-secondary">Punkte:</div>
<div class="text-xl font-bold text-emerald">
{{ gameControlsService.calculateHandValue(cards) }}
</div>
</div>
</div>
<div class="card p-6 !bg-emerald">
<div
class="flex justify-center gap-4 min-h-[160px] p-4 border-2 border-emerald-400 rounded-lg"
>
@if (cards.length > 0) {
@for (card of cardsWithState; track card.id) {
<app-playing-card
[rank]="card.rank"
[suit]="card.suit"
[hidden]="card.hidden"
[isNew]="card.isNew"
></app-playing-card>
}
} @else {
<div class="flex items-center justify-center text-white/70 text-lg font-medium">
Platziere eine Wette um zu spielen...
</div>
}
</div>
</div>
</div>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class PlayerHandComponent implements OnChanges {
@Input() cards: Card[] = [];
cardsWithState: (Card & { isNew: boolean; id: string })[] = [];
private lastCardCount = 0;
constructor(protected gameControlsService: GameControlsService) {}
ngOnChanges(changes: SimpleChanges): void {
if (changes['cards']) {
this.updateCardsWithState();
}
}
private updateCardsWithState(): void {
const newCards = this.cards.length > this.lastCardCount;
this.cardsWithState = this.cards.map((card, index) => {
const isNew = newCards && index >= this.lastCardCount;
return {
...card,
isNew,
id: `${card.suit}-${card.rank}-${index}`,
};
});
this.lastCardCount = this.cards.length;
}
}

View file

@ -1,133 +0,0 @@
import {
ChangeDetectionStrategy,
Component,
Input,
AfterViewInit,
ElementRef,
OnChanges,
SimpleChanges,
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { gsap } from 'gsap';
import { Suit, suitSymbols } from '@blackjack/models/blackjack.model';
@Component({
selector: 'app-playing-card',
standalone: true,
imports: [CommonModule],
template: `
<div
#cardElement
class="w-24 h-36 rounded-lg p-2 relative flex flex-col justify-between shadow-lg card-element"
[class]="hidden ? 'bg-red-800' : 'bg-white'"
>
@if (!hidden) {
<span class="text-xl font-bold" [class]="isRedSuit ? 'text-accent-red' : 'text-black'">{{
getDisplayRank(rank)
}}</span>
}
@if (!hidden) {
<span
class="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 text-3xl"
[class]="isRedSuit ? 'text-accent-red' : 'text-black'"
>{{ getSuitSymbol(suit) }}</span
>
}
@if (!hidden) {
<span
class="text-xl font-bold self-end rotate-180"
[class]="isRedSuit ? 'text-accent-red' : 'text-black'"
>{{ getDisplayRank(rank) }}</span
>
}
</div>
`,
styles: [
`
.card-element {
transform-style: preserve-3d;
backface-visibility: hidden;
}
`,
],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class PlayingCardComponent implements AfterViewInit, OnChanges {
@Input({ required: true }) rank!: string;
@Input({ required: true }) suit!: Suit;
@Input({ required: true }) hidden!: boolean;
@Input() isNew = false;
constructor(private elementRef: ElementRef) {}
get isRedSuit(): boolean {
return this.suit === 'HEARTS' || this.suit === 'DIAMONDS';
}
ngAfterViewInit(): void {
if (this.isNew) {
this.animateNewCard();
}
}
ngOnChanges(changes: SimpleChanges): void {
if (changes['hidden'] && !changes['hidden'].firstChange) {
this.animateCardFlip();
}
}
private animateNewCard(): void {
const cardElement = this.elementRef.nativeElement.querySelector('.card-element');
gsap.fromTo(
cardElement,
{
y: -100,
opacity: 0,
rotation: -10,
scale: 0.7,
},
{
y: 0,
opacity: 1,
rotation: 0,
scale: 1,
duration: 0.5,
ease: 'power2.out',
}
);
}
private animateCardFlip(): void {
const cardElement = this.elementRef.nativeElement.querySelector('.card-element');
gsap.to(cardElement, {
rotationY: 180,
duration: 0.3,
onComplete: () => {
gsap.set(cardElement, { rotationY: 0 });
},
});
}
protected getSuitSymbol(suit: Suit): string {
return suitSymbols[suit];
}
protected getDisplayRank(rank: string): string {
const rankMap: Record<string, string> = {
TWO: '2',
THREE: '3',
FOUR: '4',
FIVE: '5',
SIX: '6',
SEVEN: '7',
EIGHT: '8',
NINE: '9',
TEN: '10',
JACK: 'J',
QUEEN: 'Q',
KING: 'K',
ACE: 'A',
};
return rankMap[rank] || rank;
}
}

View file

@ -1,7 +0,0 @@
export enum GameState {
PLAYER_WON = 'PLAYER_WON',
IN_PROGRESS = 'IN_PROGRESS',
PLAYER_LOST = 'PLAYER_LOST',
DRAW = 'DRAW',
PLAYER_BLACKJACK = 'PLAYER_BLACKJACK',
}

View file

@ -1,23 +0,0 @@
export type Suit = 'HEARTS' | 'DIAMONDS' | 'CLUBS' | 'SPADES';
export interface Card {
suit: Suit;
rank: string;
hidden: boolean;
}
export interface BlackjackGame {
id: number;
state: string;
bet: number;
playerCards: Card[];
dealerCards: Card[];
userId: number;
}
export const suitSymbols: Record<Suit, string> = {
HEARTS: '♥',
DIAMONDS: '♦',
CLUBS: '♣',
SPADES: '♠',
};

View file

@ -1,5 +0,0 @@
export interface Card {
value: string;
suit: string;
hidden: boolean;
}

View file

@ -1,31 +0,0 @@
import { Injectable } from '@angular/core';
import { FormBuilder, FormGroup, Validators } from '@angular/forms';
@Injectable({
providedIn: 'root',
})
export class BettingService {
constructor(private fb: FormBuilder) {}
createBetForm(): FormGroup {
return this.fb.group({
bet: ['', [Validators.required, Validators.min(1)]],
});
}
updateBetFormValidators(form: FormGroup, balance: number): void {
form.reset();
form
.get('bet')
?.setValidators([Validators.required, Validators.min(1), Validators.max(balance)]);
form.get('bet')?.updateValueAndValidity();
}
calculateBetAmount(balance: number, percentage: number): number {
return Math.floor(balance * percentage * 100) / 100;
}
isValidBet(betAmount: number, balance: number): boolean {
return betAmount >= 1 && betAmount <= balance;
}
}

View file

@ -1,66 +0,0 @@
import { Injectable, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable, catchError } from 'rxjs';
import { BlackjackGame } from '@blackjack/models/blackjack.model';
@Injectable({
providedIn: 'root',
})
export class BlackjackService {
private http = inject(HttpClient);
startGame(bet: number): Observable<BlackjackGame> {
return this.http
.post<BlackjackGame>('/backend/blackjack/start', { betAmount: bet }, { responseType: 'json' })
.pipe(
catchError((error) => {
console.error('Start game error:', error);
throw error;
})
);
}
hit(gameId: number): Observable<BlackjackGame> {
return this.http
.post<BlackjackGame>(`/backend/blackjack/${gameId}/hit`, {}, { responseType: 'json' })
.pipe(
catchError((error) => {
console.error('Hit error:', error);
throw error;
})
);
}
stand(gameId: number): Observable<BlackjackGame> {
return this.http
.post<BlackjackGame>(`/backend/blackjack/${gameId}/stand`, {}, { responseType: 'json' })
.pipe(
catchError((error) => {
console.error('Stand error:', error);
throw error;
})
);
}
doubleDown(gameId: number): Observable<BlackjackGame> {
return this.http
.post<BlackjackGame>(`/backend/blackjack/${gameId}/doubleDown`, {}, { responseType: 'json' })
.pipe(
catchError((error) => {
console.error('Double Down error:', error);
throw error;
})
);
}
getGame(gameId: number): Observable<BlackjackGame> {
return this.http
.get<BlackjackGame>(`/backend/blackjack/${gameId}`, { responseType: 'json' })
.pipe(
catchError((error) => {
console.error('Get game error:', error);
throw error;
})
);
}
}

View file

@ -1,74 +0,0 @@
import { Injectable } from '@angular/core';
import { Card } from '../models/blackjack.model';
import { GameState } from '../enum/gameState';
@Injectable({
providedIn: 'root',
})
export class GameControlsService {
calculateHandValue(cards: Card[]): number {
let sum = 0;
let aceCount = 0;
const rankValues: Record<string, number> = {
TWO: 2,
THREE: 3,
FOUR: 4,
FIVE: 5,
SIX: 6,
SEVEN: 7,
EIGHT: 8,
NINE: 9,
TEN: 10,
JACK: 10,
QUEEN: 10,
KING: 10,
ACE: 11,
};
for (const card of cards) {
if (!card.hidden) {
const value = rankValues[card.rank] || 0;
sum += value;
if (card.rank === 'ACE') {
aceCount++;
}
}
}
while (sum > 21 && aceCount > 0) {
sum -= 10;
aceCount--;
}
return sum;
}
getStatusText(state: GameState): string {
switch (state) {
case GameState.IN_PROGRESS:
return 'Spiel läuft';
case GameState.PLAYER_WON:
return 'Gewonnen!';
case GameState.PLAYER_LOST:
return 'Verloren!';
case GameState.DRAW:
return 'Unentschieden!';
default:
return state;
}
}
getStatusClass(state: GameState): string {
switch (state) {
case GameState.PLAYER_WON:
return 'text-emerald';
case GameState.PLAYER_LOST:
return 'text-accent-red';
case GameState.DRAW:
return 'text-yellow-400';
default:
return 'text-white';
}
}
}

View file

@ -34,9 +34,7 @@
class="absolute bottom-4 left-4 right-4 transform translate-y-4 group-hover:translate-y-0 transition-transform duration-300"
>
<h4 class="game-heading">{{ game.name }}</h4>
<button class="button-primary w-full py-2" (click)="navigateToGame(game.route)">
Jetzt Spielen
</button>
<button class="button-primary w-full py-2">Jetzt Spielen</button>
</div>
</div>
</div>
@ -61,9 +59,7 @@
class="absolute bottom-4 left-4 right-4 transform translate-y-4 group-hover:translate-y-0 transition-transform duration-300"
>
<h4 class="game-heading">{{ game.name }}</h4>
<button class="button-primary w-full py-2" (click)="navigateToGame(game.route)">
Jetzt Spielen
</button>
<button class="button-primary w-full py-2">Jetzt Spielen</button>
</div>
</div>
</div>
@ -77,10 +73,7 @@
<h3 class="section-heading text-xl mb-4">Konto</h3>
<div class="space-y-4">
<button class="button-primary w-full py-2" (click)="openDepositModal()">Einzahlen</button>
<app-deposit
[isOpen]="isDepositModalOpen"
(closeModalEmitter)="closeDepositModal()"
></app-deposit>
<app-deposit [isOpen]="isDepositModalOpen" (close)="closeDepositModal()"></app-deposit>
<button class="bg-deep-blue-light hover:bg-deep-blue-contrast w-full py-2 rounded">
Transaktionen
</button>
@ -92,7 +85,7 @@
<app-confirmation
[successful]="isDepositSuccessful"
(closeConfirmation)="closeDepositConfirmationModal()"
(close)="closeDepositConfirmationModal()"
></app-confirmation>
<div class="card p-4">

View file

@ -1,11 +1,11 @@
import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core';
import { NavbarComponent } from '../../shared/components/navbar/navbar.component';
import { CurrencyPipe, NgFor } from '@angular/common';
import { Game } from '../../model/Game';
import { Transaction } from '../../model/Transaction';
import { DepositComponent } from '../deposit/deposit.component';
import { ActivatedRoute, Router } from '@angular/router';
import { ConfirmationComponent } from '@shared/components/confirmation/confirmation.component';
import { Transaction } from 'app/model/Transaction';
import { NavbarComponent } from '@shared/components/navbar/navbar.component';
import { Game } from 'app/model/Game';
import { ConfirmationComponent } from '../../shared/components/confirmation/confirmation.component';
import { ActivatedRoute } from '@angular/router';
@Component({
selector: 'app-homepage',
@ -18,16 +18,10 @@ export default class HomeComponent implements OnInit {
isDepositModalOpen = false;
isDepositSuccessful = false;
constructor(
public route: ActivatedRoute,
public router: Router
) {}
constructor(public route: ActivatedRoute) {}
ngOnInit() {
this.isDepositSuccessful = this.route.snapshot.queryParams['success'] == 'true';
this.router.navigate([], { queryParams: {} });
if (this.isDepositSuccessful) {
this.openDepositConfirmationModal();
}
@ -38,37 +32,31 @@ export default class HomeComponent implements OnInit {
id: '1',
name: 'Poker',
image: '/poker.webp',
route: '/game/poker',
},
{
id: '2',
name: 'Blackjack',
image: '/blackjack.webp',
route: '/game/blackjack',
},
{
id: '3',
name: 'Slots',
image: '/slots.webp',
route: '/game/slots',
},
{
id: '4',
name: 'Plinko',
image: '/plinko.webp',
route: '/game/plinko',
},
{
id: '5',
name: 'Liars Dice',
image: '/liars-dice.webp',
route: '/game/liars-dice',
},
{
id: '6',
name: 'Lootboxen',
image: '/lootbox.webp',
route: '/game/lootbox',
},
];
@ -89,8 +77,4 @@ export default class HomeComponent implements OnInit {
closeDepositConfirmationModal() {
this.isDepositSuccessful = false;
}
navigateToGame(route: string) {
this.router.navigate([route]);
}
}

View file

@ -1,6 +1,6 @@
import { ChangeDetectionStrategy, Component, OnInit, OnDestroy } from '@angular/core';
import { NavbarComponent } from '../../shared/components/navbar/navbar.component';
import { NgFor } from '@angular/common';
import { NavbarComponent } from '@shared/components/navbar/navbar.component';
@Component({
selector: 'app-landing-page',

View file

@ -1,7 +1,7 @@
import { ChangeDetectionStrategy, Component, inject, OnInit } from '@angular/core';
import { UserService } from '../../service/user.service';
import { KeycloakService } from 'keycloak-angular';
import { Router } from '@angular/router';
import { AuthService } from '../../service/auth.service';
import { OAuthService } from 'angular-oauth2-oidc';
@Component({
selector: 'app-login-success',
@ -12,32 +12,15 @@ import { OAuthService } from 'angular-oauth2-oidc';
changeDetection: ChangeDetectionStrategy.OnPush,
})
export default class LoginSuccessComponent implements OnInit {
private authService: AuthService = inject(AuthService);
private oauthService: OAuthService = inject(OAuthService);
private userService: UserService = inject(UserService);
private keycloakService: KeycloakService = inject(KeycloakService);
private router: Router = inject(Router);
async ngOnInit() {
try {
if (this.oauthService.hasValidAccessToken()) {
this.router.navigate(['/home']);
} else {
setTimeout(() => {
if (this.oauthService.hasValidAccessToken() || this.authService.getUser()) {
this.router.navigate(['/home']);
} else {
this.router.navigate(['/']);
}
}, 3000);
}
} catch (err) {
console.error('Error during login callback:', err);
setTimeout(() => {
if (this.authService.isLoggedIn()) {
this.router.navigate(['/home']);
} else {
this.router.navigate(['/']);
}
}, 3000);
}
const userProfile = await this.keycloakService.loadUserProfile();
const user = await this.userService.getOrCreateUser(userProfile);
sessionStorage.setItem('user', JSON.stringify(user));
this.router.navigate(['home']);
}
}

View file

@ -2,5 +2,4 @@ export interface Game {
id: string;
name: string;
image: string;
route: string;
}

View file

@ -1,5 +1,5 @@
export interface User {
authentikId: string;
keycloakId: string;
username: string;
balance: number;
}

View file

@ -1,208 +0,0 @@
import { inject, Injectable } from '@angular/core';
import { AuthConfig, OAuthEvent, OAuthService } from 'angular-oauth2-oidc';
import { UserService } from './user.service';
import { User } from '../model/User';
import { Router } from '@angular/router';
import { environment } from '../../environments/environment';
import { catchError, from, of } from 'rxjs';
@Injectable({
providedIn: 'root',
})
export class AuthService {
private readonly authConfig: AuthConfig = {
issuer: 'https://oauth.simonis.lol/application/o/casino-dev/',
clientId: environment.OAUTH_CLIENT_ID,
dummyClientSecret: environment.OAUTH_CLIENT_SECRET,
scope: `openid email profile ${environment.OAUTH_CLIENT_ID}`,
responseType: 'code',
redirectUri: window.location.origin + '/auth/callback',
postLogoutRedirectUri: '',
redirectUriAsPostLogoutRedirectUriFallback: false,
oidc: true,
requestAccessToken: true,
tokenEndpoint: 'https://oauth.simonis.lol/application/o/token/',
userinfoEndpoint: 'https://oauth.simonis.lol/application/o/userinfo/',
strictDiscoveryDocumentValidation: false,
skipIssuerCheck: true,
disableAtHashCheck: true,
requireHttps: false,
showDebugInformation: false,
sessionChecksEnabled: false,
};
private userService: UserService = inject(UserService);
private oauthService: OAuthService = inject(OAuthService);
private router: Router = inject(Router);
private user: User | null = null;
constructor() {
this.oauthService.configure(this.authConfig);
this.setupEventHandling();
const hasAuthParams =
window.location.search.includes('code=') ||
window.location.search.includes('token=') ||
window.location.search.includes('id_token=');
if (hasAuthParams) {
this.processCodeFlow();
} else {
this.checkExistingSession();
}
}
private processCodeFlow() {
this.oauthService
.tryLogin({
onTokenReceived: () => {
this.handleSuccessfulLogin();
},
})
.catch((err) => {
console.error('Error processing code flow:', err);
});
}
private checkExistingSession() {
this.oauthService
.loadDiscoveryDocumentAndTryLogin()
.then((isLoggedIn) => {
if (isLoggedIn && !this.user) {
this.handleSuccessfulLogin();
}
})
.catch((err) => {
console.error('Error during initial login attempt:', err);
});
}
private setupEventHandling() {
this.oauthService.events.subscribe((event: OAuthEvent) => {
if (event.type === 'token_received') {
this.handleSuccessfulLogin();
}
});
}
private handleSuccessfulLogin() {
const claims = this.oauthService.getIdentityClaims();
if (claims && (claims['sub'] || claims['email'])) {
this.processUserProfile(claims);
return;
}
try {
from(this.oauthService.loadUserProfile())
.pipe(
catchError((error) => {
console.error('Error loading user profile:', error);
if (this.oauthService.hasValidAccessToken()) {
this.oauthService.getAccessToken();
const minimalProfile = {
sub: 'user-' + Math.random().toString(36).substring(2, 10),
preferred_username: 'user' + Date.now(),
};
return of({ info: minimalProfile });
}
return of(null);
})
)
.subscribe((profile) => {
if (profile) {
this.processUserProfile(profile);
} else {
this.router.navigate(['/']);
}
});
} catch (err) {
console.error('Exception in handleSuccessfulLogin:', err);
if (this.oauthService.hasValidAccessToken()) {
this.router.navigate(['/home']);
} else {
this.router.navigate(['/']);
}
}
}
private processUserProfile(profile: unknown) {
this.fromUserProfile(profile as Record<string, unknown>).subscribe({
next: (user) => {
this.user = user;
this.router.navigate(['home']);
},
error: (err) => {
console.error('Error creating/retrieving user:', err);
if (this.oauthService.hasValidAccessToken()) {
this.router.navigate(['/home']);
} else {
this.router.navigate(['/']);
}
},
});
}
login() {
try {
this.oauthService
.loadDiscoveryDocument()
.then(() => {
this.oauthService.initLoginFlow();
})
.catch((err) => {
console.error('Error loading discovery document:', err);
this.oauthService.initLoginFlow();
});
} catch (err) {
console.error('Exception in login:', err);
const redirectUri = this.authConfig.redirectUri || window.location.origin + '/auth/callback';
const scope = this.authConfig.scope || 'openid email profile';
const authUrl = `${this.authConfig.issuer}authorize?client_id=${this.authConfig.clientId}&redirect_uri=${encodeURIComponent(redirectUri)}&response_type=code&scope=${encodeURIComponent(scope)}`;
window.location.href = authUrl;
}
}
logout() {
try {
this.user = null;
this.oauthService.logOut(true);
if (window.location.href.includes('id_token') || window.location.href.includes('logout')) {
window.location.href = window.location.origin;
}
localStorage.removeItem('access_token');
localStorage.removeItem('id_token');
localStorage.removeItem('refresh_token');
sessionStorage.removeItem('access_token');
sessionStorage.removeItem('id_token');
sessionStorage.removeItem('refresh_token');
this.router.navigate(['/']);
} catch (err) {
console.error('Exception in logout:', err);
localStorage.clear();
sessionStorage.clear();
this.router.navigate(['/']);
}
}
isLoggedIn() {
return this.oauthService.hasValidAccessToken();
}
private fromUserProfile(profile: Record<string, unknown>) {
return this.userService.getOrCreateUser(profile);
}
getAccessToken() {
return this.oauthService.getAccessToken();
}
getUser() {
return this.user;
}
}

View file

@ -1,6 +1,7 @@
import { inject, Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { BehaviorSubject, catchError, EMPTY, Observable, tap } from 'rxjs';
import { KeycloakProfile } from 'keycloak-js';
import { catchError, EMPTY, Observable } from 'rxjs';
import { User } from '../model/User';
@Injectable({
@ -8,53 +9,34 @@ import { User } from '../model/User';
})
export class UserService {
private http: HttpClient = inject(HttpClient);
private currentUserSubject = new BehaviorSubject<User | null>(null);
public currentUser$ = this.currentUserSubject.asObservable();
constructor() {
this.getCurrentUser().subscribe();
}
public getUser(id: string): Observable<User | null> {
return this.http.get<User | null>(`/backend/user/${id}`).pipe(
catchError(() => EMPTY),
tap((user) => this.currentUserSubject.next(user))
);
return this.http.get<User | null>(`/backend/user/${id}`).pipe(catchError(() => EMPTY));
}
public getCurrentUser(): Observable<User | null> {
return this.http.get<User | null>('/backend/user').pipe(
catchError(() => EMPTY),
tap((user) => this.currentUserSubject.next(user))
);
}
public refreshCurrentUser(): void {
this.getCurrentUser().subscribe();
return this.http.get<User | null>('/backend/user').pipe(catchError(() => EMPTY));
}
public createUser(id: string, username: string): Observable<User> {
return this.http
.post<User>('/backend/user', {
authentikId: id,
username: username,
})
.pipe(tap((user) => this.currentUserSubject.next(user)));
return this.http.post<User>('/backend/user', {
keycloakId: id,
username: username,
});
}
public getOrCreateUser(profile: Record<string, unknown>): Observable<User> {
const info = profile['info'] as Record<string, unknown> | undefined;
const id = (info?.['sub'] as string) || (profile['sub'] as string);
const username =
(info?.['preferred_username'] as string) ||
(profile['preferred_username'] as string) ||
(profile['email'] as string) ||
(profile['name'] as string);
if (!id || !username) {
throw new Error('Invalid user profile data');
public async getOrCreateUser(userProfile: KeycloakProfile) {
if (userProfile.id == null) {
return;
}
return await this.getUser(userProfile.id)
.toPromise()
.then(async (user) => {
if (user) {
return user;
}
return this.createUser(id, username);
return await this.createUser(userProfile.id ?? '', userProfile.username ?? '').toPromise();
});
}
}

View file

@ -8,7 +8,7 @@ import {
AfterViewInit,
OnDestroy,
} from '@angular/core';
import { ModalAnimationService } from '@shared/services/modal-animation.service';
import { ModalAnimationService } from '../../services/modal-animation.service';
import gsap from 'gsap';
@Component({
@ -19,7 +19,7 @@ import gsap from 'gsap';
})
export class ConfirmationComponent implements AfterViewInit, OnDestroy {
@Input() successful = true;
@Output() closeConfirmation = new EventEmitter<void>();
@Output() close = new EventEmitter<void>();
@ViewChild('modalBg') modalBg!: ElementRef;
@ViewChild('modalCard') modalCard!: ElementRef;
@ -43,7 +43,7 @@ export class ConfirmationComponent implements AfterViewInit, OnDestroy {
this.modalAnimationService.closeModal(
this.modalCard.nativeElement,
this.modalBg.nativeElement,
() => this.closeConfirmation.emit()
() => this.close.emit()
);
}
}

View file

@ -1,176 +0,0 @@
import {
ChangeDetectionStrategy,
Component,
EventEmitter,
Input,
OnDestroy,
OnInit,
Output,
signal,
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { animate, style, transition, trigger } from '@angular/animations';
import { interval, Subscription, takeWhile } from 'rxjs';
import { AnimatedNumberComponent } from '@blackjack/components/animated-number/animated-number.component';
@Component({
selector: 'app-debt-dialog',
standalone: true,
imports: [CommonModule, AnimatedNumberComponent],
template: `
<div *ngIf="visible" [@fadeInOut] class="modal-bg" style="z-index: 1000; position: fixed;">
<div class="modal-card" [@cardAnimation]>
<h2 class="modal-heading text-accent-red">WARNUNG!</h2>
<p class="py-2 text-text-secondary mb-4">
Du hast nicht genug Geld für den Double Down. Du bist jetzt im Minus und schuldest uns
<app-animated-number [value]="amount" [duration]="0.5"></app-animated-number>.
</p>
<p class="py-2 text-accent-red mb-4 font-bold">
Liefer das Geld sofort an den Dead Drop oder es wird unangenehme Konsequenzen geben!
</p>
<div
class="bg-deep-blue-light/50 rounded-lg p-5 mb-6 shadow-inner border border-deep-blue-light/30"
>
<div class="grid grid-cols-2 gap-4">
<div class="text-text-secondary">Schulden:</div>
<div class="font-medium text-right text-accent-red">
<app-animated-number [value]="amount" [duration]="0.5"></app-animated-number>
</div>
</div>
</div>
<div class="text-center mb-6">
<div
class="text-8xl font-bold text-accent-red"
[class.animate-pulse]="timeLeft() <= 10"
[class.animate-bounce]="timeLeft() <= 5"
[@countdown]="timeLeft()"
>
{{ timeLeft() }}
</div>
<div class="text-text-secondary mt-2">Sekunden verbleibend</div>
</div>
@if (timeLeft() === 0) {
<div class="text-center mb-6">
<div class="relative">
<div class="absolute inset-0 bg-accent-red/20 blur-xl rounded-full"></div>
<div
class="relative bg-gradient-to-b from-accent-red to-red-900 p-8 rounded-lg border-2 border-accent-red shadow-lg"
>
<div class="flex items-center justify-center gap-4 mb-4">
<svg
class="w-12 h-12 text-accent-red animate-[spin_2s_linear_infinite]"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
>
<path
d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8zm-1-13h2v6h-2zm0 8h2v2h-2z"
/>
</svg>
<span
class="text-4xl font-black tracking-wider text-white animate-[pulse_1s_ease-in-out_infinite]"
>
ZEIT ABGELAUFEN
</span>
<svg
class="w-12 h-12 text-accent-red animate-[spin_2s_linear_infinite]"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
>
<path
d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8zm-1-13h2v6h-2zm0 8h2v2h-2z"
/>
</svg>
</div>
<div
class="text-2xl font-bold text-white/90 tracking-wider animate-[pulse_1s_ease-in-out_infinite]"
>
KONSEQUENZEN FOLGEN
</div>
</div>
</div>
</div>
}
<button type="button" (click)="closeDialog()" class="button-primary w-full py-2">
Verstanden
</button>
</div>
</div>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
animations: [
trigger('fadeInOut', [
transition(':enter', [
style({ opacity: 0 }),
animate('150ms ease-out', style({ opacity: 1 })),
]),
transition(':leave', [animate('150ms ease-in', style({ opacity: 0 }))]),
]),
trigger('cardAnimation', [
transition(':enter', [
style({ opacity: 0, transform: 'scale(0.95)' }),
animate('200ms ease-out', style({ opacity: 1, transform: 'scale(1)' })),
]),
]),
trigger('countdown', [
transition('* => *', [
style({ transform: 'scale(1.2)' }),
animate('100ms ease-out', style({ transform: 'scale(1)' })),
]),
]),
],
})
export class DebtDialogComponent implements OnInit, OnDestroy {
@Input() amount = 0;
@Input() set show(value: boolean) {
this.visible = value;
if (value) {
this.startTimer();
}
}
@Output() dialogClosed = new EventEmitter<void>();
visible = false;
timeLeft = signal(30);
private timerSubscription: Subscription | undefined;
private warningSound = new Audio('assets/sounds/warning.mp3');
ngOnInit() {
if (this.visible) {
this.startTimer();
}
}
ngOnDestroy() {
this.stopTimer();
}
private startTimer() {
this.timeLeft.set(30);
this.timerSubscription = interval(1000)
.pipe(takeWhile(() => this.timeLeft() > 0))
.subscribe(() => {
this.timeLeft.update((value) => value - 1);
if (this.timeLeft() <= 5) {
this.warningSound.play();
}
if (this.timeLeft() === 0) {
setTimeout(() => this.closeDialog(), 5000);
}
});
}
private stopTimer() {
if (this.timerSubscription) {
this.timerSubscription.unsubscribe();
}
}
closeDialog(): void {
this.stopTimer();
this.visible = false;
this.dialogClosed.emit();
}
}

View file

@ -17,11 +17,8 @@
@if (isLoggedIn) {
<div
class="text-white font-bold bg-deep-blue-contrast rounded-full px-4 py-2 text-sm hover:bg-deep-blue-contrast/80 hover:cursor-pointer hover:scale-105 transition-all active:scale-95 select-none duration-300"
routerLink="/home"
>
<span [class]="balance() < 0 ? 'text-accent-red' : ''">
<app-animated-number [value]="balance()" [duration]="0.5"></app-animated-number>
</span>
<span>Balance: {{ balance() | currency: 'EUR' : 'symbol' : '1.2-2' }}</span>
</div>
<button (click)="logout()" class="button-primary px-4 py-1.5">Abmelden</button>
}

View file

@ -1,56 +1,43 @@
import {
ChangeDetectionStrategy,
Component,
inject,
OnInit,
OnDestroy,
signal,
} from '@angular/core';
import { ChangeDetectionStrategy, Component, inject, OnInit, signal } from '@angular/core';
import { RouterModule } from '@angular/router';
import { AuthService } from '../../../service/auth.service';
import { CurrencyPipe } from '@angular/common';
import { UserService } from '@service/user.service';
import { Subscription } from 'rxjs';
import { AnimatedNumberComponent } from '@blackjack/components/animated-number/animated-number.component';
import { KeycloakService } from 'keycloak-angular';
import { UserService } from '../../../service/user.service';
import { CurrencyPipe } from '@angular/common';
@Component({
selector: 'app-navbar',
templateUrl: './navbar.component.html',
standalone: true,
imports: [RouterModule, CurrencyPipe, AnimatedNumberComponent],
imports: [RouterModule, CurrencyPipe],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class NavbarComponent implements OnInit, OnDestroy {
export class NavbarComponent implements OnInit {
isMenuOpen = false;
private authService: AuthService = inject(AuthService);
isLoggedIn = this.authService.isLoggedIn();
private keycloakService: KeycloakService = inject(KeycloakService);
isLoggedIn = this.keycloakService.isLoggedIn();
private userService = inject(UserService);
private userSubscription: Subscription | undefined;
private user = this.userService.getCurrentUser();
public balance = signal(0);
ngOnInit() {
this.userSubscription = this.userService.currentUser$.subscribe((user) => {
this.user.subscribe((user) => {
this.balance.set(user?.balance ?? 0);
});
}
ngOnDestroy() {
if (this.userSubscription) {
this.userSubscription.unsubscribe();
}
}
login() {
try {
this.authService.login();
const baseUrl = window.location.origin;
this.keycloakService.login({ redirectUri: `${baseUrl}/login/success` });
} catch (error) {
console.error('Login failed:', error);
}
}
logout() {
this.authService.logout();
this.keycloakService.logout();
}
toggleMenu() {

View file

@ -1,21 +0,0 @@
import { HttpInterceptorFn } from '@angular/common/http';
import { inject } from '@angular/core';
import { OAuthStorage } from 'angular-oauth2-oidc';
export const httpInterceptor: HttpInterceptorFn = (req, next) => {
const oauthStorage = inject(OAuthStorage);
if (oauthStorage.getItem('access_token')) {
return next(
req.clone({
setHeaders: {
Authorization: 'Bearer ' + oauthStorage.getItem('access_token'),
'Access-Control-Allow-Origin': '*',
'Referrer-Policy': 'no-referrer',
},
})
);
} else {
return next(req);
}
};

View file

@ -1,7 +1,4 @@
export const environment = {
STRIPE_KEY:
'pk_test_51QrePYIvCfqz7ANgMizBorPpVjJ8S6gcaL4yvcMQnVaKyReqcQ6jqaQEF7aDZbDu8rNVsTZrw8ABek4ToxQX7KZe00jpGh8naG',
OAUTH_CLIENT_ID: 'MDqjm1kcWKuZfqHJXjxwAV20i44aT7m4VhhTL3Nm',
OAUTH_CLIENT_SECRET:
'GY2F8te6iAVYt1TNAUVLzWZEXb6JoMNp6chbjqaXNq4gS5xTDL54HqBiAlV1jFKarN28LQ7FUsYX4SbwjfEhZhgeoKuBnZKjR9eiu7RawnGgxIK9ffvUfMkjRxnmiGI5',
};

View file

@ -3,13 +3,6 @@
{
"compileOnSave": false,
"compilerOptions": {
"baseUrl": "./src",
"paths": {
"@service/*": ["app/service/*"],
"@environments/*": ["environments/*"],
"@shared/*": ["app/shared/*"],
"@blackjack/*": ["app/feature/game/blackjack/*"]
},
"outDir": "./dist/out-tsc",
"strict": true,
"noImplicitOverride": true,