Compare commits
14 Commits
react-fixes
...
add-e2e
| Author | SHA1 | Date | |
|---|---|---|---|
| c1399eea56 | |||
| 39df5bd53d | |||
| 87e267d790 | |||
| 8ebd5170fd | |||
| 3709a29a57 | |||
| e8ab39c23e | |||
| 426ce608e6 | |||
| 8815e6d1b1 | |||
| 5fce1d591f | |||
| 6e72f8b6ca | |||
| 94fb38e038 | |||
| 4c989928f1 | |||
| c14864aa29 | |||
| 229edd42e9 |
+141
-42
@@ -1,13 +1,18 @@
|
||||
name: CI compile & test & build
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ "main" ]
|
||||
pull_request:
|
||||
branches: [ "main" ]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
backend_image:
|
||||
description: 'Backend image to use in E2E tests (optional, default: ghcr.io/morbalint/kemkas-backend:b8565f8e)'
|
||||
required: false
|
||||
|
||||
jobs:
|
||||
ci-fe:
|
||||
unit-test-and-lint:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
@@ -27,52 +32,146 @@ jobs:
|
||||
- name: Yarn Install
|
||||
run: |
|
||||
cd ./frontend
|
||||
yarn install
|
||||
yarn install --frozen-lockfile
|
||||
- name: Yarn Test
|
||||
run: |
|
||||
cd ./frontend
|
||||
yarn run test
|
||||
- name: Yarn Lint
|
||||
run: |
|
||||
cd ./frontend
|
||||
yarn run lint
|
||||
|
||||
docker-fe-build:
|
||||
build-fe:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
attestations: write
|
||||
id-token: write
|
||||
outputs:
|
||||
image_tag: ${{ steps.get_image_tag.outputs.image_tag }}
|
||||
image_sha_tag: ${{ steps.image-tag.outputs.image_sha_tag }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: docker/setup-buildx-action@v3
|
||||
- name: Log in to GitHub Container Registry
|
||||
run: echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u ${{ github.actor }} --password-stdin
|
||||
- id: get_image_tag
|
||||
run: |
|
||||
echo "GITHUB_SHA_SHORT=$(echo ${GITHUB_SHA::8})" >> $GITHUB_ENV
|
||||
echo "ghcr.io/${{ github.repository_owner }}/kemkas-frontend:${GITHUB_SHA::8}"
|
||||
echo "image_tag=ghcr.io/${{ github.repository_owner }}/kemkas-fe:${GITHUB_SHA::8}" >> $GITHUB_OUTPUT
|
||||
- uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: ./frontend/
|
||||
file: ./frontend/Dockerfile
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
push: true
|
||||
tags: |
|
||||
ghcr.io/${{ github.repository_owner }}/kemkas-frontend:${{ env.GITHUB_SHA_SHORT }}
|
||||
- uses: actions/checkout@v4
|
||||
- uses: docker/setup-buildx-action@v3
|
||||
- name: Log in to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Compute frontend image tag
|
||||
id: image-tag
|
||||
run: |
|
||||
image_repo="ghcr.io/${{ github.repository_owner }}/kemkas-frontend"
|
||||
sha_tag="${image_repo}:${GITHUB_SHA::8}"
|
||||
|
||||
# No longer used, and token expired.
|
||||
# deploy-to-beta:
|
||||
# runs-on: ubuntu-latest
|
||||
# needs:
|
||||
# - docker-fe-build
|
||||
# steps:
|
||||
# - uses: actions/checkout@v4
|
||||
# with:
|
||||
# repository: ${{ github.repository_owner }}/kemkas-deployment
|
||||
# ref: refs/heads/main
|
||||
# ssh-key: ${{ secrets.DEPLOYMENT_REPO_DEPLOY_KEY }}
|
||||
# - uses: mikefarah/yq@v4
|
||||
# with:
|
||||
# cmd: yq -i '.spec.template.spec.containers[0].image = "${{ needs.docker-fe-build.outputs.image_tag }}"' ./kemkas-beta/kemkas-fe-deployment.yaml
|
||||
# - run: |
|
||||
# git config --add user.email "beta-fe-deployment-bot@kemkas.hu"
|
||||
# git config --add user.name "Deployment Bot Beta FE"
|
||||
# git commit -a -m "deploy ${{ needs.docker-fe-build.outputs.image_tag }}"
|
||||
# git push
|
||||
tags="$sha_tag"
|
||||
if [[ "${GITHUB_REF}" == "refs/heads/main" ]]; then
|
||||
tags+=$'\n'"${image_repo}:latest"
|
||||
fi
|
||||
|
||||
echo "image_sha_tag=${sha_tag}" >> "$GITHUB_OUTPUT"
|
||||
{
|
||||
echo "tags<<EOF"
|
||||
echo "$tags"
|
||||
echo "EOF"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
- name: Build and push frontend image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: ./frontend/
|
||||
file: ./frontend/Dockerfile
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
push: true
|
||||
tags: |
|
||||
${{ steps.image-tag.outputs.tags }}
|
||||
|
||||
build-e2e:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
attestations: write
|
||||
id-token: write
|
||||
outputs:
|
||||
image_sha_tag: ${{ steps.image-tag.outputs.image_sha_tag }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: docker/setup-buildx-action@v3
|
||||
- name: Log in to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Compute e2e image tag
|
||||
id: image-tag
|
||||
run: |
|
||||
image_repo="ghcr.io/${{ github.repository_owner }}/kemkas-e2e"
|
||||
sha_tag="${image_repo}:${GITHUB_SHA::8}"
|
||||
|
||||
tags="$sha_tag"
|
||||
if [[ "${GITHUB_REF}" == "refs/heads/main" ]]; then
|
||||
tags+=$'\n'"${image_repo}:latest"
|
||||
fi
|
||||
|
||||
echo "image_sha_tag=${sha_tag}" >> "$GITHUB_OUTPUT"
|
||||
{
|
||||
echo "tags<<EOF"
|
||||
echo "$tags"
|
||||
echo "EOF"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
- name: Build and push e2e image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: ./e2e/
|
||||
file: ./e2e/Dockerfile
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
push: true
|
||||
tags: |
|
||||
${{ steps.image-tag.outputs.tags }}
|
||||
|
||||
run-e2e:
|
||||
runs-on: ubuntu-latest
|
||||
needs:
|
||||
- build-fe
|
||||
- build-e2e
|
||||
permissions:
|
||||
contents: read
|
||||
packages: read
|
||||
env:
|
||||
BACKEND_IMAGE: ${{ github.event.inputs.backend_image || 'ghcr.io/morbalint/kemkas-backend:b8565f8e' }}
|
||||
FRONTEND_IMAGE: ${{ needs.build-fe.outputs.image_sha_tag }}
|
||||
E2E_IMAGE: ${{ needs.build-e2e.outputs.image_sha_tag }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Log in to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Pull images for test run
|
||||
run: docker compose -f docker-compose.e2e.yaml pull
|
||||
- name: Run Docker Compose E2E tests
|
||||
run: docker compose -f docker-compose.e2e.yaml up --no-build --abort-on-container-exit --exit-code-from e2e e2e
|
||||
- name: Upload Playwright HTML report
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: playwright-report
|
||||
path: e2e/playwright-report
|
||||
if-no-files-found: ignore
|
||||
- name: Upload Playwright test results
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: playwright-test-results
|
||||
path: e2e/test-results
|
||||
if-no-files-found: ignore
|
||||
- name: Tear down Docker Compose stack
|
||||
if: always()
|
||||
run: docker compose -f docker-compose.e2e.yaml down -v --remove-orphans
|
||||
|
||||
+9
-1
@@ -31,4 +31,12 @@ yarn-error.log*
|
||||
|
||||
obj/
|
||||
bin/
|
||||
dist/
|
||||
dist/
|
||||
|
||||
# IDEs and editors
|
||||
.idea/
|
||||
.vscode/
|
||||
|
||||
# playwright
|
||||
/e2e/playwright-report/
|
||||
/e2e/test-results/
|
||||
@@ -25,3 +25,13 @@ dotnet dev-certs https --format PEM --no-password -ep ~/.aspnet/https/kemkas.pem
|
||||
```
|
||||
|
||||
Later start the docker compose first and the project launch settings after the DB is up and running.
|
||||
|
||||
## E2E with Docker Compose
|
||||
|
||||
For a fully containerized end-to-end run (tagged backend image + freshly built frontend/e2e images):
|
||||
|
||||
```shell
|
||||
docker compose -f docker-compose.e2e.yaml up --build --abort-on-container-exit --exit-code-from e2e e2e
|
||||
```
|
||||
|
||||
Use `BACKEND_IMAGE` to select a specific backend tag, and note this compose stack auto-generates TLS certificates for the nginx proxy.
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
services:
|
||||
certs:
|
||||
image: alpine:3.21
|
||||
volumes:
|
||||
- tls-certs:/certs
|
||||
- ./scripts/generate-e2e-certs.sh:/scripts/generate-e2e-certs.sh:ro
|
||||
command: ["sh", "/scripts/generate-e2e-certs.sh"]
|
||||
|
||||
db:
|
||||
image: postgres:16.13-alpine3.23
|
||||
environment:
|
||||
POSTGRES_USER: "kemkas"
|
||||
POSTGRES_PASSWORD: "kemkas-dev42"
|
||||
volumes:
|
||||
- db-data:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U kemkas -d postgres"]
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 20
|
||||
start_period: 5s
|
||||
|
||||
backend:
|
||||
image: ${BACKEND_IMAGE:-ghcr.io/morbalint/kemkas-backend:b8565f8e}
|
||||
environment:
|
||||
ConnectionStrings__DefaultConnection: "Server=db;User Id=kemkas;Password=kemkas-dev42"
|
||||
Email__DomainName: "mail.kemkas.hu"
|
||||
Email__ApiKey: ""
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
|
||||
frontend:
|
||||
image: ${FRONTEND_IMAGE:-ghcr.io/morbalint/kemkas-frontend:local}
|
||||
build:
|
||||
context: ./frontend
|
||||
dockerfile: Dockerfile
|
||||
|
||||
proxy:
|
||||
image: nginx:1.29.7-alpine3.23
|
||||
ports:
|
||||
- "8000:8000"
|
||||
volumes:
|
||||
- ./nginx.e2e.conf:/etc/nginx/nginx.conf:ro
|
||||
- tls-certs:/etc/nginx/certs:ro
|
||||
depends_on:
|
||||
certs:
|
||||
condition: service_completed_successfully
|
||||
backend:
|
||||
condition: service_started
|
||||
frontend:
|
||||
condition: service_started
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget -q --spider --no-check-certificate https://127.0.0.1:8000 || exit 1"]
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 20
|
||||
start_period: 5s
|
||||
|
||||
e2e:
|
||||
image: ${E2E_IMAGE:-ghcr.io/morbalint/kemkas-e2e:local}
|
||||
build:
|
||||
context: ./e2e
|
||||
dockerfile: Dockerfile
|
||||
environment:
|
||||
E2E_BASE_URL: https://proxy:8000
|
||||
E2E_USE_WEBSERVER: "false"
|
||||
E2E_TRUSTED_CERT_PATH: /certs/rootCA.pem
|
||||
E2E_WAIT_FOR_URL: https://proxy:8000
|
||||
volumes:
|
||||
- tls-certs:/certs:ro
|
||||
- ./e2e/playwright-report:/work/playwright-report
|
||||
- ./e2e/test-results:/work/test-results
|
||||
depends_on:
|
||||
certs:
|
||||
condition: service_completed_successfully
|
||||
proxy:
|
||||
condition: service_healthy
|
||||
|
||||
volumes:
|
||||
db-data:
|
||||
tls-certs:
|
||||
@@ -0,0 +1,5 @@
|
||||
node_modules
|
||||
playwright-report
|
||||
test-results
|
||||
.vscode
|
||||
.git
|
||||
@@ -0,0 +1,18 @@
|
||||
FROM mcr.microsoft.com/playwright:v1.59.1-noble
|
||||
|
||||
LABEL org.opencontainers.image.source = "https://github.com/morbalint/kemkas"
|
||||
|
||||
WORKDIR /work
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends libnss3-tools \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY package.json yarn.lock ./
|
||||
RUN yarn install --frozen-lockfile
|
||||
|
||||
COPY . .
|
||||
RUN chmod +x /work/docker-entrypoint.sh
|
||||
|
||||
ENTRYPOINT ["/work/docker-entrypoint.sh"]
|
||||
CMD ["yarn", "test"]
|
||||
@@ -0,0 +1,57 @@
|
||||
# E2E tests (Playwright)
|
||||
|
||||
This folder contains browser-level end-to-end tests for public, backend-independent user flows.
|
||||
|
||||
## Current scope
|
||||
|
||||
- Guest redirect from `/` to `/2e/karakter`
|
||||
- Guest header links (`Register`, `Login`)
|
||||
- Public legal pages (`/privacy.html`, `/ogl.html`, `/aelf.html`)
|
||||
|
||||
## Run locally
|
||||
|
||||
From `frontend/`:
|
||||
|
||||
- Install Playwright browser binaries:
|
||||
- `yarn test:e2e:install`
|
||||
- Run tests:
|
||||
- `yarn test:e2e`
|
||||
- Run headed mode:
|
||||
- `yarn test:e2e:headed`
|
||||
- Run interactive UI mode:
|
||||
- `yarn test:e2e:ui`
|
||||
|
||||
## Notes
|
||||
|
||||
- Tests use the config in `../e2e/playwright.config.ts`.
|
||||
- The Playwright web server starts Vite on `http://127.0.0.1:4173`.
|
||||
- Tests mock `GET /api/User/me` so they stay deterministic without the backend.
|
||||
|
||||
## Run with Docker Compose
|
||||
|
||||
This mode runs against a full containerized stack:
|
||||
|
||||
- tagged backend image (`BACKEND_IMAGE`)
|
||||
- supporting services (`postgres`, `nginx` proxy)
|
||||
- freshly built `frontend` image
|
||||
- freshly built `e2e` image
|
||||
|
||||
Prerequisites:
|
||||
|
||||
- Docker with Compose
|
||||
|
||||
Run:
|
||||
|
||||
```shell
|
||||
docker compose -f docker-compose.e2e.yaml up --build --abort-on-container-exit --exit-code-from e2e e2e
|
||||
```
|
||||
|
||||
Run with a different backend tag:
|
||||
|
||||
```shell
|
||||
BACKEND_IMAGE=ghcr.io/morbalint/kemkas-backend:<tag> \
|
||||
docker compose -f docker-compose.e2e.yaml up --build --abort-on-container-exit --exit-code-from e2e e2e
|
||||
```
|
||||
|
||||
The `certs` service generates a local CA and nginx server certificate in a shared volume.
|
||||
The `e2e` image imports that CA (`/certs/rootCA.pem`) into both the system trust store and Chromium's NSS store before running tests.
|
||||
@@ -0,0 +1,43 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
trusted_cert_path="${E2E_TRUSTED_CERT_PATH:-}"
|
||||
if [[ -n "$trusted_cert_path" ]]; then
|
||||
if [[ ! -f "$trusted_cert_path" ]]; then
|
||||
echo "Trusted certificate not found at: $trusted_cert_path" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
install -m 0644 -D "$trusted_cert_path" /usr/local/share/ca-certificates/kemkas-proxy.crt
|
||||
update-ca-certificates
|
||||
|
||||
if command -v certutil >/dev/null 2>&1; then
|
||||
for candidate_home in /root /home/pwuser; do
|
||||
if [[ -d "$candidate_home" ]]; then
|
||||
nss_db_dir="$candidate_home/.pki/nssdb"
|
||||
mkdir -p "$nss_db_dir"
|
||||
certutil -d "sql:$nss_db_dir" -N --empty-password >/dev/null 2>&1 || true
|
||||
certutil -d "sql:$nss_db_dir" -D -n kemkas-proxy >/dev/null 2>&1 || true
|
||||
certutil -d "sql:$nss_db_dir" -A -t "C,," -n kemkas-proxy -i "$trusted_cert_path"
|
||||
fi
|
||||
done
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ -n "${E2E_WAIT_FOR_URL:-}" ]]; then
|
||||
echo "Waiting for target URL: ${E2E_WAIT_FOR_URL}"
|
||||
for attempt in {1..60}; do
|
||||
if curl --silent --show-error --fail "${E2E_WAIT_FOR_URL}" >/dev/null; then
|
||||
break
|
||||
fi
|
||||
|
||||
if [[ "$attempt" -eq 60 ]]; then
|
||||
echo "Timed out waiting for ${E2E_WAIT_FOR_URL}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
sleep 2
|
||||
done
|
||||
fi
|
||||
|
||||
exec "$@"
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"name": "kemkas-e2e",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"test": "playwright test",
|
||||
"test:headed": "playwright test --headed",
|
||||
"test:ui": "playwright test --ui",
|
||||
"install:browsers": "playwright install chromium"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "1.59.1"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { defineConfig, devices } from '@playwright/test';
|
||||
import path from 'node:path';
|
||||
|
||||
const port = Number(process.env.E2E_PORT ?? 4173);
|
||||
const baseURL = process.env.E2E_BASE_URL ?? `http://127.0.0.1:${port}`;
|
||||
const useWebServer = process.env.E2E_USE_WEBSERVER !== 'false';
|
||||
|
||||
export default defineConfig({
|
||||
testDir: path.join(__dirname, 'tests'),
|
||||
outputDir: path.join(__dirname, 'test-results'),
|
||||
fullyParallel: true,
|
||||
forbidOnly: Boolean(process.env.CI),
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
workers: process.env.CI ? 2 : undefined,
|
||||
timeout: 30_000,
|
||||
reporter: [
|
||||
['list'],
|
||||
['html', { outputFolder: path.join(__dirname, 'playwright-report'), open: 'never' }],
|
||||
],
|
||||
use: {
|
||||
baseURL,
|
||||
trace: 'on-first-retry',
|
||||
screenshot: 'only-on-failure',
|
||||
video: 'retain-on-failure',
|
||||
},
|
||||
...(useWebServer
|
||||
? {
|
||||
webServer: {
|
||||
command: `node ./node_modules/vite/bin/vite.js --host 127.0.0.1 --port ${port}`,
|
||||
cwd: path.resolve(__dirname, '../frontend'),
|
||||
port,
|
||||
timeout: 120_000,
|
||||
reuseExistingServer: !process.env.CI,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
projects: [
|
||||
{
|
||||
name: 'chromium',
|
||||
use: { ...devices['Desktop Chrome'] },
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
async function mockGuestSession(page: import('@playwright/test').Page): Promise<void> {
|
||||
await page.route('**/api/User/me', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'text/plain; charset=utf-8',
|
||||
body: '',
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
test('guest users are redirected to 2e character creation', async ({ page }) => {
|
||||
await mockGuestSession(page);
|
||||
|
||||
await page.goto('/');
|
||||
|
||||
await expect(page).toHaveURL(/\/2e\/karakter$/);
|
||||
await expect(page.getByRole('heading', { name: 'Karakter létrehozása' })).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: /Mentés/ })).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: /PDF/ })).toBeVisible();
|
||||
});
|
||||
|
||||
test('guest navigation shows Register and Login links', async ({ page }) => {
|
||||
await mockGuestSession(page);
|
||||
|
||||
await page.goto('/2e/karakter');
|
||||
|
||||
const registerLink = page.getByRole('link', { name: 'Register' });
|
||||
const loginLink = page.getByRole('link', { name: 'Login' });
|
||||
|
||||
await expect(registerLink).toBeVisible();
|
||||
await expect(registerLink).toHaveAttribute('href', '/Identity/Account/Register');
|
||||
await expect(loginLink).toBeVisible();
|
||||
await expect(loginLink).toHaveAttribute('href', '/Identity/Account/Login');
|
||||
});
|
||||
|
||||
test('public legal pages are served', async ({ page }) => {
|
||||
await page.goto('/privacy.html');
|
||||
await expect(page.getByRole('heading', { name: 'Privacy Policy', level: 1 })).toBeVisible();
|
||||
|
||||
await page.goto('/ogl.html');
|
||||
await expect(page).toHaveTitle(/Open Game License v1\.0a/);
|
||||
await expect(page.locator('body')).toContainText('OPEN GAME LICENSE Version 1.0a');
|
||||
|
||||
await page.goto('/aelf.html');
|
||||
await expect(page).toHaveTitle(/AELF Open License version 1\.0a/);
|
||||
await expect(page.locator('body')).toContainText('AELF OPEN LICENSE VERSION 1.0a');
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
|
||||
# yarn lockfile v1
|
||||
|
||||
|
||||
"@playwright/test@1.59.1":
|
||||
version "1.59.1"
|
||||
resolved "https://registry.yarnpkg.com/@playwright/test/-/test-1.59.1.tgz#5c4d38eac84a61527af466602ae20277685a02d6"
|
||||
integrity sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg==
|
||||
dependencies:
|
||||
playwright "1.59.1"
|
||||
|
||||
fsevents@2.3.2:
|
||||
version "2.3.2"
|
||||
resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.2.tgz#8a526f78b8fdf4623b709e0b975c52c24c02fd1a"
|
||||
integrity sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==
|
||||
|
||||
playwright-core@1.59.1:
|
||||
version "1.59.1"
|
||||
resolved "https://registry.yarnpkg.com/playwright-core/-/playwright-core-1.59.1.tgz#d8a2b28bcb8f2bd08ef3df93b02ae83c813244b2"
|
||||
integrity sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==
|
||||
|
||||
playwright@1.59.1:
|
||||
version "1.59.1"
|
||||
resolved "https://registry.yarnpkg.com/playwright/-/playwright-1.59.1.tgz#f7b0ca61637ae25264cec370df671bbe1f368a4a"
|
||||
integrity sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==
|
||||
dependencies:
|
||||
playwright-core "1.59.1"
|
||||
optionalDependencies:
|
||||
fsevents "2.3.2"
|
||||
@@ -12,5 +12,7 @@ RUN yarn build
|
||||
|
||||
FROM nginx:1.29.7-alpine3.23 AS proxy
|
||||
|
||||
LABEL org.opencontainers.image.source = "https://github.com/morbalint/kemkas"
|
||||
|
||||
COPY --from=builder "/src/dist" "/usr/share/nginx/html"
|
||||
COPY ./nginx.conf /etc/nginx/nginx.conf
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import tsParser from '@typescript-eslint/parser';
|
||||
|
||||
export default [
|
||||
{
|
||||
ignores: ['dist/**', 'build/**', 'node_modules/**'],
|
||||
},
|
||||
{
|
||||
files: ['src/**/*.{ts,tsx}', 'vite.config.mts', 'vitest.setup.ts'],
|
||||
languageOptions: {
|
||||
parser: tsParser,
|
||||
ecmaVersion: 'latest',
|
||||
sourceType: 'module',
|
||||
parserOptions: {
|
||||
ecmaFeatures: {
|
||||
jsx: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
'no-debugger': 'error',
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -15,7 +15,7 @@
|
||||
"@types/node": "^24.0.0",
|
||||
"@types/react": "^18.3.2",
|
||||
"@types/react-dom": "^18.2.22",
|
||||
"axios": "^1.7.4",
|
||||
"axios": "^1.13.5",
|
||||
"bootstrap": "^5.3.2",
|
||||
"downloadjs": "^1.4.7",
|
||||
"merge": "^2.1.1",
|
||||
@@ -40,8 +40,10 @@
|
||||
"workbox-streams": "^7.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@typescript-eslint/parser": "^8.31.1",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"@types/downloadjs": "^1.4.4",
|
||||
"eslint": "^9.24.0",
|
||||
"jsdom": "^25.0.1",
|
||||
"less": "^4.2.0",
|
||||
"vite": "^5.4.10",
|
||||
@@ -51,9 +53,14 @@
|
||||
"dev": "vite",
|
||||
"start": "vite",
|
||||
"build": "vite build",
|
||||
"lint": "eslint \"src/**/*.{ts,tsx}\" vite.config.mts vitest.setup.ts",
|
||||
"preview": "vite preview",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
"test:watch": "vitest",
|
||||
"test:e2e": "yarn --cwd ../e2e test",
|
||||
"test:e2e:headed": "yarn --cwd ../e2e test:headed",
|
||||
"test:e2e:ui": "yarn --cwd ../e2e test:ui",
|
||||
"test:e2e:install": "yarn --cwd ../e2e install:browsers"
|
||||
},
|
||||
"browserslist": {
|
||||
"production": [
|
||||
|
||||
@@ -3,6 +3,11 @@ import { render } from '@testing-library/react';
|
||||
import { vi } from 'vitest';
|
||||
import App from './App';
|
||||
|
||||
vi.mock('./shared/api/http', () => ({
|
||||
getText: vi.fn(async () => ''),
|
||||
getJson: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('react-router-dom', async () => {
|
||||
const actual = await vi.importActual<typeof import('react-router-dom')>('react-router-dom');
|
||||
|
||||
@@ -14,5 +19,5 @@ vi.mock('react-router-dom', async () => {
|
||||
});
|
||||
|
||||
test('renders App', () => {
|
||||
render(<App faro={undefined}/>);
|
||||
render(<App />);
|
||||
});
|
||||
|
||||
+125
-92
@@ -1,115 +1,148 @@
|
||||
import * as React from "react";
|
||||
import "./App.css";
|
||||
import {Faro} from "@grafana/faro-web-sdk";
|
||||
import {createBrowserRouter, redirect, RouterProvider} from "react-router-dom";
|
||||
import CreateCharacter from "./first-edition/pages/CreateCharacter";
|
||||
import CharacterList from "./shared/pages/CharacterList";
|
||||
import {createBrowserRouter, LoaderFunctionArgs, Navigate, Outlet, redirect, RouterProvider} from "react-router-dom";
|
||||
import CharacterList, {CharacterListItemDto} from "./shared/pages/CharacterList";
|
||||
import ErrorBoundary from "./shared/ErrorBoundary";
|
||||
import Header from "./shared/Header";
|
||||
import Footer from "./shared/Footer";
|
||||
import CreateCharacter2E from "./second-edition/pages/CreateCharacter2E";
|
||||
import store, {AppDispatch, RootState} from './store'
|
||||
import { Provider } from "react-redux"
|
||||
import { useSelector, useDispatch } from 'react-redux'
|
||||
import {load, setUser, unsetUser, userSelector} from './shared/domain-models/userSlice'
|
||||
import {fetchCurrentUser, userSelector} from './shared/domain-models/userSlice'
|
||||
import {setCharacter} from "./second-edition/domain-models/characterSlice";
|
||||
import {getJson} from "./shared/api/http";
|
||||
import {KarakterInputs} from "./first-edition/domain-models/karakter";
|
||||
import {Karakter2E} from "./second-edition/domain-models/karakter2E";
|
||||
const CreateCharacter = React.lazy(() => import("./first-edition/pages/CreateCharacter"))
|
||||
const CreateCharacter2E = React.lazy(() => import("./second-edition/pages/CreateCharacter2E"))
|
||||
|
||||
function Router(props: {faro?: Faro}) {
|
||||
const dispatch = useDispatch.withTypes<AppDispatch>()()
|
||||
const fetchedUser = useSelector.withTypes<RootState>()(userSelector)
|
||||
|
||||
if (fetchedUser.state === "not-started") {
|
||||
dispatch(load())
|
||||
fetch(`${window.location.origin}/api/User/me`)
|
||||
.then(response => {
|
||||
if (response.ok) {
|
||||
return response.text();
|
||||
}
|
||||
console.log(response)
|
||||
})
|
||||
.then(userNameResponse => {
|
||||
if (userNameResponse && userNameResponse.length > 0) {
|
||||
dispatch(setUser(userNameResponse))
|
||||
} else {
|
||||
dispatch(unsetUser())
|
||||
}
|
||||
|
||||
})
|
||||
.catch(() => {
|
||||
dispatch(unsetUser())
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
|
||||
const router = createBrowserRouter([
|
||||
{
|
||||
path: "/",
|
||||
loader: () => fetchedUser.email == null ? redirect("/2e/karakter") : redirect("/karaktereim")
|
||||
},
|
||||
{
|
||||
path: "/karaktereim",
|
||||
element: <CharacterList />,
|
||||
loader: () => fetch(`${window.location.origin}/api/Character`),
|
||||
ErrorBoundary: ErrorBoundary,
|
||||
},
|
||||
{
|
||||
path: "/1e",
|
||||
loader: () => redirect("/1e/karakter")
|
||||
},
|
||||
{
|
||||
path: "/1e/karakter",
|
||||
element: <CreateCharacter faro={props.faro} />,
|
||||
ErrorBoundary: ErrorBoundary,
|
||||
},
|
||||
{
|
||||
path: "/1e/karakter/:id",
|
||||
element: <CreateCharacter faro={props.faro}/>,
|
||||
loader: args => fetch(`${window.location.origin}/api/Character1E/${args.params.id}`),
|
||||
ErrorBoundary: ErrorBoundary,
|
||||
},
|
||||
{
|
||||
path: "/2e",
|
||||
loader: () => redirect("/2e/karakter")
|
||||
},
|
||||
{
|
||||
path: "/2e/karakter",
|
||||
element: <CreateCharacter2E faro={props.faro}/>,
|
||||
ErrorBoundary: ErrorBoundary,
|
||||
},
|
||||
{
|
||||
path: "/2e/karakter/:id",
|
||||
element: <CreateCharacter2E faro={props.faro}/>,
|
||||
loader: async args => {
|
||||
const response = await fetch(`${window.location.origin}/api/Character2E/${args.params.id}`)
|
||||
if (response.ok) {
|
||||
const loaded2Echaracter = await response.json()
|
||||
dispatch(setCharacter(loaded2Echaracter))
|
||||
return loaded2Echaracter;
|
||||
}
|
||||
return {
|
||||
isPublic: false,
|
||||
error: response.statusText,
|
||||
};
|
||||
},
|
||||
ErrorBoundary: ErrorBoundary,
|
||||
},
|
||||
]);
|
||||
const FaroContext = React.createContext<Faro | undefined>(undefined)
|
||||
|
||||
function RootLayout() {
|
||||
return (
|
||||
<div className='container'>
|
||||
<RouterProvider router={router}/>
|
||||
</div>
|
||||
<>
|
||||
<Header />
|
||||
<div className='container'>
|
||||
<Outlet />
|
||||
</div>
|
||||
<Footer />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function IndexRedirect() {
|
||||
const fetchedUser = useSelector.withTypes<RootState>()(userSelector)
|
||||
|
||||
if (fetchedUser.state !== "finished") {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <Navigate replace to={fetchedUser.email == null ? "/2e/karakter" : "/karaktereim"} />
|
||||
}
|
||||
|
||||
function CreateCharacter1ERoute() {
|
||||
const faro = React.useContext(FaroContext)
|
||||
return (
|
||||
<React.Suspense fallback={<div className="p-3">Betoltes...</div>}>
|
||||
{faro ? <CreateCharacter faro={faro} /> : <CreateCharacter />}
|
||||
</React.Suspense>
|
||||
)
|
||||
}
|
||||
|
||||
function CreateCharacter2ERoute() {
|
||||
const faro = React.useContext(FaroContext)
|
||||
return (
|
||||
<React.Suspense fallback={<div className="p-3">Betoltes...</div>}>
|
||||
{faro ? <CreateCharacter2E faro={faro} /> : <CreateCharacter2E />}
|
||||
</React.Suspense>
|
||||
)
|
||||
}
|
||||
|
||||
function UserBootstrap() {
|
||||
const dispatch = useDispatch.withTypes<AppDispatch>()()
|
||||
|
||||
React.useEffect(() => {
|
||||
void dispatch(fetchCurrentUser())
|
||||
}, [dispatch])
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
const loadCharacterList = () => getJson<CharacterListItemDto[]>("/api/Character")
|
||||
|
||||
const loadCharacter1E = ({params}: LoaderFunctionArgs) => getJson<KarakterInputs & { isPublic: boolean }>(`/api/Character1E/${params.id}`)
|
||||
|
||||
const loadCharacter2E = async ({params}: LoaderFunctionArgs) => {
|
||||
try {
|
||||
const loaded2Echaracter = await getJson<Karakter2E & { isPublic: boolean }>(`/api/Character2E/${params.id}`)
|
||||
store.dispatch(setCharacter(loaded2Echaracter))
|
||||
return loaded2Echaracter;
|
||||
} catch (error) {
|
||||
return {
|
||||
isPublic: false,
|
||||
error: error instanceof Error ? error.message : "Failed to load character",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const router = createBrowserRouter([
|
||||
{
|
||||
path: "/",
|
||||
element: <RootLayout />,
|
||||
children: [
|
||||
{
|
||||
index: true,
|
||||
element: <IndexRedirect />,
|
||||
},
|
||||
{
|
||||
path: "karaktereim",
|
||||
element: <CharacterList />,
|
||||
loader: loadCharacterList,
|
||||
ErrorBoundary: ErrorBoundary,
|
||||
},
|
||||
{
|
||||
path: "1e",
|
||||
loader: () => redirect("/1e/karakter")
|
||||
},
|
||||
{
|
||||
path: "1e/karakter",
|
||||
element: <CreateCharacter1ERoute />,
|
||||
ErrorBoundary: ErrorBoundary,
|
||||
},
|
||||
{
|
||||
path: "1e/karakter/:id",
|
||||
element: <CreateCharacter1ERoute />,
|
||||
loader: loadCharacter1E,
|
||||
ErrorBoundary: ErrorBoundary,
|
||||
},
|
||||
{
|
||||
path: "2e",
|
||||
loader: () => redirect("/2e/karakter")
|
||||
},
|
||||
{
|
||||
path: "2e/karakter",
|
||||
element: <CreateCharacter2ERoute />,
|
||||
ErrorBoundary: ErrorBoundary,
|
||||
},
|
||||
{
|
||||
path: "2e/karakter/:id",
|
||||
element: <CreateCharacter2ERoute />,
|
||||
loader: loadCharacter2E,
|
||||
ErrorBoundary: ErrorBoundary,
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
function App(props: { faro?: Faro }) {
|
||||
|
||||
return (
|
||||
<Provider store={store}>
|
||||
<Header/>
|
||||
<Router faro={props.faro} />
|
||||
<Footer />
|
||||
<FaroContext.Provider value={props.faro}>
|
||||
<UserBootstrap />
|
||||
<RouterProvider router={router}/>
|
||||
</FaroContext.Provider>
|
||||
</Provider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,32 +1,18 @@
|
||||
import {KarakterInputs} from "../domain-models/karakter";
|
||||
import axios from "axios";
|
||||
import {postJson} from "../../shared/api/http";
|
||||
|
||||
export async function StoreNewCharacter(karakter: KarakterInputs, isPublic: boolean = false) {
|
||||
let response = await axios.post(`${window.location.origin}/api/Character1E/`, karakter, {
|
||||
withCredentials: true,
|
||||
return postJson<KarakterInputs, string>("/api/Character1E/", karakter, {
|
||||
params: {
|
||||
isPublic,
|
||||
}
|
||||
})
|
||||
if (response.status < 300){
|
||||
return response.data as string
|
||||
}
|
||||
else {
|
||||
throw Error(response.statusText)
|
||||
}
|
||||
}
|
||||
|
||||
export async function UpdateCharacter(id: string, karakter: KarakterInputs, isPublic: boolean = false) {
|
||||
let response = await axios.post(`${window.location.origin}/api/Character1E/${id}`, karakter, {
|
||||
withCredentials: true,
|
||||
return postJson<KarakterInputs, string>(`/api/Character1E/${id}`, karakter, {
|
||||
params: {
|
||||
isPublic,
|
||||
}
|
||||
})
|
||||
if (response.status < 300){
|
||||
return response.data as string
|
||||
}
|
||||
else {
|
||||
throw Error(response.statusText)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, {useState} from 'react';
|
||||
import React, {useCallback, useEffect, useMemo, useState} from 'react';
|
||||
import './CreateCharacter.less'
|
||||
import {Faj, TulajdonsagokFajjal} from "../domain-models/faj";
|
||||
import {Osztaly, SetFelszerelesForChangedOsztaly} from "../domain-models/osztaly"
|
||||
@@ -56,15 +56,43 @@ function CreateCharacterPage(props: {
|
||||
|
||||
let [karakter, changeKarakter] = useState(initialKarakterInputs)
|
||||
|
||||
const tulajdonsagokFajjal = TulajdonsagokFajjal(karakter.tulajdonsagok, karakter.faj)
|
||||
const tulajdonsagokFajjal = useMemo(() => TulajdonsagokFajjal(karakter.tulajdonsagok, karakter.faj), [karakter.tulajdonsagok, karakter.faj])
|
||||
|
||||
const changeKepzettseg = (k: KepzettsegId[]) => changeKarakter({...karakter, kepzettsegek: k})
|
||||
const changeTolvajKepzettseg = (tk?: KepzettsegId[]) => changeKarakter({...karakter, tolvajKepzettsegek: tk})
|
||||
SetDefaultTolvajKepzettsegek(karakter, changeTolvajKepzettseg)
|
||||
SetDefaultKepzettsegek({...karakter, tulajdonsagok: tulajdonsagokFajjal}, changeKepzettseg)
|
||||
const availableKepzettsegList = AvailableKezpettsegList(karakter.osztaly)
|
||||
const setFelszereles = (f: KarakterFelszereles) => changeKarakter({...karakter, felszereles: f})
|
||||
SetFelszerelesForChangedOsztaly(karakter.osztaly, karakter.felszereles, setFelszereles)
|
||||
const changeKepzettseg = useCallback((k: KepzettsegId[]) => {
|
||||
changeKarakter(prev => ({...prev, kepzettsegek: k}))
|
||||
}, [])
|
||||
const changeTolvajKepzettseg = useCallback((tk?: KepzettsegId[]) => {
|
||||
changeKarakter(prev => ({...prev, tolvajKepzettsegek: tk}))
|
||||
}, [])
|
||||
const availableKepzettsegList = useMemo(() => AvailableKezpettsegList(karakter.osztaly), [karakter.osztaly])
|
||||
const setFelszereles = useCallback((f: KarakterFelszereles) => {
|
||||
changeKarakter(prev => ({...prev, felszereles: f}))
|
||||
}, [])
|
||||
|
||||
const tolvajDefaultsInput = useMemo(() => ({
|
||||
osztaly: karakter.osztaly,
|
||||
tolvajKepzettsegek: karakter.tolvajKepzettsegek,
|
||||
}), [karakter.osztaly, karakter.tolvajKepzettsegek])
|
||||
|
||||
const kepzettsegDefaultsInput = useMemo(() => ({
|
||||
osztaly: karakter.osztaly,
|
||||
faj: karakter.faj,
|
||||
tulajdonsagok: tulajdonsagokFajjal,
|
||||
kepzettsegek: karakter.kepzettsegek,
|
||||
tolvajKepzettsegek: karakter.tolvajKepzettsegek,
|
||||
}), [karakter.osztaly, karakter.faj, tulajdonsagokFajjal, karakter.kepzettsegek, karakter.tolvajKepzettsegek])
|
||||
|
||||
useEffect(() => {
|
||||
SetDefaultTolvajKepzettsegek(tolvajDefaultsInput, changeTolvajKepzettseg)
|
||||
}, [tolvajDefaultsInput, changeTolvajKepzettseg])
|
||||
|
||||
useEffect(() => {
|
||||
SetDefaultKepzettsegek(kepzettsegDefaultsInput, changeKepzettseg)
|
||||
}, [kepzettsegDefaultsInput, changeKepzettseg])
|
||||
|
||||
useEffect(() => {
|
||||
SetFelszerelesForChangedOsztaly(karakter.osztaly, karakter.felszereles, setFelszereles)
|
||||
}, [karakter.osztaly, karakter.felszereles, setFelszereles])
|
||||
|
||||
const levelUp = () => LevelUp(karakter, changeKarakter)
|
||||
const levelDown = () => LevelDown(karakter, changeKarakter)
|
||||
|
||||
@@ -1,32 +1,18 @@
|
||||
import axios from "axios";
|
||||
import {postJson} from "../../shared/api/http";
|
||||
import {Karakter2E} from "../domain-models/karakter2E";
|
||||
|
||||
export async function StoreNewCharacter2E(karakter: Karakter2E, isPublic: boolean = false) {
|
||||
let response = await axios.post(`${window.location.origin}/api/Character2E/`, karakter, {
|
||||
withCredentials: true,
|
||||
return postJson<Karakter2E, string>("/api/Character2E/", karakter, {
|
||||
params: {
|
||||
isPublic,
|
||||
}
|
||||
})
|
||||
if (response.status < 300){
|
||||
return response.data as string
|
||||
}
|
||||
else {
|
||||
throw Error(response.statusText)
|
||||
}
|
||||
}
|
||||
|
||||
export async function UpdateCharacter2E(id: string, karakter: Karakter2E, isPublic: boolean = false) {
|
||||
let response = await axios.post(`${window.location.origin}/api/Character2E/${id}`, karakter, {
|
||||
withCredentials: true,
|
||||
return postJson<Karakter2E, string>(`/api/Character2E/${id}`, karakter, {
|
||||
params: {
|
||||
isPublic,
|
||||
}
|
||||
})
|
||||
if (response.status < 300){
|
||||
return response.data as string
|
||||
}
|
||||
else {
|
||||
throw Error(response.statusText)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,24 +1,28 @@
|
||||
import * as React from "react";
|
||||
import axios from "axios";
|
||||
import {Container, Nav, Navbar} from "react-bootstrap";
|
||||
import {userSelector} from "./domain-models/userSlice";
|
||||
import {RootState} from "../store";
|
||||
import {useSelector} from "react-redux";
|
||||
|
||||
async function logout() {
|
||||
await axios.post("/Identity/Account/Logout")
|
||||
document.location.reload()
|
||||
}
|
||||
import {unsetUser, userSelector} from "./domain-models/userSlice";
|
||||
import {AppDispatch, RootState} from "../store";
|
||||
import {useDispatch, useSelector} from "react-redux";
|
||||
import {Link, useNavigate} from "react-router-dom";
|
||||
import {postVoid} from "./api/http";
|
||||
|
||||
function Header() {
|
||||
|
||||
const dispatch = useDispatch.withTypes<AppDispatch>()();
|
||||
const navigate = useNavigate();
|
||||
const fetchedUser = useSelector.withTypes<RootState>()(userSelector);
|
||||
|
||||
const logout = async () => {
|
||||
await postVoid("/Identity/Account/Logout")
|
||||
dispatch(unsetUser())
|
||||
navigate("/")
|
||||
}
|
||||
|
||||
return <header>
|
||||
<Navbar expand="sm" className="navbar-light bg-white border-bottom box-shadow mb-3">
|
||||
<Container className="ps-4 pe-4">
|
||||
<Navbar.Brand href="/">Kemkas</Navbar.Brand>
|
||||
{fetchedUser.email != null && <Nav.Link className="text-dark" href="/karaktereim">Karaktereim</Nav.Link>}
|
||||
<Navbar.Brand as={Link} to="/">Kemkas</Navbar.Brand>
|
||||
{fetchedUser.email != null && <Nav.Link as={Link} className="text-dark" to="/karaktereim">Karaktereim</Nav.Link>}
|
||||
<Navbar.Toggle aria-controls="basic-navbar-nav" />
|
||||
<Navbar.Collapse id="basic-navbar-nav" className="d-sm-inline-flex flex-sm-row-reverse">
|
||||
<Nav>
|
||||
@@ -38,7 +42,7 @@ function Header() {
|
||||
href="/Identity/Account/Manage">Hello {fetchedUser.email}!</a>
|
||||
</li>
|
||||
<li className="nav-item">
|
||||
<button className="nav-link nav text-dark" onClick={logout}>Logout</button>
|
||||
<button type="button" className="nav-link nav text-dark" onClick={logout}>Logout</button>
|
||||
</li>
|
||||
</>
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import axios, {AxiosRequestConfig} from "axios";
|
||||
|
||||
export const http = axios.create({
|
||||
withCredentials: true,
|
||||
});
|
||||
|
||||
export async function getJson<T>(url: string, config?: AxiosRequestConfig): Promise<T> {
|
||||
const response = await http.get<T>(url, config)
|
||||
return response.data
|
||||
}
|
||||
|
||||
export async function getText(url: string, config?: AxiosRequestConfig): Promise<string> {
|
||||
const response = await http.get<string>(url, {
|
||||
...config,
|
||||
responseType: "text",
|
||||
})
|
||||
return response.data
|
||||
}
|
||||
|
||||
export async function postJson<TRequest, TResponse>(url: string, body: TRequest, config?: AxiosRequestConfig): Promise<TResponse> {
|
||||
const response = await http.post<TResponse>(url, body, config)
|
||||
return response.data
|
||||
}
|
||||
|
||||
export async function postVoid<TRequest = undefined>(url: string, body?: TRequest, config?: AxiosRequestConfig): Promise<void> {
|
||||
await http.post(url, body, config)
|
||||
}
|
||||
@@ -1,18 +1,35 @@
|
||||
import { createSlice } from '@reduxjs/toolkit'
|
||||
import {createAsyncThunk, createSlice} from '@reduxjs/toolkit'
|
||||
import {getText} from '../api/http'
|
||||
|
||||
type LoadingState = "not-started" | "loading" | "finished"
|
||||
|
||||
export interface UserState {
|
||||
state: LoadingState,
|
||||
email: string | null,
|
||||
}
|
||||
|
||||
export const fetchCurrentUser = createAsyncThunk<string | null, void, { state: { user: UserState } }>(
|
||||
'user/fetchCurrentUser',
|
||||
async () => {
|
||||
try {
|
||||
const userNameResponse = await getText('/api/User/me')
|
||||
return userNameResponse.length > 0 ? userNameResponse : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
},
|
||||
{
|
||||
condition: (_, {getState}) => getState().user.state === 'not-started',
|
||||
}
|
||||
)
|
||||
|
||||
export const userSlice = createSlice({
|
||||
name: 'user',
|
||||
initialState: {
|
||||
state: "not-started" as LoadingState,
|
||||
email: null as string | null,
|
||||
},
|
||||
} as UserState,
|
||||
reducers: {
|
||||
load: (state) => {
|
||||
state.state = "loading"
|
||||
return state
|
||||
},
|
||||
setUser: (state, action: {payload: string}) => {
|
||||
state.state = "finished"
|
||||
state.email = action.payload
|
||||
@@ -24,10 +41,24 @@ export const userSlice = createSlice({
|
||||
return state
|
||||
}
|
||||
},
|
||||
extraReducers: builder => {
|
||||
builder
|
||||
.addCase(fetchCurrentUser.pending, (state) => {
|
||||
state.state = 'loading'
|
||||
})
|
||||
.addCase(fetchCurrentUser.fulfilled, (state, action) => {
|
||||
state.state = 'finished'
|
||||
state.email = action.payload
|
||||
})
|
||||
.addCase(fetchCurrentUser.rejected, (state) => {
|
||||
state.state = 'finished'
|
||||
state.email = null
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
// Action creators are generated for each case reducer function
|
||||
export const { load, setUser, unsetUser } = userSlice.actions
|
||||
export const { setUser, unsetUser } = userSlice.actions
|
||||
|
||||
export const userSelector = userSlice.selectSlice
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import * as React from "react";
|
||||
import {useLoaderData} from "react-router-dom";
|
||||
import {Link, useLoaderData} from "react-router-dom";
|
||||
import CharacterListItem from "../components/CharacterListItem";
|
||||
import {Faj, FajLabel as FajLabel1E} from "../../first-edition/domain-models/faj";
|
||||
import {Faj2E, FajLabel as FajLabel2E} from "../../second-edition/domain-models/faj2E";
|
||||
@@ -33,7 +33,7 @@ interface CharacterListItemDto2E {
|
||||
edition: "2e"
|
||||
}
|
||||
|
||||
type CharacterListItemDto = CharacterListItemDto1E | CharacterListItemDto2E;
|
||||
export type CharacterListItemDto = CharacterListItemDto1E | CharacterListItemDto2E;
|
||||
|
||||
function mapDtoToViewModel(dto: CharacterListItemDto): CharacterListItemViewModel {
|
||||
if (dto.edition === "1e") {
|
||||
@@ -68,12 +68,12 @@ function CharacterList(props: {}) {
|
||||
<div className="row">
|
||||
<div className="col-6">
|
||||
<div className="d-grid gap-1 m-3 m-lg-5">
|
||||
<a className="btn btn-dark btn-lg" href="/2e/karakter/" type="button">Új 2e karakter létrehozása</a>
|
||||
<Link className="btn btn-dark btn-lg" to="/2e/karakter">Új 2e karakter létrehozása</Link>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-6">
|
||||
<div className="d-grid gap-1 m-3 m-lg-5">
|
||||
<a className="btn btn-dark btn-lg" href="/1e/karakter/" type="button">Új 1e karakter létrehozása</a>
|
||||
<Link className="btn btn-dark btn-lg" to="/1e/karakter">Új 1e karakter létrehozása</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -18,10 +18,12 @@
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx"
|
||||
"jsx": "react-jsx",
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"exactOptionalPropertyTypes": true
|
||||
},
|
||||
"include": [
|
||||
"src",
|
||||
"vite.config.ts"
|
||||
"vite.config.mts"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -4,13 +4,13 @@ import react from '@vitejs/plugin-react';
|
||||
export default defineConfig(({ mode }) => {
|
||||
const env = loadEnv(mode, process.cwd(), '');
|
||||
const port = Number(env.PORT || 5173);
|
||||
const https = env.HTTPS === 'true';
|
||||
const httpsEnabled = env.HTTPS === 'true';
|
||||
|
||||
return {
|
||||
plugins: [react()],
|
||||
server: {
|
||||
port,
|
||||
https,
|
||||
...(httpsEnabled ? { https: {} } : {}),
|
||||
strictPort: true,
|
||||
allowedHosts: ['localhost', 'host.docker.internal', 'frontend'],
|
||||
},
|
||||
+742
-31
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,96 @@
|
||||
user nginx;
|
||||
worker_processes auto;
|
||||
|
||||
error_log /var/log/nginx/error.log notice;
|
||||
pid /var/run/nginx.pid;
|
||||
|
||||
|
||||
events {
|
||||
worker_connections 1024;
|
||||
}
|
||||
|
||||
|
||||
http {
|
||||
include /etc/nginx/mime.types;
|
||||
default_type application/octet-stream;
|
||||
|
||||
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
|
||||
'$status $body_bytes_sent "$http_referer" '
|
||||
'"$http_user_agent" "$http_x_forwarded_for"';
|
||||
|
||||
access_log /var/log/nginx/access.log main;
|
||||
|
||||
sendfile on;
|
||||
keepalive_timeout 65;
|
||||
|
||||
upstream frontend {
|
||||
server frontend:80;
|
||||
}
|
||||
|
||||
upstream backend {
|
||||
server backend:8080;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 8000 ssl;
|
||||
server_name localhost;
|
||||
|
||||
ssl_certificate /etc/nginx/certs/kemkas.pem;
|
||||
ssl_certificate_key /etc/nginx/certs/kemkas.key;
|
||||
ssl_protocols TLSv1 TLSv1.1 TLSv1.2 TLSv1.3;
|
||||
ssl_ciphers HIGH:!aNULL:!MD5;
|
||||
|
||||
location / {
|
||||
proxy_pass http://frontend;
|
||||
}
|
||||
|
||||
error_page 500 502 503 504 /50x.html;
|
||||
location = /50x.html {
|
||||
root /usr/share/nginx/html;
|
||||
}
|
||||
|
||||
location /Identity/ {
|
||||
proxy_pass_request_headers on;
|
||||
proxy_set_header X-Forwarded-For "$proxy_add_x_forwarded_for";
|
||||
proxy_set_header X-Forwarded-Proto "$scheme";
|
||||
proxy_set_header X-Forwarded-Host "$http_host";
|
||||
proxy_pass http://backend;
|
||||
}
|
||||
|
||||
location /api/ {
|
||||
proxy_pass_request_headers on;
|
||||
proxy_set_header X-Forwarded-For "$proxy_add_x_forwarded_for";
|
||||
proxy_set_header X-Forwarded-Proto "$scheme";
|
||||
proxy_set_header X-Forwarded-Host "$http_host";
|
||||
proxy_pass http://backend;
|
||||
}
|
||||
|
||||
location /signin- {
|
||||
proxy_pass_request_headers on;
|
||||
proxy_set_header X-Forwarded-For "$proxy_add_x_forwarded_for";
|
||||
proxy_set_header X-Forwarded-Proto "$scheme";
|
||||
proxy_set_header X-Forwarded-Host "$http_host";
|
||||
proxy_pass http://backend;
|
||||
}
|
||||
|
||||
location /lib/ {
|
||||
proxy_pass_request_headers on;
|
||||
proxy_pass http://backend;
|
||||
}
|
||||
|
||||
location /js/ {
|
||||
proxy_pass_request_headers on;
|
||||
proxy_pass http://backend;
|
||||
}
|
||||
|
||||
location /css/ {
|
||||
proxy_pass_request_headers on;
|
||||
proxy_pass http://backend;
|
||||
}
|
||||
|
||||
location /img/ {
|
||||
proxy_pass_request_headers on;
|
||||
proxy_pass http://backend;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
CERT_DIR="${CERT_DIR:-/certs}"
|
||||
|
||||
mkdir -p "$CERT_DIR"
|
||||
|
||||
if [ -f "$CERT_DIR/rootCA.pem" ] && [ -f "$CERT_DIR/rootCA.key" ] && [ -f "$CERT_DIR/kemkas.pem" ] && [ -f "$CERT_DIR/kemkas.key" ]; then
|
||||
echo "Using existing TLS material from $CERT_DIR"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
apk add --no-cache openssl >/dev/null
|
||||
|
||||
cat > /tmp/kemkas-server.cnf <<'EOF'
|
||||
[req]
|
||||
default_bits = 2048
|
||||
prompt = no
|
||||
default_md = sha256
|
||||
distinguished_name = dn
|
||||
req_extensions = req_ext
|
||||
|
||||
[dn]
|
||||
CN = localhost
|
||||
|
||||
[req_ext]
|
||||
subjectAltName = @alt_names
|
||||
keyUsage = digitalSignature, keyEncipherment
|
||||
extendedKeyUsage = serverAuth
|
||||
|
||||
[alt_names]
|
||||
DNS.1 = localhost
|
||||
DNS.2 = proxy
|
||||
DNS.3 = host.docker.internal
|
||||
IP.1 = 127.0.0.1
|
||||
IP.2 = ::1
|
||||
EOF
|
||||
|
||||
cat > /tmp/kemkas-v3.ext <<'EOF'
|
||||
authorityKeyIdentifier=keyid,issuer
|
||||
basicConstraints=CA:FALSE
|
||||
keyUsage=digitalSignature,keyEncipherment
|
||||
extendedKeyUsage=serverAuth
|
||||
subjectAltName=@alt_names
|
||||
|
||||
[alt_names]
|
||||
DNS.1=localhost
|
||||
DNS.2=proxy
|
||||
DNS.3=host.docker.internal
|
||||
IP.1=127.0.0.1
|
||||
IP.2=::1
|
||||
EOF
|
||||
|
||||
openssl genrsa -out "$CERT_DIR/rootCA.key" 2048
|
||||
openssl req -x509 -new -nodes -key "$CERT_DIR/rootCA.key" -sha256 -days 3650 -subj "/CN=kemkas-local-root" -out "$CERT_DIR/rootCA.pem"
|
||||
|
||||
openssl genrsa -out "$CERT_DIR/kemkas.key" 2048
|
||||
openssl req -new -key "$CERT_DIR/kemkas.key" -out /tmp/kemkas.csr -config /tmp/kemkas-server.cnf
|
||||
openssl x509 -req -in /tmp/kemkas.csr -CA "$CERT_DIR/rootCA.pem" -CAkey "$CERT_DIR/rootCA.key" -CAcreateserial -out "$CERT_DIR/kemkas.pem" -days 825 -sha256 -extfile /tmp/kemkas-v3.ext
|
||||
|
||||
echo "Generated TLS CA and nginx certificate under $CERT_DIR"
|
||||
Reference in New Issue
Block a user