Added front end login and beginnen of card mangment page. Also cleaned up the main server endpoints and added docker file

This commit is contained in:
Kayori
2026-01-18 22:02:20 +01:00
parent 24cd5e00f3
commit 8a4c4af8af
48 changed files with 1461 additions and 293 deletions
+1 -1
View File
@@ -6,7 +6,7 @@ namespace Abstractions.Entities
public class User: IdentityUser<int>
{
public int PaseliAmount { get; set; } = 0;
public int Pin { get; init; }
public string Pin { get; init; } = "0000";
public List<Card> Cards { get; init; } = null!;
}
+5
View File
@@ -1,4 +1,5 @@
using System.Collections.Generic;
using System.Threading.Tasks;
using Abstractions.Entities;
@@ -10,5 +11,9 @@ namespace Abstractions.Services
string ConvertKonamiIdToUid(string konamiId);
Task<Card?> FindByKonamiId(string konamiId);
Task<Card?> FindByCardId(string cardId);
Task<bool> Exists(string konamiId);
Task<bool> ValidatePinAsync(string konamiId, string pin);
Task<bool> IsRegistered(string konamiId, string pin);
Task<List<Card>> GetCardsByUserIdAsync(int userId);
}
}
+5 -1
View File
@@ -1,6 +1,10 @@
namespace Abstractions.Services
using Abstractions.Entities;
using System.Threading.Tasks;
namespace Abstractions.Services
{
public interface IUserService
{
Task<User?> GetUserByKonamiId(string konamiId);
}
}
+1 -1
View File
@@ -2,7 +2,7 @@
<PropertyGroup>
<StartupCommand>npm run dev</StartupCommand>
<JavaScriptTestRoot>.\</JavaScriptTestRoot>
<JavaScriptTestFramework>Jest</JavaScriptTestFramework>
<JavaScriptTestFramework>Vitest</JavaScriptTestFramework>
<!-- Allows the build (or compile) script located on package.json to run on Build -->
<ShouldRunBuildScript>false</ShouldRunBuildScript>
<!-- Folder where production build objects will be placed -->
+10 -1
View File
@@ -1,5 +1,6 @@
{
"lockfileVersion": 1,
"configVersion": 0,
"workspaces": {
"": {
"name": "client",
@@ -9,10 +10,12 @@
"axios": "^1.10.0",
"oh-vue-icons": "^1.0.0-rc3",
"pinia": "^3.0.3",
"primeicons": "^7.0.0",
"primevue": "^4.3.6",
"tailwind-merge": "^3.3.1",
"tailwindcss": "^4.1.11",
"tailwindcss-primeui": "^0.6.1",
"vee-validate": "^4.15.1",
"vite-plugin-vue-layouts-next": "^1.0.0",
"vue": "^3.4.29",
"vue-router": "4",
@@ -600,6 +603,8 @@
"prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="],
"primeicons": ["primeicons@7.0.0", "", {}, "sha512-jK3Et9UzwzTsd6tzl2RmwrVY/b8raJ3QZLzoDACj+oTJ0oX7L9Hy+XnVwgo4QVKlKpnP/Ur13SXV/pVh4LzaDw=="],
"primevue": ["primevue@4.3.6", "", { "dependencies": { "@primeuix/styled": "^0.7.0", "@primeuix/styles": "^1.2.0", "@primeuix/utils": "^0.6.0", "@primevue/core": "4.3.6", "@primevue/icons": "4.3.6" } }, "sha512-Wwg2dH6pBmOdkj9L/OnrCQf9AKPHfY5CcfnDyWeh0tNlR+XjYKGl8qvMdJOvGO9jjg6UdsX5MSaU8vDDsSG+sg=="],
"property-expr": ["property-expr@2.0.6", "", {}, "sha512-SVtmxhRE/CGkn3eZY1T6pC8Nln6Fr/lu1mKSgRud0eC73whjGfoAogbn78LkD8aFL0zz3bAFerKSnOl7NlErBA=="],
@@ -674,7 +679,7 @@
"type-check": ["type-check@0.4.0", "", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="],
"type-fest": ["type-fest@2.19.0", "", {}, "sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA=="],
"type-fest": ["type-fest@4.41.0", "", {}, "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA=="],
"typescript": ["typescript@5.4.5", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-vcI4UpRgg81oIRUFwR0WSIHKt11nJ7SAVlYNIu+QpqeyXP+gpQJy/Z4+F0aGxSE4MqwjyXvW/TzgkLAx2AGHwQ=="],
@@ -692,6 +697,8 @@
"util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="],
"vee-validate": ["vee-validate@4.15.1", "", { "dependencies": { "@vue/devtools-api": "^7.5.2", "type-fest": "^4.8.3" }, "peerDependencies": { "vue": "^3.4.26" } }, "sha512-DkFsiTwEKau8VIxyZBGdO6tOudD+QoUBPuHj3e6QFqmbfCRj1ArmYWue9lEp6jLSWBIw4XPlDLjFIZNLdRAMSg=="],
"vite": ["vite@5.4.11", "", { "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", "rollup": "^4.20.0" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || >=20.0.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.4.0" }, "optionalPeers": ["less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser"], "bin": "bin/vite.js" }, "sha512-c7jFQRklXua0mTzneGW9QVyxFjUgwcihC4bXEtujIo2ouWCe1Ajt/amn2PCxYnhYfd5k09JX3SB7OYWFKYqj8Q=="],
"vite-plugin-vue-layouts-next": ["vite-plugin-vue-layouts-next@1.0.0", "", { "dependencies": { "debug": "^4.4.1", "fast-glob": "^3.3.3" }, "peerDependencies": { "vite": "^5.0.0 || ^6.0.0 || ^7.0.0", "vue": "^3.2.4", "vue-router": "^4.0.11" } }, "sha512-y3oNYcJs5lrv4F/E0EXU7OXi88CH1/b/DOk9nsFAeglxmk4Ps635DTy9R29Nl9Vj7LteBRKaxOkKVSHKOMqo7A=="],
@@ -796,6 +803,8 @@
"vue-router/@vue/devtools-api": ["@vue/devtools-api@6.6.4", "", {}, "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g=="],
"yup/type-fest": ["type-fest@2.19.0", "", {}, "sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA=="],
"@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime/@tybys/wasm-util": ["@tybys/wasm-util@0.10.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-VyyPYFlOMNylG45GoAe0xDoLwWuowvf92F9kySqzYh8vmYm7D2u4iUJKa1tOUpS70Ku13ASrOkS4ScXFsTaCNQ=="],
"@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@2.0.1", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA=="],
+33
View File
@@ -13,10 +13,12 @@
"axios": "^1.10.0",
"oh-vue-icons": "^1.0.0-rc3",
"pinia": "^3.0.3",
"primeicons": "^7.0.0",
"primevue": "^4.3.6",
"tailwind-merge": "^3.3.1",
"tailwindcss": "^4.1.11",
"tailwindcss-primeui": "^0.6.1",
"vee-validate": "^4.15.1",
"vite-plugin-vue-layouts-next": "^1.0.0",
"vue": "^3.4.29",
"vue-router": "4",
@@ -4089,6 +4091,12 @@
"node": ">= 0.8.0"
}
},
"node_modules/primeicons": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/primeicons/-/primeicons-7.0.0.tgz",
"integrity": "sha512-jK3Et9UzwzTsd6tzl2RmwrVY/b8raJ3QZLzoDACj+oTJ0oX7L9Hy+XnVwgo4QVKlKpnP/Ur13SXV/pVh4LzaDw==",
"license": "MIT"
},
"node_modules/primevue": {
"version": "4.3.6",
"resolved": "https://registry.npmjs.org/primevue/-/primevue-4.3.6.tgz",
@@ -4701,6 +4709,31 @@
"dev": true,
"license": "MIT"
},
"node_modules/vee-validate": {
"version": "4.15.1",
"resolved": "https://registry.npmjs.org/vee-validate/-/vee-validate-4.15.1.tgz",
"integrity": "sha512-DkFsiTwEKau8VIxyZBGdO6tOudD+QoUBPuHj3e6QFqmbfCRj1ArmYWue9lEp6jLSWBIw4XPlDLjFIZNLdRAMSg==",
"license": "MIT",
"dependencies": {
"@vue/devtools-api": "^7.5.2",
"type-fest": "^4.8.3"
},
"peerDependencies": {
"vue": "^3.4.26"
}
},
"node_modules/vee-validate/node_modules/type-fest": {
"version": "4.41.0",
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz",
"integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==",
"license": "(MIT OR CC0-1.0)",
"engines": {
"node": ">=16"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/vite": {
"version": "5.4.11",
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.11.tgz",
+2
View File
@@ -17,10 +17,12 @@
"axios": "^1.10.0",
"oh-vue-icons": "^1.0.0-rc3",
"pinia": "^3.0.3",
"primeicons": "^7.0.0",
"primevue": "^4.3.6",
"tailwind-merge": "^3.3.1",
"tailwindcss": "^4.1.11",
"tailwindcss-primeui": "^0.6.1",
"vee-validate": "^4.15.1",
"vite-plugin-vue-layouts-next": "^1.0.0",
"vue": "^3.4.29",
"vue-router": "4",
-1
View File
@@ -2,7 +2,6 @@
@import "tailwindcss";
@import "tailwindcss-primeui";
/* Primary and Surface Palettes */
:root {
--p-primary-50: #ecfdf5;
+37
View File
@@ -0,0 +1,37 @@
<script setup lang="ts">
import { useUserStore } from '@/store/userStore';
import Menu from '@/volt/Menu.vue';
import { ref } from 'vue';
import { useRouter } from 'vue-router';
const router = useRouter();
const items = ref([
{ label: 'Home', icon: 'pi pi-home' , command: () => router.push('/') },
{ label: 'Card management', icon: 'pi pi-id-card' , command: () => router.push('/cards') },
{ label: 'logout', icon: 'pi pi-sign-out', command: () => {
const userStore = useUserStore();
userStore.unsetAccessToken();
userStore.unsetUser();
userStore.unsetRefreshToken();
userStore.unsetLoggedIn();
sessionStorage.removeItem("accessToken");
localStorage.removeItem("refreshToken");
router.push('/auth');
} },
]);
</script>
<template>
<div class="flex flex-col justify-center w-2xs h-dvh rounded-none! bg-surface-0 dark:bg-surface-900 text-surface-700 dark:text-surface-0 shadow-md"
data-pc-name="card" pc4="" data-pc-section="root">
<div class="p-5 flex flex-col gap-2 h-full" data-pc-section="body">
<div class="flex flex-col gap-2" data-pc-section="caption">
<div class="font-medium text-xl" data-pc-section="title">Medusa.net</div>
</div>
<div class="" data-pc-section="content">
<Menu :model="items" />
</div>
</div>
</div>
</template>
+117 -3
View File
@@ -1,7 +1,121 @@
import { useUserStore } from "@/store/userStore";
import type { AuthenticationResponse } from "@/types/authenticationResponse";
import type { LoginRequest } from "@/types/loginRequest";
import type { TokenResponse } from "@/types/tokenResponse";
import { UserService } from "./userService";
export class AuthenticationService {
static async refreshAccessToken(refreshToken: string, userId: string) {
return false;
static async refreshAccessToken(refreshToken: string) {
const store = useUserStore();
const response = await fetch("/api/auth/refresh", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
refreshToken: refreshToken
}),
});
if (!response.ok) {
return false;
}
const tokenResponse = (await response.json()) as TokenResponse;
sessionStorage.setItem("accessToken", tokenResponse.accessToken);
localStorage.setItem("refreshToken", tokenResponse.refreshToken);
store.setAccessToken(tokenResponse.accessToken);
store.setRefreshToken(tokenResponse.refreshToken);
store.setLoggedIn(true);
if (await UserService.GetUserClaims()) {
return true;
}
}
return false;
}
static async login(
loginRequest: LoginRequest,
): Promise<AuthenticationResponse> {
const response = await fetch("/api/auth/login", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(loginRequest),
});
if (!response.ok) {
const json = await response.json();
if (json.toString().includes(
"Invalid email or password",
)
) {
return {
twoFactorRequired: false,
incorrectCredentials: true,
};
}
}
const tokenResponse = (await response.json()) as TokenResponse;
sessionStorage.setItem("accessToken", tokenResponse.accessToken);
localStorage.setItem("refreshToken", tokenResponse.refreshToken);
const store = useUserStore();
store.setAccessToken(tokenResponse.accessToken);
store.setRefreshToken(tokenResponse.refreshToken);
store.setLoggedIn(true);
if (await UserService.GetUserClaims()) {
return {
twoFactorRequired: false,
incorrectCredentials: false,
};
}
return {
twoFactorRequired: true,
incorrectCredentials: true,
};
}
static async registerUser(
konamiId: string,
pinCode: string,
username: string,
email: string,
password: string,
) {
const response = await fetch("/api/auth/register", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
konamiId,
pin: pinCode,
username,
email,
password,
}),
});
if (!response.ok) {
return false;
}
return !(
await this.login({
email: email,
password: password,
twoFactorCode: undefined,
twoFactorRecoveryCode: undefined,
})
).incorrectCredentials;
}
}
+26
View File
@@ -0,0 +1,26 @@
import { useUserStore } from "@/store/userStore";
import type { UserClaim } from "@/types/userClaim";
export class UserService {
static async GetUserClaims(): Promise<boolean> {
const response = await fetch("/api/user/claims", {
method: "GET",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${sessionStorage.getItem("accessToken")}`,
},
});
if (!response.ok) {
return false;
}
const userClaims = (await response.json()) as UserClaim[];
const store = useUserStore();
store.setUser(userClaims);
store.setName(
userClaims.find((claim) => claim.type === "name")?.value || "",
);
return true;
}
}
+14 -5
View File
@@ -1,7 +1,16 @@
<script setup lang="ts">
import Sidebar from '@/components/Sidebar.vue';
</script>
<template>
<router-view v-slot="{ Component }">
<transition name="fade" mode="out-in">
<component :is="Component" />
</transition>
</router-view>
<div class="flex gap-4 h-full w-full">
<Sidebar />
<main class="w-full h-dvh flex items-center">
<router-view v-slot="{ Component }">
<transition name="fade" mode="out-in">
<component :is="Component" />
</transition>
</router-view>
</main>
</div>
</template>
+1
View File
@@ -1,4 +1,5 @@
import './assets/main.css'
import 'primeicons/primeicons.css'
import { createApp } from 'vue'
import PrimeVue from 'primevue/config';
+55 -22
View File
@@ -1,18 +1,45 @@
<script setup lang="ts">
import { Form } from '@primevue/forms';
import { Form, type FormSubmitEvent } from '@primevue/forms';
import Card from '@/volt/Card.vue';
import Button from '@/volt/Button.vue';
import Message from '@/volt/Message.vue';
import InputText from "@/volt/InputText.vue";
import { useRouter } from 'vue-router';
import * as yup from 'yup';
import { yupResolver } from '@primevue/forms/resolvers/yup';
import { ref } from 'vue';
import { AuthenticationService } from '@/data/authenticationService';
definePage({
meta: {
requiresAuth: false,
},
meta: {
requiresAuth: false,
},
})
const router = useRouter();
const loading = ref(false);
const resolver = yupResolver(
yup.object({
email: yup.string().required('Username or Email is required'),
password: yup.string().required('Password is required'),
})
);
const onFormSubmit = async (event: FormSubmitEvent<Record<string, any>>) => {
loading.value = true;
if (!event.valid) {
loading.value = false;
return;
}
const result = await AuthenticationService.login({ email: event.values.email!, password: event.values.password!, twoFactorCode: undefined, twoFactorRecoveryCode: undefined });
loading.value = false;
if (!result.incorrectCredentials) {
router.push('/');
}
};
</script>
<route lang="yaml">
@@ -23,26 +50,32 @@ meta:
<template>
<div>
<Card class="p-24 pt-16 pb-16">
<template #title>Login</template>
<template #title>Login</template>
<template #content>
<Form class="flex flex-col gap-2">
<div class="flex flex-col gap-2">
<label for="email">Username or Email</label>
<InputText id="email" name="email" />
<template #content>
<Form v-slot="$form" class="flex flex-col gap-4" :resolver="resolver" :validate-on-value-update="true"
@submit="onFormSubmit">
<div class="flex flex-col gap-2">
<label for="email">Username or Email</label>
<InputText id="email" name="email" />
<Message v-if="$form.email?.invalid" severity="error" size="small" variant="simple">{{
$form.email.error.message }}</Message>
</div>
<div class="flex flex-col gap-2">
<label for="password">Password</label>
<InputText id="password" type="password" name="password" />
<Message v-if="$form.password?.invalid" severity="error" size="small" variant="simple">{{
$form.password.error.message }}</Message>
</div>
<div class="flex justify-between w-full">
<div class="flex gap-4 mt-1 w-full">
<Button @click="() => router.push('/auth/register')" label="Register" severity="secondary"
variant="outlined" class="w-full" />
<Button type="submit" label="Login" class="w-full" :loading="loading" loading-icon="ProgressSpinner">Login</Button>
</div>
<div class="flex flex-col gap-2">
<label for="password">Password</label>
<InputText id="password" name="password" />
</div>
<div class="flex justify-between">
<div class="flex gap-4 mt-1">
<Button @click="() => router.push('/auth/register')" label="Register" severity="secondary" variant="outlined" class="w-full" />
<Button label="Login" class="w-full">Login</Button>
</div>
</div>
</Form>
</template>
</div>
</Form>
</template>
</Card>
</div>
</template>
+141 -51
View File
@@ -1,65 +1,155 @@
<script setup lang="ts">
import { Form } from '@primevue/forms';
import Card from '@/volt/Card.vue';
import Button from '@/volt/Button.vue';
import InputText from "@/volt/InputText.vue";
import InputNumber from "@/volt/InputNumber.vue";
import { useRouter } from 'vue-router';
<script setup lang="ts">
import { Form, type FormSubmitEvent } from '@primevue/forms';
import Card from '@/volt/Card.vue';
import Button from '@/volt/Button.vue';
import Message from '@/volt/Message.vue';
import InputText from "@/volt/InputText.vue";
import { useRouter } from 'vue-router';
import * as yup from 'yup';
import { yupResolver } from '@primevue/forms/resolvers/yup';
import { ref } from 'vue';
import { AuthenticationService } from '@/data/authenticationService';
definePage({
meta: {
requiresAuth: false,
},
})
definePage({
meta: {
requiresAuth: false,
},
})
const router = useRouter();
const router = useRouter();
const loading = ref(false);
const cardResponse = ref<{ success: boolean; message: string }>({ success: false, message: 'Waiting for card' });
const resolver = yupResolver(
yup.object({
cardNumber: yup.string().length(16, 'Card number must be 16 digits').required('Card number is required')
.test('validate-card', '', async function (value) {
const { pinCode } = this.parent;
const response = await validateCard(value!, pinCode!);
cardResponse.value = response;
return response.success;
}),
pinCode: yup
.string()
.required('Pin code is required')
.matches(/^\d{4}$/, 'Pin code must be exactly 4 digits (0000–9999)')
.test('validate-pin', '', async function (value) {
const { cardNumber } = this.parent;
const response = await validateCard(cardNumber!, value!);
cardResponse.value = response;
return response.success;
}),
username: yup.string().min(3, 'Username must be at least 3 characters').required('Username is required'),
email: yup.string().email('Invalid email format').required('Email is required'),
password: yup.string().required('Password is required').min(8, 'Password must be at least 6 characters')
.matches(/(?=.*\d)/, 'Password must contain a number')
.matches(/(?=.*[a-z])/, 'Password must contain a lowercase letter')
.matches(/(?=.*[A-Z])/, 'Password must contain an uppercase letter')
.matches(/(?=.*\W)/, 'Password must contain a special character'),
passwordConfirmation: yup.string()
.oneOf([yup.ref('password')], 'Passwords must match')
.required('Password confirmation is required'),
})
);
const validateCard = async (cardNumber: string, pinCode: string) => {
const response = await fetch('/api/cards/validate', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ cardId: cardNumber, pin: pinCode }),
});
if (!response.ok) {
return { success: false, message: 'Failed to validate card' };
}
return response.json();
};
const onFormSubmit = async (event: FormSubmitEvent<Record<string, any>>) => {
loading.value = true;
console.log('Form Submitted', event);
if (!event.valid) {
loading.value = false;
return;
}
const result = await AuthenticationService.registerUser(event.values.cardNumber, event.values.pinCode, event.values.username, event.values.email, event.values.password);
loading.value = false;
if (result) {
router.push('/auth/login');
}
};
</script>
<route lang="yaml">
meta:
layout: auth
</route>
<route lang="yaml">
meta:
layout: auth
</route>
<template>
<div>
<Card class="p-24 pt-16 pb-16">
<template #title>Register</template>
<template>
<div>
<Card class="p-24 pt-16 pb-16">
<template #title>Register</template>
<template #content>
<Form class="flex flex-col gap-4">
<div class="flex gap-4">
<template #content>
<Form v-slot="$form" class="flex flex-col gap-4" :resolver="resolver" :validate-on-value-update="true"
@submit="onFormSubmit">
<div class="flex gap-4">
<div class="flex flex-col gap-2">
<label for="cardNumber">16 digit Card Number</label>
<InputText id="cardNumber" name="cardNumber" />
<Message v-if="$form.cardNumber?.invalid" severity="error" size="small" variant="simple">{{
$form.cardNumber.error.message }}</Message>
</div>
<div class="flex flex-col gap-2">
<label for="pinCode">Pin code</label>
<InputText id="pinCode" name="pinCode" :format="false" />
<Message v-if="$form.pinCode?.invalid" severity="error" size="small" variant="simple">{{
$form.pinCode.error.message }}</Message>
</div>
</div>
<Message :severity="cardResponse?.success ? 'success' : 'warn'">{{ cardResponse?.message }}</Message>
<div class="flex flex-col gap-2">
<label for="cardNumber">Card Number</label>
<InputText id="cardNumber" name="cardNumber" />
<label for="username">Username</label>
<InputText id="username" name="username" />
<Message v-if="$form.username?.invalid" severity="error" size="small" variant="simple">{{
$form.username.error.message }}</Message>
</div>
<div class="flex flex-col gap-2">
<label for="pinCode">Pin code</label>
<InputNumber id="pinCode" name="pinCode" :format="false" max="9999"/>
<label for="email">Email</label>
<InputText id="email" name="email" />
<Message v-if="$form.email?.invalid" severity="error" size="small" variant="simple">{{
$form.email.error.message }}</Message>
</div>
</div>
<p>Card status:</p>
<div class="flex flex-col gap-2">
<label for="username">Username</label>
<InputText id="username" name="username" />
</div>
<div class="flex flex-col gap-2">
<label for="email">Email</label>
<InputText id="email" name="email" />
</div>
<div class="flex gap-4">
<div class="flex flex-col gap-2">
<label for="password">Password</label>
<InputText id="password" name="password" />
<div class="flex gap-4">
<div class="flex flex-col gap-2">
<label for="password">Password</label>
<InputText id="password" type="password" name="password" />
<Message v-if="$form.password?.invalid" severity="error" size="small" variant="simple">{{
$form.password.error.message }}</Message>
</div>
<div class="flex flex-col gap-2">
<label for="passwordConfirmation">Confirm Password</label>
<InputText type="password" id="passwordConfirmation" name="passwordConfirmation" />
<Message v-if="$form.passwordConfirmation?.invalid" severity="error" size="small" variant="simple">{{
$form.passwordConfirmation.error.message }}</Message>
</div>
</div>
<div class="flex flex-col gap-2">
<label for="passwordConfirmation">Confirm Password</label>
<InputText id="passwordConfirmation" name="passwordConfirmation" />
<div class="flex justify-between w-full">
<div class="flex gap-4 mt-1 w-full">
<Button @click="() => router.push('/auth/login')" label="Login" severity="secondary" variant="outlined"
class="w-full" />
<Button :loading="loading" type="submit" label="Register" class="w-full" loading-icon="ProgressSpinner">Register</Button>
</div>
</div>
</div>
</Form>
</template>
</Form>
</template>
</Card>
</div>
</template>
</Card>
</div>
</template>
+49
View File
@@ -0,0 +1,49 @@
<script setup lang="ts">
import Card from '@/volt/Card.vue';
import DataTable from '@/volt/DataTable.vue';
import Column from 'primevue/column';
import type { Card as MedusaCard } from '@/types/card';
import { onMounted, ref } from 'vue';
import { useUserStore } from '@/store/userStore';
const cards = ref<MedusaCard[] | null>(null);
onMounted(async () => {
const store = useUserStore();
fetch('/api/cards/my',
{
method: 'GET',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${store.accessToken}`,
},
credentials: 'include',
}
)
.then(response => response.json())
.then(data => {
cards.value = data;
});
});
</script>
<template>
<div class="w-full flex justify-center">
<Card class="p-4 pt-6 pb-6">
<template #title>Manage your cards</template>
<template #content>
<div v-if="cards === null">
<p>Loading cards...</p>
</div>
<div v-else>
<DataTable :value="cards" pt:table="min-w-200">
<Column field="konamiId" header="KonamiId"></Column>
<Column field="rawId" header="Raw Id"></Column>
</DataTable>
</div>
</template>
</Card>
</div>
</template>
+10 -4
View File
@@ -1,11 +1,17 @@
<script setup lang="ts">
import Card from '@/volt/Card.vue';
</script>
<template>
<div class="w-full flex justify-center">
<Card class="p-4 pt-6 pb-6">
<template #title>Welcome to Medusa.net</template>
<template #content>
<p class="mb-16">This is the home page. Use the sidebar to navigate through the application.</p>
</template>
</Card>
</div>
</template>
<style scoped>
</style>
<style scoped></style>
+2 -9
View File
@@ -15,17 +15,11 @@ router.beforeEach(async (to, from, next) => {
let store = useUserStore();
let user = store.user;
//dont let people go back to the splash page
if (to.path === "/" && from.path !== "/") {
next(from);
return;
}
//if the user is not logged in, redirect them to the welcome page
const requiresAuth =
to.meta.requiresAuth !== undefined && to.meta.requiresAuth === false;
if (!requiresAuth && user === undefined) {
const refreshToken = localStorage.getItem("refreshToken") as string;
const userId = localStorage.getItem("userId") as string;
if (!refreshToken) {
next("/auth");
return;
@@ -33,12 +27,11 @@ router.beforeEach(async (to, from, next) => {
//i think store gets reset when you switch between apps. So we need to rehydrate the store
const success = await AuthenticationService.refreshAccessToken(
refreshToken,
userId
refreshToken
);
if (!success) {
next("/");
next("/auth");
return;
}
}
+42 -49
View File
@@ -1,53 +1,46 @@
import { defineStore } from "pinia";
import type { UserClaim } from "@/types/userClaim";
export const useUserStore = defineStore('user', {
state: () => {
return {
user: undefined as UserClaim[] | undefined,
refreshToken: undefined as string | undefined,
accessToken: undefined as string | undefined,
name: undefined as string | undefined,
profilePicture: undefined as string | undefined,
notificationCount: 0
};
export const useUserStore = defineStore("user", {
state: () => {
return {
user: undefined as UserClaim[] | undefined,
refreshToken: undefined as string | undefined,
accessToken: undefined as string | undefined,
isLoggedIn: false,
name: undefined as string | undefined,
};
},
actions: {
setUser(user: UserClaim[]) {
this.user = user;
},
actions: {
setUser(user: UserClaim[]) {
this.user = user;
},
setRefreshToken(refreshToken: string) {
this.refreshToken = refreshToken;
},
setAccessToken(accessToken: string) {
this.accessToken = accessToken;
},
setName(name: string) {
this.name = name;
},
setProfilePicture(profilePicture: string) {
this.profilePicture = profilePicture;
},
setNotificationCount(count: number) {
this.notificationCount = count;
},
unsetUser() {
this.user = undefined;
},
unsetRefreshToken() {
this.refreshToken = undefined;
},
unsetAccessToken() {
this.accessToken = undefined;
},
unsetName() {
this.name = undefined;
},
unsetProfilePicture() {
this.profilePicture = undefined;
},
resetNotificationCount() {
this.notificationCount = 0;
}
}
})
setRefreshToken(refreshToken: string) {
this.refreshToken = refreshToken;
},
setAccessToken(accessToken: string) {
this.accessToken = accessToken;
},
setLoggedIn(isLoggedIn: boolean) {
this.isLoggedIn = isLoggedIn;
},
setName(name: string) {
this.name = name;
},
unsetUser() {
this.user = undefined;
},
unsetRefreshToken() {
this.refreshToken = undefined;
},
unsetAccessToken() {
this.accessToken = undefined;
},
unsetLoggedIn() {
this.isLoggedIn = false;
},
unsetName() {
this.name = undefined;
},
},
});
@@ -0,0 +1,4 @@
export type AuthenticationResponse = {
incorrectCredentials: boolean;
twoFactorRequired: boolean;
};
+5
View File
@@ -0,0 +1,5 @@
export type Card = {
konamiId: string;
rawId: string;
userId: number;
}
+6
View File
@@ -0,0 +1,6 @@
export type LoginRequest = {
email: string;
password: string;
twoFactorCode: string | null | undefined;
twoFactorRecoveryCode: string | null | undefined;
};
+5
View File
@@ -0,0 +1,5 @@
export type TokenResponse = {
accessToken: string;
refreshToken: string;
expiresIn: number;
};
+55
View File
@@ -0,0 +1,55 @@
<template>
<Menu
ref="el"
unstyled
:pt="theme"
:ptOptions="{
mergeProps: ptViewMerge
}"
>
<template v-for="(_, slotName) in $slots" #[slotName]="slotProps">
<slot :name="slotName" v-bind="slotProps ?? {}" />
</template>
</Menu>
</template>
<script setup lang="ts">
import Menu, { type MenuPassThroughOptions, type MenuProps } from 'primevue/menu';
import { ref } from 'vue';
import { ptViewMerge } from './utils';
interface Props extends /* @vue-ignore */ MenuProps {}
defineProps<Props>();
const theme = ref<MenuPassThroughOptions>({
root: `bg-surface-0 dark:bg-surface-900
text-surface-700 dark:text-surface-0
border border-surface-200 dark:border-surface-700
rounded-md min-w-52
p-popup:shadow-[0_4px_6px_-1px_rgba(0,0,0,0.1),0_2px_4px_-2px_rgba(0,0,0,0.1)]`,
list: `m-0 p-1 list-none outline-none flex flex-col gap-[2px]`,
item: `p-disabled:opacity-60 p-disabled:pointer-events-none`,
itemContent: `group transition-colors duration-200 rounded-sm text-surface-700 dark:text-surface-0
p-focus:bg-surface-100 dark:p-focus:bg-surface-800 p-focus:text-surface-800 dark:p-focus:text-surface-0
hover:bg-surface-100 dark:hover:bg-surface-800 hover:text-surface-800 dark:hover:text-surface-0`,
itemLink: `cursor-pointer flex items-center no-underline overflow-hidden relative text-inherit
px-3 py-2 gap-2 select-none outline-none`,
itemIcon: `text-surface-400 dark:text-surface-500
p-focus:text-surface-500 dark:p-focus:text-surface-400
group-hover:text-surface-500 dark:group-hover:text-surface-400`,
itemLabel: ``,
submenuLabel: `bg-transparent px-3 py-2 text-surface-500 dark:text-surface-400 font-semibold`,
separator: `border-t border-surface-200 dark:border-surface-700`,
transition: {
enterFromClass: 'opacity-0 scale-y-75',
enterActiveClass: 'transition duration-120 ease-[cubic-bezier(0,0,0.2,1)]',
leaveActiveClass: 'transition-opacity duration-100 ease-linear',
leaveToClass: 'opacity-0'
}
});
const el = ref();
defineExpose({
toggle: (event) => el.value.toggle(event)
});
</script>
+79
View File
@@ -0,0 +1,79 @@
<template>
<Message
unstyled
:pt="theme"
:ptOptions="{
mergeProps: ptViewMerge
}"
>
<template #closeicon>
<TimesIcon />
</template>
<template v-for="(_, slotName) in $slots" #[slotName]="slotProps">
<slot :name="slotName" v-bind="slotProps ?? {}" />
</template>
</Message>
</template>
<script setup lang="ts">
import TimesIcon from '@primevue/icons/times';
import Message, { type MessagePassThroughOptions, type MessageProps } from 'primevue/message';
import { ref } from 'vue';
import { ptViewMerge } from './utils';
interface Props extends /* @vue-ignore */ MessageProps {}
defineProps<Props>();
const theme = ref<MessagePassThroughOptions>({
root: `rounded-md outline outline-1
p-outlined:bg-transparent p-outlined:outline p-outlined:outline-1
p-simple:bg-transparent p-simple:outline-none
p-info:bg-blue-50/95 p-info:outline-blue-200 p-info:text-blue-600 dark:p-info:bg-blue-500/15 dark:p-info:outline-blue-700/35 dark:p-info:text-blue-500
p-info:p-outlined:text-blue-500 p-info:p-outlined:outline-blue-500 dark:p-info:p-outlined:text-blue-600 dark:p-info:p-outlined:outline-blue-600
p-info:p-simple:text-blue-500 dark:p-info:p-simple:text-blue-600
p-success:bg-green-50/95 p-success:outline-green-200 p-success:text-green-600 dark:p-success:bg-green-500/15 dark:p-success:outline-green-700/35 dark:p-success:text-green-500
p-success:p-outlined:text-green-500 p-success:p-outlined:outline-green-500 dark:p-success:p-outlined:text-green-600 dark:p-success:p-outlined:outline-green-600
p-success:p-simple:text-green-500 dark:p-success:p-simple:text-green-600
p-warn:bg-yellow-50/95 p-warn:outline-yellow-200 p-warn:text-yellow-600 dark:p-warn:bg-yellow-500/15 dark:p-warn:outline-yellow-700/35 dark:p-warn:text-yellow-500
p-warn:p-outlined:text-yellow-500 p-warn:p-outlined:outline-yellow-500 dark: p-warn:p-outlined:text-yellow-600 dark:p-warn:p-outlined:outline-yellow-600
p-warn:p-simple:text-yellow-500 dark:p-warn:p-simple:text-yellow-600
p-error:bg-red-50/95 p-error:outline-red-200 p-error:text-red-600 dark:p-error:bg-red-500/15 dark:p-error:outline-red-700/35 dark:p-error:text-red-500
p-error:p-outlined:text-red-500 p-error:p-outlined:outline-red-500 dark:p-error:p-outlined:text-red-600 dark:p-error:p-outlined:outline-red-600
p-error:p-simple:text-red-500 dark:p-error:p-simple:text-red-600
p-secondary:bg-surface-100 p-secondary:outline-surface-200 p-secondary:text-surface-600 dark:p-secondary:bg-surface-800 dark:p-secondary:outline-surface-700 dark:p-secondary:text-surface-300
p-secondary:p-outlined:text-surface-500 p-secondary:p-outlined:outline-surface-500 dark:p-secondary:p-outlined:text-surface-400 dark:p-secondary:p-outlined:outline-surface-400
p-secondary:p-simple:text-surface-500 dark:p-secondary:p-simple:text-surface-400
p-contrast:bg-surface-900 p-contrast:outline-surface-950 p-contrast:text-surface-50 dark:p-contrast:bg-surface-0 dark:p-contrast:outline-surface-100 dark:p-contrast:text-surface-950
p-contrast:p-outlined:text-surface-950 p-contrast:p-outlined:outline-surface-950 dark:p-contrast:p-outlined:text-surface-0 dark:p-contrast:p-outlined:outline-surface-0
p-contrast:p-simple:text-surface-950 dark:p-contrast:p-simple:text-surface-0`,
content: `flex items-center
p-simple:p-0
px-3 py-2 gap-2 h-full
p-small:px-2.5 p-small:py-[0.375rem]
p-large:px-3.5 p-large:py-2.5`,
icon: `flex-shrink-0 text-lg w-[1.125rem] h-[1.125rem]
p-small:w-[0.875rem] p-small:h-[0.875rem] p-small:text-sm
p-large:w-5 p-large:h-5 p-large:text-xl`,
text: `text-base font-medium p-small:text-sm p-large:text-xl`,
closeButton: `flex items-center justify-center flex-shrink-0 ms-auto overflow-hidden relative cursor-pointer select-none
w-7 h-7 rounded-full bg-transparent transition-colors duration-200 text-inherit p-0 border-none
focus-visible:outline focus-visible:outline-1 focus-visible:outline-offset-2
p-info:hover:bg-blue-100 p-info:focus-visible:outline-blue-600 dark:p-info:hover:bg-white/5 dark:p-info:focus-visible:outline-blue-500
p-success:hover:bg-green-100 p-success:focus-visible:outline-green-600 dark:p-success:hover:bg-white/5 dark:p-success:focus-visible:outline-green-500
p:warn:hover:bg-yellow-100 p:warn:focus-visible:outline-yellow-600 dark:p:warn:hover:bg-white/5 dark:p:warn:focus-visible:outline-yellow-500
p-error:hover:bg-red-100 p-error:focus-visible:outline-red-600 dark:p-error:hover:bg-white/5 dark:p-error:focus-visible:outline-red-500
p-secondary:hover:bg-surface-200 p-secondary:focus-visible:outline-surface-600 dark:p-secondary:hover:bg-surface-700 dark:p-secondary:focus-visible:outline-surface-300
p-contrast:hover:bg-surface-800 p-contrast:focus-visible:outline-surface-50 dark:p-contrast:hover:bg-surface-100 dark:p-contrast:focus-visible:outline-surface-950
p-outlined:hover:bg-transparent p-simple:hover:bg-transparent`,
closeIcon: `w-4 h-4 text-base
p-small:w-3.5 p-small:h-3.5 p-small:text-sm
p-large:w-[1.125rem] p-large:h-[1.125rem] p-large:text-xl`,
transition: {
enterFromClass: 'opacity-0',
enterActiveClass: 'transition-opacity duration-300',
leaveFromClass: 'max-h-40',
leaveActiveClass: 'overflow-hidden transition-all duration-300 ease-in',
leaveToClass: 'max-h-0 opacity-0 !m-0'
}
});
</script>
+1
View File
@@ -21,5 +21,6 @@ declare module 'vue-router/auto-routes' {
'/': RouteRecordInfo<'/', '/', Record<never, never>, Record<never, never>>,
'/auth/': RouteRecordInfo<'/auth/', '/auth', Record<never, never>, Record<never, never>>,
'/auth/register': RouteRecordInfo<'/auth/register', '/auth/register', Record<never, never>, Record<never, never>>,
'/cards/': RouteRecordInfo<'/cards/', '/cards', Record<never, never>, Record<never, never>>,
}
}
+2 -2
View File
@@ -47,10 +47,10 @@ export default defineConfig({
},
server: {
proxy: {
'^/weatherforecast': {
'^/api': {
target,
secure: false
}
},
},
port: 5173,
https: {
+76
View File
@@ -0,0 +1,76 @@
using Abstractions.Services;
using Facet.Extensions;
using Microsoft.AspNetCore.Mvc;
using Server.Models.Request;
using Server.Models.Response;
namespace Server.Api;
public static class CardsApi
{
public static RouteGroupBuilder MapCardsApiEndpoints(this RouteGroupBuilder group)
{
var cardsGroup = group.MapGroup("/cards").WithTags("Cards");
cardsGroup.MapPost("/validate", ValidateCard).Produces<ValidateCardResponse>(200);
cardsGroup.MapGet("/my", GetUserCards).Produces<CardDto[]>(200).RequireAuthorization();
return group;
}
internal static async Task<IResult> ValidateCard(ValidateCardRequest request, [FromServices] ICardService cardService)
{
var isRegistered = await cardService.IsRegistered(request.CardId, request.Pin);
if (isRegistered)
{
return Results.Ok(new ValidateCardResponse
{
Message = "Card already registered.",
Success = false
});
}
var exists = await cardService.Exists(request.CardId);
if (!exists)
{
return Results.Ok(new ValidateCardResponse
{
Message = "Card does not exist.",
Success = false
});
}
var validPin = await cardService.ValidatePinAsync(request.CardId, request.Pin);
if (validPin)
{
return Results.Ok(new ValidateCardResponse
{
Message = "Card is valid.",
Success = true
});
}
return Results.Ok(new ValidateCardResponse
{
Message = "Card does not exist.",
Success = false
});
}
internal static async Task<IResult> GetUserCards(HttpContext httpContext, [FromServices] ICardService cardService)
{
var user = httpContext.User;
if (!(httpContext.User.Identity?.IsAuthenticated ?? false))
{
return Results.Unauthorized();
}
var userId = user.FindFirst("http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier")?.Value;
if (string.IsNullOrEmpty(userId))
{
return Results.Unauthorized();
}
var cards = await cardService.GetCardsByUserIdAsync(int.Parse(userId));
return Results.Ok(cards.SelectFacets<CardDto>());
}
}
+26
View File
@@ -0,0 +1,26 @@
using System.Security.Claims;
namespace Server.Api;
public static class UserApi
{
public static RouteGroupBuilder MapUserApiEndpoints(this RouteGroupBuilder group)
{
var userGroup = group.MapGroup("/user").WithTags("User");
userGroup.MapGet("/claims", GetClaims).Produces<Claim[]>(200).RequireAuthorization();
return group;
}
internal static IResult GetClaims(HttpContext httpContext)
{
var user = httpContext.User;
if(!(httpContext.User.Identity?.IsAuthenticated ?? false))
{
return Results.Unauthorized();
}
var claims = user.Claims.Select(c => new Claim(c.Type, c.Value)).ToArray();
return Results.Ok(claims);
}
}
+39 -10
View File
@@ -4,6 +4,7 @@ using System.Security.Claims;
using System.Text;
using System.Text.Encodings.Web;
using Abstractions.Entities;
using Abstractions.Services;
using Microsoft.AspNetCore.Authentication.BearerToken;
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.AspNetCore.Http.Metadata;
@@ -31,13 +32,16 @@ public static class AuthEndpoints
// We'll figure out a unique endpoint name based on the final route pattern during endpoint generation.
string? confirmEmailEndpointName = null;
var routeGroup = endpoints.MapGroup("");
var routeGroup = endpoints.MapGroup("/auth");
routeGroup.MapPost("/register", async Task<Results<Ok, ValidationProblem>>
([FromBody] RegisterRequest registration, HttpContext context, [FromServices] IServiceProvider sp) =>
([FromBody] Models.Request.RegisterRequest registration, HttpContext context, [FromServices] IServiceProvider sp) =>
{
var userManager = sp.GetRequiredService<UserManager<User>>();
var signinManager = sp.GetRequiredService<SignInManager<User>>();
var userService = sp.GetRequiredService<IUserService>();
var cardService = sp.GetRequiredService<ICardService>();
if (!userManager.SupportsUserEmail)
{
throw new NotSupportedException($"{nameof(MapIdentityApi)} requires a user store with email support.");
@@ -51,17 +55,34 @@ public static class AuthEndpoints
{
return CreateValidationProblem(IdentityResult.Failed(userManager.ErrorDescriber.InvalidEmail(email)));
}
var user = new User();
await userStore.SetUserNameAsync(user, email, CancellationToken.None);
var validCard = await cardService.ValidatePinAsync(registration.KonamiId, registration.Pin);
if (!validCard)
{
return CreateValidationProblem("InvalidCard",
"The provided Card is invalid");
}
var user = await userService.GetUserByKonamiId(registration.KonamiId);
if (user == null)
{
return CreateValidationProblem("InvalidKonamiId",
"The provided Card is not found");
}
await userStore.SetUserNameAsync(user, registration.Username, CancellationToken.None);
await emailStore.SetEmailAsync(user, email, CancellationToken.None);
var result = await userManager.CreateAsync(user, registration.Password);
var result = await userManager.UpdateAsync(user);
if (!result.Succeeded)
{
return CreateValidationProblem(result);
}
result = await userManager.AddPasswordAsync(user, registration.Password);
await SendConfirmationEmailAsync(user, userManager, context, email);
return TypedResults.Ok();
});
@@ -75,8 +96,16 @@ public static class AuthEndpoints
var isPersistent = (useCookies == true) && (useSessionCookies != true);
signInManager.AuthenticationScheme = useCookieScheme ? IdentityConstants.ApplicationScheme : IdentityConstants.BearerScheme;
var result = await signInManager.PasswordSignInAsync(login.Email, login.Password, isPersistent, lockoutOnFailure: true);
var user = await signInManager.UserManager.FindByEmailAsync(login.Email);
user ??= await signInManager.UserManager.FindByNameAsync(login.Email);
if (user == null)
{
return TypedResults.Problem("Invalid login attempt.", statusCode: StatusCodes.Status401Unauthorized);
}
var result = await signInManager.PasswordSignInAsync(user!.UserName!, login.Password, isPersistent, lockoutOnFailure: true);
if (result.RequiresTwoFactor)
{
if (!string.IsNullOrEmpty(login.TwoFactorCode))
+38
View File
@@ -0,0 +1,38 @@
# 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:10.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:10.0 AS with-node
RUN apt-get update
RUN apt-get install curl
RUN curl -sL https://deb.nodesource.com/setup_20.x | bash
RUN apt-get -y install nodejs
FROM with-node AS build
ARG BUILD_CONFIGURATION=Release
WORKDIR /src
COPY ["Core/Server/Server.csproj", "Core/Server/"]
COPY ["Core/Client/Client.esproj", "Core/Client/"]
RUN dotnet restore "./Core/Server/Server.csproj"
COPY . .
WORKDIR "/src/Core/Server"
RUN dotnet build "./Server.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 "./Server.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", "Server.dll"]
@@ -25,7 +25,7 @@ public class AuthPassCardManegementHandler(ICardService cardService) : Handler<A
status = 116;
}
if (card!.User.Pin != int.Parse(req.Password))
if (card!.User.Pin != req.Password)
{
status = 116;
}
@@ -21,7 +21,7 @@ public class GetRefIdCardManagementHandler(ICardService cardService, UserManager
var konamiId = cardService.ConvertUidToKonamiId(req.CardId);
var card = new Abstractions.Entities.Card { RawId = req.CardId, KonamiId = konamiId };
var user = new User { Pin = int.Parse(req.Password), UserName = konamiId, Cards = [card] };
var user = new User { Pin = req.Password, UserName = konamiId, Cards = [card] };
await userManager.CreateAsync(user);
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,314 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Server;
#nullable disable
namespace Server.Migrations
{
[DbContext(typeof(AppDbContext))]
[Migration("20260118164523_SavePinAsStringInsteadOfInt")]
partial class SavePinAsStringInsteadOfInt
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "10.0.2");
modelBuilder.Entity("Abstractions.Entities.Card", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("KonamiId")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("RawId")
.IsRequired()
.HasColumnType("TEXT");
b.Property<int>("UserId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("Cards");
});
modelBuilder.Entity("Abstractions.Entities.User", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("AccessFailedCount")
.HasColumnType("INTEGER");
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("TEXT");
b.Property<string>("Email")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<bool>("EmailConfirmed")
.HasColumnType("INTEGER");
b.Property<bool>("LockoutEnabled")
.HasColumnType("INTEGER");
b.Property<DateTimeOffset?>("LockoutEnd")
.HasColumnType("TEXT");
b.Property<string>("NormalizedEmail")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<string>("NormalizedUserName")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<int>("PaseliAmount")
.HasColumnType("INTEGER");
b.Property<string>("PasswordHash")
.HasColumnType("TEXT");
b.Property<string>("PhoneNumber")
.HasColumnType("TEXT");
b.Property<bool>("PhoneNumberConfirmed")
.HasColumnType("INTEGER");
b.Property<string>("Pin")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("SecurityStamp")
.HasColumnType("TEXT");
b.Property<bool>("TwoFactorEnabled")
.HasColumnType("INTEGER");
b.Property<string>("UserName")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("NormalizedEmail")
.HasDatabaseName("EmailIndex");
b.HasIndex("NormalizedUserName")
.IsUnique()
.HasDatabaseName("UserNameIndex");
b.ToTable("AspNetUsers", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole<int>", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("TEXT");
b.Property<string>("Name")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<string>("NormalizedName")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("NormalizedName")
.IsUnique()
.HasDatabaseName("RoleNameIndex");
b.ToTable("AspNetRoles", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<int>", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("ClaimType")
.HasColumnType("TEXT");
b.Property<string>("ClaimValue")
.HasColumnType("TEXT");
b.Property<int>("RoleId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("RoleId");
b.ToTable("AspNetRoleClaims", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<int>", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("ClaimType")
.HasColumnType("TEXT");
b.Property<string>("ClaimValue")
.HasColumnType("TEXT");
b.Property<int>("UserId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("AspNetUserClaims", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<int>", b =>
{
b.Property<string>("LoginProvider")
.HasColumnType("TEXT");
b.Property<string>("ProviderKey")
.HasColumnType("TEXT");
b.Property<string>("ProviderDisplayName")
.HasColumnType("TEXT");
b.Property<int>("UserId")
.HasColumnType("INTEGER");
b.HasKey("LoginProvider", "ProviderKey");
b.HasIndex("UserId");
b.ToTable("AspNetUserLogins", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<int>", b =>
{
b.Property<int>("UserId")
.HasColumnType("INTEGER");
b.Property<int>("RoleId")
.HasColumnType("INTEGER");
b.HasKey("UserId", "RoleId");
b.HasIndex("RoleId");
b.ToTable("AspNetUserRoles", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<int>", b =>
{
b.Property<int>("UserId")
.HasColumnType("INTEGER");
b.Property<string>("LoginProvider")
.HasColumnType("TEXT");
b.Property<string>("Name")
.HasColumnType("TEXT");
b.Property<string>("Value")
.HasColumnType("TEXT");
b.HasKey("UserId", "LoginProvider", "Name");
b.ToTable("AspNetUserTokens", (string)null);
});
modelBuilder.Entity("Abstractions.Entities.Card", b =>
{
b.HasOne("Abstractions.Entities.User", "User")
.WithMany("Cards")
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("User");
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<int>", b =>
{
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole<int>", null)
.WithMany()
.HasForeignKey("RoleId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<int>", b =>
{
b.HasOne("Abstractions.Entities.User", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<int>", b =>
{
b.HasOne("Abstractions.Entities.User", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<int>", b =>
{
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole<int>", null)
.WithMany()
.HasForeignKey("RoleId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Abstractions.Entities.User", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<int>", b =>
{
b.HasOne("Abstractions.Entities.User", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Abstractions.Entities.User", b =>
{
b.Navigation("Cards");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,34 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Server.Migrations
{
/// <inheritdoc />
public partial class SavePinAsStringInsteadOfInt : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AlterColumn<string>(
name: "Pin",
table: "AspNetUsers",
type: "TEXT",
nullable: false,
oldClrType: typeof(int),
oldType: "INTEGER");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.AlterColumn<int>(
name: "Pin",
table: "AspNetUsers",
type: "INTEGER",
nullable: false,
oldClrType: typeof(string),
oldType: "TEXT");
}
}
}
@@ -15,7 +15,103 @@ namespace Server.Migrations
protected override void BuildModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "9.0.0");
modelBuilder.HasAnnotation("ProductVersion", "10.0.2");
modelBuilder.Entity("Abstractions.Entities.Card", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("KonamiId")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("RawId")
.IsRequired()
.HasColumnType("TEXT");
b.Property<int>("UserId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("Cards");
});
modelBuilder.Entity("Abstractions.Entities.User", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("AccessFailedCount")
.HasColumnType("INTEGER");
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("TEXT");
b.Property<string>("Email")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<bool>("EmailConfirmed")
.HasColumnType("INTEGER");
b.Property<bool>("LockoutEnabled")
.HasColumnType("INTEGER");
b.Property<DateTimeOffset?>("LockoutEnd")
.HasColumnType("TEXT");
b.Property<string>("NormalizedEmail")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<string>("NormalizedUserName")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<int>("PaseliAmount")
.HasColumnType("INTEGER");
b.Property<string>("PasswordHash")
.HasColumnType("TEXT");
b.Property<string>("PhoneNumber")
.HasColumnType("TEXT");
b.Property<bool>("PhoneNumberConfirmed")
.HasColumnType("INTEGER");
b.Property<string>("Pin")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("SecurityStamp")
.HasColumnType("TEXT");
b.Property<bool>("TwoFactorEnabled")
.HasColumnType("INTEGER");
b.Property<string>("UserName")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("NormalizedEmail")
.HasDatabaseName("EmailIndex");
b.HasIndex("NormalizedUserName")
.IsUnique()
.HasDatabaseName("UserNameIndex");
b.ToTable("AspNetUsers", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole<int>", b =>
{
@@ -143,99 +239,15 @@ namespace Server.Migrations
b.ToTable("AspNetUserTokens", (string)null);
});
modelBuilder.Entity("Server.Entities.Card", b =>
modelBuilder.Entity("Abstractions.Entities.Card", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.HasOne("Abstractions.Entities.User", "User")
.WithMany("Cards")
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Property<string>("KonamiId")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("RawId")
.IsRequired()
.HasColumnType("TEXT");
b.Property<int>("UserId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("Cards", (string)null);
});
modelBuilder.Entity("Server.Entities.User", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("AccessFailedCount")
.HasColumnType("INTEGER");
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("TEXT");
b.Property<string>("Email")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<bool>("EmailConfirmed")
.HasColumnType("INTEGER");
b.Property<bool>("LockoutEnabled")
.HasColumnType("INTEGER");
b.Property<DateTimeOffset?>("LockoutEnd")
.HasColumnType("TEXT");
b.Property<string>("NormalizedEmail")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<string>("NormalizedUserName")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<int>("PaseliAmount")
.HasColumnType("INTEGER");
b.Property<string>("PasswordHash")
.HasColumnType("TEXT");
b.Property<string>("PhoneNumber")
.HasColumnType("TEXT");
b.Property<bool>("PhoneNumberConfirmed")
.HasColumnType("INTEGER");
b.Property<int>("Pin")
.HasColumnType("INTEGER");
b.Property<string>("SecurityStamp")
.HasColumnType("TEXT");
b.Property<bool>("TwoFactorEnabled")
.HasColumnType("INTEGER");
b.Property<string>("UserName")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("NormalizedEmail")
.HasDatabaseName("EmailIndex");
b.HasIndex("NormalizedUserName")
.IsUnique()
.HasDatabaseName("UserNameIndex");
b.ToTable("AspNetUsers", (string)null);
b.Navigation("User");
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<int>", b =>
@@ -249,7 +261,7 @@ namespace Server.Migrations
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<int>", b =>
{
b.HasOne("Server.Entities.User", null)
b.HasOne("Abstractions.Entities.User", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
@@ -258,7 +270,7 @@ namespace Server.Migrations
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<int>", b =>
{
b.HasOne("Server.Entities.User", null)
b.HasOne("Abstractions.Entities.User", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
@@ -273,7 +285,7 @@ namespace Server.Migrations
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Server.Entities.User", null)
b.HasOne("Abstractions.Entities.User", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
@@ -282,25 +294,14 @@ namespace Server.Migrations
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<int>", b =>
{
b.HasOne("Server.Entities.User", null)
b.HasOne("Abstractions.Entities.User", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Server.Entities.Card", b =>
{
b.HasOne("Server.Entities.User", "User")
.WithMany("Cards")
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("User");
});
modelBuilder.Entity("Server.Entities.User", b =>
modelBuilder.Entity("Abstractions.Entities.User", b =>
{
b.Navigation("Cards");
});
@@ -0,0 +1,10 @@
namespace Server.Models.Request;
public class RegisterRequest
{
public required string KonamiId { get; set; }
public required string Pin { get; set; }
public required string Username { get; set; }
public required string Email { get; set; }
public required string Password { get; set; }
}
@@ -0,0 +1,7 @@
namespace Server.Models.Request;
public class ValidateCardRequest
{
public required string CardId { get; set; }
public required string Pin { get; set; }
}
+10
View File
@@ -0,0 +1,10 @@
using Abstractions.Entities;
using Facet;
using System;
namespace Server.Models.Response;
[Facet(typeof(Card), exclude: nameof(Card.User))]
public partial class CardDto
{
}
@@ -0,0 +1,7 @@
namespace Server.Models.Response;
public class ValidateCardResponse
{
public required string Message { get; set; }
public bool Success { get; set; } = false;
}
+7 -2
View File
@@ -8,6 +8,7 @@ using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Server;
using Server.Api;
using Server.Authentication;
using Server.Extensions;
using Server.Middlewares;
@@ -56,12 +57,13 @@ builder.Services.AddDbContext<AppDbContext>(options => options.UseSqlite("Data S
builder.Services.AddIdentityCore<User>(config =>
{
config.Password.RequiredLength = 8;
config.SignIn.RequireConfirmedEmail = true;
config.SignIn.RequireConfirmedEmail = false;
config.Lockout.AllowedForNewUsers = true;
}).AddEntityFrameworkStores<AppDbContext>();
builder.Services.AddTransient<ICardService, CardService>();
builder.Services.AddSingleton<IPluginService>(pluginService);
builder.Services.AddTransient<IUserService, UserService>();
builder.Services.AddSingleton<IXmlLogService, XmlLogService>();
var app = builder.Build();
@@ -96,7 +98,10 @@ foreach (var plugin in plugins)
await plugin.OnAppInitialize(app);
}
app.MapIdentityApi();
var apiGroup = app.MapGroup("/api").WithTags("API");
apiGroup.MapCardsApiEndpoints();
apiGroup.MapUserApiEndpoints();
apiGroup.MapIdentityApi();
var eamuseGroup = app.MapGroup("eamuse");
@@ -39,6 +39,16 @@
"ASPNETCORE_ENVIRONMENT": "Development",
"ASPNETCORE_HOSTINGSTARTUPASSEMBLIES": "Microsoft.AspNetCore.SpaProxy"
}
},
"Container (Dockerfile)": {
"commandName": "Docker",
"launchUrl": "{Scheme}://{ServiceHost}:{ServicePort}",
"environmentVariables": {
"ASPNETCORE_HTTPS_PORTS": "8081",
"ASPNETCORE_HTTP_PORTS": "8080"
},
"publishAllPorts": true,
"useSSL": true
}
}
}
+27 -19
View File
@@ -1,14 +1,23 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<SpaRoot>..\Client</SpaRoot>
<UserSecretsId>43f0ff47-d090-4bfc-a428-9a16f4291fa3</UserSecretsId>
<DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
<SpaRoot>..\Client</SpaRoot>
<SpaProxyLaunchCommand>npm run dev</SpaProxyLaunchCommand>
<SpaProxyServerUrl>https://localhost:5173</SpaProxyServerUrl>
</PropertyGroup>
<ItemGroup>
<Compile Remove="Endpoints\**" />
<Content Remove="Endpoints\**" />
<EmbeddedResource Remove="Endpoints\**" />
<None Remove="Endpoints\**" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\Abstractions\Abstractions.csproj" />
<ProjectReference Include="..\Client\Client.esproj">
@@ -17,26 +26,29 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="FastEndpoints" Version="7.0.1" />
<PackageReference Include="FastEndpoints.ClientGen" Version="7.0.1" />
<PackageReference Include="FastEndpoints.Security" Version="7.0.1" />
<PackageReference Include="KbinXml.Net" Version="2.0.0" />
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="9.0.0" />
<PackageReference Include="Facet" Version="5.4.1" />
<PackageReference Include="Facet.Extensions" Version="5.4.1" />
<PackageReference Include="FastEndpoints" Version="7.2.0" />
<PackageReference Include="FastEndpoints.ClientGen" Version="7.2.0" />
<PackageReference Include="FastEndpoints.Security" Version="7.2.0" />
<PackageReference Include="KbinXml.Net" Version="2.1.3" />
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="10.0.2" />
<PackageReference Include="Microsoft.AspNetCore.SpaProxy">
<Version>9.0.0</Version>
<Version>10.*-*</Version>
</PackageReference>
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="9.0.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.0">
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.2" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.2">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="9.0.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="9.0.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="9.0.0">
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="10.0.2" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.2" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="10.0.2">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Riok.Mapperly" Version="4.2.1" />
<PackageReference Include="Riok.Mapperly" Version="4.3.1" />
<PackageReference Include="Microsoft.VisualStudio.Azure.Containers.Tools.Targets" Version="1.23.0" />
</ItemGroup>
<ItemGroup>
@@ -48,10 +60,6 @@
<None Remove="ResponseXml_1.xml" />
</ItemGroup>
<ItemGroup>
<Folder Include="Endpoints\" />
</ItemGroup>
<Target Name="CreateCustomFolder" AfterTargets="Build">
<MakeDir Directories="$(OutputPath)/plugins" />
</Target>
+20
View File
@@ -203,4 +203,24 @@ public class CardService : ICardService
{
return _context.Cards.Include(x => x.User).FirstOrDefaultAsync(c => c.RawId == cardId);
}
public Task<bool> Exists(string konamiId)
{
return _context.Cards.AnyAsync(c => c.KonamiId == konamiId);
}
public Task<bool> ValidatePinAsync(string konamiId, string pin)
{
return _context.Cards.AnyAsync(c => c.KonamiId == konamiId && c.User.Pin == pin);
}
public Task<bool> IsRegistered(string konamiId, string pin)
{
return _context.Cards.AnyAsync(c => c.KonamiId == konamiId && c.User.Pin == pin && c.User.PasswordHash != null);
}
public Task<List<Card>> GetCardsByUserIdAsync(int userId)
{
return _context.Cards.Where(c => c.UserId == userId).ToListAsync();
}
}
+10 -2
View File
@@ -1,8 +1,16 @@
using Abstractions.Services;
using Abstractions.Entities;
using Abstractions.Services;
using Microsoft.EntityFrameworkCore;
namespace Server.Services
{
public class UserService: IUserService
public class UserService(AppDbContext context) : IUserService
{
public Task<User?> GetUserByKonamiId(string konamiId)
{
return context.Users
.Include(u => u.Cards)
.FirstOrDefaultAsync(u => u.Cards.Any(c => c.KonamiId == konamiId));
}
}
}
+7
View File
@@ -1,4 +1,11 @@
{
"Kestrel": {
"Endpoints": {
"MyHttpEndpoint": {
"Url": "http://0.0.0.0:5120"
}
}
},
"Logging": {
"LogLevel": {
"Default": "Information",