Compare commits
159 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
44560ac82d | ||
|
|
74c466b56f | ||
|
|
74dfb95d99 | ||
|
|
441b00b510 | ||
|
|
89ac32cd07 | ||
|
|
1dcd23a776 | ||
|
|
f34d523413 | ||
|
|
c14f62849f | ||
|
|
66e6bc93f9 | ||
|
|
7b22f2d237 | ||
|
|
426b4c55fc | ||
|
|
4c85d18a79 | ||
|
|
a19fd6ea72 | ||
|
|
2653b6f6c1 | ||
|
|
a6c31bb93b | ||
|
|
9683b069af | ||
|
|
b7cf4ab106 | ||
|
|
1cb5c3d03a | ||
|
|
e1bb591a38 | ||
|
|
b2a57675a7 | ||
|
|
b7cb864982 | ||
|
|
d5619e42da | ||
|
|
74f15cabd9 | ||
|
|
204006a4cb | ||
|
|
ed5cba4677 | ||
|
|
52696196b2 | ||
|
|
f939866d36 | ||
|
|
331c3d50f2 | ||
|
|
1de0aabf6c | ||
|
|
e57fb48c88 | ||
|
|
c27568435e | ||
|
|
9d644a9b79 | ||
|
|
498d2c7ade | ||
|
|
213fd63860 | ||
|
|
61d52a5bd1 | ||
|
|
bae87359b0 | ||
|
|
8cc8be7c2f | ||
|
|
2602c9a3b9 | ||
|
|
aae3eb92fa | ||
|
|
d3d9a9037e | ||
|
|
ccfcc57f6b | ||
|
|
4e9cac7b44 | ||
|
|
147700ab82 | ||
|
|
dea59357c3 | ||
|
|
f31ead1b6d | ||
|
|
ba45c54e1d | ||
|
|
56a5851783 | ||
|
|
aba8943d17 | ||
|
|
9deee01ba3 | ||
|
|
f21ba09a6f | ||
|
|
0e5f91d02e | ||
|
|
6ee421e109 | ||
|
|
8e5561b80d | ||
|
|
7a29d18494 | ||
|
|
dcc17e7e25 | ||
|
|
4c110f7f6e | ||
|
|
08d4432351 | ||
|
|
4dda50cbf4 | ||
|
|
9451589a9d | ||
|
|
5c3ea53231 | ||
|
|
7919a93ab4 | ||
|
|
02116aa3df | ||
|
|
83ca026a55 | ||
|
|
58d981b629 | ||
|
|
3fd3a77057 | ||
|
|
9cd2f4ef9c | ||
|
|
4d26344665 | ||
|
|
f8c555bc84 | ||
|
|
09b9969292 | ||
|
|
3638606e45 | ||
|
|
fa229e1e0c | ||
|
|
fc164063c8 | ||
|
|
2df87904c1 | ||
|
|
8447f82dcf | ||
|
|
f25af78f81 | ||
|
|
c251589885 | ||
|
|
7902cc4dcf | ||
|
|
e7c75609d2 | ||
|
|
2f86edb5e6 | ||
|
|
0ab0402172 | ||
|
|
42f84e878f | ||
|
|
66757b4b9f | ||
|
|
af127b716e | ||
|
|
f032df2894 | ||
|
|
329fb94a3a | ||
|
|
9217e805e9 | ||
|
|
50f0bb7f57 | ||
|
|
35d7160fdd | ||
|
|
228b303d3e | ||
|
|
d66e7e7126 | ||
|
|
b3e459211d | ||
|
|
e84cf9dfb7 | ||
|
|
a20155bb1e | ||
|
|
14c3307339 | ||
|
|
e35fe1bf6c | ||
|
|
7b9ac121b6 | ||
|
|
7e3481e17f | ||
|
|
e77a275885 | ||
|
|
6940ba1fc0 | ||
|
|
c8fde6f44c | ||
|
|
75e43c7473 | ||
|
|
2edb7aa3b3 | ||
|
|
22159b15f3 | ||
|
|
2199e1f5b2 | ||
|
|
b0521f60a9 | ||
|
|
014cf13072 | ||
|
|
91ffd122e9 | ||
|
|
246c22ff70 | ||
|
|
c7806a5463 | ||
|
|
cda065e25f | ||
|
|
68cf1b30df | ||
|
|
adb09771cb | ||
|
|
10e2e631d6 | ||
|
|
e0a1523e49 | ||
|
|
d5b870530a | ||
|
|
fd10c53dfb | ||
|
|
0e4bbea454 | ||
|
|
75e762ac6c | ||
|
|
4ede06b99e | ||
|
|
316d891da4 | ||
|
|
a551ec95c0 | ||
|
|
003625113c | ||
|
|
e84149bc11 | ||
|
|
af3b0aefe4 | ||
|
|
b991f009c7 | ||
|
|
813b7ac9aa | ||
|
|
e969008873 | ||
|
|
5f921e5a2c | ||
|
|
f465a0d9eb | ||
|
|
eb20ae9106 | ||
|
|
e3c22289cf | ||
|
|
d9b58b0502 | ||
|
|
1a5358d7e6 | ||
|
|
de28a90819 | ||
|
|
3863d2e78f | ||
|
|
ac67904da8 | ||
|
|
1f41c900a7 | ||
|
|
5c82bca4a3 | ||
|
|
7782a8ce76 | ||
|
|
c773f4d97b | ||
|
|
ea76886869 | ||
|
|
52c2fe3941 | ||
|
|
cd6dba6876 | ||
|
|
2d59e8afd3 | ||
|
|
6a1dc75b3b | ||
|
|
57b35ae431 | ||
|
|
103548eb7e | ||
|
|
4351f41d31 | ||
|
|
aeeb3fd2f0 | ||
|
|
6f9864e6ac | ||
|
|
18740f5119 | ||
|
|
dcd57dbfe2 | ||
|
|
37acfb5d06 | ||
|
|
38aaec1f38 | ||
|
|
e2ba6c52e3 | ||
|
|
3c93b53819 | ||
|
|
9201f49b5b | ||
|
|
e467247175 | ||
|
|
d2496f6aca |
12
.devcontainer/Dockerfile
Normal file → Executable file
@@ -2,15 +2,17 @@ FROM mcr.microsoft.com/devcontainers/dotnet:1-8.0-bookworm
|
||||
|
||||
# Install SQL Tools: SQLPackage and sqlcmd
|
||||
COPY mssql/installSQLtools.sh installSQLtools.sh
|
||||
RUN bash ./installSQLtools.sh \
|
||||
&& apt-get clean -y && rm -rf /var/lib/apt/lists/* /tmp/library-scripts
|
||||
RUN bash ./installSQLtools.sh && apt-get clean -y && rm -rf /var/lib/apt/lists/* /tmp/library-scripts
|
||||
|
||||
RUN wget -q -O - https://dl-ssl.google.com/linux/linux_signing_key.pub | apt-key add - && \
|
||||
echo "deb http://dl.google.com/linux/chrome/deb/ stable main" > /etc/apt/sources.list.d/google.list && \
|
||||
apt-get update && apt-get install -y google-chrome-stable xvfb
|
||||
# RUN wget -q -O - https://dl-ssl.google.com/linux/linux_signing_key.pub | apt-key add - && \
|
||||
# echo "deb http://dl.google.com/linux/chrome/deb/ stable main" > /etc/apt/sources.list.d/google.list && \
|
||||
# apt-get update && apt-get install -y google-chrome-stable xvfb
|
||||
# [Optional] Uncomment this section to install additional OS packages.
|
||||
# RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \
|
||||
# && apt-get -y install --no-install-recommends <your-package-list-here>
|
||||
|
||||
ENV ASPNETCORE_HTTP_PORTS=5000
|
||||
|
||||
EXPOSE 4200
|
||||
# [Optional] Uncomment this line to install global node packages.
|
||||
RUN su vscode -c "source /usr/local/share/nvm/nvm.sh && nvm install --lts && nvm use --lts && npm install -g typescript" 2>&1
|
||||
|
||||
31
.devcontainer/devcontainer.json
Normal file → Executable file
@@ -1,14 +1,13 @@
|
||||
// For format details, see https://aka.ms/devcontainer.json. For config options, see the
|
||||
// README at: https://github.com/devcontainers/templates/tree/main/src/dotnet-mssql
|
||||
{
|
||||
|
||||
"name": ".NET (C#), Node.js (TypeScript) & MS SQL",
|
||||
"dockerComposeFile": "docker-compose.yml",
|
||||
"service": "app",
|
||||
"workspaceFolder": "/workspaces/${localWorkspaceFolderBasename}",
|
||||
|
||||
// Features to add to the dev container. More info: https://containers.dev/features.
|
||||
// "features": {},
|
||||
|
||||
// Configure tool-specific properties.
|
||||
"customizations": {
|
||||
// Configure properties specific to VS Code.
|
||||
@@ -28,7 +27,6 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
// Add the IDs of extensions you want installed when the container is created.
|
||||
"extensions": [
|
||||
"ms-dotnettools.csharp",
|
||||
@@ -36,18 +34,27 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
|
||||
// Use 'forwardPorts' to make a list of ports inside the container available locally.
|
||||
// "forwardPorts": [5000, 5001],
|
||||
// "portsAttributes": {
|
||||
// "5001": {
|
||||
// "protocol": "https"
|
||||
// }
|
||||
// }
|
||||
|
||||
"forwardPorts": [
|
||||
4200,
|
||||
5000,
|
||||
5001,
|
||||
1433
|
||||
],
|
||||
"portsAttributes": {
|
||||
"5001": {
|
||||
"protocol": "https"
|
||||
},
|
||||
"1433": {
|
||||
"protocol": "tcp"
|
||||
}
|
||||
},
|
||||
// postCreateCommand.sh parameters: $1=SA password, $2=dacpac path, $3=sql script(s) path
|
||||
"containerEnv": {
|
||||
"SA_PASSWORD": "P@ssw0rd",
|
||||
"ACCEPT_EULA": "Y"
|
||||
},
|
||||
"postCreateCommand": "bash .devcontainer/mssql/postCreateCommand.sh 'P@ssw0rd' './bin/Debug/' './.devcontainer/mssql/'"
|
||||
|
||||
// Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root.
|
||||
// "remoteUser": "root"
|
||||
}
|
||||
3
.devcontainer/docker-compose.yml
Normal file → Executable file
@@ -1,5 +1,3 @@
|
||||
version: '3'
|
||||
|
||||
services:
|
||||
app:
|
||||
build:
|
||||
@@ -8,7 +6,6 @@ services:
|
||||
|
||||
volumes:
|
||||
- ../..:/workspaces:cached
|
||||
|
||||
# Overrides default command so things don't shut down after the process ends.
|
||||
command: sleep infinity
|
||||
ports:
|
||||
|
||||
0
.devcontainer/mssql/installSQLtools.sh
Normal file → Executable file
64
.devcontainer/mssql/postCreateCommand-backup.sh
Executable file
@@ -0,0 +1,64 @@
|
||||
#!/bin/bash
|
||||
dacpac="false"
|
||||
sqlfiles="false"
|
||||
SApassword=$1
|
||||
dacpath=$2
|
||||
sqlpath=$3
|
||||
|
||||
echo "SELECT * FROM SYS.DATABASES" | dd of=testsqlconnection.sql
|
||||
for i in {1..60};
|
||||
do
|
||||
/opt/mssql-tools/bin/sqlcmd -S localhost -U sa -P $SApassword -d master -i testsqlconnection.sql > /dev/null
|
||||
if [ $? -eq 0 ]
|
||||
then
|
||||
echo "SQL server ready"
|
||||
break
|
||||
else
|
||||
echo "Not ready yet..."
|
||||
sleep 1
|
||||
fi
|
||||
done
|
||||
rm testsqlconnection.sql
|
||||
|
||||
for f in $dacpath/*
|
||||
do
|
||||
if [ $f == $dacpath/*".dacpac" ]
|
||||
then
|
||||
dacpac="true"
|
||||
echo "Found dacpac $f"
|
||||
fi
|
||||
done
|
||||
|
||||
for f in $sqlpath/*
|
||||
do
|
||||
if [ $f == $sqlpath/*".sql" ]
|
||||
then
|
||||
sqlfiles="true"
|
||||
echo "Found SQL file $f"
|
||||
fi
|
||||
done
|
||||
|
||||
if [ $sqlfiles == "true" ]
|
||||
then
|
||||
for f in $sqlpath/*
|
||||
do
|
||||
if [ $f == $sqlpath/*".sql" ]
|
||||
then
|
||||
echo "Executing $f"
|
||||
/opt/mssql-tools/bin/sqlcmd -S localhost -U sa -P $SApassword -d master -i $f
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
if [ $dacpac == "true" ]
|
||||
then
|
||||
for f in $dacpath/*
|
||||
do
|
||||
if [ $f == $dacpath/*".dacpac" ]
|
||||
then
|
||||
dbname=$(basename $f ".dacpac")
|
||||
echo "Deploying dacpac $f"
|
||||
/opt/sqlpackage/sqlpackage /Action:Publish /SourceFile:$f /TargetServerName:localhost /TargetDatabaseName:$dbname /TargetUser:sa /TargetPassword:$SApassword
|
||||
fi
|
||||
done
|
||||
fi
|
||||
63
.devcontainer/mssql/postCreateCommand.sh
Normal file → Executable file
@@ -5,60 +5,9 @@ SApassword=$1
|
||||
dacpath=$2
|
||||
sqlpath=$3
|
||||
|
||||
echo "SELECT * FROM SYS.DATABASES" | dd of=testsqlconnection.sql
|
||||
for i in {1..60};
|
||||
do
|
||||
/opt/mssql-tools/bin/sqlcmd -S localhost -U sa -P $SApassword -d master -i testsqlconnection.sql > /dev/null
|
||||
if [ $? -eq 0 ]
|
||||
then
|
||||
echo "SQL server ready"
|
||||
break
|
||||
else
|
||||
echo "Not ready yet..."
|
||||
sleep 1
|
||||
fi
|
||||
done
|
||||
rm testsqlconnection.sql
|
||||
|
||||
for f in $dacpath/*
|
||||
do
|
||||
if [ $f == $dacpath/*".dacpac" ]
|
||||
then
|
||||
dacpac="true"
|
||||
echo "Found dacpac $f"
|
||||
fi
|
||||
done
|
||||
|
||||
for f in $sqlpath/*
|
||||
do
|
||||
if [ $f == $sqlpath/*".sql" ]
|
||||
then
|
||||
sqlfiles="true"
|
||||
echo "Found SQL file $f"
|
||||
fi
|
||||
done
|
||||
|
||||
if [ $sqlfiles == "true" ]
|
||||
then
|
||||
for f in $sqlpath/*
|
||||
do
|
||||
if [ $f == $sqlpath/*".sql" ]
|
||||
then
|
||||
echo "Executing $f"
|
||||
/opt/mssql-tools/bin/sqlcmd -S localhost -U sa -P $SApassword -d master -i $f
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
if [ $dacpac == "true" ]
|
||||
then
|
||||
for f in $dacpath/*
|
||||
do
|
||||
if [ $f == $dacpath/*".dacpac" ]
|
||||
then
|
||||
dbname=$(basename $f ".dacpac")
|
||||
echo "Deploying dacpac $f"
|
||||
/opt/sqlpackage/sqlpackage /Action:Publish /SourceFile:$f /TargetServerName:localhost /TargetDatabaseName:$dbname /TargetUser:sa /TargetPassword:$SApassword
|
||||
fi
|
||||
done
|
||||
fi
|
||||
cd Api
|
||||
dotnet restore ./Api.csproj
|
||||
dotnet build ./Api.csproj
|
||||
dotnet tool install --global dotnet-ef --version 8.*
|
||||
export PATH="$PATH:/root/.dotnet/tools"
|
||||
dotnet-ef database update --project ./Api.csproj --startup-project ./Api.csproj
|
||||
@@ -1,2 +0,0 @@
|
||||
CREATE DATABASE ApplicationDB;
|
||||
GO
|
||||
0
.github/dependabot.yml
vendored
Normal file → Executable file
1
.gitignore
vendored
Executable file
@@ -0,0 +1 @@
|
||||
.vs/
|
||||
85
.gitlab-ci.yml
Normal file → Executable file
@@ -4,33 +4,84 @@ stages: # Define the stages of the pipeline.
|
||||
#- test
|
||||
- release
|
||||
|
||||
install-job: # This job runs in the build stage, which runs first.
|
||||
stage: build
|
||||
image: node:24
|
||||
script:
|
||||
- cd Web
|
||||
- npm install
|
||||
- npm run build
|
||||
artifacts: # Artifacts are files that are passed between stages.
|
||||
paths:
|
||||
- Web/dist/Web/browser # The 'dist' directory will be available in the next stage.
|
||||
|
||||
semantic-release:
|
||||
image: node:24
|
||||
stage: release
|
||||
script:
|
||||
- apt update && apt install zip -y
|
||||
- zip -r dist.zip Web/dist/Web/browser
|
||||
- zip -r api.zip Api/build
|
||||
- npm install --save-dev @semantic-release/gitlab
|
||||
- npx semantic-release --debug
|
||||
only:
|
||||
- main
|
||||
|
||||
deploy-job: # This job runs in the deploy stage.
|
||||
stage: release # It only runs when *both* jobs in the test stage complete successfully.
|
||||
environment: production
|
||||
docker-build:
|
||||
stage: build
|
||||
image: docker:latest
|
||||
tags:
|
||||
- deployment
|
||||
- shared
|
||||
services:
|
||||
- name: docker:dind
|
||||
alias: docker
|
||||
variables:
|
||||
DOCKER_DRIVER: overlay2
|
||||
DOCKER_HOST: tcp://docker:2375
|
||||
DOCKER_TLS_CERTDIR: ""
|
||||
before_script:
|
||||
- docker info
|
||||
|
||||
- ip a
|
||||
script:
|
||||
- cp -ir Web/dist/Web/browser/* /var/www/html
|
||||
- echo "Application successfully deployed."
|
||||
- 'sed -i "s|\"apiEndpoint\": \"[^\"]*\"|\"apiEndpoint\": \"https\:\/\/""$PUBLIC_WEB_URL""\"|" Web/public/config.json'
|
||||
- 'sed -i "s|\"AllowedHosts\": \"[^\"]*\"|\"AllowedHosts\": \"$PUBLIC_WEB_URL\"|" Api/appsettings.json'
|
||||
- 'sed -i "s|\"CorsOrigins\": \"[^\"]*\"|\"CorsOrigins\": \"https\:\/\/""$PUBLIC_WEB_URL""\"|" Api/appsettings.json'
|
||||
- docker build -t $CI_REGISTRY_IMAGE:latest -t $CI_REGISTRY_IMAGE:${CI_PIPELINE_IID} -f Dockerfile .
|
||||
- echo "$CI_REGISTRY_PASSWORD" | docker login -u "$CI_REGISTRY_USER" --password-stdin $CI_REGISTRY
|
||||
- docker push $CI_REGISTRY_IMAGE:latest
|
||||
- docker push $CI_REGISTRY_IMAGE:${CI_PIPELINE_IID}
|
||||
retry:
|
||||
max: 2
|
||||
when:
|
||||
- runner_system_failure
|
||||
- script_failure
|
||||
- api_failure
|
||||
|
||||
only:
|
||||
- dev
|
||||
- main
|
||||
|
||||
docker-deploy:
|
||||
stage: release
|
||||
tags:
|
||||
- production
|
||||
- shell
|
||||
script:
|
||||
- echo "$CI_REGISTRY_PASSWORD" | docker login -u "$CI_REGISTRY_USER" --password-stdin $CI_REGISTRY
|
||||
- cd deployment
|
||||
- docker compose pull
|
||||
- docker compose -f docker-compose.yaml up -d
|
||||
- docker image prune -f
|
||||
only:
|
||||
- dev
|
||||
- main
|
||||
|
||||
update_db:
|
||||
image: mcr.microsoft.com/dotnet/sdk:8.0
|
||||
stage: release
|
||||
needs: ["docker-deploy"]
|
||||
script:
|
||||
- cd Api
|
||||
- dotnet restore ./Api.csproj
|
||||
- dotnet build ./Api.csproj
|
||||
- dotnet tool install --global dotnet-ef --version 8.*
|
||||
- export PATH="$PATH:/root/.dotnet/tools"
|
||||
- dotnet-ef database update --project ./Api.csproj --startup-project ./Api.csproj
|
||||
only:
|
||||
- dev
|
||||
- main
|
||||
tags:
|
||||
- production
|
||||
- docker
|
||||
variables:
|
||||
ConnectionStrings__DefaultConnection: "Server=localhost;Database=CentrumDb;User=sa;Password=$DB_PASSWORD;TrustServerCertificate=True;"
|
||||
|
||||
6
.gitpod.yml
Executable file
@@ -0,0 +1,6 @@
|
||||
coreDump:
|
||||
enabled: false
|
||||
tasks:
|
||||
- init: |
|
||||
npm install -g @devcontainers/cli@0.40.0
|
||||
devcontainer --version
|
||||
28
.gitpod/automations.yaml
Normal file → Executable file
@@ -7,31 +7,3 @@ tasks:
|
||||
- manual
|
||||
# - postEnvironmentStart
|
||||
# - postDevcontainerStart
|
||||
|
||||
services:
|
||||
example-service:
|
||||
name: Example Service
|
||||
description: Example service simulating a backend
|
||||
commands:
|
||||
start: |
|
||||
echo "Starting backend service..."
|
||||
touch /tmp/backend.started
|
||||
while true; do
|
||||
sleep 1
|
||||
date
|
||||
done
|
||||
ready: |
|
||||
if [ -f /tmp/backend.started ]; then
|
||||
echo "Backend service is ready"
|
||||
exit 0
|
||||
else
|
||||
echo "Backend service is not ready"
|
||||
exit 1
|
||||
fi
|
||||
# stop: |
|
||||
# echo "Stopping backend service..."
|
||||
# rm /tmp/backend.started
|
||||
# pkill backend
|
||||
triggeredBy:
|
||||
- postEnvironmentStart
|
||||
# - postDevcontainerStart
|
||||
|
||||
1
.releaserc.json
Normal file → Executable file
@@ -12,6 +12,7 @@
|
||||
],
|
||||
"assets": [
|
||||
{ "path": "dist.zip", "label": "Web.zip" },
|
||||
{ "path": "api.zip", "label": "Api.zip" },
|
||||
{ "url": "https://gitlab.lesko.me/marek/centrum/-/blob/main/README.md", "label": "README.md" }
|
||||
]
|
||||
}
|
||||
|
||||
2
.vscode/launch.json
vendored
Normal file → Executable file
@@ -9,7 +9,7 @@
|
||||
"request": "launch",
|
||||
"name": "Launch Chrome against localhost",
|
||||
"url": "http://localhost:4200",
|
||||
"webRoot": "${workspaceFolder}"
|
||||
"webRoot": "${workspaceFolder}/Web"
|
||||
}
|
||||
]
|
||||
}
|
||||
13
.vscode/mcp.json
vendored
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"servers": {
|
||||
"my-mcp-server-93a33ffa": {
|
||||
"type": "stdio",
|
||||
"command": "npx",
|
||||
"args": [
|
||||
"mcp-remote",
|
||||
"https://mcp.linear.app/sse"
|
||||
]
|
||||
}
|
||||
},
|
||||
"inputs": []
|
||||
}
|
||||
41
.vscode/tasks.json
vendored
Executable file
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"version": "2.0.0",
|
||||
"tasks": [
|
||||
{
|
||||
"label": "build",
|
||||
"command": "dotnet",
|
||||
"type": "process",
|
||||
"args": [
|
||||
"build",
|
||||
"${workspaceFolder}/Api/Api.sln",
|
||||
"/property:GenerateFullPaths=true",
|
||||
"/consoleloggerparameters:NoSummary;ForceNoAlign"
|
||||
],
|
||||
"problemMatcher": "$msCompile"
|
||||
},
|
||||
{
|
||||
"label": "publish",
|
||||
"command": "dotnet",
|
||||
"type": "process",
|
||||
"args": [
|
||||
"publish",
|
||||
"${workspaceFolder}/Api/Api.sln",
|
||||
"/property:GenerateFullPaths=true",
|
||||
"/consoleloggerparameters:NoSummary;ForceNoAlign"
|
||||
],
|
||||
"problemMatcher": "$msCompile"
|
||||
},
|
||||
{
|
||||
"label": "watch",
|
||||
"command": "dotnet",
|
||||
"type": "process",
|
||||
"args": [
|
||||
"watch",
|
||||
"run",
|
||||
"--project",
|
||||
"${workspaceFolder}/Api/Api.sln"
|
||||
],
|
||||
"problemMatcher": "$msCompile"
|
||||
}
|
||||
]
|
||||
}
|
||||
5
Api/.config/dotnet-tools.json
Executable file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"version": 1,
|
||||
"isRoot": true,
|
||||
"tools": {}
|
||||
}
|
||||
30
Api/.dockerignore
Executable file
@@ -0,0 +1,30 @@
|
||||
**/.classpath
|
||||
**/.dockerignore
|
||||
**/.env
|
||||
**/.git
|
||||
**/.gitignore
|
||||
**/.project
|
||||
**/.settings
|
||||
**/.toolstarget
|
||||
**/.vs
|
||||
**/.vscode
|
||||
**/*.*proj.user
|
||||
**/*.dbmdl
|
||||
**/*.jfm
|
||||
**/azds.yaml
|
||||
**/bin
|
||||
**/charts
|
||||
**/docker-compose*
|
||||
**/Dockerfile*
|
||||
**/node_modules
|
||||
**/npm-debug.log
|
||||
**/obj
|
||||
**/secrets.dev.yaml
|
||||
**/values.dev.yaml
|
||||
LICENSE
|
||||
README.md
|
||||
!**/.gitignore
|
||||
!.git/HEAD
|
||||
!.git/config
|
||||
!.git/packed-refs
|
||||
!.git/refs/heads/**
|
||||
5
Api/.gitignore
vendored
Executable file
@@ -0,0 +1,5 @@
|
||||
obj/
|
||||
bin/
|
||||
.vs/
|
||||
build/
|
||||
**/core
|
||||
30
Api/Api.csproj
Executable file
@@ -0,0 +1,30 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<UserSecretsId>aspnet-Api-50db7f4c-1c75-467a-a923-a5477d7decc4</UserSecretsId>
|
||||
<DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
|
||||
<DockerfileContext>.</DockerfileContext>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Google.Cloud.RecaptchaEnterprise.V1" Version="2.18.0" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.18" NoWarn="NU1605" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" Version="8.0.18" NoWarn="NU1605" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.18">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="8.0.18" />
|
||||
<PackageReference Include="Microsoft.Identity.Web" Version="3.10.0" />
|
||||
<PackageReference Include="Microsoft.Identity.Web.DownstreamApi" Version="3.10.0" />
|
||||
<PackageReference Include="Microsoft.VisualStudio.Azure.Containers.Tools.Targets" Version="1.22.1" />
|
||||
<PackageReference Include="Swashbuckle.AspNetCore" Version="9.0.3" />
|
||||
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.12.1" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.Google" Version="8.0.18" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.MicrosoftAccount" Version="8.0.18" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
9
Api/Api.csproj.user
Executable file
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<Project ToolsVersion="Current" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
|
||||
<PropertyGroup>
|
||||
<ActiveDebugProfile>https</ActiveDebugProfile>
|
||||
</PropertyGroup>
|
||||
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'">
|
||||
<DebuggerFlavor>ProjectDebugger</DebuggerFlavor>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
6
Api/Api.http
Executable file
@@ -0,0 +1,6 @@
|
||||
@Api_HostAddress = http://localhost:5012
|
||||
|
||||
GET {{Api_HostAddress}}/weatherforecast/
|
||||
Accept: application/json
|
||||
|
||||
###
|
||||
31
Api/Api.sln
Executable file
@@ -0,0 +1,31 @@
|
||||
|
||||
Microsoft Visual Studio Solution File, Format Version 12.00
|
||||
# Visual Studio Version 17
|
||||
VisualStudioVersion = 17.14.36301.6
|
||||
MinimumVisualStudioVersion = 10.0.40219.1
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Api", "Api.csproj", "{EC86C676-06E8-4011-A4E3-5118543F4728}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Tests", "..\Tests\Tests.csproj", "{819F3EDC-F488-4C63-89D0-4BE09CD0C485}"
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
Debug|Any CPU = Debug|Any CPU
|
||||
Release|Any CPU = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
||||
{EC86C676-06E8-4011-A4E3-5118543F4728}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{EC86C676-06E8-4011-A4E3-5118543F4728}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{EC86C676-06E8-4011-A4E3-5118543F4728}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{EC86C676-06E8-4011-A4E3-5118543F4728}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{819F3EDC-F488-4C63-89D0-4BE09CD0C485}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{819F3EDC-F488-4C63-89D0-4BE09CD0C485}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{819F3EDC-F488-4C63-89D0-4BE09CD0C485}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{819F3EDC-F488-4C63-89D0-4BE09CD0C485}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(SolutionProperties) = preSolution
|
||||
HideSolutionNode = FALSE
|
||||
EndGlobalSection
|
||||
GlobalSection(ExtensibilityGlobals) = postSolution
|
||||
SolutionGuid = {A7800B0A-84E4-432A-9E11-6EF3987291FC}
|
||||
EndGlobalSection
|
||||
EndGlobal
|
||||
273
Api/Controllers/AuthController.cs
Normal file
@@ -0,0 +1,273 @@
|
||||
using Api.Models;
|
||||
using Api.Models.DTOs;
|
||||
using Api.Services;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Api.Controllers
|
||||
{
|
||||
[ApiController]
|
||||
[Route("api/[controller]")]
|
||||
public class AuthController : ControllerBase
|
||||
{
|
||||
private readonly AppDbContext _context;
|
||||
private readonly IOAuthValidationService _oauthValidationService;
|
||||
private readonly IJwtService _jwtService;
|
||||
private readonly ILogger<AuthController> _logger;
|
||||
|
||||
public AuthController(
|
||||
AppDbContext context,
|
||||
IOAuthValidationService oauthValidationService,
|
||||
IJwtService jwtService,
|
||||
ILogger<AuthController> logger)
|
||||
{
|
||||
_context = context;
|
||||
_oauthValidationService = oauthValidationService;
|
||||
_jwtService = jwtService;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Authenticates a user with an OAuth ID token and returns a custom access token
|
||||
/// </summary>
|
||||
/// <param name="request">Authentication request containing ID token and provider</param>
|
||||
/// <returns>Custom access token and user information</returns>
|
||||
[HttpPost("authenticate")]
|
||||
public async Task<ActionResult<AuthenticateResponse>> AuthenticateAsync([FromBody] AuthenticateRequest request)
|
||||
{
|
||||
if (!ModelState.IsValid)
|
||||
{
|
||||
return BadRequest(ModelState);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// Validate the ID token with the specified provider
|
||||
var (isValid, principal, errorMessage) = await _oauthValidationService
|
||||
.ValidateIdTokenAsync(request.IdToken, request.Provider);
|
||||
|
||||
if (!isValid || principal == null)
|
||||
{
|
||||
_logger.LogWarning("Invalid ID token for provider {Provider}: {Error}", request.Provider, errorMessage);
|
||||
return BadRequest(new { error = "invalid_token", message = errorMessage ?? "Invalid ID token" });
|
||||
}
|
||||
|
||||
// Extract user information from the validated token
|
||||
var userInfo = request.Provider.ToLowerInvariant() == "microsoft"
|
||||
? await _oauthValidationService.ExtractUserInfoAsync(principal, request.Provider, request.IdToken, request.AccessToken)
|
||||
: _oauthValidationService.ExtractUserInfo(principal, request.Provider);
|
||||
|
||||
if (string.IsNullOrEmpty(userInfo.Email))
|
||||
{
|
||||
_logger.LogWarning("No email found in {Provider} token", request.Provider);
|
||||
return BadRequest(new { error = "invalid_token", message = "Email claim is required" });
|
||||
}
|
||||
|
||||
// Parse the provider enum
|
||||
if (!Enum.TryParse<OAuthProvider>(request.Provider, true, out var providerEnum))
|
||||
{
|
||||
return BadRequest(new { error = "invalid_provider", message = $"Unsupported provider: {request.Provider}" });
|
||||
}
|
||||
|
||||
// Find or create user
|
||||
var (user, isNewUser) = await FindOrCreateUserAsync(userInfo, providerEnum);
|
||||
|
||||
// Update last login time
|
||||
user.LastLoginAt = DateTime.UtcNow;
|
||||
|
||||
// Update or create OAuth provider record
|
||||
await UpdateUserOAuthProviderAsync(user, userInfo, providerEnum);
|
||||
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
// Generate custom access token
|
||||
var accessToken = _jwtService.GenerateToken(user);
|
||||
var expiresAt = _jwtService.GetTokenExpiration(accessToken);
|
||||
|
||||
// Prepare response
|
||||
var userProfile = new UserProfile
|
||||
{
|
||||
Id = user.Id,
|
||||
Email = user.Email,
|
||||
FirstName = user.FirstName,
|
||||
LastName = user.LastName,
|
||||
ProfilePictureUrl = user.ProfilePictureUrl,
|
||||
CreatedAt = user.CreatedAt,
|
||||
LastLoginAt = user.LastLoginAt,
|
||||
Providers = user.OAuthProviders.Select(p => p.Provider.ToString()).ToList()
|
||||
};
|
||||
|
||||
var response = new AuthenticateResponse
|
||||
{
|
||||
AccessToken = accessToken,
|
||||
ExpiresAt = expiresAt,
|
||||
User = userProfile,
|
||||
IsNewUser = isNewUser
|
||||
};
|
||||
|
||||
_logger.LogInformation("User {Email} authenticated successfully with {Provider} (New: {IsNew})",
|
||||
user.Email, request.Provider, isNewUser);
|
||||
|
||||
return Ok(response);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Authentication failed for provider {Provider}", request.Provider);
|
||||
return StatusCode(500, new { error = "internal_error", message = "Authentication failed" });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the current user's profile information
|
||||
/// </summary>
|
||||
/// <returns>User profile information</returns>
|
||||
[HttpGet("me")]
|
||||
[Authorize]
|
||||
public async Task<ActionResult<UserProfile>> GetCurrentUserAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
var userIdClaim = User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier);
|
||||
if (userIdClaim == null || !int.TryParse(userIdClaim.Value, out var userId))
|
||||
{
|
||||
return Unauthorized(new { error = "invalid_token", message = "User ID not found in token" });
|
||||
}
|
||||
|
||||
var user = await _context.Users
|
||||
.Include(u => u.OAuthProviders)
|
||||
.FirstOrDefaultAsync(u => u.Id == userId && u.IsActive);
|
||||
|
||||
if (user == null)
|
||||
{
|
||||
return NotFound(new { error = "user_not_found", message = "User not found or inactive" });
|
||||
}
|
||||
|
||||
var userProfile = new UserProfile
|
||||
{
|
||||
Id = user.Id,
|
||||
Email = user.Email,
|
||||
FirstName = user.FirstName,
|
||||
LastName = user.LastName,
|
||||
ProfilePictureUrl = user.ProfilePictureUrl,
|
||||
CreatedAt = user.CreatedAt,
|
||||
LastLoginAt = user.LastLoginAt,
|
||||
Providers = user.OAuthProviders.Select(p => p.Provider.ToString()).ToList()
|
||||
};
|
||||
|
||||
return Ok(userProfile);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error retrieving current user profile");
|
||||
return StatusCode(500, new { error = "internal_error", message = "Failed to retrieve user profile" });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Revokes the current access token (logout)
|
||||
/// </summary>
|
||||
/// <returns>Success message</returns>
|
||||
[HttpPost("logout")]
|
||||
[Authorize]
|
||||
public IActionResult Logout()
|
||||
{
|
||||
// In a real application, you might want to maintain a blacklist of revoked tokens
|
||||
// For now, we'll just return success as JWT tokens are stateless
|
||||
_logger.LogInformation("User logged out");
|
||||
return Ok(new { message = "Logged out successfully" });
|
||||
}
|
||||
|
||||
private async Task<(User User, bool IsNewUser)> FindOrCreateUserAsync(
|
||||
(string Email, string? FirstName, string? LastName, string? ProfilePictureUrl, string ProviderId) userInfo,
|
||||
OAuthProvider provider)
|
||||
{
|
||||
var existingUser = await _context.Users
|
||||
.Include(u => u.OAuthProviders)
|
||||
.FirstOrDefaultAsync(u => u.Email == userInfo.Email && u.IsActive);
|
||||
|
||||
if (existingUser != null)
|
||||
{
|
||||
// Update user information if it has changed
|
||||
var hasChanges = false;
|
||||
|
||||
if (!string.IsNullOrEmpty(userInfo.FirstName) && existingUser.FirstName != userInfo.FirstName)
|
||||
{
|
||||
existingUser.FirstName = userInfo.FirstName;
|
||||
hasChanges = true;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(userInfo.LastName) && existingUser.LastName != userInfo.LastName)
|
||||
{
|
||||
existingUser.LastName = userInfo.LastName;
|
||||
hasChanges = true;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(userInfo.ProfilePictureUrl) && existingUser.ProfilePictureUrl != userInfo.ProfilePictureUrl)
|
||||
{
|
||||
existingUser.ProfilePictureUrl = userInfo.ProfilePictureUrl;
|
||||
hasChanges = true;
|
||||
}
|
||||
|
||||
if (hasChanges)
|
||||
{
|
||||
_logger.LogInformation("Updated user information for {Email}", userInfo.Email);
|
||||
}
|
||||
|
||||
return (existingUser, false);
|
||||
}
|
||||
|
||||
// Create new user
|
||||
var newUser = new User
|
||||
{
|
||||
Email = userInfo.Email,
|
||||
FirstName = userInfo.FirstName,
|
||||
LastName = userInfo.LastName,
|
||||
ProfilePictureUrl = userInfo.ProfilePictureUrl,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
IsActive = true
|
||||
};
|
||||
|
||||
_context.Users.Add(newUser);
|
||||
_logger.LogInformation("Created new user for {Email}", userInfo.Email);
|
||||
|
||||
return (newUser, true);
|
||||
}
|
||||
|
||||
private async Task UpdateUserOAuthProviderAsync(
|
||||
User user,
|
||||
(string Email, string? FirstName, string? LastName, string? ProfilePictureUrl, string ProviderId) userInfo,
|
||||
OAuthProvider provider)
|
||||
{
|
||||
var existingProvider = user.OAuthProviders
|
||||
.FirstOrDefault(p => p.Provider == provider);
|
||||
|
||||
if (existingProvider != null)
|
||||
{
|
||||
// Update existing provider record
|
||||
existingProvider.ProviderEmail = userInfo.Email;
|
||||
existingProvider.ProviderName = $"{userInfo.FirstName} {userInfo.LastName}".Trim();
|
||||
existingProvider.LastUsedAt = DateTime.UtcNow;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Create new provider record
|
||||
var newProvider = new UserOAuthProvider
|
||||
{
|
||||
UserId = user.Id,
|
||||
Provider = provider,
|
||||
ProviderId = userInfo.ProviderId,
|
||||
ProviderEmail = userInfo.Email,
|
||||
ProviderName = $"{userInfo.FirstName} {userInfo.LastName}".Trim(),
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
LastUsedAt = DateTime.UtcNow
|
||||
};
|
||||
|
||||
user.OAuthProviders.Add(newProvider);
|
||||
_logger.LogInformation("Added {Provider} OAuth provider for user {Email}", provider, user.Email);
|
||||
}
|
||||
|
||||
await Task.CompletedTask; // Make the method actually async
|
||||
}
|
||||
}
|
||||
}
|
||||
70
Api/Controllers/ProductController.cs
Executable file
@@ -0,0 +1,70 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Api.Models;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
|
||||
namespace Api.Controllers
|
||||
{
|
||||
[ApiController]
|
||||
[Authorize]
|
||||
[Route("api/product")]
|
||||
public class ProductController : ControllerBase
|
||||
{
|
||||
private readonly AppDbContext _context;
|
||||
|
||||
public ProductController(AppDbContext context)
|
||||
{
|
||||
_context = context;
|
||||
}
|
||||
|
||||
// GET: api/Product
|
||||
[HttpGet]
|
||||
public async Task<ActionResult<IEnumerable<Product>>> GetProducts([FromQuery] int? id = null)
|
||||
{
|
||||
if (id.HasValue)
|
||||
{
|
||||
return await _context.Products
|
||||
.Where(p => p.Id == id.Value)
|
||||
.ToListAsync();
|
||||
}
|
||||
else
|
||||
return await _context.Products.ToListAsync();
|
||||
}
|
||||
|
||||
// POST: api/Product
|
||||
[HttpPost]
|
||||
public async Task<ActionResult<Product>> PostProduct([FromBody] Product product)
|
||||
{
|
||||
_context.Products.Add(product);
|
||||
await _context.SaveChangesAsync();
|
||||
return CreatedAtAction(nameof(GetProducts), new { id = product.Id }, product);
|
||||
}
|
||||
|
||||
// PUT: api/Product/{id}
|
||||
[HttpPut("{id}")]
|
||||
public async Task<IActionResult> PutProduct(int id, [FromBody] Product product)
|
||||
{
|
||||
if (id != product.Id)
|
||||
{
|
||||
return BadRequest();
|
||||
}
|
||||
_context.Entry(product).State = EntityState.Modified;
|
||||
try
|
||||
{
|
||||
await _context.SaveChangesAsync();
|
||||
}
|
||||
catch (DbUpdateConcurrencyException)
|
||||
{
|
||||
if (!await _context.Products.AnyAsync(e => e.Id == id))
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
else
|
||||
{
|
||||
throw;
|
||||
}
|
||||
}
|
||||
return NoContent();
|
||||
}
|
||||
}
|
||||
}
|
||||
483
Api/Controllers/UserController.cs
Normal file
@@ -0,0 +1,483 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Api.Models;
|
||||
using Api.Models.DTOs;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using System.Security.Claims;
|
||||
|
||||
namespace Api.Controllers
|
||||
{
|
||||
[ApiController]
|
||||
[Authorize]
|
||||
[Route("api/[controller]")]
|
||||
public class UserController : ControllerBase
|
||||
{
|
||||
private readonly AppDbContext _context;
|
||||
private readonly ILogger<UserController> _logger;
|
||||
|
||||
public UserController(AppDbContext context, ILogger<UserController> logger)
|
||||
{
|
||||
_context = context;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get all users with pagination support
|
||||
/// </summary>
|
||||
/// <param name="page">Page number (default: 1)</param>
|
||||
/// <param name="pageSize">Page size (default: 10, max: 100)</param>
|
||||
/// <param name="search">Search term for email, first name, or last name</param>
|
||||
/// <param name="isActive">Filter by active status</param>
|
||||
/// <returns>Paginated list of users</returns>
|
||||
[HttpGet]
|
||||
public async Task<ActionResult<object>> GetUsers(
|
||||
[FromQuery] int page = 1,
|
||||
[FromQuery] int pageSize = 10,
|
||||
[FromQuery] string? search = null,
|
||||
[FromQuery] bool? isActive = null)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Validate pagination parameters
|
||||
page = Math.Max(1, page);
|
||||
pageSize = Math.Min(100, Math.Max(1, pageSize));
|
||||
|
||||
var query = _context.Users.Include(u => u.OAuthProviders).AsQueryable();
|
||||
|
||||
// Apply filters
|
||||
if (isActive.HasValue)
|
||||
{
|
||||
query = query.Where(u => u.IsActive == isActive.Value);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(search))
|
||||
{
|
||||
var searchTerm = search.ToLower();
|
||||
query = query.Where(u =>
|
||||
u.Email.ToLower().Contains(searchTerm) ||
|
||||
(u.FirstName != null && u.FirstName.ToLower().Contains(searchTerm)) ||
|
||||
(u.LastName != null && u.LastName.ToLower().Contains(searchTerm)));
|
||||
}
|
||||
|
||||
var totalCount = await query.CountAsync();
|
||||
var users = await query
|
||||
.OrderByDescending(u => u.CreatedAt)
|
||||
.Skip((page - 1) * pageSize)
|
||||
.Take(pageSize)
|
||||
.Select(u => new UserDto
|
||||
{
|
||||
Id = u.Id,
|
||||
Email = u.Email,
|
||||
FirstName = u.FirstName,
|
||||
LastName = u.LastName,
|
||||
ProfilePictureUrl = u.ProfilePictureUrl,
|
||||
CreatedAt = u.CreatedAt,
|
||||
LastLoginAt = u.LastLoginAt,
|
||||
IsActive = u.IsActive,
|
||||
OAuthProviders = u.OAuthProviders.Select(op => new UserOAuthProviderDto
|
||||
{
|
||||
Id = op.Id,
|
||||
Provider = op.Provider,
|
||||
ProviderId = op.ProviderId,
|
||||
ProviderEmail = op.ProviderEmail,
|
||||
ProviderName = op.ProviderName,
|
||||
CreatedAt = op.CreatedAt,
|
||||
LastUsedAt = op.LastUsedAt
|
||||
}).ToList()
|
||||
})
|
||||
.ToListAsync();
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
users,
|
||||
pagination = new
|
||||
{
|
||||
page,
|
||||
pageSize,
|
||||
totalCount,
|
||||
totalPages = (int)Math.Ceiling(totalCount / (double)pageSize),
|
||||
hasNext = page * pageSize < totalCount,
|
||||
hasPrevious = page > 1
|
||||
}
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error retrieving users");
|
||||
return StatusCode(500, "Internal server error while retrieving users");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get a specific user by ID
|
||||
/// </summary>
|
||||
/// <param name="id">User ID</param>
|
||||
/// <returns>User details</returns>
|
||||
[HttpGet("{id}")]
|
||||
public async Task<ActionResult<UserDto>> GetUser(int id)
|
||||
{
|
||||
try
|
||||
{
|
||||
var user = await _context.Users
|
||||
.Include(u => u.OAuthProviders)
|
||||
.FirstOrDefaultAsync(u => u.Id == id);
|
||||
|
||||
if (user == null)
|
||||
{
|
||||
return NotFound($"User with ID {id} not found");
|
||||
}
|
||||
|
||||
var userDto = new UserDto
|
||||
{
|
||||
Id = user.Id,
|
||||
Email = user.Email,
|
||||
FirstName = user.FirstName,
|
||||
LastName = user.LastName,
|
||||
ProfilePictureUrl = user.ProfilePictureUrl,
|
||||
CreatedAt = user.CreatedAt,
|
||||
LastLoginAt = user.LastLoginAt,
|
||||
IsActive = user.IsActive,
|
||||
OAuthProviders = user.OAuthProviders.Select(op => new UserOAuthProviderDto
|
||||
{
|
||||
Id = op.Id,
|
||||
Provider = op.Provider,
|
||||
ProviderId = op.ProviderId,
|
||||
ProviderEmail = op.ProviderEmail,
|
||||
ProviderName = op.ProviderName,
|
||||
CreatedAt = op.CreatedAt,
|
||||
LastUsedAt = op.LastUsedAt
|
||||
}).ToList()
|
||||
};
|
||||
|
||||
return Ok(userDto);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error retrieving user with ID {UserId}", id);
|
||||
return StatusCode(500, "Internal server error while retrieving user");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get the current user's profile
|
||||
/// </summary>
|
||||
/// <returns>Current user's profile</returns>
|
||||
[HttpGet("me")]
|
||||
public async Task<ActionResult<UserDto>> GetCurrentUser()
|
||||
{
|
||||
try
|
||||
{
|
||||
var userIdClaim = User.FindFirst("user_id")?.Value;
|
||||
if (string.IsNullOrEmpty(userIdClaim) || !int.TryParse(userIdClaim, out var userId))
|
||||
{
|
||||
return Unauthorized("Invalid user token");
|
||||
}
|
||||
|
||||
return await GetUser(userId);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error retrieving current user profile");
|
||||
return StatusCode(500, "Internal server error while retrieving current user");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Create a new user
|
||||
/// </summary>
|
||||
/// <param name="request">User creation request</param>
|
||||
/// <returns>Created user</returns>
|
||||
[HttpPost]
|
||||
public async Task<ActionResult<UserDto>> CreateUser([FromBody] CreateUserRequest request)
|
||||
{
|
||||
if (!ModelState.IsValid)
|
||||
{
|
||||
return BadRequest(ModelState);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// Check if user with this email already exists
|
||||
var existingUser = await _context.Users
|
||||
.FirstOrDefaultAsync(u => u.Email == request.Email);
|
||||
|
||||
if (existingUser != null)
|
||||
{
|
||||
return Conflict($"User with email {request.Email} already exists");
|
||||
}
|
||||
|
||||
var user = new User
|
||||
{
|
||||
Email = request.Email,
|
||||
FirstName = request.FirstName,
|
||||
LastName = request.LastName,
|
||||
ProfilePictureUrl = request.ProfilePictureUrl,
|
||||
IsActive = request.IsActive,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
};
|
||||
|
||||
_context.Users.Add(user);
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
_logger.LogInformation("Created new user with ID {UserId} and email {Email}", user.Id, user.Email);
|
||||
|
||||
var userDto = new UserDto
|
||||
{
|
||||
Id = user.Id,
|
||||
Email = user.Email,
|
||||
FirstName = user.FirstName,
|
||||
LastName = user.LastName,
|
||||
ProfilePictureUrl = user.ProfilePictureUrl,
|
||||
CreatedAt = user.CreatedAt,
|
||||
LastLoginAt = user.LastLoginAt,
|
||||
IsActive = user.IsActive,
|
||||
OAuthProviders = new List<UserOAuthProviderDto>()
|
||||
};
|
||||
|
||||
return CreatedAtAction(nameof(GetUser), new { id = user.Id }, userDto);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error creating user with email {Email}", request.Email);
|
||||
return StatusCode(500, "Internal server error while creating user");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Update a user
|
||||
/// </summary>
|
||||
/// <param name="id">User ID</param>
|
||||
/// <param name="request">User update request</param>
|
||||
/// <returns>No content on success</returns>
|
||||
[HttpPut("{id}")]
|
||||
public async Task<IActionResult> UpdateUser(int id, [FromBody] UpdateUserRequest request)
|
||||
{
|
||||
if (!ModelState.IsValid)
|
||||
{
|
||||
return BadRequest(ModelState);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var user = await _context.Users.FindAsync(id);
|
||||
if (user == null)
|
||||
{
|
||||
return NotFound($"User with ID {id} not found");
|
||||
}
|
||||
|
||||
// Update only provided fields
|
||||
if (request.FirstName != null)
|
||||
{
|
||||
user.FirstName = request.FirstName;
|
||||
}
|
||||
|
||||
if (request.LastName != null)
|
||||
{
|
||||
user.LastName = request.LastName;
|
||||
}
|
||||
|
||||
if (request.ProfilePictureUrl != null)
|
||||
{
|
||||
user.ProfilePictureUrl = request.ProfilePictureUrl;
|
||||
}
|
||||
|
||||
if (request.IsActive.HasValue)
|
||||
{
|
||||
user.IsActive = request.IsActive.Value;
|
||||
}
|
||||
|
||||
_context.Entry(user).State = EntityState.Modified;
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
_logger.LogInformation("Updated user with ID {UserId}", id);
|
||||
|
||||
return NoContent();
|
||||
}
|
||||
catch (DbUpdateConcurrencyException)
|
||||
{
|
||||
if (!await _context.Users.AnyAsync(e => e.Id == id))
|
||||
{
|
||||
return NotFound($"User with ID {id} not found");
|
||||
}
|
||||
else
|
||||
{
|
||||
throw;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error updating user with ID {UserId}", id);
|
||||
return StatusCode(500, "Internal server error while updating user");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Update the current user's profile
|
||||
/// </summary>
|
||||
/// <param name="request">User update request</param>
|
||||
/// <returns>No content on success</returns>
|
||||
[HttpPut("me")]
|
||||
public async Task<IActionResult> UpdateCurrentUser([FromBody] UpdateUserRequest request)
|
||||
{
|
||||
try
|
||||
{
|
||||
var userIdClaim = User.FindFirst("user_id")?.Value;
|
||||
if (string.IsNullOrEmpty(userIdClaim) || !int.TryParse(userIdClaim, out var userId))
|
||||
{
|
||||
return Unauthorized("Invalid user token");
|
||||
}
|
||||
|
||||
return await UpdateUser(userId, request);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error updating current user profile");
|
||||
return StatusCode(500, "Internal server error while updating current user");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Delete a user (soft delete by setting IsActive to false)
|
||||
/// </summary>
|
||||
/// <param name="id">User ID</param>
|
||||
/// <returns>No content on success</returns>
|
||||
[HttpDelete("{id}")]
|
||||
public async Task<IActionResult> DeleteUser(int id)
|
||||
{
|
||||
try
|
||||
{
|
||||
var user = await _context.Users.FindAsync(id);
|
||||
if (user == null)
|
||||
{
|
||||
return NotFound($"User with ID {id} not found");
|
||||
}
|
||||
|
||||
// Soft delete by setting IsActive to false
|
||||
user.IsActive = false;
|
||||
_context.Entry(user).State = EntityState.Modified;
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
_logger.LogInformation("Soft deleted user with ID {UserId}", id);
|
||||
|
||||
return NoContent();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error deleting user with ID {UserId}", id);
|
||||
return StatusCode(500, "Internal server error while deleting user");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Permanently delete a user and all associated OAuth providers
|
||||
/// </summary>
|
||||
/// <param name="id">User ID</param>
|
||||
/// <returns>No content on success</returns>
|
||||
[HttpDelete("{id}/permanent")]
|
||||
public async Task<IActionResult> PermanentlyDeleteUser(int id)
|
||||
{
|
||||
try
|
||||
{
|
||||
var user = await _context.Users
|
||||
.Include(u => u.OAuthProviders)
|
||||
.FirstOrDefaultAsync(u => u.Id == id);
|
||||
|
||||
if (user == null)
|
||||
{
|
||||
return NotFound($"User with ID {id} not found");
|
||||
}
|
||||
|
||||
// Remove all OAuth providers first due to foreign key constraints
|
||||
_context.UserOAuthProviders.RemoveRange(user.OAuthProviders);
|
||||
|
||||
// Remove the user
|
||||
_context.Users.Remove(user);
|
||||
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
_logger.LogInformation("Permanently deleted user with ID {UserId} and {ProviderCount} OAuth providers",
|
||||
id, user.OAuthProviders.Count);
|
||||
|
||||
return NoContent();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error permanently deleting user with ID {UserId}", id);
|
||||
return StatusCode(500, "Internal server error while permanently deleting user");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reactivate a soft-deleted user
|
||||
/// </summary>
|
||||
/// <param name="id">User ID</param>
|
||||
/// <returns>No content on success</returns>
|
||||
[HttpPost("{id}/reactivate")]
|
||||
public async Task<IActionResult> ReactivateUser(int id)
|
||||
{
|
||||
try
|
||||
{
|
||||
var user = await _context.Users.FindAsync(id);
|
||||
if (user == null)
|
||||
{
|
||||
return NotFound($"User with ID {id} not found");
|
||||
}
|
||||
|
||||
user.IsActive = true;
|
||||
_context.Entry(user).State = EntityState.Modified;
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
_logger.LogInformation("Reactivated user with ID {UserId}", id);
|
||||
|
||||
return NoContent();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error reactivating user with ID {UserId}", id);
|
||||
return StatusCode(500, "Internal server error while reactivating user");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get user statistics
|
||||
/// </summary>
|
||||
/// <returns>User statistics</returns>
|
||||
[HttpGet("statistics")]
|
||||
public async Task<ActionResult<object>> GetUserStatistics()
|
||||
{
|
||||
try
|
||||
{
|
||||
var totalUsers = await _context.Users.CountAsync();
|
||||
var activeUsers = await _context.Users.CountAsync(u => u.IsActive);
|
||||
var inactiveUsers = totalUsers - activeUsers;
|
||||
var usersWithLogin = await _context.Users.CountAsync(u => u.LastLoginAt != null);
|
||||
var recentUsers = await _context.Users.CountAsync(u => u.CreatedAt >= DateTime.UtcNow.AddDays(-30));
|
||||
|
||||
var providerStats = await _context.UserOAuthProviders
|
||||
.GroupBy(op => op.Provider)
|
||||
.Select(g => new
|
||||
{
|
||||
Provider = g.Key.ToString(),
|
||||
Count = g.Count(),
|
||||
UniqueUsers = g.Select(op => op.UserId).Distinct().Count()
|
||||
})
|
||||
.ToListAsync();
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
totalUsers,
|
||||
activeUsers,
|
||||
inactiveUsers,
|
||||
usersWithLogin,
|
||||
recentUsers,
|
||||
providerStatistics = providerStats
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error retrieving user statistics");
|
||||
return StatusCode(500, "Internal server error while retrieving statistics");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
72
Api/Controllers/WebMessagesController.cs
Normal file
@@ -0,0 +1,72 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using Api.Helpers;
|
||||
using Api.Models;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Api.Controllers
|
||||
{
|
||||
// Controller to expose WebMessage read endpoints for the SPA.
|
||||
// Assumes an EF DbContext named ApiDbContext with DbSet<WebMessage> WebMessages.
|
||||
[ApiController]
|
||||
[Route("api/[controller]")]
|
||||
public class WebMessagesController : ControllerBase
|
||||
{
|
||||
private readonly AppDbContext _context;
|
||||
|
||||
public WebMessagesController(AppDbContext context)
|
||||
{
|
||||
_context = context;
|
||||
}
|
||||
|
||||
// GET: api/webmessages
|
||||
// Optional pagination via ?page=1&pageSize=20
|
||||
[HttpGet]
|
||||
public async Task<ActionResult<IEnumerable<WebMessage>>> GetAll([FromQuery] int? page, [FromQuery] int? pageSize)
|
||||
{
|
||||
var query = _context.WebMessages.AsNoTracking().OrderByDescending(m => m.Id).AsQueryable();
|
||||
|
||||
if (page.HasValue && pageSize.HasValue && page > 0 && pageSize > 0)
|
||||
{
|
||||
var skip = (page.Value - 1) * pageSize.Value;
|
||||
query = query.Skip(skip).Take(pageSize.Value);
|
||||
}
|
||||
|
||||
var list = await query.ToListAsync();
|
||||
return Ok(list);
|
||||
}
|
||||
|
||||
// GET: api/webmessages/5
|
||||
[HttpGet("{id:int}")]
|
||||
public async Task<ActionResult<WebMessage>> GetById(int id)
|
||||
{
|
||||
var message = await _context.WebMessages.AsNoTracking().FirstOrDefaultAsync(m => m.Id == id);
|
||||
if (message == null) return NotFound();
|
||||
return Ok(message);
|
||||
}
|
||||
|
||||
// POST: api/webmessages
|
||||
// Saves a new WebMessage. Expects JSON body. Returns 201 with Location header.
|
||||
[HttpPost]
|
||||
public async Task<ActionResult<WebMessage>> Create([FromBody] WebMessage message)
|
||||
{
|
||||
if (message == null)
|
||||
return BadRequest();
|
||||
|
||||
// optional: basic server-side validation
|
||||
if (string.IsNullOrWhiteSpace(message.Message) && string.IsNullOrWhiteSpace(message.Subject))
|
||||
return BadRequest("Message or Subject is required.");
|
||||
|
||||
// optional: validate ReCaptcha token
|
||||
// if(ReCaptchaAssessment.CheckToken(message.RecaptchaToken, out string reason) == false)
|
||||
// return BadRequest($"ReCaptcha validation failed: {reason}");
|
||||
|
||||
_context.WebMessages.Add(message);
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
return CreatedAtAction(nameof(GetById), new { id = message.Id }, message);
|
||||
}
|
||||
}
|
||||
}
|
||||
30
Api/Dockerfile
Executable file
@@ -0,0 +1,30 @@
|
||||
# See https://aka.ms/customizecontainer to learn how to customize your debug container and how Visual Studio uses this Dockerfile to build your images for faster debugging.
|
||||
|
||||
# This stage is used when running from VS in fast mode (Default for Debug configuration)
|
||||
FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS base
|
||||
USER $APP_UID
|
||||
WORKDIR /app
|
||||
EXPOSE 8080
|
||||
EXPOSE 8081
|
||||
|
||||
|
||||
# This stage is used to build the service project
|
||||
FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
|
||||
ARG BUILD_CONFIGURATION=Release
|
||||
WORKDIR /src
|
||||
COPY ["Api.csproj", "."]
|
||||
RUN dotnet restore "./Api.csproj"
|
||||
COPY . .
|
||||
WORKDIR "/src/."
|
||||
RUN dotnet build "./Api.csproj" -c $BUILD_CONFIGURATION -o /app/build
|
||||
|
||||
# This stage is used to publish the service project to be copied to the final stage
|
||||
FROM build AS publish
|
||||
ARG BUILD_CONFIGURATION=Release
|
||||
RUN dotnet publish "./Api.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false
|
||||
|
||||
# This stage is used in production or when running from VS in regular mode (Default when not using the Debug configuration)
|
||||
FROM base AS final
|
||||
WORKDIR /app
|
||||
COPY --from=publish /app/publish .
|
||||
ENTRYPOINT ["dotnet", "Api.dll"]
|
||||
62
Api/Helpers/ReCapthchaAssessment.cs
Normal file
@@ -0,0 +1,62 @@
|
||||
using System;
|
||||
using Google.Api.Gax.ResourceNames;
|
||||
using Google.Cloud.RecaptchaEnterprise.V1;
|
||||
|
||||
namespace Api.Helpers
|
||||
{
|
||||
public static class ReCaptchaAssessment
|
||||
{
|
||||
// Checks the supplied token with Recaptcha Enterprise and returns true if valid.
|
||||
// Outputs a short reason when false (invalid reason or exception message).
|
||||
public static bool CheckToken(string token, out string reason, string projectId = "webserverfarm", string recaptchaKey = "6Lf6df0rAAAAAMXcAx1umneXl1QJo9rTrflpWCvB")
|
||||
{
|
||||
reason = string.Empty;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(token))
|
||||
{
|
||||
reason = "empty-token";
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var client = RecaptchaEnterpriseServiceClient.Create();
|
||||
|
||||
var request = new CreateAssessmentRequest
|
||||
{
|
||||
Parent = $"projects/{projectId}",
|
||||
Assessment = new Assessment
|
||||
{
|
||||
Event = new Event
|
||||
{
|
||||
SiteKey = recaptchaKey,
|
||||
Token = token
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
var response = client.CreateAssessment(request);
|
||||
|
||||
if (response?.TokenProperties == null)
|
||||
{
|
||||
reason = "missing-token-properties";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!response.TokenProperties.Valid)
|
||||
{
|
||||
reason = response.TokenProperties.InvalidReason.ToString();
|
||||
return false;
|
||||
}
|
||||
|
||||
reason = "valid";
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
reason = ex.Message;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
56
Api/Migrations/20250727154636_InitialCreate.Designer.cs
generated
Executable file
@@ -0,0 +1,56 @@
|
||||
// <auto-generated />
|
||||
using Api.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Metadata;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Api.Migrations
|
||||
{
|
||||
[DbContext(typeof(AppDbContext))]
|
||||
[Migration("20250727154636_InitialCreate")]
|
||||
partial class InitialCreate
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasAnnotation("ProductVersion", "9.0.7")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 128);
|
||||
|
||||
SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("Api.Models.Product", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("int");
|
||||
|
||||
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<string>("Name")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<decimal>("Price")
|
||||
.HasColumnType("decimal(18,2)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("Products");
|
||||
|
||||
b.HasData(
|
||||
new
|
||||
{
|
||||
Id = 1,
|
||||
Name = "Sample Product",
|
||||
Price = 9.99m
|
||||
});
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
40
Api/Migrations/20250727154636_InitialCreate.cs
Executable file
@@ -0,0 +1,40 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Api.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class InitialCreate : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "Products",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "int", nullable: false)
|
||||
.Annotation("SqlServer:Identity", "1, 1"),
|
||||
Name = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||
Price = table.Column<decimal>(type: "decimal(18,2)", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_Products", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.InsertData(
|
||||
table: "Products",
|
||||
columns: new[] { "Id", "Name", "Price" },
|
||||
values: new object[] { 1, "Sample Product", 9.99m });
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "Products");
|
||||
}
|
||||
}
|
||||
}
|
||||
87
Api/Migrations/20251031151451_AddWebMessages.Designer.cs
generated
Normal file
@@ -0,0 +1,87 @@
|
||||
// <auto-generated />
|
||||
using Api.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Metadata;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Api.Migrations
|
||||
{
|
||||
[DbContext(typeof(AppDbContext))]
|
||||
[Migration("20251031151451_AddWebMessages")]
|
||||
partial class AddWebMessages
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasAnnotation("ProductVersion", "8.0.18")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 128);
|
||||
|
||||
SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("Api.Models.Product", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("int");
|
||||
|
||||
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<string>("Name")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<decimal>("Price")
|
||||
.HasColumnType("decimal(18,2)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("Products");
|
||||
|
||||
b.HasData(
|
||||
new
|
||||
{
|
||||
Id = 1,
|
||||
Name = "Sample Product",
|
||||
Price = 9.99m
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("WebMessage", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("int");
|
||||
|
||||
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<string>("Email")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("Message")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("Phone")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("Subject")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("Surname")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("WebMessages");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
39
Api/Migrations/20251031151451_AddWebMessages.cs
Normal file
@@ -0,0 +1,39 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Api.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddWebMessages : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "WebMessages",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "int", nullable: false)
|
||||
.Annotation("SqlServer:Identity", "1, 1"),
|
||||
Name = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||
Surname = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||
Email = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||
Phone = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||
Subject = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||
Message = table.Column<string>(type: "nvarchar(max)", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_WebMessages", x => x.Id);
|
||||
});
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "WebMessages");
|
||||
}
|
||||
}
|
||||
}
|
||||
189
Api/Migrations/20251107172421_AddUserAuthentication.Designer.cs
generated
Normal file
@@ -0,0 +1,189 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using Api.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Metadata;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Api.Migrations
|
||||
{
|
||||
[DbContext(typeof(AppDbContext))]
|
||||
[Migration("20251107172421_AddUserAuthentication")]
|
||||
partial class AddUserAuthentication
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasAnnotation("ProductVersion", "8.0.18")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 128);
|
||||
|
||||
SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("Api.Models.Product", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("int");
|
||||
|
||||
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<string>("Name")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<decimal>("Price")
|
||||
.HasColumnType("decimal(18,2)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("Products");
|
||||
|
||||
b.HasData(
|
||||
new
|
||||
{
|
||||
Id = 1,
|
||||
Name = "Sample Product",
|
||||
Price = 9.99m
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Api.Models.User", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("int");
|
||||
|
||||
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<string>("Email")
|
||||
.IsRequired()
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("nvarchar(255)");
|
||||
|
||||
b.Property<string>("FirstName")
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("nvarchar(255)");
|
||||
|
||||
b.Property<bool>("IsActive")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<DateTime?>("LastLoginAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<string>("LastName")
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("nvarchar(255)");
|
||||
|
||||
b.Property<string>("ProfilePictureUrl")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("nvarchar(500)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Email")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("Users");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Api.Models.UserOAuthProvider", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("int");
|
||||
|
||||
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<DateTime?>("LastUsedAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<int>("Provider")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<string>("ProviderEmail")
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("nvarchar(255)");
|
||||
|
||||
b.Property<string>("ProviderId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("nvarchar(255)");
|
||||
|
||||
b.Property<string>("ProviderName")
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("nvarchar(255)");
|
||||
|
||||
b.Property<int>("UserId")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("UserId");
|
||||
|
||||
b.HasIndex("Provider", "ProviderId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("UserOAuthProviders");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("WebMessage", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("int");
|
||||
|
||||
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<string>("Email")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("Message")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("Phone")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("Subject")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("Surname")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("WebMessages");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Api.Models.UserOAuthProvider", b =>
|
||||
{
|
||||
b.HasOne("Api.Models.User", "User")
|
||||
.WithMany("OAuthProviders")
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("User");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Api.Models.User", b =>
|
||||
{
|
||||
b.Navigation("OAuthProviders");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
86
Api/Migrations/20251107172421_AddUserAuthentication.cs
Normal file
@@ -0,0 +1,86 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Api.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddUserAuthentication : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "Users",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "int", nullable: false)
|
||||
.Annotation("SqlServer:Identity", "1, 1"),
|
||||
Email = table.Column<string>(type: "nvarchar(255)", maxLength: 255, nullable: false),
|
||||
FirstName = table.Column<string>(type: "nvarchar(255)", maxLength: 255, nullable: true),
|
||||
LastName = table.Column<string>(type: "nvarchar(255)", maxLength: 255, nullable: true),
|
||||
ProfilePictureUrl = table.Column<string>(type: "nvarchar(500)", maxLength: 500, nullable: true),
|
||||
CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: false),
|
||||
LastLoginAt = table.Column<DateTime>(type: "datetime2", nullable: true),
|
||||
IsActive = table.Column<bool>(type: "bit", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_Users", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "UserOAuthProviders",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "int", nullable: false)
|
||||
.Annotation("SqlServer:Identity", "1, 1"),
|
||||
UserId = table.Column<int>(type: "int", nullable: false),
|
||||
Provider = table.Column<int>(type: "int", nullable: false),
|
||||
ProviderId = table.Column<string>(type: "nvarchar(255)", maxLength: 255, nullable: false),
|
||||
ProviderEmail = table.Column<string>(type: "nvarchar(255)", maxLength: 255, nullable: true),
|
||||
ProviderName = table.Column<string>(type: "nvarchar(255)", maxLength: 255, nullable: true),
|
||||
CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: false),
|
||||
LastUsedAt = table.Column<DateTime>(type: "datetime2", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_UserOAuthProviders", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_UserOAuthProviders_Users_UserId",
|
||||
column: x => x.UserId,
|
||||
principalTable: "Users",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_UserOAuthProviders_Provider_ProviderId",
|
||||
table: "UserOAuthProviders",
|
||||
columns: new[] { "Provider", "ProviderId" },
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_UserOAuthProviders_UserId",
|
||||
table: "UserOAuthProviders",
|
||||
column: "UserId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Users_Email",
|
||||
table: "Users",
|
||||
column: "Email",
|
||||
unique: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "UserOAuthProviders");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "Users");
|
||||
}
|
||||
}
|
||||
}
|
||||
186
Api/Migrations/AppDbContextModelSnapshot.cs
Executable file
@@ -0,0 +1,186 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using Api.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Metadata;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Api.Migrations
|
||||
{
|
||||
[DbContext(typeof(AppDbContext))]
|
||||
partial class AppDbContextModelSnapshot : ModelSnapshot
|
||||
{
|
||||
protected override void BuildModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasAnnotation("ProductVersion", "8.0.18")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 128);
|
||||
|
||||
SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("Api.Models.Product", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("int");
|
||||
|
||||
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<string>("Name")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<decimal>("Price")
|
||||
.HasColumnType("decimal(18,2)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("Products");
|
||||
|
||||
b.HasData(
|
||||
new
|
||||
{
|
||||
Id = 1,
|
||||
Name = "Sample Product",
|
||||
Price = 9.99m
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Api.Models.User", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("int");
|
||||
|
||||
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<string>("Email")
|
||||
.IsRequired()
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("nvarchar(255)");
|
||||
|
||||
b.Property<string>("FirstName")
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("nvarchar(255)");
|
||||
|
||||
b.Property<bool>("IsActive")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<DateTime?>("LastLoginAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<string>("LastName")
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("nvarchar(255)");
|
||||
|
||||
b.Property<string>("ProfilePictureUrl")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("nvarchar(500)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Email")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("Users");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Api.Models.UserOAuthProvider", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("int");
|
||||
|
||||
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<DateTime?>("LastUsedAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<int>("Provider")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<string>("ProviderEmail")
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("nvarchar(255)");
|
||||
|
||||
b.Property<string>("ProviderId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("nvarchar(255)");
|
||||
|
||||
b.Property<string>("ProviderName")
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("nvarchar(255)");
|
||||
|
||||
b.Property<int>("UserId")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("UserId");
|
||||
|
||||
b.HasIndex("Provider", "ProviderId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("UserOAuthProviders");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("WebMessage", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("int");
|
||||
|
||||
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<string>("Email")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("Message")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("Phone")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("Subject")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("Surname")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("WebMessages");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Api.Models.UserOAuthProvider", b =>
|
||||
{
|
||||
b.HasOne("Api.Models.User", "User")
|
||||
.WithMany("OAuthProviders")
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("User");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Api.Models.User", b =>
|
||||
{
|
||||
b.Navigation("OAuthProviders");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
57
Api/Models/AppDbContext.cs
Executable file
@@ -0,0 +1,57 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace Api.Models
|
||||
{
|
||||
public class Product
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string? Name { get; set; }
|
||||
public decimal Price { get; set; }
|
||||
}
|
||||
|
||||
public class AppDbContext : DbContext
|
||||
{
|
||||
public AppDbContext(DbContextOptions<AppDbContext> options) : base(options) { }
|
||||
public DbSet<Product> Products { get; set; }
|
||||
public DbSet<WebMessage> WebMessages { get; set; }
|
||||
public DbSet<User> Users { get; set; }
|
||||
public DbSet<UserOAuthProvider> UserOAuthProviders { get; set; }
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
base.OnModelCreating(modelBuilder);
|
||||
|
||||
// Configure User entity
|
||||
modelBuilder.Entity<User>(entity =>
|
||||
{
|
||||
entity.HasKey(e => e.Id);
|
||||
entity.HasIndex(e => e.Email).IsUnique();
|
||||
entity.Property(e => e.Email).IsRequired().HasMaxLength(255);
|
||||
entity.Property(e => e.FirstName).HasMaxLength(255);
|
||||
entity.Property(e => e.LastName).HasMaxLength(255);
|
||||
entity.Property(e => e.ProfilePictureUrl).HasMaxLength(500);
|
||||
});
|
||||
|
||||
// Configure UserOAuthProvider entity
|
||||
modelBuilder.Entity<UserOAuthProvider>(entity =>
|
||||
{
|
||||
entity.HasKey(e => e.Id);
|
||||
entity.HasIndex(e => new { e.Provider, e.ProviderId }).IsUnique();
|
||||
entity.Property(e => e.ProviderId).IsRequired().HasMaxLength(255);
|
||||
entity.Property(e => e.ProviderEmail).HasMaxLength(255);
|
||||
entity.Property(e => e.ProviderName).HasMaxLength(255);
|
||||
|
||||
entity.HasOne(e => e.User)
|
||||
.WithMany(u => u.OAuthProviders)
|
||||
.HasForeignKey(e => e.UserId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
});
|
||||
|
||||
// Seed data
|
||||
modelBuilder.Entity<Product>().HasData(
|
||||
new Product { Id = 1, Name = "Sample Product", Price = 9.99M }
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
117
Api/Models/DTOs/AuthenticationDtos.cs
Normal file
@@ -0,0 +1,117 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace Api.Models.DTOs
|
||||
{
|
||||
public class AuthenticateRequest
|
||||
{
|
||||
[Required]
|
||||
public string IdToken { get; set; } = string.Empty;
|
||||
|
||||
[Required]
|
||||
public string Provider { get; set; } = string.Empty; // "Microsoft", "Google", "PocketId"
|
||||
|
||||
/// <summary>
|
||||
/// Optional access token for API calls (e.g., Microsoft Graph)
|
||||
/// </summary>
|
||||
public string? AccessToken { get; set; }
|
||||
}
|
||||
|
||||
public class AuthenticateResponse
|
||||
{
|
||||
public string AccessToken { get; set; } = string.Empty;
|
||||
public DateTime ExpiresAt { get; set; }
|
||||
public UserProfile User { get; set; } = null!;
|
||||
public bool IsNewUser { get; set; }
|
||||
}
|
||||
|
||||
public class UserProfile
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string Email { get; set; } = string.Empty;
|
||||
public string? FirstName { get; set; }
|
||||
public string? LastName { get; set; }
|
||||
public string? ProfilePictureUrl { get; set; }
|
||||
public DateTime CreatedAt { get; set; }
|
||||
public DateTime? LastLoginAt { get; set; }
|
||||
public List<string> Providers { get; set; } = new List<string>();
|
||||
}
|
||||
|
||||
// User CRUD DTOs
|
||||
public class CreateUserRequest
|
||||
{
|
||||
[Required]
|
||||
[EmailAddress]
|
||||
[StringLength(255)]
|
||||
public string Email { get; set; } = string.Empty;
|
||||
|
||||
[StringLength(255)]
|
||||
public string? FirstName { get; set; }
|
||||
|
||||
[StringLength(255)]
|
||||
public string? LastName { get; set; }
|
||||
|
||||
[StringLength(500)]
|
||||
public string? ProfilePictureUrl { get; set; }
|
||||
|
||||
public bool IsActive { get; set; } = true;
|
||||
}
|
||||
|
||||
public class UpdateUserRequest
|
||||
{
|
||||
[StringLength(255)]
|
||||
public string? FirstName { get; set; }
|
||||
|
||||
[StringLength(255)]
|
||||
public string? LastName { get; set; }
|
||||
|
||||
[StringLength(500)]
|
||||
public string? ProfilePictureUrl { get; set; }
|
||||
|
||||
public bool? IsActive { get; set; }
|
||||
}
|
||||
|
||||
public class UserDto
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string Email { get; set; } = string.Empty;
|
||||
public string? FirstName { get; set; }
|
||||
public string? LastName { get; set; }
|
||||
public string? ProfilePictureUrl { get; set; }
|
||||
public DateTime CreatedAt { get; set; }
|
||||
public DateTime? LastLoginAt { get; set; }
|
||||
public bool IsActive { get; set; }
|
||||
public List<UserOAuthProviderDto> OAuthProviders { get; set; } = new List<UserOAuthProviderDto>();
|
||||
}
|
||||
|
||||
public class UserOAuthProviderDto
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public OAuthProvider Provider { get; set; }
|
||||
public string ProviderId { get; set; } = string.Empty;
|
||||
public string? ProviderEmail { get; set; }
|
||||
public string? ProviderName { get; set; }
|
||||
public DateTime CreatedAt { get; set; }
|
||||
public DateTime? LastUsedAt { get; set; }
|
||||
}
|
||||
|
||||
public class JwtSettings
|
||||
{
|
||||
public string SecretKey { get; set; } = string.Empty;
|
||||
public string Issuer { get; set; } = string.Empty;
|
||||
public string Audience { get; set; } = string.Empty;
|
||||
public int ExpirationMinutes { get; set; } = 60;
|
||||
}
|
||||
|
||||
public class OAuthProviderSettings
|
||||
{
|
||||
public Dictionary<string, ProviderConfig> Providers { get; set; } = new Dictionary<string, ProviderConfig>();
|
||||
}
|
||||
|
||||
public class ProviderConfig
|
||||
{
|
||||
public string Authority { get; set; } = string.Empty;
|
||||
public string ClientId { get; set; } = string.Empty;
|
||||
public string? ClientSecret { get; set; }
|
||||
public List<string> ValidAudiences { get; set; } = new List<string>();
|
||||
}
|
||||
}
|
||||
72
Api/Models/User.cs
Normal file
@@ -0,0 +1,72 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
|
||||
namespace Api.Models
|
||||
{
|
||||
public class User
|
||||
{
|
||||
[Key]
|
||||
public int Id { get; set; }
|
||||
|
||||
[Required]
|
||||
[StringLength(255)]
|
||||
public string Email { get; set; } = string.Empty;
|
||||
|
||||
[StringLength(255)]
|
||||
public string? FirstName { get; set; }
|
||||
|
||||
[StringLength(255)]
|
||||
public string? LastName { get; set; }
|
||||
|
||||
[StringLength(500)]
|
||||
public string? ProfilePictureUrl { get; set; }
|
||||
|
||||
[Required]
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
|
||||
public DateTime? LastLoginAt { get; set; }
|
||||
|
||||
public bool IsActive { get; set; } = true;
|
||||
|
||||
// Navigation property for OAuth providers
|
||||
public virtual ICollection<UserOAuthProvider> OAuthProviders { get; set; } = new List<UserOAuthProvider>();
|
||||
}
|
||||
|
||||
public class UserOAuthProvider
|
||||
{
|
||||
[Key]
|
||||
public int Id { get; set; }
|
||||
|
||||
[Required]
|
||||
public int UserId { get; set; }
|
||||
|
||||
[Required]
|
||||
public OAuthProvider Provider { get; set; }
|
||||
|
||||
[Required]
|
||||
[StringLength(255)]
|
||||
public string ProviderId { get; set; } = string.Empty;
|
||||
|
||||
[StringLength(255)]
|
||||
public string? ProviderEmail { get; set; }
|
||||
|
||||
[StringLength(255)]
|
||||
public string? ProviderName { get; set; }
|
||||
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
|
||||
public DateTime? LastUsedAt { get; set; }
|
||||
|
||||
// Navigation property
|
||||
[ForeignKey("UserId")]
|
||||
public virtual User User { get; set; } = null!;
|
||||
}
|
||||
|
||||
public enum OAuthProvider
|
||||
{
|
||||
Microsoft = 1,
|
||||
Google = 2,
|
||||
PocketId = 3
|
||||
// Add more providers as needed
|
||||
}
|
||||
}
|
||||
16
Api/Models/WebMessage.cs
Normal file
@@ -0,0 +1,16 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
|
||||
public class WebMessage
|
||||
{
|
||||
[Key]
|
||||
public int Id { get; set; }
|
||||
public string? Name { get; set; }
|
||||
public string? Surname { get; set; }
|
||||
public string? Email { get; set; }
|
||||
public string? Phone { get; set; }
|
||||
public string? Subject { get; set; }
|
||||
public string? Message { get; set; }
|
||||
[NotMapped]
|
||||
public string? RecaptchaToken { get; set; }
|
||||
}
|
||||
179
Api/Program.cs
Executable file
@@ -0,0 +1,179 @@
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||
using Microsoft.Identity.Abstractions;
|
||||
using Microsoft.Identity.Web;
|
||||
using Microsoft.Identity.Web.Resource;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using System.Text;
|
||||
using Api.Services;
|
||||
|
||||
namespace Api
|
||||
{
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Api.Models;
|
||||
using Microsoft.AspNetCore.Rewrite;
|
||||
|
||||
public static class Program
|
||||
{
|
||||
public static void Main(string[] args)
|
||||
{
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
// Add services to the container.
|
||||
|
||||
// Configure JWT authentication for custom tokens
|
||||
var jwtSettings = builder.Configuration.GetSection("Jwt");
|
||||
var secretKey = jwtSettings["SecretKey"];
|
||||
if (string.IsNullOrEmpty(secretKey))
|
||||
{
|
||||
throw new InvalidOperationException("JWT SecretKey must be configured");
|
||||
}
|
||||
|
||||
builder.Services.AddAuthentication(options =>
|
||||
{
|
||||
options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
|
||||
options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
|
||||
})
|
||||
.AddJwtBearer(options =>
|
||||
{
|
||||
options.TokenValidationParameters = new TokenValidationParameters
|
||||
{
|
||||
ValidateIssuerSigningKey = true,
|
||||
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(secretKey)),
|
||||
ValidateIssuer = true,
|
||||
ValidIssuer = jwtSettings["Issuer"],
|
||||
ValidateAudience = true,
|
||||
ValidAudience = jwtSettings["Audience"],
|
||||
ValidateLifetime = true,
|
||||
ClockSkew = TimeSpan.FromMinutes(5)
|
||||
};
|
||||
|
||||
options.Events = new JwtBearerEvents
|
||||
{
|
||||
OnAuthenticationFailed = context =>
|
||||
{
|
||||
var logger = context.HttpContext.RequestServices.GetRequiredService<ILoggerFactory>()
|
||||
.CreateLogger("JwtAuthentication");
|
||||
logger.LogWarning("JWT authentication failed: {Exception}", context.Exception.Message);
|
||||
return Task.CompletedTask;
|
||||
},
|
||||
OnTokenValidated = context =>
|
||||
{
|
||||
var logger = context.HttpContext.RequestServices.GetRequiredService<ILoggerFactory>()
|
||||
.CreateLogger("JwtAuthentication");
|
||||
var userId = context.Principal?.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value;
|
||||
logger.LogInformation("JWT token validated for user {UserId}", userId);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
// Add authorization
|
||||
builder.Services.AddAuthorization();
|
||||
|
||||
// Register custom services
|
||||
builder.Services.AddHttpClient<IOAuthValidationService, OAuthValidationService>();
|
||||
builder.Services.AddScoped<IJwtService, JwtService>();
|
||||
builder.Services.AddScoped<IOAuthValidationService, OAuthValidationService>();
|
||||
|
||||
builder.Services.AddCors(options =>
|
||||
{
|
||||
options.AddPolicy("Default", policy =>
|
||||
{
|
||||
var allowedHostsConfiguration = builder.Configuration["CorsOrigins"]?
|
||||
.ToString()
|
||||
.Split(',');
|
||||
|
||||
policy
|
||||
.WithOrigins(allowedHostsConfiguration ?? new[] { "*" })
|
||||
.AllowAnyHeader()
|
||||
.AllowAnyMethod()
|
||||
.AllowCredentials(); // Allow credentials for JWT tokens
|
||||
});
|
||||
});
|
||||
|
||||
builder.Services.AddControllers();
|
||||
|
||||
// Add DbContext with SQL Server
|
||||
builder.Services.AddDbContext<AppDbContext>(options =>
|
||||
options.UseSqlServer(builder.Configuration.GetConnectionString("DefaultConnection")));
|
||||
|
||||
// Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle
|
||||
builder.Services.AddEndpointsApiExplorer();
|
||||
builder.Services.AddSwaggerGen(options =>
|
||||
{
|
||||
options.AddSecurityDefinition("Bearer", new Microsoft.OpenApi.Models.OpenApiSecurityScheme
|
||||
{
|
||||
Name = "Authorization",
|
||||
Type = Microsoft.OpenApi.Models.SecuritySchemeType.Http,
|
||||
Scheme = "Bearer",
|
||||
BearerFormat = "JWT",
|
||||
In = Microsoft.OpenApi.Models.ParameterLocation.Header,
|
||||
Description = "JWT Authorization header using the Bearer scheme."
|
||||
});
|
||||
|
||||
options.AddSecurityRequirement(new Microsoft.OpenApi.Models.OpenApiSecurityRequirement
|
||||
{
|
||||
{
|
||||
new Microsoft.OpenApi.Models.OpenApiSecurityScheme
|
||||
{
|
||||
Reference = new Microsoft.OpenApi.Models.OpenApiReference
|
||||
{
|
||||
Type = Microsoft.OpenApi.Models.ReferenceType.SecurityScheme,
|
||||
Id = "Bearer"
|
||||
}
|
||||
},
|
||||
Array.Empty<string>()
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
app.UseSwagger();
|
||||
app.UseSwaggerUI();
|
||||
|
||||
if (!app.Environment.IsDevelopment())
|
||||
{
|
||||
app.UseHttpsRedirection();
|
||||
}
|
||||
|
||||
// Angular rewrite for SPA hosting
|
||||
var routes = new[] { "api", "swagger" };
|
||||
var rewriteString = String.Join("|", routes);
|
||||
var rewriteOptions = new RewriteOptions()
|
||||
.AddRewrite(@$"^(?!.*?\b({rewriteString}))^(?!.*?\.\b(jpg|jpeg|png|svg|ttf|woff|woff2|html|js|json|css|ico))", "index.html", false);
|
||||
app.UseRewriter(rewriteOptions);
|
||||
|
||||
// Serve static files from the Angular app
|
||||
if (app.Environment.IsDevelopment() && Directory.Exists(Path.Combine(Directory.GetCurrentDirectory(), "../Web/dist/Web/browser")))
|
||||
{
|
||||
var currentDirectory = Directory.GetCurrentDirectory();
|
||||
var staticFilePath = Path.Combine(currentDirectory, "../Web/dist/Web/browser");
|
||||
app.UseDefaultFiles(new DefaultFilesOptions
|
||||
{
|
||||
FileProvider = new Microsoft.Extensions.FileProviders.PhysicalFileProvider(staticFilePath),
|
||||
DefaultFileNames = new List<string> { "index.html" }
|
||||
});
|
||||
app.UseStaticFiles(new StaticFileOptions
|
||||
{
|
||||
FileProvider = new Microsoft.Extensions.FileProviders.PhysicalFileProvider(staticFilePath),
|
||||
RequestPath = ""
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
app.UseDefaultFiles(); // Uses wwwroot by default
|
||||
app.UseStaticFiles();
|
||||
}
|
||||
|
||||
app.UseCors("Default");
|
||||
app.UseAuthentication();
|
||||
app.UseAuthorization();
|
||||
|
||||
app.MapControllers();
|
||||
|
||||
app.Run();
|
||||
}
|
||||
}
|
||||
}
|
||||
42
Api/Properties/launchSettings.json
Executable file
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"profiles": {
|
||||
"https": {
|
||||
"commandName": "Project",
|
||||
"launchBrowser": true,
|
||||
"launchUrl": "swagger",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development",
|
||||
},
|
||||
"dotnetRunMessages": true,
|
||||
"applicationUrl": "https://0.0.0.0:5001;http://0.0.0.0:5000"
|
||||
},
|
||||
"IIS Express": {
|
||||
"commandName": "IISExpress",
|
||||
"launchBrowser": true,
|
||||
"launchUrl": "swagger",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development",
|
||||
}
|
||||
},
|
||||
"Container (Dockerfile)": {
|
||||
"commandName": "Docker",
|
||||
"launchBrowser": true,
|
||||
"launchUrl": "{Scheme}://{ServiceHost}:{ServicePort}/swagger",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_HTTPS_PORTS": "8081",
|
||||
"ASPNETCORE_HTTP_PORTS": "8080"
|
||||
},
|
||||
"publishAllPorts": true,
|
||||
"useSSL": true
|
||||
}
|
||||
},
|
||||
"$schema": "http://json.schemastore.org/launchsettings.json",
|
||||
"iisSettings": {
|
||||
"windowsAuthentication": false,
|
||||
"anonymousAuthentication": true,
|
||||
"iisExpress": {
|
||||
"applicationUrl": "http://localhost:60760",
|
||||
"sslPort": 44315
|
||||
}
|
||||
}
|
||||
}
|
||||
139
Api/Services/JwtService.cs
Normal file
@@ -0,0 +1,139 @@
|
||||
using Api.Models;
|
||||
using Api.Models.DTOs;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using System.IdentityModel.Tokens.Jwt;
|
||||
using System.Security.Claims;
|
||||
using System.Text;
|
||||
|
||||
namespace Api.Services
|
||||
{
|
||||
public interface IJwtService
|
||||
{
|
||||
string GenerateToken(User user);
|
||||
ClaimsPrincipal? ValidateToken(string token);
|
||||
DateTime GetTokenExpiration(string token);
|
||||
}
|
||||
|
||||
public class JwtService : IJwtService
|
||||
{
|
||||
private readonly JwtSettings _jwtSettings;
|
||||
private readonly ILogger<JwtService> _logger;
|
||||
private readonly SymmetricSecurityKey _key;
|
||||
|
||||
public JwtService(IConfiguration configuration, ILogger<JwtService> logger)
|
||||
{
|
||||
_jwtSettings = configuration.GetSection("Jwt").Get<JwtSettings>()
|
||||
?? throw new InvalidOperationException("JWT settings not configured");
|
||||
_logger = logger;
|
||||
_key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_jwtSettings.SecretKey));
|
||||
}
|
||||
|
||||
public string GenerateToken(User user)
|
||||
{
|
||||
try
|
||||
{
|
||||
var tokenHandler = new JwtSecurityTokenHandler();
|
||||
var credentials = new SigningCredentials(_key, SecurityAlgorithms.HmacSha256);
|
||||
|
||||
var claims = new List<Claim>
|
||||
{
|
||||
new Claim(ClaimTypes.NameIdentifier, user.Id.ToString()),
|
||||
new Claim(ClaimTypes.Email, user.Email),
|
||||
new Claim("jti", Guid.NewGuid().ToString()),
|
||||
new Claim("iat", DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString(), ClaimValueTypes.Integer64)
|
||||
};
|
||||
|
||||
// Add optional claims if available
|
||||
if (!string.IsNullOrEmpty(user.FirstName))
|
||||
{
|
||||
claims.Add(new Claim(ClaimTypes.GivenName, user.FirstName));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(user.LastName))
|
||||
{
|
||||
claims.Add(new Claim(ClaimTypes.Surname, user.LastName));
|
||||
}
|
||||
|
||||
// Add provider information
|
||||
var providers = user.OAuthProviders.Select(p => p.Provider.ToString()).ToList();
|
||||
if (providers.Any())
|
||||
{
|
||||
claims.Add(new Claim("providers", string.Join(",", providers)));
|
||||
}
|
||||
|
||||
var tokenDescriptor = new SecurityTokenDescriptor
|
||||
{
|
||||
Subject = new ClaimsIdentity(claims),
|
||||
Expires = DateTime.UtcNow.AddMinutes(_jwtSettings.ExpirationMinutes),
|
||||
SigningCredentials = credentials,
|
||||
Issuer = _jwtSettings.Issuer,
|
||||
Audience = _jwtSettings.Audience
|
||||
};
|
||||
|
||||
var token = tokenHandler.CreateToken(tokenDescriptor);
|
||||
return tokenHandler.WriteToken(token);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error generating JWT token for user {UserId}", user.Id);
|
||||
throw new InvalidOperationException("Failed to generate access token", ex);
|
||||
}
|
||||
}
|
||||
|
||||
public ClaimsPrincipal? ValidateToken(string token)
|
||||
{
|
||||
try
|
||||
{
|
||||
var tokenHandler = new JwtSecurityTokenHandler();
|
||||
|
||||
var validationParameters = new TokenValidationParameters
|
||||
{
|
||||
ValidateIssuerSigningKey = true,
|
||||
IssuerSigningKey = _key,
|
||||
ValidateIssuer = true,
|
||||
ValidIssuer = _jwtSettings.Issuer,
|
||||
ValidateAudience = true,
|
||||
ValidAudience = _jwtSettings.Audience,
|
||||
ValidateLifetime = true,
|
||||
ClockSkew = TimeSpan.FromMinutes(5) // Allow 5 minutes clock skew
|
||||
};
|
||||
|
||||
var principal = tokenHandler.ValidateToken(token, validationParameters, out var validatedToken);
|
||||
|
||||
// Ensure the token is a JWT token with the correct algorithm
|
||||
if (validatedToken is not JwtSecurityToken jwtToken ||
|
||||
!jwtToken.Header.Alg.Equals(SecurityAlgorithms.HmacSha256, StringComparison.InvariantCultureIgnoreCase))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return principal;
|
||||
}
|
||||
catch (SecurityTokenException ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Invalid token validation attempt");
|
||||
return null;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error validating JWT token");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public DateTime GetTokenExpiration(string token)
|
||||
{
|
||||
try
|
||||
{
|
||||
var tokenHandler = new JwtSecurityTokenHandler();
|
||||
var jsonToken = tokenHandler.ReadJwtToken(token);
|
||||
return jsonToken.ValidTo;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error reading token expiration");
|
||||
throw new InvalidOperationException("Invalid token format", ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
507
Api/Services/OAuthValidationService.cs
Normal file
@@ -0,0 +1,507 @@
|
||||
using Api.Models;
|
||||
using Api.Models.DTOs;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using System.IdentityModel.Tokens.Jwt;
|
||||
using System.Security.Claims;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Api.Services
|
||||
{
|
||||
public interface IOAuthValidationService
|
||||
{
|
||||
Task<(bool IsValid, ClaimsPrincipal? Principal, string? ErrorMessage)> ValidateIdTokenAsync(string idToken, string provider);
|
||||
(string Email, string? FirstName, string? LastName, string? ProfilePictureUrl, string ProviderId) ExtractUserInfo(ClaimsPrincipal principal, string provider);
|
||||
Task<(string Email, string? FirstName, string? LastName, string? ProfilePictureUrl, string ProviderId)> ExtractUserInfoAsync(ClaimsPrincipal principal, string provider, string? idToken = null, string? accessToken = null);
|
||||
}
|
||||
|
||||
public class OAuthValidationService : IOAuthValidationService
|
||||
{
|
||||
private readonly OAuthProviderSettings _providerSettings;
|
||||
private readonly ILogger<OAuthValidationService> _logger;
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly JwtSecurityTokenHandler _tokenHandler;
|
||||
|
||||
public OAuthValidationService(
|
||||
IConfiguration configuration,
|
||||
ILogger<OAuthValidationService> logger,
|
||||
HttpClient httpClient)
|
||||
{
|
||||
_providerSettings = configuration.GetSection("OAuth").Get<OAuthProviderSettings>()
|
||||
?? throw new InvalidOperationException("OAuth provider settings not configured");
|
||||
_logger = logger;
|
||||
_httpClient = httpClient;
|
||||
_tokenHandler = new JwtSecurityTokenHandler();
|
||||
}
|
||||
|
||||
public async Task<(bool IsValid, ClaimsPrincipal? Principal, string? ErrorMessage)> ValidateIdTokenAsync(string idToken, string provider)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!_providerSettings.Providers.TryGetValue(provider.ToLowerInvariant(), out var config))
|
||||
{
|
||||
return (false, null, $"Unsupported OAuth provider: {provider}");
|
||||
}
|
||||
|
||||
return provider.ToLowerInvariant() switch
|
||||
{
|
||||
"microsoft" => await ValidateMicrosoftTokenAsync(idToken, config),
|
||||
"google" => await ValidateGoogleTokenAsync(idToken, config),
|
||||
"pocketid" => await ValidatePocketIdTokenAsync(idToken, config),
|
||||
_ => (false, null, $"Provider {provider} validation not implemented")
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error validating {Provider} ID token", provider);
|
||||
return (false, null, "Token validation failed due to internal error");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<(bool IsValid, ClaimsPrincipal? Principal, string? ErrorMessage)> ValidateMicrosoftTokenAsync(string idToken, ProviderConfig config)
|
||||
{
|
||||
try
|
||||
{
|
||||
// For Microsoft, we need to validate against their OIDC discovery document
|
||||
var discoveryUrl = $"{config.Authority}/.well-known/openid-configuration";
|
||||
var discovery = await GetDiscoveryDocumentAsync(discoveryUrl);
|
||||
|
||||
if (discovery == null)
|
||||
{
|
||||
return (false, null, "Failed to retrieve Microsoft discovery document");
|
||||
}
|
||||
|
||||
var validationParameters = new TokenValidationParameters
|
||||
{
|
||||
ValidateIssuerSigningKey = true,
|
||||
IssuerSigningKeys = await GetSigningKeysAsync(discovery.JwksUri),
|
||||
ValidateIssuer = true,
|
||||
ValidIssuer = discovery.Issuer,
|
||||
ValidateAudience = true,
|
||||
ValidAudiences = config.ValidAudiences,
|
||||
ValidateLifetime = true,
|
||||
ClockSkew = TimeSpan.FromMinutes(5)
|
||||
};
|
||||
|
||||
var principal = _tokenHandler.ValidateToken(idToken, validationParameters, out var validatedToken);
|
||||
return (true, principal, null);
|
||||
}
|
||||
catch (SecurityTokenException ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Microsoft token validation failed");
|
||||
return (false, null, "Invalid Microsoft ID token");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<(bool IsValid, ClaimsPrincipal? Principal, string? ErrorMessage)> ValidateGoogleTokenAsync(string idToken, ProviderConfig config)
|
||||
{
|
||||
try
|
||||
{
|
||||
// For Google, use their discovery document
|
||||
var discoveryUrl = "https://accounts.google.com/.well-known/openid-configuration";
|
||||
var discovery = await GetDiscoveryDocumentAsync(discoveryUrl);
|
||||
|
||||
if (discovery == null)
|
||||
{
|
||||
return (false, null, "Failed to retrieve Google discovery document");
|
||||
}
|
||||
|
||||
var validationParameters = new TokenValidationParameters
|
||||
{
|
||||
ValidateIssuerSigningKey = true,
|
||||
IssuerSigningKeys = await GetSigningKeysAsync(discovery.JwksUri),
|
||||
ValidateIssuer = true,
|
||||
ValidIssuers = new[] { "https://accounts.google.com", "accounts.google.com" },
|
||||
ValidateAudience = true,
|
||||
ValidAudiences = config.ValidAudiences,
|
||||
ValidateLifetime = true,
|
||||
ClockSkew = TimeSpan.FromMinutes(5)
|
||||
};
|
||||
|
||||
var principal = _tokenHandler.ValidateToken(idToken, validationParameters, out var validatedToken);
|
||||
return (true, principal, null);
|
||||
}
|
||||
catch (SecurityTokenException ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Google token validation failed");
|
||||
return (false, null, "Invalid Google ID token");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<(bool IsValid, ClaimsPrincipal? Principal, string? ErrorMessage)> ValidatePocketIdTokenAsync(string idToken, ProviderConfig config)
|
||||
{
|
||||
try
|
||||
{
|
||||
var discoveryUrl = $"{config.Authority}/.well-known/openid-configuration";
|
||||
var discovery = await GetDiscoveryDocumentAsync(discoveryUrl);
|
||||
|
||||
if (discovery == null)
|
||||
{
|
||||
return (false, null, "Failed to retrieve PocketId discovery document");
|
||||
}
|
||||
|
||||
var validationParameters = new TokenValidationParameters
|
||||
{
|
||||
ValidateIssuerSigningKey = true,
|
||||
IssuerSigningKeys = await GetSigningKeysAsync(discovery.JwksUri),
|
||||
ValidateIssuer = true,
|
||||
ValidIssuer = discovery.Issuer,
|
||||
ValidateAudience = true,
|
||||
ValidAudiences = config.ValidAudiences,
|
||||
ValidateLifetime = true,
|
||||
ClockSkew = TimeSpan.FromMinutes(5)
|
||||
};
|
||||
|
||||
var principal = _tokenHandler.ValidateToken(idToken, validationParameters, out var validatedToken);
|
||||
return (true, principal, null);
|
||||
}
|
||||
catch (SecurityTokenException ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "PocketId token validation failed");
|
||||
return (false, null, "Invalid PocketId token");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<DiscoveryDocument?> GetDiscoveryDocumentAsync(string discoveryUrl)
|
||||
{
|
||||
try
|
||||
{
|
||||
var response = await _httpClient.GetStringAsync(discoveryUrl);
|
||||
return JsonSerializer.Deserialize<DiscoveryDocument>(response, new JsonSerializerOptions
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to retrieve discovery document from {DiscoveryUrl}", discoveryUrl);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<IEnumerable<SecurityKey>> GetSigningKeysAsync(string jwksUri)
|
||||
{
|
||||
try
|
||||
{
|
||||
var response = await _httpClient.GetStringAsync(jwksUri);
|
||||
var jwks = new JsonWebKeySet(response);
|
||||
return jwks.Keys;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to retrieve signing keys from {JwksUri}", jwksUri);
|
||||
return Enumerable.Empty<SecurityKey>();
|
||||
}
|
||||
}
|
||||
|
||||
public (string Email, string? FirstName, string? LastName, string? ProfilePictureUrl, string ProviderId) ExtractUserInfo(ClaimsPrincipal principal, string provider)
|
||||
{
|
||||
return provider.ToLowerInvariant() switch
|
||||
{
|
||||
"microsoft" => ExtractMicrosoftUserInfo(principal),
|
||||
"google" => ExtractGoogleUserInfo(principal),
|
||||
"pocketid" => ExtractPocketIdUserInfo(principal),
|
||||
_ => throw new ArgumentException($"Unsupported provider: {provider}")
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<(string Email, string? FirstName, string? LastName, string? ProfilePictureUrl, string ProviderId)> ExtractUserInfoAsync(ClaimsPrincipal principal, string provider, string? idToken = null, string? accessToken = null)
|
||||
{
|
||||
return provider.ToLowerInvariant() switch
|
||||
{
|
||||
"microsoft" => await ExtractMicrosoftUserInfoAsync(principal, idToken, accessToken),
|
||||
"google" => ExtractGoogleUserInfo(principal),
|
||||
"pocketid" => ExtractPocketIdUserInfo(principal),
|
||||
_ => throw new ArgumentException($"Unsupported provider: {provider}")
|
||||
};
|
||||
}
|
||||
|
||||
private async Task<(string Email, string? FirstName, string? LastName, string? ProfilePictureUrl, string ProviderId)> ExtractMicrosoftUserInfoAsync(ClaimsPrincipal principal, string? idToken = null, string? accessToken = null)
|
||||
{
|
||||
var email = principal.FindFirst("email")?.Value ?? principal.FindFirst(ClaimTypes.Email)?.Value ?? "";
|
||||
var firstName = principal.FindFirst("given_name")?.Value ?? principal.FindFirst(ClaimTypes.GivenName)?.Value;
|
||||
var lastName = principal.FindFirst("family_name")?.Value ?? principal.FindFirst(ClaimTypes.Surname)?.Value;
|
||||
var providerId = principal.FindFirst("sub")?.Value ?? principal.FindFirst("oid")?.Value ?? "";
|
||||
var profilePicture = principal.FindFirst("picture")?.Value;
|
||||
|
||||
// Log available claims for debugging
|
||||
_logger.LogInformation("Microsoft token claims: {Claims}",
|
||||
string.Join(", ", principal.Claims.Select(c => $"{c.Type}={c.Value}")));
|
||||
|
||||
// Try to get additional info from Microsoft Graph using access token
|
||||
if ((string.IsNullOrEmpty(firstName) || string.IsNullOrEmpty(lastName) || string.IsNullOrEmpty(profilePicture)))
|
||||
{
|
||||
string? tokenToUse = null;
|
||||
|
||||
// Prefer access token over ID token for Microsoft Graph API calls
|
||||
if (!string.IsNullOrEmpty(accessToken))
|
||||
{
|
||||
tokenToUse = accessToken;
|
||||
_logger.LogInformation("Using access token for Microsoft Graph API calls");
|
||||
}
|
||||
else if (!string.IsNullOrEmpty(idToken))
|
||||
{
|
||||
tokenToUse = idToken;
|
||||
_logger.LogWarning("Using ID token for Microsoft Graph API calls (access token preferred)");
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(tokenToUse))
|
||||
{
|
||||
try
|
||||
{
|
||||
var graphProfile = await GetMicrosoftGraphProfileAsync(tokenToUse);
|
||||
if (graphProfile != null)
|
||||
{
|
||||
firstName ??= graphProfile.GivenName;
|
||||
lastName ??= graphProfile.Surname;
|
||||
email = string.IsNullOrEmpty(email) ? (graphProfile.Mail ?? graphProfile.UserPrincipalName ?? email) : email;
|
||||
|
||||
// Try to get profile picture
|
||||
if (string.IsNullOrEmpty(profilePicture))
|
||||
{
|
||||
profilePicture = await GetMicrosoftGraphProfilePictureUrlAsync(tokenToUse);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to get additional user info from Microsoft Graph using {TokenType}",
|
||||
!string.IsNullOrEmpty(accessToken) ? "access token" : "ID token");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If we still don't have name information from the token, try to extract from other claims
|
||||
if (string.IsNullOrEmpty(firstName) && string.IsNullOrEmpty(lastName))
|
||||
{
|
||||
// Try the 'name' claim first
|
||||
var name = principal.FindFirst("name")?.Value ?? principal.FindFirst(ClaimTypes.Name)?.Value;
|
||||
if (!string.IsNullOrEmpty(name))
|
||||
{
|
||||
var nameParts = name.Split(' ', StringSplitOptions.RemoveEmptyEntries);
|
||||
if (nameParts.Length >= 2)
|
||||
{
|
||||
firstName = nameParts[0];
|
||||
lastName = string.Join(" ", nameParts.Skip(1));
|
||||
}
|
||||
else if (nameParts.Length == 1)
|
||||
{
|
||||
firstName = nameParts[0];
|
||||
}
|
||||
}
|
||||
else if (!string.IsNullOrEmpty(email))
|
||||
{
|
||||
// Extract name from email as fallback
|
||||
var emailName = email.Split('@')[0];
|
||||
var emailParts = emailName.Split('.', StringSplitOptions.RemoveEmptyEntries);
|
||||
if (emailParts.Length >= 2)
|
||||
{
|
||||
firstName = FormatNameFromEmail(emailParts[0]);
|
||||
lastName = FormatNameFromEmail(emailParts[1]);
|
||||
}
|
||||
else if (emailParts.Length == 1)
|
||||
{
|
||||
firstName = FormatNameFromEmail(emailName);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (email, firstName, lastName, profilePicture, providerId);
|
||||
}
|
||||
|
||||
private async Task<MicrosoftGraphProfile?> GetMicrosoftGraphProfileAsync(string token)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Try using the token as Authorization Bearer
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, "https://graph.microsoft.com/v1.0/me");
|
||||
request.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", token);
|
||||
|
||||
var response = await _httpClient.SendAsync(request);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
_logger.LogWarning("Microsoft Graph API call failed with status {StatusCode}: {ReasonPhrase}",
|
||||
response.StatusCode, response.ReasonPhrase);
|
||||
return null;
|
||||
}
|
||||
|
||||
var responseContent = await response.Content.ReadAsStringAsync();
|
||||
|
||||
var profile = JsonSerializer.Deserialize<MicrosoftGraphProfile>(responseContent, new JsonSerializerOptions
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
||||
});
|
||||
|
||||
_logger.LogInformation("Successfully retrieved Microsoft Graph profile: {DisplayName}", profile?.DisplayName);
|
||||
return profile;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to get Microsoft Graph profile");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<string?> GetMicrosoftGraphProfilePictureUrlAsync(string token)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Try to get the photo directly - 404 is normal if user has no photo
|
||||
using var photoRequest = new HttpRequestMessage(HttpMethod.Get, "https://graph.microsoft.com/v1.0/me/photo/$value");
|
||||
photoRequest.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", token);
|
||||
|
||||
var photoResponse = await _httpClient.SendAsync(photoRequest);
|
||||
|
||||
if (photoResponse.StatusCode == System.Net.HttpStatusCode.NotFound)
|
||||
{
|
||||
_logger.LogDebug("User has no profile picture in Microsoft Graph (404 - normal behavior)");
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!photoResponse.IsSuccessStatusCode)
|
||||
{
|
||||
_logger.LogDebug("Microsoft Graph photo API call failed with status {StatusCode}: {ReasonPhrase}",
|
||||
photoResponse.StatusCode, photoResponse.ReasonPhrase);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Get the photo bytes and convert to base64 data URL
|
||||
var photoBytes = await photoResponse.Content.ReadAsByteArrayAsync();
|
||||
|
||||
// Check if we got valid image data
|
||||
if (photoBytes == null || photoBytes.Length == 0)
|
||||
{
|
||||
_logger.LogDebug("Microsoft Graph returned empty photo data");
|
||||
return null;
|
||||
}
|
||||
|
||||
var contentType = photoResponse.Content.Headers.ContentType?.MediaType ?? "image/jpeg";
|
||||
var base64Photo = Convert.ToBase64String(photoBytes);
|
||||
var dataUrl = $"data:{contentType};base64,{base64Photo}";
|
||||
|
||||
_logger.LogInformation("Successfully retrieved Microsoft Graph profile picture as base64 data URL ({Size} bytes, type: {ContentType})",
|
||||
photoBytes.Length, contentType);
|
||||
|
||||
return dataUrl;
|
||||
}
|
||||
catch (HttpRequestException ex) when (ex.Message.Contains("404"))
|
||||
{
|
||||
_logger.LogDebug("User has no profile picture in Microsoft Graph (404)");
|
||||
return null;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogDebug(ex, "Failed to get Microsoft Graph profile picture");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private (string Email, string? FirstName, string? LastName, string? ProfilePictureUrl, string ProviderId) ExtractMicrosoftUserInfo(ClaimsPrincipal principal)
|
||||
{
|
||||
var email = principal.FindFirst("email")?.Value ?? principal.FindFirst(ClaimTypes.Email)?.Value ?? "";
|
||||
var firstName = principal.FindFirst("given_name")?.Value ?? principal.FindFirst(ClaimTypes.GivenName)?.Value;
|
||||
var lastName = principal.FindFirst("family_name")?.Value ?? principal.FindFirst(ClaimTypes.Surname)?.Value;
|
||||
var providerId = principal.FindFirst("sub")?.Value ?? principal.FindFirst("oid")?.Value ?? "";
|
||||
var profilePicture = principal.FindFirst("picture")?.Value;
|
||||
|
||||
// Log available claims for debugging
|
||||
_logger.LogInformation("Microsoft token claims: {Claims}",
|
||||
string.Join(", ", principal.Claims.Select(c => $"{c.Type}={c.Value}")));
|
||||
|
||||
// If we don't have name information from the token, try to extract from other claims
|
||||
if (string.IsNullOrEmpty(firstName) && string.IsNullOrEmpty(lastName))
|
||||
{
|
||||
// Try the 'name' claim first
|
||||
var name = principal.FindFirst("name")?.Value ?? principal.FindFirst(ClaimTypes.Name)?.Value;
|
||||
if (!string.IsNullOrEmpty(name))
|
||||
{
|
||||
var nameParts = name.Split(' ', StringSplitOptions.RemoveEmptyEntries);
|
||||
if (nameParts.Length >= 2)
|
||||
{
|
||||
firstName = nameParts[0];
|
||||
lastName = string.Join(" ", nameParts.Skip(1));
|
||||
}
|
||||
else if (nameParts.Length == 1)
|
||||
{
|
||||
firstName = nameParts[0];
|
||||
}
|
||||
}
|
||||
else if (!string.IsNullOrEmpty(email))
|
||||
{
|
||||
// Extract name from email as fallback
|
||||
var emailName = email.Split('@')[0];
|
||||
var emailParts = emailName.Split('.', StringSplitOptions.RemoveEmptyEntries);
|
||||
if (emailParts.Length >= 2)
|
||||
{
|
||||
firstName = FormatNameFromEmail(emailParts[0]);
|
||||
lastName = FormatNameFromEmail(emailParts[1]);
|
||||
}
|
||||
else if (emailParts.Length == 1)
|
||||
{
|
||||
firstName = FormatNameFromEmail(emailName);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (email, firstName, lastName, profilePicture, providerId);
|
||||
}
|
||||
|
||||
private static string FormatNameFromEmail(string emailPart)
|
||||
{
|
||||
if (string.IsNullOrEmpty(emailPart)) return emailPart;
|
||||
|
||||
// Remove numbers and special characters, capitalize first letter
|
||||
var cleaned = new string(emailPart.Where(c => char.IsLetter(c)).ToArray());
|
||||
if (string.IsNullOrEmpty(cleaned)) return emailPart;
|
||||
|
||||
return char.ToUpper(cleaned[0]) + cleaned[1..].ToLower();
|
||||
}
|
||||
|
||||
private (string Email, string? FirstName, string? LastName, string? ProfilePictureUrl, string ProviderId) ExtractGoogleUserInfo(ClaimsPrincipal principal)
|
||||
{
|
||||
var email = principal.FindFirst("email")?.Value ?? principal.FindFirst(ClaimTypes.Email)?.Value ?? "";
|
||||
var firstName = principal.FindFirst("given_name")?.Value ?? principal.FindFirst(ClaimTypes.GivenName)?.Value;
|
||||
var lastName = principal.FindFirst("family_name")?.Value ?? principal.FindFirst(ClaimTypes.Surname)?.Value;
|
||||
var providerId = principal.FindFirst("sub")?.Value ?? "";
|
||||
var profilePicture = principal.FindFirst("picture")?.Value;
|
||||
|
||||
return (email, firstName, lastName, profilePicture, providerId);
|
||||
}
|
||||
|
||||
private (string Email, string? FirstName, string? LastName, string? ProfilePictureUrl, string ProviderId) ExtractPocketIdUserInfo(ClaimsPrincipal principal)
|
||||
{
|
||||
var email = principal.FindFirst("email")?.Value ?? principal.FindFirst(ClaimTypes.Email)?.Value ?? "";
|
||||
var firstName = principal.FindFirst("given_name")?.Value ?? principal.FindFirst(ClaimTypes.GivenName)?.Value;
|
||||
var lastName = principal.FindFirst("family_name")?.Value ?? principal.FindFirst(ClaimTypes.Surname)?.Value;
|
||||
var providerId = principal.FindFirst("sub")?.Value ?? "";
|
||||
var profilePicture = principal.FindFirst("picture")?.Value;
|
||||
|
||||
return (email, firstName, lastName, profilePicture, providerId);
|
||||
}
|
||||
}
|
||||
|
||||
public class DiscoveryDocument
|
||||
{
|
||||
public string Issuer { get; set; } = string.Empty;
|
||||
public string JwksUri { get; set; } = string.Empty;
|
||||
public string AuthorizationEndpoint { get; set; } = string.Empty;
|
||||
public string TokenEndpoint { get; set; } = string.Empty;
|
||||
public string UserinfoEndpoint { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
public class MicrosoftGraphProfile
|
||||
{
|
||||
public string? GivenName { get; set; }
|
||||
public string? Surname { get; set; }
|
||||
public string? DisplayName { get; set; }
|
||||
public string? Mail { get; set; }
|
||||
public string? UserPrincipalName { get; set; }
|
||||
public string? Id { get; set; }
|
||||
public string? JobTitle { get; set; }
|
||||
public string? MobilePhone { get; set; }
|
||||
public string? OfficeLocation { get; set; }
|
||||
public string? PreferredLanguage { get; set; }
|
||||
// Note: Microsoft Graph doesn't provide direct profile picture URLs in the /me endpoint
|
||||
// Profile pictures must be retrieved separately via /me/photo/$value
|
||||
}
|
||||
}
|
||||
70
Api/User.http
Normal file
@@ -0,0 +1,70 @@
|
||||
### User Controller API Tests
|
||||
|
||||
@baseUrl = http://localhost:5000
|
||||
@authToken = YOUR_JWT_TOKEN_HERE
|
||||
|
||||
### Get all users with pagination
|
||||
GET {{baseUrl}}/api/user?page=1&pageSize=10
|
||||
Authorization: Bearer {{authToken}}
|
||||
|
||||
### Get all users with search
|
||||
GET {{baseUrl}}/api/user?search=john&isActive=true
|
||||
Authorization: Bearer {{authToken}}
|
||||
|
||||
### Get specific user by ID
|
||||
GET {{baseUrl}}/api/user/1
|
||||
Authorization: Bearer {{authToken}}
|
||||
|
||||
### Get current user profile
|
||||
GET {{baseUrl}}/api/user/me
|
||||
Authorization: Bearer {{authToken}}
|
||||
|
||||
### Create a new user
|
||||
POST {{baseUrl}}/api/user
|
||||
Authorization: Bearer {{authToken}}
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"email": "newuser@example.com",
|
||||
"firstName": "John",
|
||||
"lastName": "Doe",
|
||||
"profilePictureUrl": "https://example.com/profile.jpg",
|
||||
"isActive": true
|
||||
}
|
||||
|
||||
### Update a user
|
||||
PUT {{baseUrl}}/api/user/1
|
||||
Authorization: Bearer {{authToken}}
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"firstName": "Updated John",
|
||||
"lastName": "Updated Doe",
|
||||
"isActive": true
|
||||
}
|
||||
|
||||
### Update current user profile
|
||||
PUT {{baseUrl}}/api/user/me
|
||||
Authorization: Bearer {{authToken}}
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"firstName": "My Updated Name",
|
||||
"profilePictureUrl": "https://example.com/new-profile.jpg"
|
||||
}
|
||||
|
||||
### Soft delete user (deactivate)
|
||||
DELETE {{baseUrl}}/api/user/1
|
||||
Authorization: Bearer {{authToken}}
|
||||
|
||||
### Reactivate a soft-deleted user
|
||||
POST {{baseUrl}}/api/user/1/reactivate
|
||||
Authorization: Bearer {{authToken}}
|
||||
|
||||
### Permanently delete user
|
||||
DELETE {{baseUrl}}/api/user/1/permanent
|
||||
Authorization: Bearer {{authToken}}
|
||||
|
||||
### Get user statistics
|
||||
GET {{baseUrl}}/api/user/statistics
|
||||
Authorization: Bearer {{authToken}}
|
||||
21
Api/appsettings.Development.json
Executable file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Trace",
|
||||
"Microsoft.AspNetCore": "Trace"
|
||||
}
|
||||
},
|
||||
"ConnectionStrings": {
|
||||
"DefaultConnection": "Server=localhost,1433;Database=CentrumDb;User=sa;Password=P@ssw0rd;TrustServerCertificate=True;"
|
||||
}
|
||||
,
|
||||
"Authentication": {
|
||||
"PocketId": {
|
||||
"Authority": "https://identity.lesko.me",
|
||||
"ClientId": "21131567-fea1-42a2-8907-21abd874eff8",
|
||||
"ClientSecret": "a633GE6G3JoY8WopnsxhSXQpmsTuXa63",
|
||||
"CallbackPath": "/signin-pocketid",
|
||||
"Scopes": "openid profile email"
|
||||
}
|
||||
}
|
||||
}
|
||||
44
Api/appsettings.json
Executable file
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"Jwt": {
|
||||
"SecretKey": "your-very-secure-secret-key-that-should-be-at-least-32-characters-long",
|
||||
"Issuer": "https://api.yourapp.com",
|
||||
"Audience": "https://yourapp.com",
|
||||
"ExpirationMinutes": 60
|
||||
},
|
||||
"OAuth": {
|
||||
"Providers": {
|
||||
"microsoft": {
|
||||
"Authority": "https://login.microsoftonline.com/9188040d-6c67-4c5b-b112-36a304b66dad/v2.0",
|
||||
"ClientId": "904abfa3-d392-400b-8de8-284e6041e929",
|
||||
"ValidAudiences": ["904abfa3-d392-400b-8de8-284e6041e929"]
|
||||
},
|
||||
"google": {
|
||||
"Authority": "https://accounts.google.com",
|
||||
"ClientId": "1000025801082-09ikmt61a9c9vbdjhpdab9b0ui3vdnij.apps.googleusercontent.com",
|
||||
"ValidAudiences": ["1000025801082-09ikmt61a9c9vbdjhpdab9b0ui3vdnij.apps.googleusercontent.com"]
|
||||
},
|
||||
"pocketid": {
|
||||
"Authority": "https://identity.lesko.me",
|
||||
"ClientId": "21131567-fea1-42a2-8907-21abd874eff8",
|
||||
"ValidAudiences": ["21131567-fea1-42a2-8907-21abd874eff8"]
|
||||
}
|
||||
}
|
||||
},
|
||||
"Authentication": {
|
||||
"PocketId": {
|
||||
"Authority": "https://identity.lesko.me",
|
||||
"ClientId": "21131567-fea1-42a2-8907-21abd874eff8",
|
||||
"ClientSecret": "a633GE6G3JoY8WopnsxhSXQpmsTuXa63",
|
||||
"CallbackPath": "/signin-pocketid",
|
||||
"Scopes": "openid profile email"
|
||||
}
|
||||
},
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
},
|
||||
"AllowedHosts": "localhost",
|
||||
"CorsOrigins": "https://localhost:5001,http://localhost:4200,http://localhost:5000"
|
||||
}
|
||||
23
Dockerfile
Executable file
@@ -0,0 +1,23 @@
|
||||
# Build Angular Web app
|
||||
FROM node:24 AS web-build
|
||||
WORKDIR /app/web
|
||||
COPY Web/ ./
|
||||
RUN npm ci
|
||||
RUN npm run build -- --output-path=dist/Web
|
||||
RUN ls /app/web/dist/Web/browser
|
||||
|
||||
# Build .NET Api app
|
||||
FROM mcr.microsoft.com/dotnet/sdk:8.0 AS api-build
|
||||
WORKDIR /app/api
|
||||
COPY Api/ ./
|
||||
RUN dotnet restore Api.csproj
|
||||
RUN dotnet publish Api.csproj -c Release -o /app/api/build
|
||||
|
||||
# Final runtime image
|
||||
FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS final
|
||||
WORKDIR /app
|
||||
COPY --from=api-build /app/api/build ./
|
||||
COPY --from=web-build /app/web/dist/Web/browser/ ./wwwroot/
|
||||
ENV ASPNETCORE_URLS=http://+:5000
|
||||
EXPOSE 5000
|
||||
ENTRYPOINT ["dotnet", "Api.dll"]
|
||||
2
Tests/.gitignore
vendored
Executable file
@@ -0,0 +1,2 @@
|
||||
obj/
|
||||
bin/
|
||||
38
Tests/IntegrationTest1.cs
Executable file
@@ -0,0 +1,38 @@
|
||||
namespace Tests.Tests
|
||||
{
|
||||
public class IntegrationTest1
|
||||
{
|
||||
// Instructions:
|
||||
// 1. Add a project reference to the target AppHost project, e.g.:
|
||||
//
|
||||
// <ItemGroup>
|
||||
// <ProjectReference Include="../MyAspireApp.AppHost/MyAspireApp.AppHost.csproj" />
|
||||
// </ItemGroup>
|
||||
//
|
||||
// 2. Uncomment the following example test and update 'Projects.MyAspireApp_AppHost' to match your AppHost project:
|
||||
//
|
||||
// [Fact]
|
||||
// public async Task GetWebResourceRootReturnsOkStatusCode()
|
||||
// {
|
||||
// // Arrange
|
||||
// var appHost = await DistributedApplicationTestingBuilder.CreateAsync<Projects.MyAspireApp_AppHost>();
|
||||
// appHost.Services.ConfigureHttpClientDefaults(clientBuilder =>
|
||||
// {
|
||||
// clientBuilder.AddStandardResilienceHandler();
|
||||
// });
|
||||
// // To output logs to the xUnit.net ITestOutputHelper, consider adding a package from https://www.nuget.org/packages?q=xunit+logging
|
||||
//
|
||||
// await using var app = await appHost.BuildAsync();
|
||||
// var resourceNotificationService = app.Services.GetRequiredService<ResourceNotificationService>();
|
||||
// await app.StartAsync();
|
||||
|
||||
// // Act
|
||||
// var httpClient = app.CreateHttpClient("webfrontend");
|
||||
// await resourceNotificationService.WaitForResourceAsync("webfrontend", KnownResourceStates.Running).WaitAsync(TimeSpan.FromSeconds(30));
|
||||
// var response = await httpClient.GetAsync("/");
|
||||
|
||||
// // Assert
|
||||
// Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
// }
|
||||
}
|
||||
}
|
||||
27
Tests/Tests.csproj
Executable file
@@ -0,0 +1,27 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Aspire.Hosting.Testing" Version="9.3.1" />
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.2" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.10.0" />
|
||||
<PackageReference Include="xunit" Version="2.9.2" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Using Include="System.Net" />
|
||||
<Using Include="Microsoft.Extensions.DependencyInjection" />
|
||||
<Using Include="Aspire.Hosting.ApplicationModel" />
|
||||
<Using Include="Aspire.Hosting.Testing" />
|
||||
<Using Include="Xunit" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
0
Web/.editorconfig
Normal file → Executable file
0
Web/.gitignore
vendored
Normal file → Executable file
0
Web/themes/modernize/.npmrc → Web/.npmrc
Normal file → Executable file
0
Web/.vscode/extensions.json
vendored
Normal file → Executable file
3
Web/.vscode/launch.json
vendored
Normal file → Executable file
@@ -7,7 +7,8 @@
|
||||
"type": "chrome",
|
||||
"request": "launch",
|
||||
"preLaunchTask": "npm: start",
|
||||
"url": "http://localhost:4200/"
|
||||
"url": "http://localhost:4200/",
|
||||
"webRoot": "${workspaceFolder}/Web",
|
||||
},
|
||||
{
|
||||
"name": "ng test",
|
||||
|
||||
0
Web/.vscode/tasks.json
vendored
Normal file → Executable file
0
Web/README.md
Normal file → Executable file
14
Web/angular.json
Normal file → Executable file
@@ -23,11 +23,16 @@
|
||||
"assets": [
|
||||
{
|
||||
"glob": "**/*",
|
||||
"input": "public"
|
||||
"input": "public" ,
|
||||
"output": "assets"
|
||||
}
|
||||
],
|
||||
"styles": [
|
||||
"src/styles.scss"
|
||||
"src/styles.scss",
|
||||
"public/scss/style.scss",
|
||||
"node_modules/ngx-toastr/toastr.css",
|
||||
"node_modules/angular-calendar/css/angular-calendar.css",
|
||||
"node_modules/highlight.js/styles/atom-one-dark.min.css"
|
||||
]
|
||||
},
|
||||
"configurations": {
|
||||
@@ -36,7 +41,7 @@
|
||||
{
|
||||
"type": "initial",
|
||||
"maximumWarning": "500kB",
|
||||
"maximumError": "1MB"
|
||||
"maximumError": "5MB"
|
||||
},
|
||||
{
|
||||
"type": "anyComponentStyle",
|
||||
@@ -83,7 +88,8 @@
|
||||
"assets": [
|
||||
{
|
||||
"glob": "**/*",
|
||||
"input": "public"
|
||||
"input": "public",
|
||||
"output": "assets"
|
||||
}
|
||||
],
|
||||
"styles": [
|
||||
|
||||
4137
Web/package-lock.json
generated
60
Web/package.json
Normal file → Executable file
@@ -20,26 +20,54 @@
|
||||
},
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@angular/common": "^20.0.0",
|
||||
"@angular/compiler": "^20.0.0",
|
||||
"@angular/core": "^20.0.0",
|
||||
"@angular/forms": "^20.0.0",
|
||||
"@angular/platform-browser": "^20.0.0",
|
||||
"@angular/router": "^20.0.0",
|
||||
"rxjs": "~7.8.0",
|
||||
"tslib": "^2.3.0"
|
||||
"@angular/animations": "^20.0.4",
|
||||
"@angular/cdk": "^20.0.3",
|
||||
"@angular/common": "^20.0.4",
|
||||
"@angular/compiler": "^20.0.4",
|
||||
"@angular/core": "^20.0.4",
|
||||
"@angular/forms": "^20.0.4",
|
||||
"@angular/material": "^20.0.3",
|
||||
"@angular/platform-browser": "^20.0.4",
|
||||
"@angular/platform-browser-dynamic": "^20.0.4",
|
||||
"@angular/router": "^20.0.4",
|
||||
"@ng-matero/extensions": "^20.1.0",
|
||||
"@ngx-translate/core": "^16.0.4",
|
||||
"@ngx-translate/http-loader": "^16.0.1",
|
||||
"angular-calendar": "^0.31.1",
|
||||
"angular-tabler-icons": "^3.26.0",
|
||||
"apexcharts": "^4.7.0",
|
||||
"chance": "^1.1.13",
|
||||
"date-fns": "^4.1.0",
|
||||
"highlight.js": "^11.11.1",
|
||||
"ng-apexcharts": "^1.16.0",
|
||||
"ng2-search-filter": "^0.5.1",
|
||||
"ngx-dropzone": "^3.1.0",
|
||||
"ngx-editor": "^19.0.0-beta.1",
|
||||
"ngx-highlightjs": "^14.0.1",
|
||||
"ngx-owl-carousel-o": "^20.0.0",
|
||||
"ngx-pagination": "^6.0.3",
|
||||
"ngx-permissions": "^19.0.0",
|
||||
"ngx-scrollbar": "^18.0.0",
|
||||
"angular-oauth2-oidc": "^20.0.2",
|
||||
"ngx-toastr": "^19.0.0",
|
||||
"rxjs": "~7.8.2",
|
||||
"sass": "^1.89.2",
|
||||
"tslib": "^2.8.1",
|
||||
"zone.js": "~0.15.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@angular/build": "^20.0.6",
|
||||
"@angular/cli": "^20.0.6",
|
||||
"@angular/compiler-cli": "^20.0.0",
|
||||
"@types/jasmine": "~5.1.0",
|
||||
"jasmine-core": "~5.7.0",
|
||||
"karma": "~6.4.0",
|
||||
"@angular/build": "^20.0.3",
|
||||
"@angular/cli": "~20.3.6",
|
||||
"@angular/compiler-cli": "^20.0.4",
|
||||
"@types/chance": "^1.1.6",
|
||||
"@types/date-fns": "^2.6.3",
|
||||
"@types/jasmine": "~5.1.8",
|
||||
"jasmine-core": "~5.8.0",
|
||||
"karma": "~6.4.4",
|
||||
"karma-chrome-launcher": "~3.2.0",
|
||||
"karma-coverage": "~2.2.0",
|
||||
"karma-coverage": "~2.2.1",
|
||||
"karma-jasmine": "~5.1.0",
|
||||
"karma-jasmine-html-reporter": "~2.1.0",
|
||||
"typescript": "~5.8.2"
|
||||
"typescript": "~5.8.3"
|
||||
}
|
||||
}
|
||||
18
Web/public/config.json
Executable file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"apiEndpoint": "http://localhost:5000",
|
||||
"oauthProviders": {
|
||||
"microsoft": {
|
||||
"clientId": "904abfa3-d392-400b-8de8-284e6041e929",
|
||||
"issuer": "https://login.microsoftonline.com/9188040d-6c67-4c5b-b112-36a304b66dad/v2.0"
|
||||
},
|
||||
"google": {
|
||||
"clientId": "1000025801082-09ikmt61a9c9vbdjhpdab9b0ui3vdnij.apps.googleusercontent.com",
|
||||
"issuer": "https://accounts.google.com",
|
||||
"dummyClientSecret": "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"
|
||||
},
|
||||
"pocketid": {
|
||||
"clientId": "21131567-fea1-42a2-8907-21abd874eff8",
|
||||
"issuer": "https://identity.lesko.me"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 15 KiB |
BIN
Web/public/images/backgrounds/404-error-idea.gif
Executable file
|
After Width: | Height: | Size: 269 KiB |
BIN
Web/public/images/backgrounds/bronze.png
Executable file
|
After Width: | Height: | Size: 148 KiB |
44
Web/public/images/backgrounds/errorimg.svg
Executable file
@@ -0,0 +1,44 @@
|
||||
<svg width="360" height="360" viewBox="0 0 360 360" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_1928_12651)">
|
||||
<path d="M46.6595 263.061H132.417V302.279H46.6595V263.061Z" fill="white" stroke="#2E186A" stroke-width="1.80839" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M68.098 262.016L68.1161 286.854L74.3762 276.658L80.6362 286.854L86.2362 277.737L91.351 286.07V262.082L68.098 262.016Z" fill="#FEBA91"/>
|
||||
<path d="M78.3365 135.682L76.1996 127.746" stroke="#2E186A" stroke-width="1.80839" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M119.924 200.748C120.451 195.082 122.094 184.575 108.492 178.053C94.8894 171.531 86.5919 165.048 82.9661 150.535" stroke="#2E186A" stroke-width="1.80839" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M85.479 148.43L82.0888 135.831C81.7372 134.524 80.3928 133.75 79.0859 134.102L77.3426 134.571C76.0357 134.922 75.2614 136.267 75.613 137.574L79.0032 150.173C79.3548 151.48 80.6993 152.254 82.0061 151.902L83.7495 151.433C85.0563 151.082 85.8306 149.737 85.479 148.43Z" fill="#49BEFF"/>
|
||||
<path d="M54.4356 174.313L46.2195 174.307" stroke="#2E186A" stroke-width="1.80839" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M68.6013 170.738H55.5538C54.2005 170.738 53.1034 171.835 53.1034 173.189V174.994C53.1034 176.347 54.2005 177.444 55.5538 177.444H68.6013C69.9546 177.444 71.0517 176.347 71.0517 174.994V173.189C71.0517 171.835 69.9546 170.738 68.6013 170.738Z" fill="#49BEFF"/>
|
||||
<path d="M69.1439 173.776C69.1439 173.776 99.3411 172.076 113.45 197.439" stroke="#2E186A" stroke-width="1.80839" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M247.717 230.643C255.821 230.643 261.581 232.535 261.967 250.773C262.353 269.011 261.545 285.497 277.459 285.286C292.164 285.09 288.897 279.141 295.693 279.403" stroke="#2E186A" stroke-width="1.80839" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M311.252 272.082H294.259C293.175 272.082 292.297 272.96 292.297 274.044V284.629C292.297 285.713 293.175 286.591 294.259 286.591H311.252C312.335 286.591 313.214 285.713 313.214 284.629V274.044C313.214 272.96 312.335 272.082 311.252 272.082Z" fill="#49BEFF"/>
|
||||
<path d="M306.022 272.061L306.001 286.615" stroke="#2E186A" stroke-width="1.80839" stroke-linejoin="round"/>
|
||||
<path d="M309.769 275.961H320.969M309.769 281.386H320.969" stroke="#2E186A" stroke-width="1.80839" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M254.122 66.3633V73.7898M257.835 70.0765H250.411" stroke="#2E186A" stroke-width="1.80839" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M201.31 93.5551V100.979M205.021 97.2683H197.597M158.693 57.4746V64.8981M162.403 61.1878H154.979M113.983 117.872V125.295M117.696 121.582H110.27M62.7392 89.1094V96.5359M66.4495 92.8227H59.026M54.6346 199.183V206.607M58.3448 202.897H50.9214M39.7304 137.74V145.167M43.4437 141.453H36.0172M287.719 159.049V166.476M291.432 162.762H284.008M301.447 97.4763V104.903M305.157 101.19H297.734M328.899 206.504V213.928M332.612 210.215H325.185" stroke="#2E186A" stroke-width="1.80839" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M290.784 206.212L257.244 175.49H205.03L205.542 176.129H114.245L80.7749 206.851V214.781H114.23V303.283H257.784V214.127H290.784V206.212Z" fill="#5D87FF"/>
|
||||
<path d="M245.128 256.461H237.807V287.05H229.44V255.937H222.643L234.148 223.777L245.128 256.461Z" fill="white"/>
|
||||
<path d="M205.623 183.318V300.451M205.361 183.282L174.727 214.353M201.964 179.529L174.468 207.418M114.55 214.694H174.513V207.556H81.6188M257.569 214.067H225.401V206.929H288.071M205.711 183.107L225.003 213.973M213.176 187.754L225.449 207.068" stroke="#2E186A" stroke-width="1.80839" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M187.069 233.648H130.578C128.081 233.648 126.057 235.673 126.057 238.169V264.424C126.057 266.921 128.081 268.945 130.578 268.945H187.069C189.566 268.945 191.59 266.921 191.59 264.424V238.169C191.59 235.673 189.566 233.648 187.069 233.648Z" fill="#2E186A"/>
|
||||
<path d="M132.637 257.178C132.511 257.18 132.387 257.157 132.271 257.109C132.155 257.062 132.05 256.992 131.962 256.903C131.871 256.816 131.801 256.711 131.753 256.595C131.706 256.479 131.684 256.354 131.687 256.228C131.684 256.031 131.743 255.838 131.856 255.677L143.288 239.148C143.374 239.022 143.491 238.921 143.628 238.854C143.765 238.786 143.917 238.756 144.069 238.765C144.194 238.764 144.318 238.787 144.434 238.834C144.55 238.881 144.655 238.951 144.744 239.04C144.832 239.128 144.902 239.234 144.949 239.349C144.996 239.465 145.02 239.59 145.018 239.715V255.279H147.881C148.006 255.279 148.13 255.304 148.245 255.351C148.36 255.399 148.465 255.469 148.553 255.557C148.641 255.645 148.711 255.75 148.759 255.865C148.806 255.98 148.831 256.104 148.831 256.228C148.831 256.353 148.806 256.476 148.759 256.592C148.711 256.707 148.641 256.812 148.553 256.9C148.465 256.988 148.36 257.058 148.245 257.105C148.13 257.153 148.006 257.178 147.881 257.178H145.018V262.32C145.018 262.571 144.918 262.813 144.74 262.991C144.562 263.169 144.321 263.269 144.069 263.269C143.817 263.269 143.575 263.169 143.397 262.991C143.219 262.813 143.119 262.571 143.119 262.32V257.178H132.637ZM143.119 255.279V242.789L134.472 255.279H143.119Z" fill="white"/>
|
||||
<path d="M151.278 250.972C151.239 248.843 151.507 246.719 152.074 244.667C152.604 242.893 153.35 241.486 154.31 240.447C154.885 239.911 155.547 239.479 156.269 239.169C157.003 238.863 157.789 238.705 158.584 238.705C159.379 238.705 160.166 238.863 160.899 239.169C161.616 239.482 162.276 239.914 162.849 240.447C163.819 241.49 164.567 242.897 165.091 244.667C166.144 248.81 166.144 253.152 165.091 257.295C164.567 259.072 163.819 260.478 162.849 261.515C162.277 262.05 161.618 262.482 160.899 262.793C160.166 263.101 159.379 263.26 158.584 263.26C157.789 263.26 157.002 263.101 156.269 262.793C155.549 262.487 154.888 262.056 154.316 261.521C153.356 260.48 152.61 259.074 152.08 257.301C151.51 255.241 151.24 253.109 151.278 250.972ZM158.578 240.595C158.033 240.587 157.492 240.692 156.989 240.902C156.486 241.113 156.032 241.425 155.654 241.819C154.011 243.657 153.19 246.708 153.192 250.972C153.194 255.236 154.015 258.302 155.654 260.171C156.033 260.562 156.487 260.871 156.99 261.078C157.494 261.286 158.034 261.387 158.578 261.376C159.122 261.384 159.661 261.281 160.164 261.074C160.666 260.867 161.121 260.559 161.502 260.171C163.157 258.314 163.984 255.248 163.982 250.972C163.98 246.696 163.153 243.645 161.502 241.819C161.122 241.429 160.667 241.12 160.164 240.91C159.662 240.701 159.122 240.596 158.578 240.601V240.595Z" fill="white"/>
|
||||
<path d="M169.368 257.178C169.243 257.179 169.119 257.155 169.004 257.108C168.888 257.061 168.783 256.991 168.695 256.902C168.607 256.814 168.537 256.709 168.49 256.593C168.443 256.477 168.42 256.353 168.422 256.228C168.419 256.032 168.477 255.839 168.588 255.677L180.023 239.148C180.108 239.022 180.225 238.92 180.362 238.853C180.499 238.786 180.651 238.756 180.803 238.765C180.928 238.762 181.052 238.785 181.168 238.832C181.284 238.879 181.388 238.95 181.475 239.04C181.565 239.127 181.636 239.233 181.684 239.349C181.731 239.465 181.755 239.589 181.753 239.715V255.279H184.613C184.738 255.277 184.863 255.3 184.979 255.348C185.095 255.396 185.2 255.467 185.288 255.556C185.464 255.736 185.562 255.978 185.562 256.23C185.562 256.482 185.464 256.723 185.288 256.904C185.2 256.992 185.094 257.062 184.978 257.109C184.862 257.157 184.738 257.18 184.613 257.178H181.744V262.32C181.746 262.445 181.722 262.57 181.675 262.686C181.627 262.802 181.556 262.907 181.466 262.995C181.287 263.17 181.045 263.269 180.794 263.269C180.543 263.269 180.302 263.17 180.122 262.995C180.032 262.907 179.961 262.802 179.914 262.686C179.866 262.57 179.843 262.445 179.845 262.32V257.178H169.368ZM179.854 255.279V242.789L171.195 255.279H179.854Z" fill="white"/>
|
||||
<path d="M240.396 123.902C234.368 122.248 230.025 123.269 230.025 123.269C226.191 114.731 214.051 106.798 214.051 106.798C205.512 111.244 209.433 129.373 209.433 129.373L204.553 131.202C198.453 123.794 189.911 115.689 187.449 115.514C181.762 115.111 181.566 140.615 181.566 140.615C167.728 148.846 153.575 157.448 142.562 164.859C142.969 159.564 145.491 152.722 151.64 144.364C162.882 129.068 165.366 108.838 154.994 96.6047C140.44 79.4249 127.106 85.2721 119.176 92.8583C111.156 100.526 109.935 114.035 122.492 105.059C135.123 96.0169 141.317 100.701 147.242 111.858C153.168 123.016 134.867 140.19 126.328 154.127C121.539 161.945 119.179 169.67 119.282 176.129H177.211C177.093 176.261 176.978 176.391 176.876 176.521L179.748 176.129H198.191C205.554 183.215 222.815 196.419 226.67 188.809C228.614 184.978 229.464 180.776 228.656 176.789C230.365 180.053 235.712 189.276 240.36 186.693C244.386 184.456 247.593 178.193 246.813 171.623C249.808 168.859 252.114 165.917 253.558 162.831C265.322 141.661 245.746 125.376 240.396 123.902Z" fill="#49BEFF"/>
|
||||
<path d="M246.409 136.519C253.6 125.536 270.596 123.969 270.596 123.969M249.417 145.407C251.117 142.007 263.667 134.949 273.079 137.694" stroke="#2E186A" stroke-width="1.80839" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M208.243 154.688C203.098 153.006 192.811 151.852 184.938 159.411M207.803 150.158C202.835 145.845 189.631 144.666 180.481 151.595" stroke="#2E186A" stroke-width="1.80839" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M190.198 117.693C190.198 117.693 183.66 133.249 190.852 138.49" stroke="#2E186A" stroke-width="1.80839" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M215.428 108.936C215.428 108.936 212.028 120.69 215.166 128.674" stroke="#2E186A" stroke-width="1.80839" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M216.733 147.931C217.961 147.509 218.416 145.596 217.75 143.658C217.084 141.72 215.549 140.491 214.321 140.914C213.093 141.336 212.638 143.249 213.304 145.186C213.97 147.124 215.505 148.353 216.733 147.931Z" fill="#2E186A"/>
|
||||
<path d="M234.379 142.31C235.607 141.888 236.062 139.975 235.396 138.037C234.73 136.099 233.195 134.87 231.967 135.292C230.739 135.714 230.284 137.628 230.95 139.565C231.616 141.503 233.151 142.732 234.379 142.31Z" fill="#2E186A"/>
|
||||
<path d="M232.795 145.946L230.624 149.521C230.465 149.769 230.215 149.946 229.927 150.013C229.64 150.08 229.337 150.032 229.084 149.879L225.724 147.875C225.544 147.774 225.4 147.621 225.308 147.437C225.217 147.252 225.183 147.045 225.211 146.841C225.239 146.637 225.327 146.446 225.465 146.293C225.603 146.14 225.783 146.032 225.983 145.982L231.514 144.412C232.469 144.138 233.283 145.123 232.795 145.946Z" fill="#2E186A"/>
|
||||
<path d="M227.002 153.281C230.465 157.672 230.353 152.202 229.805 149.32C230.92 151.825 233.87 155.457 234.326 151.43" stroke="#2E186A" stroke-width="1.80839" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M222.683 190.966C222.333 190.008 216.866 179.133 211.461 175.037M219.219 171.812C219.479 171.9 225.85 182.361 226.103 189.505" stroke="#2E186A" stroke-width="1.80839" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M176.864 176.526C181.831 170.381 204.593 158.485 217.251 164.238C229.91 169.992 230.977 180.318 226.664 188.814" stroke="#2E186A" stroke-width="1.80839" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M232.487 170.498C232.487 170.498 238.37 181.674 238.5 187.099M239.808 168.023C239.808 168.023 244.121 181.029 241.574 186.062" stroke="#2E186A" stroke-width="1.80839" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M222.086 166.15C223.674 163.597 237.635 155.254 243.82 163.934C244.944 165.511 246.171 168.582 246.671 170.246" stroke="#2E186A" stroke-width="1.80839" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M0.904175 302.523H359.096" stroke="#2E186A" stroke-width="1.80839" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_1928_12651">
|
||||
<rect width="360" height="360" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 12 KiB |
BIN
Web/public/images/backgrounds/gold.png
Executable file
|
After Width: | Height: | Size: 156 KiB |
1
Web/public/images/backgrounds/login-bg.svg
Executable file
|
After Width: | Height: | Size: 24 KiB |
54
Web/public/images/backgrounds/maintenance.svg
Executable file
@@ -0,0 +1,54 @@
|
||||
<svg width="360" height="199" viewBox="0 0 360 199" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<mask id="mask0_1968_12144" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="0" y="-78" width="360" height="360">
|
||||
<path d="M360 -78H0V282H360V-78Z" fill="white"/>
|
||||
</mask>
|
||||
<g mask="url(#mask0_1968_12144)">
|
||||
<path d="M263.066 164.022C265.813 164.411 275.401 164.23 280.366 146.041C285.331 127.853 307.094 135.002 307.094 135.002" stroke="#2E186A" stroke-width="1.57703" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M307.577 139.39C310.101 139.39 312.147 137.343 312.147 134.819C312.147 132.294 310.101 130.248 307.577 130.248C305.052 130.248 303.006 132.294 303.006 134.819C303.006 137.343 305.052 139.39 307.577 139.39Z" fill="#FEBA91"/>
|
||||
<path d="M260.459 99.9435C268.081 101.207 286.48 94.0115 282.132 76.7165C277.785 59.4215 294.73 54.7715 294.73 54.7715" stroke="#2E186A" stroke-width="1.57703" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M306.416 57.6909L310.64 52.3029M301.593 53.0179L305.817 47.6299M295.561 48.1389L299.787 42.7539M288.895 43.9759L293.122 38.5879" stroke="#2E186A" stroke-width="1.57703" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M307.901 57.1488L305.081 61.0938C304.781 61.5118 304.328 61.7948 303.821 61.8788C303.313 61.9628 302.793 61.8428 302.374 61.5428L285.987 49.8238C285.569 49.5248 285.286 49.0718 285.202 48.5638C285.118 48.0568 285.238 47.5358 285.538 47.1178L288.358 43.1738L307.901 57.1488Z" fill="#FEBA91"/>
|
||||
<path d="M316.421 87.9154L325.292 82.1484M243.365 158.256C250.662 157.602 264.345 157.223 269.42 138.48C274.496 119.737 280.765 107.723 298.285 99.7304" stroke="#2E186A" stroke-width="1.57703" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M302.478 101.8L318.267 91.5354C319.256 90.8924 319.537 89.5684 318.893 88.5794L316.518 84.9254C315.875 83.9364 314.551 83.6554 313.562 84.2984L297.773 94.5634C296.783 95.2074 296.503 96.5304 297.146 97.5204L299.522 101.173C300.165 102.163 301.488 102.443 302.478 101.8Z" fill="#49BEFF"/>
|
||||
<path d="M27.002 136.25C40.8483 137.301 42.8564 142.742 46.4783 148.23C50.1003 153.718 55.3544 158.712 65.2135 152.78C71.9474 148.724 75.8953 148.54 78.5894 149.839" stroke="#2E186A" stroke-width="1.57703" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M24.0298 141.362C26.5541 141.362 28.6005 139.316 28.6005 136.791C28.6005 134.267 26.5541 132.221 24.0298 132.221C21.5054 132.221 19.459 134.267 19.459 136.791C19.459 139.316 21.5054 141.362 24.0298 141.362Z" fill="#49BEFF"/>
|
||||
<path d="M46.0536 50.4507L31.7096 48.9787C30.2656 48.8307 28.9748 49.8817 28.8266 51.3257L26.9007 70.0907C26.7525 71.5347 27.8029 72.8257 29.247 72.9737L43.591 74.4457C45.035 74.5937 46.3258 73.5438 46.474 72.0998L48.3999 53.3337C48.5482 51.8897 47.4977 50.5997 46.0536 50.4507Z" fill="#FEBA91"/>
|
||||
<path d="M35.9447 55.1831L23.1077 53.6641M35.1851 61.6021L22.3481 60.0801M34.4229 68.0201L21.5859 66.4981M77.4181 87.8171C69.7669 76.0611 63.0277 62.1771 47.2915 62.6531" stroke="#2E186A" stroke-width="1.57703" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M41.4292 49.8164L38.5801 73.8064" stroke="#2E186A" stroke-width="1.57703" stroke-linejoin="round"/>
|
||||
<path d="M18.4208 111.183C18.8955 110.795 19.2752 110.303 19.5308 109.746C19.7864 109.188 19.9111 108.58 19.8953 107.967C19.8796 107.354 19.7239 106.752 19.4401 106.209C19.1563 105.665 18.7519 105.193 18.2578 104.83" stroke="#2E186A" stroke-width="1.57703" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M32.2626 101.65L17.0536 103.745C14.9325 104.037 13.4498 105.993 13.7418 108.114L13.8616 108.984C14.1537 111.105 16.1099 112.588 18.2311 112.296L33.44 110.201C35.5611 109.909 37.0438 107.953 36.7518 105.832L36.632 104.962C36.34 102.841 34.3837 101.358 32.2626 101.65Z" fill="#FEBA91"/>
|
||||
<path d="M16.4911 108.211L6.01172 109.654M78.7498 112.587C65.0979 104.226 53.3569 100.867 36.9925 105.672" stroke="#2E186A" stroke-width="1.57703" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M262.077 11.5234L194.422 11.555L200.236 15.8944L199.894 191.568L215.665 191.489V182.216C215.664 181.639 215.777 181.067 215.997 180.534C216.218 180 216.541 179.515 216.949 179.106C217.357 178.698 217.841 178.374 218.374 178.152C218.908 177.931 219.479 177.817 220.057 177.816H249.991C250.568 177.817 251.14 177.931 251.673 178.153C252.206 178.374 252.69 178.699 253.097 179.107C253.505 179.516 253.828 180.001 254.048 180.534C254.268 181.068 254.381 181.639 254.381 182.216V191.679H268.5V17.984C268.503 17.138 268.339 16.2998 268.017 15.5173C267.696 14.7348 267.223 14.0232 266.627 13.4233C266.03 12.8234 265.321 12.3468 264.541 12.0209C263.76 11.6949 262.923 11.5259 262.077 11.5234Z" fill="#5D87FF"/>
|
||||
<path d="M190.524 191.568V181.72C190.524 181.144 190.41 180.573 190.189 180.042C189.968 179.51 189.645 179.026 189.237 178.619C188.829 178.213 188.346 177.89 187.813 177.67C187.281 177.45 186.711 177.337 186.135 177.338H100.783C100.207 177.337 99.6365 177.45 99.1042 177.67C98.5719 177.89 98.0881 178.213 97.6804 178.619C97.2728 179.026 96.9493 179.51 96.7285 180.042C96.5077 180.573 96.3939 181.144 96.3935 181.72V191.518H77.5795L77.5664 18.2417C77.5671 17.4722 77.7194 16.7105 78.0147 15.9999C78.3099 15.2893 78.7423 14.6439 79.2872 14.1005C79.832 13.5572 80.4786 13.1265 81.1899 12.8332C81.9013 12.5398 82.6635 12.3895 83.433 12.3909L194.088 12.3594C194.933 12.3587 195.769 12.5245 196.55 12.8473C197.33 13.1701 198.039 13.6436 198.637 14.2407C199.234 14.8378 199.708 15.5468 200.031 16.3271C200.354 17.1074 200.52 17.9438 200.52 18.7884V191.687L190.524 191.568Z" fill="white" stroke="#2E186A" stroke-width="1.57703" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M183.478 24.7461H96.0396C91.6006 24.7461 88.002 28.3451 88.002 32.7841V159.706C88.002 164.145 91.6006 167.744 96.0396 167.744H183.478C187.917 167.744 191.516 164.145 191.516 159.706V32.7841C191.516 28.3451 187.917 24.7461 183.478 24.7461Z" fill="#2E186A"/>
|
||||
<path d="M200.113 86.9531H268.496V106.219H200.113V86.9531Z" fill="#2E186A"/>
|
||||
<path d="M239.104 98.3616C240.086 98.3616 240.881 97.5666 240.881 96.5846C240.881 95.6036 240.086 94.8086 239.104 94.8086C238.123 94.8086 237.328 95.6036 237.328 96.5846C237.328 97.5666 238.123 98.3616 239.104 98.3616Z" fill="white"/>
|
||||
<path d="M229.675 98.3616C230.656 98.3616 231.452 97.5666 231.452 96.5846C231.452 95.6036 230.656 94.8086 229.675 94.8086C228.693 94.8086 227.898 95.6036 227.898 96.5846C227.898 97.5666 228.693 98.3616 229.675 98.3616Z" fill="white"/>
|
||||
<path d="M259.795 94.3105H249.102C248.551 94.3105 248.104 94.7575 248.104 95.3085V97.8635C248.104 98.4155 248.551 98.8625 249.102 98.8625H259.795C260.346 98.8625 260.792 98.4155 260.792 97.8635V95.3085C260.792 94.7575 260.346 94.3105 259.795 94.3105Z" fill="#5D87FF"/>
|
||||
<path d="M219.539 94.3105H208.846C208.295 94.3105 207.848 94.7575 207.848 95.3085V97.8635C207.848 98.4155 208.295 98.8625 208.846 98.8625H219.539C220.09 98.8625 220.538 98.4155 220.538 97.8635V95.3085C220.538 94.7575 220.09 94.3105 219.539 94.3105Z" fill="#5D87FF"/>
|
||||
<path d="M200.578 23.541H268.408V40.678H200.578V23.541Z" fill="#2E186A"/>
|
||||
<path d="M200.73 114.354H268.466V143.108H200.73V114.354Z" fill="#2E186A"/>
|
||||
<path d="M201.283 50.8906H268.409M201.283 63.8406H268.409M201.283 76.7906H268.409" stroke="white" stroke-width="1.57703" stroke-linejoin="round"/>
|
||||
<path d="M185.786 24.7476H144.92C130.09 23.5096 85.9698 27.3386 83.6463 38.8956C81.3228 50.4526 105.987 55.2496 105.987 55.2496C95.8367 81.1286 104.824 90.9956 114.925 96.8806C118.342 102.987 123.274 107.597 129.193 109.76L129.125 109.928C126.015 112.649 123.56 118.36 125.445 122.842C122.087 121.56 118.466 121.125 114.9 121.574C111.333 122.023 107.933 123.343 104.997 125.418L88.1618 119.399C87.5029 119.32 86.8348 119.407 86.2176 119.651C85.6004 119.894 85.0533 120.288 84.6255 120.795C84.1977 121.302 83.9026 121.908 83.7665 122.557C83.6305 123.207 83.6578 123.88 83.846 124.516C86.3877 133.058 91.2791 138.349 95.5056 141.501C95.2951 142.878 95.1757 144.268 95.1481 145.661C95.1323 146.468 95.1271 147.252 95.1297 148.027C90.757 150.744 86.837 154.129 83.5122 158.06C81.9352 159.899 82.5055 162.478 84.0195 164.176C87.5547 168.118 95.5844 171.12 104.648 168.523H185.786C186.556 168.523 187.318 168.371 188.029 168.076C188.74 167.781 189.386 167.349 189.93 166.804C190.473 166.259 190.904 165.612 191.198 164.901C191.491 164.189 191.641 163.426 191.639 162.657V31.4206C191.639 28.1876 189.019 24.7476 185.786 24.7476Z" fill="#49BEFF"/>
|
||||
<path d="M173.108 150.378C151.632 147.895 156.878 129.141 156.039 123.716C155.209 118.333 140.795 115.445 140.056 125.383C139.557 132.064 136.043 153.703 152.381 160.382" stroke="#2E186A" stroke-width="1.57703" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M145.287 119.439C144.627 122.88 143.184 131.753 145 135.473M150.79 119.534C150.168 122.725 148.751 131.262 150.588 134.873" stroke="#2E186A" stroke-width="1.57703" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M150.817 119.068C143.005 112.284 132.681 107.211 127.104 110.744M123.855 121.289C125.621 125.965 128.978 127.397 132.999 128.386C135.706 129.053 137.667 129.679 139.491 130.538M139.949 126.06L134.553 123.778M142.835 120.13L138.274 117.622" stroke="#2E186A" stroke-width="1.57703" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M83.6113 159.766C83.6113 159.766 91.5911 167.125 102.707 160.791" stroke="#2E186A" stroke-width="1.57703" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M85.9863 121.498C85.9863 121.498 87.5449 130.637 97.814 134.056" stroke="#2E186A" stroke-width="1.57703" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M186.923 103.065C176.788 101.152 167.165 94.3181 165.081 70.2861C164.963 70.1841 144.075 86.1221 134.237 70.8881C127.952 59.2711 152.184 44.8091 163.814 36.9531" stroke="#2E186A" stroke-width="1.57703" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M141.962 25.4336C137.494 27.7826 133.025 31.2976 128.649 35.3216C108.148 54.2136 106.526 81.4916 114.456 97.5196" stroke="#2E186A" stroke-width="1.57703" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M142.377 64.025C143.569 64.025 144.535 63.059 144.535 61.867C144.535 60.675 143.569 59.709 142.377 59.709C141.185 59.709 140.219 60.675 140.219 61.867C140.219 63.059 141.185 64.025 142.377 64.025Z" fill="#2E186A"/>
|
||||
<path d="M144.824 70.773C146.016 70.773 146.982 69.807 146.982 68.615C146.982 67.423 146.016 66.457 144.824 66.457C143.632 66.457 142.666 67.423 142.666 68.615C142.666 69.807 143.632 70.773 144.824 70.773Z" fill="#2E186A"/>
|
||||
<path d="M152.554 72.8414C153.746 72.8414 154.712 71.8754 154.712 70.6834C154.712 69.4924 153.746 68.5254 152.554 68.5254C151.363 68.5254 150.396 69.4924 150.396 70.6834C150.396 71.8754 151.363 72.8414 152.554 72.8414Z" fill="#2E186A"/>
|
||||
<path d="M151.178 66.8543C154.029 66.8543 156.34 64.5423 156.34 61.6913C156.34 58.8403 154.029 56.5293 151.178 56.5293C148.327 56.5293 146.016 58.8403 146.016 61.6913C146.016 64.5423 148.327 66.8543 151.178 66.8543Z" fill="#2E186A"/>
|
||||
<path d="M94.8278 40.984C96.0196 40.984 96.9857 40.018 96.9857 38.826C96.9857 37.634 96.0196 36.668 94.8278 36.668C93.636 36.668 92.6699 37.634 92.6699 38.826C92.6699 40.018 93.636 40.984 94.8278 40.984Z" fill="#2E186A"/>
|
||||
<path d="M97.3616 35.306C97.9424 35.325 98.5213 35.23 99.0652 35.025C99.6091 34.821 100.108 34.511 100.532 34.114C100.956 33.717 101.298 33.24 101.538 32.711C101.778 32.182 101.911 31.61 101.93 31.029C101.935 30.241 101.762 29.461 101.425 28.748C98.4235 29.169 95.6768 30.278 93.1588 31.143C93.1168 33.553 94.9409 35.224 97.3616 35.306ZM86.3802 35.143C86.366 35.556 86.471 35.965 86.6826 36.321C86.8942 36.676 87.2035 36.963 87.5737 37.148C87.9439 37.333 88.3594 37.407 88.7706 37.362C89.1819 37.317 89.5716 37.155 89.8934 36.895C90.2151 36.635 90.4553 36.288 90.5854 35.895C90.7154 35.503 90.7298 35.081 90.6269 34.68C90.524 34.279 90.3081 33.917 90.0048 33.635C89.7016 33.354 89.3239 33.166 88.9166 33.093C88.0234 33.596 87.1833 34.187 86.4091 34.859C86.3927 34.953 86.3831 35.048 86.3802 35.143Z" fill="#2E186A"/>
|
||||
<path d="M107.737 138.46C109.506 138.174 110.793 137.033 110.612 135.912C110.431 134.791 108.85 134.113 107.081 134.399C105.312 134.685 104.025 135.827 104.206 136.948C104.387 138.069 105.968 138.745 107.737 138.46Z" fill="#2E186A"/>
|
||||
<path d="M111.662 153.458C113.427 153.177 114.713 152.043 114.535 150.925C114.357 149.807 112.782 149.128 111.017 149.409C109.253 149.69 107.967 150.824 108.145 151.943C108.323 153.061 109.898 153.739 111.662 153.458Z" fill="#2E186A"/>
|
||||
<path d="M115.442 135.708L119.33 138.157C119.462 138.241 119.576 138.35 119.665 138.478C119.754 138.607 119.817 138.752 119.849 138.905C119.881 139.058 119.881 139.216 119.851 139.369C119.82 139.523 119.759 139.668 119.671 139.798L117.332 143.246C117.207 143.431 117.031 143.576 116.825 143.663C116.619 143.751 116.393 143.778 116.172 143.74C115.952 143.702 115.747 143.602 115.582 143.451C115.417 143.3 115.299 143.105 115.243 142.888L113.692 136.99C113.631 136.764 113.641 136.524 113.718 136.303C113.796 136.082 113.939 135.889 114.129 135.751C114.318 135.612 114.544 135.534 114.779 135.526C115.013 135.518 115.245 135.582 115.442 135.708Z" fill="#2E186A"/>
|
||||
<path d="M123.384 141.793C128.378 138.361 122.332 138.213 119.102 138.639C121.925 137.588 126.057 134.655 121.623 133.984" stroke="#2E186A" stroke-width="1.57703" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M111.985 102.421C109.018 108.971 108.269 119.137 115.373 130.545M123.009 101.756C118.611 107.638 115.846 117.455 120.275 129.948" stroke="white" stroke-width="1.57703" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M128.924 178.978C124.588 175.133 118.915 163.734 120.55 151.906M139.254 175.091C134.194 172.268 126.209 162.364 125.263 150.463" stroke="#2E186A" stroke-width="1.57703" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M359.212 191.643H0.789062" stroke="#2E186A" stroke-width="1.57703" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 14 KiB |
57
Web/public/images/backgrounds/maintenance2.svg
Executable file
@@ -0,0 +1,57 @@
|
||||
<svg width="360" height="360" viewBox="0 0 360 360" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_1928_12776)">
|
||||
<path d="M263.065 242.022C265.812 242.411 275.4 242.23 280.365 224.041C285.33 205.853 307.093 213.002 307.093 213.002" stroke="#2E186A" stroke-width="1.57703" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M307.577 217.39C310.101 217.39 312.147 215.343 312.147 212.819C312.147 210.294 310.101 208.248 307.577 208.248C305.052 208.248 303.006 210.294 303.006 212.819C303.006 215.343 305.052 217.39 307.577 217.39Z" fill="#FEBA91"/>
|
||||
<path d="M260.458 177.943C268.08 179.207 286.479 172.011 282.131 154.716C277.784 137.421 294.729 132.771 294.729 132.771" stroke="#2E186A" stroke-width="1.57703" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M306.415 135.691L310.639 130.303ZM301.592 131.018L305.816 125.63ZM295.56 126.139L299.786 120.754ZM288.894 121.976L293.121 116.588Z" fill="white"/>
|
||||
<path d="M306.415 135.691L310.639 130.303M301.592 131.018L305.816 125.63M295.56 126.139L299.786 120.754M288.894 121.976L293.121 116.588" stroke="#2E186A" stroke-width="1.57703" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M307.9 135.148L305.08 139.093C304.78 139.511 304.327 139.794 303.82 139.878C303.312 139.962 302.792 139.842 302.373 139.542L285.986 127.823C285.568 127.524 285.285 127.071 285.201 126.563C285.117 126.056 285.237 125.535 285.537 125.117L288.357 121.173L307.9 135.148Z" fill="#FEBA91"/>
|
||||
<path d="M316.421 165.915L325.292 160.148M243.365 236.256C250.662 235.602 264.345 235.223 269.42 216.48C274.496 197.737 280.765 185.723 298.285 177.73" stroke="#2E186A" stroke-width="1.57703" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M302.477 179.801L318.266 169.536C319.255 168.893 319.536 167.569 318.892 166.58L316.517 162.926C315.874 161.937 314.55 161.656 313.561 162.299L297.772 172.564C296.782 173.208 296.502 174.531 297.145 175.521L299.521 179.174C300.164 180.164 301.487 180.444 302.477 179.801Z" fill="#49BEFF"/>
|
||||
<path d="M27.0015 214.25C40.8478 215.301 42.8559 220.742 46.4778 226.23C50.0998 231.718 55.3539 236.712 65.213 230.78C71.9469 226.724 75.8948 226.54 78.5889 227.839" stroke="#2E186A" stroke-width="1.57703" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M24.0288 219.362C26.5531 219.362 28.5995 217.316 28.5995 214.791C28.5995 212.267 26.5531 210.221 24.0288 210.221C21.5044 210.221 19.458 212.267 19.458 214.791C19.458 217.316 21.5044 219.362 24.0288 219.362Z" fill="#49BEFF"/>
|
||||
<path d="M46.0537 128.451L31.7097 126.979C30.2657 126.831 28.9749 127.882 28.8267 129.326L26.9008 148.091C26.7526 149.535 27.803 150.826 29.2471 150.974L43.5911 152.446C45.0351 152.594 46.3259 151.544 46.4741 150.1L48.4 131.334C48.5483 129.89 47.4978 128.6 46.0537 128.451Z" fill="#FEBA91"/>
|
||||
<path d="M35.9432 133.183L23.1062 131.664M35.1836 139.602L22.3466 138.08M34.4214 146.02L21.5844 144.498M77.4166 165.817C69.7654 154.061 63.0262 140.177 47.29 140.653" stroke="#2E186A" stroke-width="1.57703" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M41.4286 127.816L38.5795 151.806" stroke="#2E186A" stroke-width="1.57703" stroke-linejoin="round"/>
|
||||
<path d="M18.4198 189.183C18.8945 188.795 19.2742 188.303 19.5298 187.746C19.7854 187.188 19.9101 186.58 19.8943 185.967C19.8786 185.354 19.7229 184.752 19.4391 184.209C19.1553 183.665 18.7509 183.193 18.2568 182.83" stroke="#2E186A" stroke-width="1.57703" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M32.262 179.65L17.053 181.745C14.9319 182.037 13.4492 183.993 13.7412 186.114L13.861 186.984C14.1531 189.105 16.1093 190.588 18.2305 190.296L33.4394 188.201C35.5605 187.909 37.0432 185.953 36.7512 183.832L36.6314 182.962C36.3394 180.841 34.3831 179.358 32.262 179.65Z" fill="#FEBA91"/>
|
||||
<path d="M16.4905 186.211L6.01111 187.654M78.7492 190.587C65.0973 182.226 53.3563 178.867 36.9919 183.672" stroke="#2E186A" stroke-width="1.57703" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M262.077 89.5234L194.422 89.555L200.236 93.8944L199.894 269.568L215.665 269.489V260.216C215.664 259.639 215.777 259.067 215.997 258.534C216.218 258 216.541 257.515 216.949 257.106C217.357 256.698 217.841 256.374 218.374 256.152C218.908 255.931 219.479 255.817 220.057 255.816H249.991C250.568 255.817 251.14 255.931 251.673 256.153C252.206 256.374 252.69 256.699 253.097 257.107C253.505 257.516 253.828 258.001 254.048 258.534C254.268 259.068 254.381 259.639 254.381 260.216V269.679H268.5V95.984C268.503 95.138 268.339 94.2998 268.017 93.5173C267.696 92.7348 267.223 92.0232 266.627 91.4233C266.03 90.8234 265.321 90.3468 264.541 90.0209C263.76 89.6949 262.923 89.5259 262.077 89.5234Z" fill="#5D87FF"/>
|
||||
<path d="M190.524 269.568V259.72C190.524 259.144 190.41 258.573 190.189 258.042C189.968 257.51 189.645 257.026 189.237 256.619C188.829 256.213 188.346 255.89 187.813 255.67C187.281 255.45 186.711 255.337 186.135 255.338H100.783C100.207 255.337 99.6364 255.45 99.1041 255.67C98.5718 255.89 98.088 256.213 97.6803 256.619C97.2727 257.026 96.9492 257.51 96.7284 258.042C96.5076 258.573 96.3938 259.144 96.3934 259.72V269.518H77.5794L77.5663 96.2417C77.567 95.4722 77.7193 94.7105 78.0146 93.9999C78.3098 93.2893 78.7422 92.6439 79.2871 92.1005C79.8319 91.5572 80.4785 91.1265 81.1898 90.8332C81.9012 90.5398 82.6634 90.3895 83.4329 90.3909L194.088 90.3594C194.933 90.3587 195.769 90.5245 196.55 90.8473C197.33 91.1701 198.039 91.6436 198.637 92.2407C199.234 92.8378 199.708 93.5468 200.031 94.3271C200.354 95.1074 200.52 95.9438 200.52 96.7884V269.687L190.524 269.568Z" fill="white" stroke="#2E186A" stroke-width="1.57703" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M183.477 102.746H96.0387C91.5997 102.746 88.0011 106.345 88.0011 110.784V237.706C88.0011 242.145 91.5997 245.744 96.0387 245.744H183.477C187.916 245.744 191.515 242.145 191.515 237.706V110.784C191.515 106.345 187.916 102.746 183.477 102.746Z" fill="#2E186A"/>
|
||||
<path d="M200.112 164.953H268.495V184.219H200.112V164.953Z" fill="#2E186A"/>
|
||||
<path d="M239.104 176.362C240.086 176.362 240.881 175.567 240.881 174.585C240.881 173.604 240.086 172.809 239.104 172.809C238.123 172.809 237.328 173.604 237.328 174.585C237.328 175.567 238.123 176.362 239.104 176.362Z" fill="white"/>
|
||||
<path d="M229.674 176.362C230.655 176.362 231.451 175.567 231.451 174.585C231.451 173.604 230.655 172.809 229.674 172.809C228.692 172.809 227.897 173.604 227.897 174.585C227.897 175.567 228.692 176.362 229.674 176.362Z" fill="white"/>
|
||||
<path d="M259.793 172.311H249.1C248.549 172.311 248.102 172.758 248.102 173.309V175.864C248.102 176.416 248.549 176.863 249.1 176.863H259.793C260.344 176.863 260.791 176.416 260.791 175.864V173.309C260.791 172.758 260.344 172.311 259.793 172.311Z" fill="#5D87FF"/>
|
||||
<path d="M219.539 172.311H208.846C208.295 172.311 207.848 172.758 207.848 173.309V175.864C207.848 176.416 208.295 176.863 208.846 176.863H219.539C220.09 176.863 220.538 176.416 220.538 175.864V173.309C220.538 172.758 220.09 172.311 219.539 172.311Z" fill="#5D87FF"/>
|
||||
<path d="M200.578 101.541H268.408V118.678H200.578V101.541Z" fill="#2E186A"/>
|
||||
<path d="M200.73 192.354H268.466V221.108H200.73V192.354Z" fill="#2E186A"/>
|
||||
<path d="M201.282 128.891H268.408M201.282 141.841H268.408M201.282 154.791H268.408" stroke="white" stroke-width="1.57703" stroke-linejoin="round"/>
|
||||
<path d="M185.785 102.748H144.919C130.089 101.51 85.9693 105.339 83.6458 116.896C81.3223 128.453 105.987 133.25 105.987 133.25C95.8362 159.129 104.823 168.996 114.924 174.881C118.341 180.987 123.274 185.597 129.193 187.76L129.125 187.928C126.015 190.649 123.56 196.36 125.445 200.842C122.087 199.56 118.465 199.125 114.899 199.574C111.332 200.023 107.932 201.343 104.996 203.418L88.1613 197.399C87.5024 197.32 86.8343 197.407 86.2171 197.651C85.5999 197.894 85.0528 198.288 84.625 198.795C84.1972 199.302 83.9021 199.908 83.766 200.557C83.63 201.207 83.6573 201.88 83.8455 202.516C86.3872 211.058 91.2786 216.349 95.5051 219.501C95.2946 220.878 95.1752 222.268 95.1476 223.661C95.1318 224.468 95.1266 225.252 95.1292 226.027C90.7565 228.744 86.8365 232.129 83.5117 236.06C81.9347 237.899 82.505 240.478 84.019 242.176C87.5542 246.118 95.5839 249.12 104.647 246.523H185.785C186.555 246.523 187.317 246.371 188.028 246.076C188.739 245.781 189.385 245.349 189.929 244.804C190.472 244.259 190.903 243.612 191.197 242.901C191.49 242.189 191.64 241.426 191.638 240.657V109.421C191.638 106.188 189.018 102.748 185.785 102.748Z" fill="#49BEFF"/>
|
||||
<path d="M173.108 228.378C151.632 225.895 156.878 207.141 156.039 201.716C155.209 196.333 140.795 193.445 140.056 203.383C139.557 210.064 136.043 231.703 152.381 238.382" stroke="#2E186A" stroke-width="1.57703" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M145.287 197.439C144.627 200.88 143.184 209.753 145 213.473M150.79 197.534C150.168 200.725 148.751 209.262 150.588 212.873" stroke="#2E186A" stroke-width="1.57703" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M150.817 197.068C143.005 190.284 132.681 185.211 127.104 188.744M123.855 199.289C125.621 203.965 128.978 205.397 132.999 206.386C135.706 207.053 137.667 207.679 139.491 208.538M139.949 204.06L134.553 201.778M142.835 198.13L138.274 195.622" stroke="#2E186A" stroke-width="1.57703" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M83.6116 237.766C83.6116 237.766 91.5914 245.125 102.707 238.791" stroke="#2E186A" stroke-width="1.57703" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M85.985 199.498C85.985 199.498 87.5436 208.637 97.8127 212.056" stroke="#2E186A" stroke-width="1.57703" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M186.923 181.065C176.788 179.152 167.165 172.318 165.081 148.286C164.963 148.184 144.075 164.122 134.237 148.888C127.952 137.271 152.184 122.809 163.814 114.953" stroke="#2E186A" stroke-width="1.57703" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M141.962 103.434C137.494 105.783 133.025 109.298 128.649 113.322C108.148 132.214 106.526 159.492 114.456 175.52" stroke="#2E186A" stroke-width="1.57703" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M142.377 142.025C143.569 142.025 144.535 141.059 144.535 139.867C144.535 138.675 143.569 137.709 142.377 137.709C141.185 137.709 140.219 138.675 140.219 139.867C140.219 141.059 141.185 142.025 142.377 142.025Z" fill="#2E186A"/>
|
||||
<path d="M144.824 148.773C146.016 148.773 146.982 147.807 146.982 146.615C146.982 145.423 146.016 144.457 144.824 144.457C143.632 144.457 142.666 145.423 142.666 146.615C142.666 147.807 143.632 148.773 144.824 148.773Z" fill="#2E186A"/>
|
||||
<path d="M152.554 150.841C153.746 150.841 154.712 149.875 154.712 148.683C154.712 147.492 153.746 146.525 152.554 146.525C151.363 146.525 150.396 147.492 150.396 148.683C150.396 149.875 151.363 150.841 152.554 150.841Z" fill="#2E186A"/>
|
||||
<path d="M151.177 144.854C154.028 144.854 156.339 142.542 156.339 139.691C156.339 136.84 154.028 134.529 151.177 134.529C148.326 134.529 146.015 136.84 146.015 139.691C146.015 142.542 148.326 144.854 151.177 144.854Z" fill="#2E186A"/>
|
||||
<path d="M94.827 118.984C96.0188 118.984 96.9849 118.018 96.9849 116.826C96.9849 115.634 96.0188 114.668 94.827 114.668C93.6352 114.668 92.6691 115.634 92.6691 116.826C92.6691 118.018 93.6352 118.984 94.827 118.984Z" fill="#2E186A"/>
|
||||
<path d="M97.3607 113.306C97.9415 113.325 98.5204 113.23 99.0643 113.025C99.6082 112.821 100.107 112.511 100.531 112.114C100.955 111.717 101.297 111.24 101.537 110.711C101.777 110.182 101.91 109.61 101.929 109.029C101.934 108.241 101.761 107.461 101.424 106.748C98.4226 107.169 95.6759 108.278 93.1579 109.143C93.1159 111.553 94.94 113.224 97.3607 113.306ZM86.3793 113.143C86.3651 113.556 86.4701 113.965 86.6817 114.321C86.8933 114.676 87.2026 114.963 87.5728 115.148C87.943 115.333 88.3585 115.407 88.7697 115.362C89.181 115.317 89.5707 115.155 89.8925 114.895C90.2142 114.635 90.4544 114.288 90.5845 113.895C90.7145 113.503 90.7289 113.081 90.626 112.68C90.5231 112.279 90.3072 111.917 90.0039 111.635C89.7007 111.354 89.323 111.166 88.9157 111.093C88.0225 111.596 87.1824 112.187 86.4082 112.859C86.3918 112.953 86.3822 113.048 86.3793 113.143Z" fill="#2E186A"/>
|
||||
<path d="M107.737 216.46C109.506 216.174 110.793 215.033 110.612 213.912C110.431 212.791 108.85 212.114 107.081 212.4C105.312 212.686 104.025 213.827 104.206 214.948C104.387 216.069 105.968 216.745 107.737 216.46Z" fill="#2E186A"/>
|
||||
<path d="M111.662 231.458C113.427 231.177 114.713 230.043 114.535 228.925C114.357 227.807 112.782 227.128 111.017 227.409C109.253 227.69 107.967 228.824 108.145 229.943C108.323 231.061 109.898 231.739 111.662 231.458Z" fill="#2E186A"/>
|
||||
<path d="M115.441 213.708L119.329 216.157C119.461 216.241 119.575 216.35 119.664 216.478C119.753 216.607 119.816 216.752 119.848 216.905C119.88 217.058 119.88 217.216 119.85 217.369C119.819 217.523 119.758 217.668 119.67 217.798L117.331 221.246C117.206 221.431 117.03 221.576 116.824 221.663C116.618 221.751 116.392 221.778 116.171 221.74C115.951 221.702 115.746 221.602 115.581 221.451C115.416 221.3 115.298 221.105 115.242 220.888L113.691 214.99C113.63 214.764 113.64 214.524 113.717 214.303C113.795 214.082 113.938 213.889 114.128 213.751C114.317 213.612 114.543 213.534 114.778 213.526C115.012 213.518 115.244 213.582 115.441 213.708Z" fill="#2E186A"/>
|
||||
<path d="M123.382 219.793C128.376 216.361 122.33 216.213 119.1 216.639C121.923 215.588 126.055 212.655 121.621 211.984" stroke="#2E186A" stroke-width="1.57703" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M111.985 180.421C109.018 186.971 108.269 197.137 115.373 208.545M123.009 179.756C118.611 185.638 115.846 195.455 120.275 207.948" stroke="white" stroke-width="1.57703" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M128.922 256.978C124.586 253.133 118.913 241.734 120.548 229.906M139.252 253.091C134.192 250.268 126.207 240.364 125.261 228.463" stroke="#2E186A" stroke-width="1.57703" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M359.211 269.643H0.788452" stroke="#2E186A" stroke-width="1.57703" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_1928_12776">
|
||||
<rect width="360" height="360" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 14 KiB |
BIN
Web/public/images/backgrounds/piggy.png
Executable file
|
After Width: | Height: | Size: 81 KiB |
BIN
Web/public/images/backgrounds/profilebg.jpg
Executable file
|
After Width: | Height: | Size: 49 KiB |
BIN
Web/public/images/backgrounds/silver.png
Executable file
|
After Width: | Height: | Size: 149 KiB |
BIN
Web/public/images/backgrounds/track-bg.png
Executable file
|
After Width: | Height: | Size: 26 KiB |
BIN
Web/public/images/backgrounds/unlimited-bg.png
Executable file
|
After Width: | Height: | Size: 60 KiB |
BIN
Web/public/images/backgrounds/website-under-construction.gif
Executable file
|
After Width: | Height: | Size: 3.2 MiB |
BIN
Web/public/images/backgrounds/welcome-bg2.png
Executable file
|
After Width: | Height: | Size: 137 KiB |
BIN
Web/public/images/blog/blog-img1.jpg
Executable file
|
After Width: | Height: | Size: 23 KiB |
BIN
Web/public/images/blog/blog-img10.jpg
Executable file
|
After Width: | Height: | Size: 45 KiB |
BIN
Web/public/images/blog/blog-img11.jpg
Executable file
|
After Width: | Height: | Size: 104 KiB |
BIN
Web/public/images/blog/blog-img2.jpg
Executable file
|
After Width: | Height: | Size: 41 KiB |
BIN
Web/public/images/blog/blog-img3.jpg
Executable file
|
After Width: | Height: | Size: 28 KiB |
BIN
Web/public/images/blog/blog-img4.jpg
Executable file
|
After Width: | Height: | Size: 30 KiB |
BIN
Web/public/images/blog/blog-img5.jpg
Executable file
|
After Width: | Height: | Size: 32 KiB |
BIN
Web/public/images/blog/blog-img6.jpg
Executable file
|
After Width: | Height: | Size: 33 KiB |
BIN
Web/public/images/blog/blog-img8.jpg
Executable file
|
After Width: | Height: | Size: 43 KiB |
BIN
Web/public/images/blog/blog-img9.jpg
Executable file
|
After Width: | Height: | Size: 28 KiB |
BIN
Web/public/images/breadcrumb/ChatBc.png
Executable file
|
After Width: | Height: | Size: 19 KiB |
BIN
Web/public/images/breadcrumb/emailSv.png
Executable file
|
After Width: | Height: | Size: 22 KiB |
11
Web/public/images/chat/icon-adobe.svg
Executable file
@@ -0,0 +1,11 @@
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_631_1669)">
|
||||
<path d="M23.993 0H0.00703125C0.003148 0 0 0.003148 0 0.00703125V23.993C0 23.9969 0.003148 24 0.00703125 24H23.993C23.9969 24 24 23.9969 24 23.993V0.00703125C24 0.003148 23.9969 0 23.993 0Z" fill="#ED2224"/>
|
||||
<path d="M13.875 5.625L19.2188 18.375V5.625H13.875ZM4.78125 5.625V18.375L10.125 5.625H4.78125ZM9.70312 15.7969H12.1406L13.2188 18.375H15.375L11.9531 10.2656L9.70312 15.7969Z" fill="white"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_631_1669">
|
||||
<rect width="24" height="24" rx="3" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 663 B |
13
Web/public/images/chat/icon-chrome.svg
Executable file
@@ -0,0 +1,13 @@
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_631_1649)">
|
||||
<path d="M12 6.5625H22.6875C23.6383 8.43365 24.0817 10.5215 23.9735 12.6175C23.8652 14.7136 23.2091 16.7446 22.0706 18.5079C20.9321 20.2711 19.3511 21.7049 17.4852 22.6662C15.6194 23.6274 13.5341 24.0825 11.4375 23.9859" fill="#FFCC44"/>
|
||||
<path d="M16.7344 14.7188L11.4375 23.9859C9.33318 23.8901 7.29114 23.2421 5.51666 22.1069C3.74218 20.9718 2.29777 19.3895 1.32864 17.5192C0.359502 15.6488 -0.100216 13.5563 -0.00430812 11.452C0.0915998 9.34768 0.739754 7.30568 1.87501 5.53125" fill="#0F9D58"/>
|
||||
<path d="M12 6.56259H22.6875C21.7301 4.67609 20.291 3.0762 18.5161 1.92509C16.7411 0.773985 14.6934 0.112566 12.5805 0.0078703C10.4675 -0.096825 8.36448 0.358925 6.48443 1.32893C4.60438 2.29894 3.01418 3.74872 1.875 5.53134L7.26562 14.7188" fill="#DB4437"/>
|
||||
<path d="M12 16.9219C14.7183 16.9219 16.9219 14.7183 16.9219 12C16.9219 9.28172 14.7183 7.07812 12 7.07812C9.28172 7.07812 7.07812 9.28172 7.07812 12C7.07812 14.7183 9.28172 16.9219 12 16.9219Z" fill="#4285F4" stroke="#F1F1F1" stroke-width="2"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_631_1649">
|
||||
<rect width="24" height="24" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
7
Web/public/images/chat/icon-figma.svg
Executable file
@@ -0,0 +1,7 @@
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M8 24C10.208 24 12 22.208 12 20V16H8C5.792 16 4 17.792 4 20C4 22.208 5.792 24 8 24Z" fill="#0ACF83"/>
|
||||
<path d="M4 12C4 9.792 5.792 8 8 8H12V16H8C5.792 16 4 14.208 4 12Z" fill="#A259FF"/>
|
||||
<path d="M4 4C4 1.792 5.792 0 8 0H12V8H8C5.792 8 4 6.208 4 4Z" fill="#F24E1E"/>
|
||||
<path d="M12 0H16C18.208 0 20 1.792 20 4C20 6.208 18.208 8 16 8H12V0Z" fill="#FF7262"/>
|
||||
<path d="M20 12C20 14.208 18.208 16 16 16C13.792 16 12 14.208 12 12C12 9.792 13.792 8 16 8C18.208 8 20 9.792 20 12Z" fill="#1ABCFE"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 608 B |
11
Web/public/images/chat/icon-javascript.svg
Executable file
@@ -0,0 +1,11 @@
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_631_1629)">
|
||||
<path d="M23.993 0H0.00703125C0.003148 0 0 0.003148 0 0.00703125V23.993C0 23.9969 0.003148 24 0.00703125 24H23.993C23.9969 24 24 23.9969 24 23.993V0.00703125C24 0.003148 23.9969 0 23.993 0Z" fill="#F7DF1E"/>
|
||||
<path d="M15.1875 17.3438C15.6562 18.1406 16.3125 18.7031 17.3906 18.7031C18.3281 18.7031 18.9375 18.2344 18.9375 17.5781C18.9375 16.8281 18.3281 16.5469 17.2969 16.0781L16.7344 15.8438C15.0938 15.1406 14.0156 14.2969 14.0156 12.4688C14.0156 10.7812 15.2812 9.46875 17.2969 9.46875C18.75 9.46875 19.7812 9.98438 20.4844 11.2969L18.75 12.4219C18.375 11.7188 17.9531 11.4375 17.2969 11.4375C16.6406 11.4375 16.2188 11.8594 16.2188 12.4219C16.2188 13.0781 16.6406 13.3594 17.625 13.7812L18.1875 14.0156C20.1094 14.8594 21.1875 15.6562 21.1875 17.5781C21.1875 19.5938 19.5938 20.7188 17.4375 20.7188C15.3281 20.7188 13.9688 19.7344 13.3125 18.4219L15.1875 17.3438ZM7.21875 17.5312C7.59375 18.1406 7.875 18.7031 8.67188 18.7031C9.42188 18.7031 9.89062 18.4219 9.89062 17.2969V9.51562H12.1406V17.2031C12.1406 19.5469 10.7812 20.5781 8.76562 20.5781C6.9375 20.5781 5.90625 19.6406 5.39062 18.5156L7.21875 17.5312Z" fill="black"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_631_1629">
|
||||
<rect width="24" height="24" rx="3" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
4
Web/public/images/chat/icon-zip-folder.svg
Executable file
@@ -0,0 +1,4 @@
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M21.25 21H2.75C2.02082 20.9995 1.32165 20.7096 0.806041 20.194C0.290431 19.6783 0.000529737 18.9792 0 18.25L0 5.75C0.000529737 5.02082 0.290431 4.32165 0.806041 3.80604C1.32165 3.29043 2.02082 3.00053 2.75 3H8C8.199 3 8.39 3.079 8.53 3.22L10.311 5H21.25C21.9792 5.00053 22.6783 5.29043 23.194 5.80604C23.7096 6.32165 23.9995 7.02082 24 7.75V18.25C23.9995 18.9792 23.7096 19.6783 23.194 20.194C22.6783 20.7096 21.9792 20.9995 21.25 21Z" fill="#03A9F4"/>
|
||||
<path d="M11.5 16C11.3011 16 11.1103 15.921 10.9697 15.7803C10.829 15.6397 10.75 15.4489 10.75 15.25V10.75C10.75 10.5511 10.829 10.3603 10.9697 10.2197C11.1103 10.079 11.3011 10 11.5 10C11.6989 10 11.8897 10.079 12.0303 10.2197C12.171 10.3603 12.25 10.5511 12.25 10.75V15.25C12.25 15.4489 12.171 15.6397 12.0303 15.7803C11.8897 15.921 11.6989 16 11.5 16ZM8.24999 16H5.74999C5.61924 16.0005 5.49066 15.9667 5.37706 15.9019C5.26345 15.8372 5.1688 15.7438 5.10253 15.6311C5.03626 15.5184 5.00069 15.3903 4.99937 15.2596C4.99804 15.1288 5.03101 15 5.09499 14.886L6.97599 11.5H5.74999C5.55108 11.5 5.36031 11.421 5.21966 11.2803C5.07901 11.1397 4.99999 10.9489 4.99999 10.75C4.99999 10.5511 5.07901 10.3603 5.21966 10.2197C5.36031 10.079 5.55108 10 5.74999 10H8.24999C8.38074 9.99954 8.50932 10.0334 8.62293 10.0981C8.73653 10.1628 8.83119 10.2562 8.89745 10.3689C8.96372 10.4816 8.99929 10.6097 9.00061 10.7404C9.00194 10.8712 8.96897 11 8.90499 11.114L7.02399 14.5H8.24999C8.4489 14.5 8.63967 14.579 8.78032 14.7197C8.92097 14.8603 8.99999 15.0511 8.99999 15.25C8.99999 15.4489 8.92097 15.6397 8.78032 15.7803C8.63967 15.921 8.4489 16 8.24999 16ZM14.75 16C14.5511 16 14.3603 15.921 14.2197 15.7803C14.079 15.6397 14 15.4489 14 15.25V10.75C14 10.5511 14.079 10.3603 14.2197 10.2197C14.3603 10.079 14.5511 10 14.75 10H16C17.103 10 18 10.897 18 12C18 13.103 17.103 14 16 14H15.5V15.25C15.5 15.4489 15.421 15.6397 15.2803 15.7803C15.1397 15.921 14.9489 16 14.75 16ZM15.5 12.5H16C16.275 12.5 16.5 12.275 16.5 12C16.5 11.725 16.275 11.5 16 11.5H15.5V12.5Z" fill="white"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.1 KiB |
10
Web/public/images/flag/icon-flag-de.svg
Executable file
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg width="28px" height="20px" viewBox="0 0 28 20" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||
<title>ic_flag_cn</title>
|
||||
<g id="Page-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
|
||||
<g id="ic_flag_cn">
|
||||
<rect id="Mask" fill="#F1361D" x="0" y="0" width="28" height="20" rx="3"></rect>
|
||||
<path d="M11.9592954,10.1560699 L11.9708698,11.1384189 L12.5105968,11.9592954 L11.5282478,11.9708698 L10.7073712,12.5105968 L10.6957968,11.5282478 L10.1560699,10.7073712 L11.1384189,10.6957968 L11.9592954,10.1560699 Z M6.66666667,2.66666667 L7.5836117,5.40460011 L10.4708927,5.43059869 L8.15031489,7.1487332 L9.01780768,9.90273464 L6.66666667,8.22666673 L4.31552566,9.90273464 L5.18301844,7.1487332 L2.8624406,5.43059869 L5.74972164,5.40460011 L6.66666667,2.66666667 Z M12.5685648,7.57446394 L13.4490988,8.01012816 L14.4255361,7.90189808 L13.9898718,8.78243212 L14.0981019,9.75886939 L13.2175679,9.32320517 L12.2411306,9.43143525 L12.6767948,8.55090121 L12.5685648,7.57446394 Z M14,4.17863279 L13.9772839,5.1607873 L14.4880339,6 L13.5058794,5.97728388 L12.6666667,6.48803387 L12.6893828,5.50587936 L12.1786328,4.66666667 L13.1607873,4.68938278 L14,4.17863279 Z M10.8992425,1.40597523 L11.6255808,2.06747064 L12.5940248,2.23257579 L11.9325294,2.9589141 L11.7674242,3.9273581 L11.0410859,3.2658627 L10.0726419,3.10075754 L10.7341373,2.37441924 L10.8992425,1.40597523 Z" id="Combined-Shape" fill="#FFDC42"></path>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
1
Web/public/images/flag/icon-flag-en.svg
Executable file
@@ -0,0 +1 @@
|
||||
<svg height="20" viewBox="0 0 28 20" width="28" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><defs><rect id="a" height="20" rx="3" width="28"/><mask id="b" fill="#fff"><use fill="#fff" fill-rule="evenodd" xlink:href="#a"/></mask></defs><g fill="none" fill-rule="evenodd"><use fill="#0a17a7" xlink:href="#a"/><path d="m29.2824692-1.91644623 1.4911811 2.21076686-9.4483006 6.37223314 6.6746503.0001129v6.66666663l-6.6746503-.0007795 9.4483006 6.3731256-1.4911811 2.2107668-11.9501195-8.0608924.0009836 7.4777795h-6.6666666l-.000317-7.4777795-11.9488189 8.0608924-1.49118107-2.2107668 9.448-6.3731256-6.67434973.0007795v-6.66666663l6.67434973-.0001129-9.448-6.37223314 1.49118107-2.21076686 11.9488189 8.06.000317-7.4768871h6.6666666l-.0009836 7.4768871z" fill="#fff" mask="url(#b)"/><g stroke="#db1f35" stroke-linecap="round" stroke-width=".667"><path d="m18.668 6.332 12.665-8.332" mask="url(#b)"/><path d="m20.013 21.35 11.354-7.652" mask="url(#b)" transform="matrix(1 0 0 -1 0 35.048)"/><path d="m8.006 6.31-11.843-7.981" mask="url(#b)"/><path d="m9.29 22.31-13.127-8.705" mask="url(#b)" transform="matrix(1 0 0 -1 0 35.915)"/></g><path d="m0 12h12v8h4v-8h12v-4h-12v-8h-4v8h-12z" fill="#e6273e" mask="url(#b)"/></g></svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
10
Web/public/images/flag/icon-flag-es.svg
Executable file
|
After Width: | Height: | Size: 6.7 KiB |
1
Web/public/images/flag/icon-flag-fr.svg
Executable file
@@ -0,0 +1 @@
|
||||
<svg height="20" viewBox="0 0 28 20" width="28" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><defs><rect id="a" height="20" rx="3" width="28"/><mask id="b" fill="#fff"><use fill="#fff" fill-rule="evenodd" xlink:href="#a"/></mask></defs><g fill="none" fill-rule="evenodd"><use fill="#fff" xlink:href="#a"/><path d="m19 0h9v20h-9z" fill="#f44653" mask="url(#b)"/><path d="m0 0h9v20h-9z" fill="#1035bb" mask="url(#b)"/></g></svg>
|
||||
|
After Width: | Height: | Size: 459 B |