Compare commits

..

27 Commits

Author SHA1 Message Date
d26b230f91 Merge pull request 'develop' (#14) from develop into main
Reviewed-on: #14
2026-03-16 16:46:18 +00:00
710735f56d Merge pull request 'add guards admin-dashboard and user-dashboard components' (#13) from feature/guards into develop
Reviewed-on: #13
2026-03-16 16:44:14 +00:00
9452732730 add guards admin-dashboard and user-dashboard components 2026-03-16 17:43:26 +01:00
53190c2db0 Merge pull request 'fix dropdown' (#12) from feature/haeder into develop
Reviewed-on: #12
2026-03-15 19:02:16 +00:00
a9f29fff6e fix dropdown 2026-03-15 20:01:51 +01:00
ee46cf09f4 Merge pull request 'update Angular core to new version' (#11) from feature/angular into develop
Reviewed-on: #11
2026-03-15 18:11:18 +00:00
d853ac7486 update Angular core to new version 2026-03-15 19:10:57 +01:00
06675219e0 Merge pull request 'update angular cli to new Version' (#10) from feature/nuget into develop
Reviewed-on: #10
2026-03-15 18:07:15 +00:00
b75c4152fb Merge branch 'develop' into feature/nuget 2026-03-15 18:06:58 +00:00
3407f9ec51 update angular cli to new Version 2026-03-15 19:06:27 +01:00
f544bc0b2c Merge pull request 'update NuGet Package to new Version' (#9) from feature/nuget into develop
Reviewed-on: #9
2026-03-15 18:04:11 +00:00
74bae6d90c update NuGet Package to new Version 2026-03-15 19:03:25 +01:00
9d6ba08be2 Merge pull request 'fix angular login and register' (#8) from feature/angular into develop
Reviewed-on: #8
2026-03-11 17:02:20 +00:00
2598adb286 fix angular login and register 2026-03-11 18:00:48 +01:00
b10b6cca60 Merge pull request 'Bearer-Authentifcation' (#7) from feature/bearer into develop
Reviewed-on: #7
2026-03-09 11:35:44 +00:00
bbb4fec581 Bearer-Authentifcation 2026-03-09 12:35:11 +01:00
ecf84016dc Merge pull request 'update nuget packages to new versions' (#6) from feature/nuget into develop
Reviewed-on: #6
2026-02-19 13:06:00 +00:00
10f8d36727 update nuget packages to new versions 2026-02-19 14:05:32 +01:00
2c4f1ff440 Merge pull request 'Backend login and register' (#5) from feature/dotnet into develop
Reviewed-on: #5
2026-02-19 12:50:40 +00:00
93a78e4614 Backend login and register 2026-02-19 13:49:01 +01:00
0b6bb019b6 Merge pull request 'update to dotnet 10' (#4) from feature/dotnet into develop
Reviewed-on: #4
2026-02-05 19:10:10 +00:00
be6ce2f604 update to dotnet 10 2026-02-05 20:07:50 +01:00
ede6fe77de Merge pull request 'login.component, register.component' (#3) from feature/components into develop
Reviewed-on: #3
2026-02-05 15:48:56 +00:00
c53efb7612 login.component, register.component 2026-02-05 16:48:06 +01:00
97a7ccdb4d Merge pull request 'update README.md' (#2) from feature/setup-infrastructure into develop
Reviewed-on: #2
2026-02-04 12:49:04 +00:00
24850ae792 Merge branch 'develop' into feature/setup-infrastructure 2026-02-04 12:48:54 +00:00
975d3ed433 update README.md 2026-02-04 13:48:23 +01:00
181 changed files with 7321 additions and 2816 deletions

View File

@@ -1,7 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="UserContentModel">
<attachedFolders />
<attachedFolders>
<Path>src/ClientApp</Path>
</attachedFolders>
<explicitIncludes />
<explicitExcludes />
</component>

View File

@@ -0,0 +1,21 @@
<component name="InspectionProjectProfileManager">
<profile version="1.0">
<option name="myName" value="Project Default" />
<inspection_tool class="HtmlUnknownTag" enabled="true" level="WARNING" enabled_by_default="true">
<option name="myValues">
<value>
<list size="7">
<item index="0" class="java.lang.String" itemvalue="nobr" />
<item index="1" class="java.lang.String" itemvalue="noembed" />
<item index="2" class="java.lang.String" itemvalue="comment" />
<item index="3" class="java.lang.String" itemvalue="noscript" />
<item index="4" class="java.lang.String" itemvalue="embed" />
<item index="5" class="java.lang.String" itemvalue="script" />
<item index="6" class="java.lang.String" itemvalue="ngb-toast" />
</list>
</value>
</option>
<option name="myCustomValuesEnabled" value="true" />
</inspection_tool>
</profile>
</component>

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,4 @@
<changelist name="Uncommitted_changes_before_Checkout_at_11_03_26,_18_04_[Changes]" date="1773248701579" recycled="true" deleted="true">
<option name="PATH" value="$PROJECT_DIR$/.idea/.idea.DotNetAngular/.idea/shelf/Uncommitted_changes_before_Checkout_at_11_03_26,_18_04_[Changes]/shelved.patch" />
<option name="DESCRIPTION" value="Uncommitted changes before Checkout at 11.03.26, 18:04 [Changes]" />
</changelist>

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,4 @@
<changelist name="Uncommitted_changes_before_Update_at_09_03_26,_12_36_[Changes]" date="1773056200358" recycled="false" toDelete="true">
<option name="PATH" value="$PROJECT_DIR$/.idea/.idea.DotNetAngular/.idea/shelf/Uncommitted_changes_before_Update_at_09_03_26,_12_36_[Changes]/shelved.patch" />
<option name="DESCRIPTION" value="Uncommitted changes before Update at 09.03.26, 12:36 [Changes]" />
</changelist>

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,4 @@
<changelist name="Uncommitted_changes_before_Update_at_11_03_26,_18_06_[Changes]" date="1773248835929" recycled="true" deleted="true">
<option name="PATH" value="$PROJECT_DIR$/.idea/.idea.DotNetAngular/.idea/shelf/Uncommitted_changes_before_Update_at_11_03_26,_18_06_[Changes]/shelved.patch" />
<option name="DESCRIPTION" value="Uncommitted changes before Update at 11.03.26, 18:06 [Changes]" />
</changelist>

View File

@@ -0,0 +1,25 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectTasksOptions">
<TaskOptions isEnabled="false">
<option name="arguments" value="$FileName$:$FileNameWithoutExtension$.css" />
<option name="checkSyntaxErrors" value="true" />
<option name="description" />
<option name="exitCodeBehavior" value="ERROR" />
<option name="fileExtension" value="scss" />
<option name="immediateSync" value="true" />
<option name="name" value="SCSS" />
<option name="output" value="$FileNameWithoutExtension$.css:$FileNameWithoutExtension$.css.map" />
<option name="outputFilters">
<array />
</option>
<option name="outputFromStdout" value="false" />
<option name="program" value="sass" />
<option name="runOnExternalChanges" value="true" />
<option name="scopeName" value="All Places" />
<option name="trackOnlyRoot" value="true" />
<option name="workingDir" value="$FileDir$" />
<envs />
</TaskOptions>
</component>
</project>

View File

@@ -5,18 +5,49 @@
<projectFile profileName="Angular_test">src/API/API.csproj</projectFile>
<projectFile profileName="swagger_dev">src/API/API.csproj</projectFile>
<projectFile profileName="swagger_test">src/API/API.csproj</projectFile>
<projectFile>tests/Application.FunctionalTest/Application.FunctionalTest.csproj</projectFile>
<projectFile>tests/Application.UnitTest/Application.UnitTest.csproj</projectFile>
</component>
<component name="AutoImportSettings">
<option name="autoReloadType" value="SELECTIVE" />
</component>
<component name="ChangeListManager">
<list default="true" id="1ac72a4a-52ad-4e70-9b15-c330b1ed3e7a" name="Changes" comment="">
<change afterPath="$PROJECT_DIR$/DotNetAngular.sln.DotSettings.user" afterDir="false" />
<change afterPath="$PROJECT_DIR$/src/ClientApp/src/app/presentation/components/admin-dashboard/admin-dashboard.component.html" afterDir="false" />
<change afterPath="$PROJECT_DIR$/src/ClientApp/src/app/presentation/components/admin-dashboard/admin-dashboard.component.scss" afterDir="false" />
<change afterPath="$PROJECT_DIR$/src/ClientApp/src/app/presentation/components/admin-dashboard/admin-dashboard.component.spec.ts" afterDir="false" />
<change afterPath="$PROJECT_DIR$/src/ClientApp/src/app/presentation/components/admin-dashboard/admin-dashboard.component.ts" afterDir="false" />
<change afterPath="$PROJECT_DIR$/src/ClientApp/src/app/presentation/components/unauthorized/unauthorized.component.html" afterDir="false" />
<change afterPath="$PROJECT_DIR$/src/ClientApp/src/app/presentation/components/unauthorized/unauthorized.component.scss" afterDir="false" />
<change afterPath="$PROJECT_DIR$/src/ClientApp/src/app/presentation/components/unauthorized/unauthorized.component.spec.ts" afterDir="false" />
<change afterPath="$PROJECT_DIR$/src/ClientApp/src/app/presentation/components/unauthorized/unauthorized.component.ts" afterDir="false" />
<change afterPath="$PROJECT_DIR$/src/ClientApp/src/app/presentation/components/user-dashboard/user-dashboard.component.html" afterDir="false" />
<change afterPath="$PROJECT_DIR$/src/ClientApp/src/app/presentation/components/user-dashboard/user-dashboard.component.scss" afterDir="false" />
<change afterPath="$PROJECT_DIR$/src/ClientApp/src/app/presentation/components/user-dashboard/user-dashboard.component.spec.ts" afterDir="false" />
<change afterPath="$PROJECT_DIR$/src/ClientApp/src/app/presentation/components/user-dashboard/user-dashboard.component.ts" afterDir="false" />
<change afterPath="$PROJECT_DIR$/src/ClientApp/src/app/presentation/components/user-table/user-table.component.html" afterDir="false" />
<change afterPath="$PROJECT_DIR$/src/ClientApp/src/app/presentation/components/user-table/user-table.component.scss" afterDir="false" />
<change afterPath="$PROJECT_DIR$/src/ClientApp/src/app/presentation/components/user-table/user-table.component.spec.ts" afterDir="false" />
<change afterPath="$PROJECT_DIR$/src/ClientApp/src/app/presentation/components/user-table/user-table.component.ts" afterDir="false" />
<change afterPath="$PROJECT_DIR$/src/ClientApp/src/app/presentation/guards/admin.guard.spec.ts" afterDir="false" />
<change afterPath="$PROJECT_DIR$/src/ClientApp/src/app/presentation/guards/admin.guard.ts" afterDir="false" />
<change afterPath="$PROJECT_DIR$/src/ClientApp/src/app/presentation/guards/authentication.guard.spec.ts" afterDir="false" />
<change afterPath="$PROJECT_DIR$/src/ClientApp/src/app/presentation/guards/authentication.guard.ts" afterDir="false" />
<change afterPath="$PROJECT_DIR$/src/ClientApp/src/app/presentation/guards/guest.guard.spec.ts" afterDir="false" />
<change afterPath="$PROJECT_DIR$/src/ClientApp/src/app/presentation/guards/guest.guard.ts" afterDir="false" />
<change afterPath="$PROJECT_DIR$/src/ClientApp/src/app/presentation/interceptors/token.interceptor.spec.ts" afterDir="false" />
<change afterPath="$PROJECT_DIR$/src/ClientApp/src/app/presentation/interceptors/token.interceptor.ts" afterDir="false" />
<change afterPath="$PROJECT_DIR$/src/ClientApp/src/app/presentation/shared/popup-modal/popup-modal.component.html" afterDir="false" />
<change afterPath="$PROJECT_DIR$/src/ClientApp/src/app/presentation/shared/popup-modal/popup-modal.component.scss" afterDir="false" />
<change afterPath="$PROJECT_DIR$/src/ClientApp/src/app/presentation/shared/popup-modal/popup-modal.component.spec.ts" afterDir="false" />
<change afterPath="$PROJECT_DIR$/src/ClientApp/src/app/presentation/shared/popup-modal/popup-modal.component.ts" afterDir="false" />
<change beforePath="$PROJECT_DIR$/.idea/.idea.DotNetAngular/.idea/workspace.xml" beforeDir="false" afterPath="$PROJECT_DIR$/.idea/.idea.DotNetAngular/.idea/workspace.xml" afterDir="false" />
<change beforePath="$PROJECT_DIR$/README.md" beforeDir="false" afterPath="$PROJECT_DIR$/README.md" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/ClientApp/package-lock.json" beforeDir="false" afterPath="$PROJECT_DIR$/src/ClientApp/package-lock.json" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/ClientApp/package.json" beforeDir="false" afterPath="$PROJECT_DIR$/src/ClientApp/package.json" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/ClientApp/tsconfig.json" beforeDir="false" afterPath="$PROJECT_DIR$/src/ClientApp/tsconfig.json" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/ClientApp/src/app/app.config.ts" beforeDir="false" afterPath="$PROJECT_DIR$/src/ClientApp/src/app/app.config.ts" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/ClientApp/src/app/app.routes.ts" beforeDir="false" afterPath="$PROJECT_DIR$/src/ClientApp/src/app/app.routes.ts" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/ClientApp/src/app/domain/entities/user.ts" beforeDir="false" afterPath="$PROJECT_DIR$/src/ClientApp/src/app/domain/entities/user.ts" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/ClientApp/src/app/presentation/authentication/login/login.component.ts" beforeDir="false" afterPath="$PROJECT_DIR$/src/ClientApp/src/app/presentation/authentication/login/login.component.ts" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/ClientApp/src/app/presentation/components/header/header.component.html" beforeDir="false" afterPath="$PROJECT_DIR$/src/ClientApp/src/app/presentation/components/header/header.component.html" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/ClientApp/src/app/presentation/components/header/header.component.ts" beforeDir="false" afterPath="$PROJECT_DIR$/src/ClientApp/src/app/presentation/components/header/header.component.ts" afterDir="false" />
</list>
<option name="SHOW_DIALOG" value="false" />
<option name="HIGHLIGHT_CONFLICTS" value="true" />
@@ -27,7 +58,7 @@
<option name="firstShow" value="false" />
</component>
<component name="EmbeddingIndexingInfo">
<option name="cachedIndexableFilesCount" value="3" />
<option name="cachedIndexableFilesCount" value="17" />
<option name="fileBasedEmbeddingIndicesEnabled" value="true" />
</component>
<component name="Git.Settings">
@@ -39,6 +70,7 @@
<option name="RECENT_GIT_ROOT_PATH" value="$PROJECT_DIR$" />
</component>
<component name="HighlightingSettingsPerFile">
<setting file="file://$PROJECT_DIR$/dotnet-tools.json" root0="FORCE_HIGHLIGHTING" />
<setting file="file://$PROJECT_DIR$/src/ClientApp/package-lock.json" root0="SKIP_HIGHLIGHTING" />
<setting file="file://$PROJECT_DIR$/src/ClientApp/package.json" root0="FORCE_HIGHLIGHTING" />
</component>
@@ -47,6 +79,9 @@
<urls />
</component>
<component name="MetaFilesCheckinStateConfiguration" checkMetaFiles="true" />
<component name="ProblemsViewState">
<option name="selectedTabId" value="Toolset" />
</component>
<component name="ProjectColorInfo">{
&quot;associatedIndex&quot;: 5
}</component>
@@ -55,31 +90,74 @@
<option name="hideEmptyMiddlePackages" value="true" />
<option name="showLibraryContents" value="true" />
</component>
<component name="PropertiesComponent"><![CDATA[{
"keyToString": {
".NET Launch Settings Profile.API: Angular_dev.executor": "Run",
"RunOnceActivity.MCP Project settings loaded": "true",
"RunOnceActivity.ShowReadmeOnStart": "true",
"RunOnceActivity.TerminalTabsStorage.copyFrom.TerminalArrangementManager.252": "true",
"RunOnceActivity.git.unshallow": "true",
"RunOnceActivity.typescript.service.memoryLimit.init": "true",
"com.intellij.ml.llm.matterhorn.ej.ui.settings.DefaultModelSelectionForGA.v1": "true",
"git-widget-placeholder": "feature/setup-infrastructure",
"junie.onboarding.icon.badge.shown": "true",
"node.js.detected.package.eslint": "true",
"node.js.detected.package.tslint": "true",
"node.js.selected.package.eslint": "(autodetect)",
"node.js.selected.package.tslint": "(autodetect)",
"nodejs_package_manager_path": "npm",
"settings.editor.selected.configurable": "vcs.Git",
"to.speed.mode.migration.done": "true",
"vue.rearranger.settings.migration": "true"
<component name="PropertiesComponent">{
&quot;keyToString&quot;: {
&quot;.NET Launch Settings Profile.API: Angular_dev.executor&quot;: &quot;Run&quot;,
&quot;RunOnceActivity.MCP Project settings loaded&quot;: &quot;true&quot;,
&quot;RunOnceActivity.ShowReadmeOnStart&quot;: &quot;true&quot;,
&quot;RunOnceActivity.TerminalTabsStorage.copyFrom.TerminalArrangementManager.252&quot;: &quot;true&quot;,
&quot;RunOnceActivity.git.unshallow&quot;: &quot;true&quot;,
&quot;RunOnceActivity.typescript.service.memoryLimit.init&quot;: &quot;true&quot;,
&quot;com.intellij.ml.llm.matterhorn.ej.ui.settings.DefaultModelSelectionForGA.v1&quot;: &quot;true&quot;,
&quot;git-widget-placeholder&quot;: &quot;feature/guards&quot;,
&quot;junie.onboarding.icon.badge.shown&quot;: &quot;true&quot;,
&quot;node.js.detected.package.eslint&quot;: &quot;true&quot;,
&quot;node.js.detected.package.tslint&quot;: &quot;true&quot;,
&quot;node.js.selected.package.eslint&quot;: &quot;(autodetect)&quot;,
&quot;node.js.selected.package.tslint&quot;: &quot;(autodetect)&quot;,
&quot;nodejs_package_manager_path&quot;: &quot;npm&quot;,
&quot;settings.editor.selected.configurable&quot;: &quot;preferences.pluginManager&quot;,
&quot;to.speed.mode.migration.done&quot;: &quot;true&quot;,
&quot;ts.external.directory.path&quot;: &quot;/home/natlinux/RiderProjects/DotNetAngular/src/ClientApp/node_modules/typescript/lib&quot;,
&quot;vue.rearranger.settings.migration&quot;: &quot;true&quot;
}
}]]></component>
}</component>
<component name="RecapUselessUpdatesCounter">
<option name="suspendCountdown" value="9" />
<option name="suspendCountdown" value="0" />
</component>
<component name="RunManager" selected=".NET Launch Settings Profile.API: Angular_dev">
<configuration name="Application.FunctionalTest" type="DotNetProject" factoryName=".NET Project">
<option name="EXE_PATH" value="" />
<option name="PROGRAM_PARAMETERS" value="" />
<option name="WORKING_DIRECTORY" value="" />
<option name="PASS_PARENT_ENVS" value="1" />
<option name="ENV_FILE_PATHS" value="" />
<option name="REDIRECT_INPUT_PATH" value="" />
<option name="MIXED_MODE_DEBUG" value="0" />
<option name="USE_MONO" value="0" />
<option name="RUNTIME_ARGUMENTS" value="" />
<option name="AUTO_ATTACH_CHILDREN" value="0" />
<option name="PROJECT_PATH" value="$PROJECT_DIR$/tests/Application.FunctionalTest/Application.FunctionalTest.csproj" />
<option name="PROJECT_EXE_PATH_TRACKING" value="1" />
<option name="PROJECT_ARGUMENTS_TRACKING" value="1" />
<option name="PROJECT_WORKING_DIRECTORY_TRACKING" value="1" />
<option name="PROJECT_KIND" value="DotNetCore" />
<option name="PROJECT_TFM" value="" />
<method v="2">
<option name="Build" />
</method>
</configuration>
<configuration name="Application.UnitTest" type="DotNetProject" factoryName=".NET Project">
<option name="EXE_PATH" value="" />
<option name="PROGRAM_PARAMETERS" value="" />
<option name="WORKING_DIRECTORY" value="" />
<option name="PASS_PARENT_ENVS" value="1" />
<option name="ENV_FILE_PATHS" value="" />
<option name="REDIRECT_INPUT_PATH" value="" />
<option name="MIXED_MODE_DEBUG" value="0" />
<option name="USE_MONO" value="0" />
<option name="RUNTIME_ARGUMENTS" value="" />
<option name="AUTO_ATTACH_CHILDREN" value="0" />
<option name="PROJECT_PATH" value="$PROJECT_DIR$/tests/Application.UnitTest/Application.UnitTest.csproj" />
<option name="PROJECT_EXE_PATH_TRACKING" value="1" />
<option name="PROJECT_ARGUMENTS_TRACKING" value="1" />
<option name="PROJECT_WORKING_DIRECTORY_TRACKING" value="1" />
<option name="PROJECT_KIND" value="DotNetCore" />
<option name="PROJECT_TFM" value="" />
<method v="2">
<option name="Build" />
</method>
</configuration>
<configuration name="API: Angular_dev" type="LaunchSettings" factoryName=".NET Launch Settings Profile">
<option name="LAUNCH_PROFILE_PROJECT_FILE_PATH" value="$PROJECT_DIR$/src/API/API.csproj" />
<option name="LAUNCH_PROFILE_TFM" value="net9.0" />
@@ -158,6 +236,24 @@
<workItem from="1770057982196" duration="640000" />
<workItem from="1770062612983" duration="466000" />
<workItem from="1770204235235" duration="1487000" />
<workItem from="1770294298887" duration="1216000" />
<workItem from="1770295524085" duration="8729000" />
<workItem from="1770308954185" duration="9016000" />
<workItem from="1770369615769" duration="3456000" />
<workItem from="1770373101221" duration="2750000" />
<workItem from="1770377559983" duration="818000" />
<workItem from="1770379380312" duration="1252000" />
<workItem from="1770382791102" duration="1343000" />
<workItem from="1771239672703" duration="359000" />
<workItem from="1771331713319" duration="2451000" />
<workItem from="1771503996420" duration="1283000" />
<workItem from="1772664092787" duration="54000" />
<workItem from="1772664174287" duration="646000" />
<workItem from="1773053172796" duration="1432000" />
<workItem from="1773597121547" duration="689000" />
<workItem from="1773599776473" duration="3799000" />
<workItem from="1773605341198" duration="3147000" />
<workItem from="1773678089390" duration="1025000" />
</task>
<task id="LOCAL-00001" summary="updating template">
<option name="closed" value="true" />
@@ -175,7 +271,55 @@
<option name="project" value="LOCAL" />
<updated>1770057870215</updated>
</task>
<option name="localTasksCounter" value="3" />
<task id="LOCAL-00003" summary="login.component, register.component">
<option name="closed" value="true" />
<created>1770306489835</created>
<option name="number" value="00003" />
<option name="presentableId" value="LOCAL-00003" />
<option name="project" value="LOCAL" />
<updated>1770306489835</updated>
</task>
<task id="LOCAL-00004" summary="update to dotnet 10">
<option name="closed" value="true" />
<created>1770318472632</created>
<option name="number" value="00004" />
<option name="presentableId" value="LOCAL-00004" />
<option name="project" value="LOCAL" />
<updated>1770318472632</updated>
</task>
<task id="LOCAL-00005" summary="update nuget packages to new versions">
<option name="closed" value="true" />
<created>1771506336945</created>
<option name="number" value="00005" />
<option name="presentableId" value="LOCAL-00005" />
<option name="project" value="LOCAL" />
<updated>1771506336945</updated>
</task>
<task id="LOCAL-00006" summary="update NuGet Package to new Version">
<option name="closed" value="true" />
<created>1773597809588</created>
<option name="number" value="00006" />
<option name="presentableId" value="LOCAL-00006" />
<option name="project" value="LOCAL" />
<updated>1773597809588</updated>
</task>
<task id="LOCAL-00007" summary="update Angular core to new version">
<option name="closed" value="true" />
<created>1773598259701</created>
<option name="number" value="00007" />
<option name="presentableId" value="LOCAL-00007" />
<option name="project" value="LOCAL" />
<updated>1773598259701</updated>
</task>
<task id="LOCAL-00008" summary="fix dropdown">
<option name="closed" value="true" />
<created>1773601314558</created>
<option name="number" value="00008" />
<option name="presentableId" value="LOCAL-00008" />
<option name="project" value="LOCAL" />
<updated>1773601314558</updated>
</task>
<option name="localTasksCounter" value="9" />
<servers />
</component>
<component name="TypeScriptGeneratedFilesManager">
@@ -184,6 +328,21 @@
<component name="UnityCheckinConfiguration" checkUnsavedScenes="true" />
<component name="UnityProjectConfiguration" hasMinimizedUI="false" />
<component name="Vcs.Log.Tabs.Properties">
<option name="RECENT_FILTERS">
<map>
<entry key="Branch">
<value>
<list>
<RecentGroup>
<option name="FILTER_VALUES">
<option value="feature/dotnet" />
</option>
</RecentGroup>
</list>
</value>
</entry>
</map>
</option>
<option name="TAB_STATES">
<map>
<entry key="MAIN">
@@ -198,7 +357,13 @@
<option name="CLEAR_INITIAL_COMMIT_MESSAGE" value="true" />
<MESSAGE value="updating template" />
<MESSAGE value="update to angular 20" />
<option name="LAST_COMMIT_MESSAGE" value="update to angular 20" />
<MESSAGE value="login.component, register.component" />
<MESSAGE value="update to dotnet 10" />
<MESSAGE value="update nuget packages to new versions" />
<MESSAGE value="update NuGet Package to new Version" />
<MESSAGE value="update Angular core to new version" />
<MESSAGE value="fix dropdown" />
<option name="LAST_COMMIT_MESSAGE" value="fix dropdown" />
</component>
<component name="XDebuggerManager">
<breakpoint-manager>
@@ -218,4 +383,8 @@
</breakpoints>
</breakpoint-manager>
</component>
<component name="XSLT-Support.FileAssociations.UIState">
<expand />
<select />
</component>
</project>

View File

@@ -4,18 +4,32 @@ This project is a full-stack web application Template combining **ASP.NET Core**
## 🛠️ Technologies Used
- **Backend:** ASP.NET Core (.NET 9)
- **Backend:** ASP.NET Core (.NET 10)
- **Frontend:** Angular
- **API Documentation:** Swagger (Swashbuckle)
## 🔐 Authentication (Register & Login)
The template includes a preconfigured user registration and authentication flow, designed as a solid baseline for production-ready applications.
- **Register:**
Creation of new user accounts via a dedicated API endpoint with server-side validation and secure password hashing.
- **Login:**
Token-based authentication using JWT (JSON Web Token). Upon successful authentication, a token is issued for authorized API access.
- **Authorization:**
Protected backend endpoints and frontend routes are secured using role- or policy-based access control.
The authentication architecture is secure, extensible, and aligned with enterprise best practices.
## 🚀 Getting Started
### Prerequisites
- [.NET 9 SDK](https://dotnet.microsoft.com/en-us/download/dotnet/9.0)
- [.NET 10 SDK](https://dotnet.microsoft.com/en-us/download/dotnet/10.0)
- [Node.js (LTS)](https://nodejs.org/)
@@ -48,34 +62,27 @@ This project is a full-stack web application Template combining **ASP.NET Core**
---
## 🧪 Angular-Tests ausführen (Wie mache ich Tests in Angular?)
## 🧪 Running Angular Tests
Die Angular-Frontend-Tests sind mit Karma + Jasmine eingerichtet. Alle Testdateien enden auf `.spec.ts` und liegen neben den jeweiligen Komponenten/Services im Ordner `src/ClientApp/src/`.
Angular frontend tests are set up using Karma + Jasmine. All test files follow the `.spec.ts` naming convention and are located alongside their respective components and services in `src/ClientApp/src/`.
### Schnellstart
### Quick Start
1. Abhängigkeiten installieren (falls noch nicht erledigt):
```bash
cd src/ClientApp
npm install
```
2. Tests im Watch-Modus starten (öffnet den Browser, ideal für Entwicklung):
```bash
npm test
```
### Headless/CI und Coverage
- Einmalige Testausführung mit Coverage-Bericht (headless):
```bash
cd src/ClientApp
npm run test:coverage
```
Der Coverage-Report wird unter `src/ClientApp/coverage/` erzeugt (HTML-Report in `index.html`).
The coverage report is generated under `src/ClientApp/coverage/` (HTML-Report in `index.html`).
- CI-freundliche Ausführung in Chrome Headless inklusive Coverage:
```bash
cd src/ClientApp
npm run test:ci
```
Hinweis: Für Headless-Tests muss eine Chrome/Chromium-Laufzeit auf der Maschine vorhanden sein.
Note: For headless tests, a Chrome/Chromium runtime must be present on the machine.

13
dotnet-tools.json Normal file
View File

@@ -0,0 +1,13 @@
{
"version": 1,
"isRoot": true,
"tools": {
"dotnet-ef": {
"version": "10.0.4",
"commands": [
"dotnet-ef"
],
"rollForward": false
}
}
}

View File

@@ -1,19 +1,25 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<UserSecretsId>9c947c10-2373-4590-92a9-e5fe6b759c69</UserSecretsId>
<SpaRoot>..\ClientApp\</SpaRoot>
<SpaProxyServerUrl>http://localhost:44492</SpaProxyServerUrl>
<SpaProxyLaunchCommand>npm start</SpaProxyLaunchCommand>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.SpaProxy" Version="9.0.4" />
<PackageReference Include="Swashbuckle.AspNetCore.Swagger" Version="8.1.1" />
<PackageReference Include="Swashbuckle.AspNetCore.SwaggerGen" Version="8.1.1" />
<PackageReference Include="Swashbuckle.AspNetCore.SwaggerUI" Version="8.1.1" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.5" />
<PackageReference Include="Microsoft.AspNetCore.SpaProxy" Version="10.0.5" />
<PackageReference Include="Swashbuckle.AspNetCore.Swagger" Version="10.1.5" />
<PackageReference Include="Swashbuckle.AspNetCore.SwaggerGen" Version="10.1.5" />
<PackageReference Include="Swashbuckle.AspNetCore.SwaggerUI" Version="10.1.5" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Application\Application.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,45 @@
using API.Extension;
using Application.DTOs;
using Application.Interfaces;
using Application.Models;
using Microsoft.AspNetCore.Mvc;
namespace API.Controllers;
public class AuthController(IAuthenticationService authService) : BaseApiController
{
[HttpPost("register")]
public async Task<IResult> Register(RegisterRequest registerRequest)
{
var response = await authService.RegisterAsync(registerRequest);
return response.ToHttpResponse();
}
[HttpPost("login")]
public async Task<IResult> Login(LoginRequest loginRequest)
{
var response = await authService.LoginAsync(loginRequest);
return response.ToHttpResponse();
}
[HttpPost("refresh-token")]
public async Task<IResult> RefreshToken(RefreshTokenRequest refreshTokenRequest)
{
var response = await authService.RefreshTokensAsync(refreshTokenRequest);
return response.ToHttpResponse();
}
[HttpPost("send-reset-email/{email}")]
public async Task<IResult> SendResetEmail(string email)
{
var response = await authService.SendResetEmailAsync(email);
return response.ToHttpResponse();
}
[HttpPost("reset-password")]
public async Task<IResult> ResetPassword(ResetPasswordDto resetPasswordDto)
{
var response = await authService.ResetPasswordAsync(resetPasswordDto);
return response.ToHttpResponse();
}
}

View File

@@ -0,0 +1,63 @@
using API.Extension;
using Application.Interfaces;
using Application.Models;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace API.Controllers;
public class UserController(IUserService userService) : BaseApiController
{
[Authorize(Roles = "SuperAdmin, Admin")]
[HttpGet]
public async Task<IResult> GetAllUsers(
[FromQuery] int pageNumber = 1,
[FromQuery] int pageSize = 10)
{
var users = await userService.GetAsync(pageNumber, pageSize);
return users.ToHttpResponse();
}
[Authorize]
[HttpPut]
public async Task<IResult> UpdateUser([FromBody] UserUpdateRequest userUpdateRequest)
{
var result = await userService.UpdateAsync(userUpdateRequest);
return result.ToHttpResponse();
}
[Authorize]
[HttpDelete("{id}")]
public async Task<IResult> DeleteUser(int id)
{
var currentUserId = int.Parse(User.FindFirst("UserId")!.Value);
var result = await userService.DeleteAsync(id, currentUserId);
return result.ToHttpResponse();
}
[Authorize]
[HttpGet("{id}")]
public async Task<IResult> GetUserById(int id)
{
var user = await userService.GetUserByIdAsync(id);
return user.ToHttpResponse();
}
[Authorize(Roles = "SuperAdmin")]
[HttpPost("assign-role")]
public async Task<IResult> AssignRole([FromBody] AssingRoleRequest roleRequest)
{
var result = await userService.AssignRoleAsync(roleRequest);
return result.ToHttpResponse();
}
[Authorize(Roles = "SuperAdmin")]
[HttpDelete("revoke-role")]
public async Task<IResult> RevokeRole([FromBody] AssingRoleRequest roleRequest)
{
var result = await userService.RevokeRoleAsync(roleRequest);
return result.ToHttpResponse();
}
}

View File

@@ -0,0 +1,32 @@
using Application.Common.Results;
namespace API.Extension;
public static class ResultExtension
{
public static IResult ToHttpResponse(this Result result)
{
if (result.IsSuccess) return Results.Ok(result);
return MapErrorResponse(result.Error, result);
}
public static IResult ToHttpResponse<T>(this Result<T> result)
{
if (result.IsSuccess)
return Results.Ok(result);
return MapErrorResponse(result.Error, result);
}
private static IResult MapErrorResponse(Error? error, object result)
{
return error?.Code switch
{
ErrorTypeConstant.ValidationError => Results.BadRequest(result),
ErrorTypeConstant.NotFound => Results.NotFound(result),
ErrorTypeConstant.Forbidden => Results.Forbid(),
ErrorTypeConstant.Unauthorized => Results.Unauthorized(),
_ => Results.Problem(error?.Message, statusCode: 500)
};
}
}

View File

@@ -1,10 +1,14 @@
using Microsoft.OpenApi.Models;
using System.Text;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.IdentityModel.Tokens;
using Microsoft.OpenApi;
namespace API.Extension;
public static class ServiceCollectionExtensions
{
public static IServiceCollection AddWebServices(this IServiceCollection services)
public static IServiceCollection AddWebServices(this IServiceCollection services,
IConfiguration configuration)
{
services.AddSwaggerGen(options =>
{
@@ -12,7 +16,43 @@ public static class ServiceCollectionExtensions
options.SwaggerDoc("v1", new OpenApiInfo { Title = "DotNetAngular API", Version = "v1" });
// update names of the api
options.SwaggerGeneratorOptions.DocumentFilters.Add(new LowerCaseDocumentFilter());
// configure JWT authentication
options.AddSecurityDefinition("Bearer", new OpenApiSecurityScheme
{
Name = "Authorization",
Type = SecuritySchemeType.Http,
Scheme = "Bearer",
BearerFormat = "JWT",
In = ParameterLocation.Header,
Description =
"JWT Authorization header using the Bearer scheme. Enter your token in the text input below."
});
options.AddSecurityRequirement(doc =>
{
var schemeRef = new OpenApiSecuritySchemeReference("Bearer", doc);
return new OpenApiSecurityRequirement
{
{ schemeRef, new List<string>() }
};
});
});
services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidateAudience = true,
ValidateLifetime = true,
ValidateIssuerSigningKey = true,
ValidIssuer = configuration["Jwt:Issuer"],
ValidAudience = configuration["Jwt:Audience"],
IssuerSigningKey = new SymmetricSecurityKey
(Encoding.UTF8.GetBytes(configuration["Jwt:Key"] ??
throw new InvalidOperationException("Jwt:Key is not configured"))),
ClockSkew = TimeSpan.Zero // remove delay of token expiration time
};
});
return services;
}

View File

@@ -1,5 +1,10 @@
using API.Extension;
using Microsoft.OpenApi.Models;
using Application.Extensions;
using Infrastructure.Context;
using Infrastructure.Extensions;
using Infrastructure.Utilities;
using Microsoft.EntityFrameworkCore;
using Microsoft.OpenApi;
using Swashbuckle.AspNetCore.SwaggerGen;
var builder = WebApplication.CreateBuilder(args);
@@ -7,7 +12,18 @@ var builder = WebApplication.CreateBuilder(args);
// Add services to the container.
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddControllers();
builder.Services.AddWebServices();
builder.Services.AddWebServices(builder.Configuration);
builder.Services.AddInfrastructure(builder.Configuration);
builder.Services.AddApplication();
// PostgreSql Database for development
builder.Services.AddDbContext<ApplicationDbContext>(options =>
{
var postgreSqlSettings =
builder.Configuration.GetRequiredSection("PostgreSqlSettings").Get<PostgreSqlSettings>();
var connectionString = postgreSqlSettings?.ConnectionString;
options.UseNpgsql(connectionString);
});
builder.Services.AddCors(options =>
{

View File

@@ -5,5 +5,18 @@
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*"
"AllowedHosts": "*",
"PostgreSqlSettings": {
"Database": "template_db",
"Username": "natlinux"
},
"jwt": {
"Key": "veryveryveryveryveryveryverysecretkey",
"Issuer": "https://localhost:7091",
"Audience": "http://localhost:5184"
},
"EmailSettings": {
"SmtpServer": "smtp.gmail.com",
"Port": 465
}
}

View File

@@ -1,9 +1,25 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\Domain\Domain.csproj" />
<ProjectReference Include="..\Infrastructure\Infrastructure.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="FluentValidation.DependencyInjectionExtensions" Version="12.1.1" />
<PackageReference Include="MailKit" Version="4.15.1" />
<PackageReference Include="Microsoft.AspNetCore.Identity" Version="2.3.9" />
<PackageReference Include="Microsoft.AspNetCore.Server.Kestrel.Core" Version="2.3.9" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="10.0.5" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.5" />
<PackageReference Include="MimeKit" Version="4.15.1" />
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.16.0" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,6 @@
namespace Application.Common.Results;
public sealed record Error(string Code, string Message)
{
internal static Error None => new(ErrorTypeConstant.None, string.Empty);
}

View File

@@ -0,0 +1,11 @@
namespace Application.Common.Results;
public class ErrorTypeConstant
{
public const string None = "None";
public const string ValidationError = "ValidationError";
public const string InternalServerError = "InternalServerError";
public const string NotFound = "NotFoundError";
public const string Unauthorized = "UnauthorizedError";
public const string Forbidden = "ForbiddenError";
}

View File

@@ -0,0 +1,37 @@
namespace Application.Common.Results;
public class Result
{
protected Result(bool isSuccess, Error error)
{
if ((isSuccess && error != Error.None) || (!isSuccess && error == Error.None))
throw new InvalidOperationException("Invalid operation");
IsSuccess = isSuccess;
Error = error;
}
public bool IsSuccess { get; }
public bool IsFailure => !IsSuccess;
public Error Error { get; }
public static Result Success()
{
return new Result(true, Error.None);
}
public static Result<TValue> Success<TValue>(TValue value)
{
return new Result<TValue>(value, true, Error.None);
}
public static Result Failure(Error error)
{
return new Result(false, error);
}
public static Result<TValue> Failure<TValue>(Error error)
{
return new Result<TValue>(default!, false, error);
}
}

View File

@@ -0,0 +1,13 @@
namespace Application.Common.Results;
public class Result<TValue> : Result
{
private readonly TValue _value;
protected internal Result(TValue value, bool isSuccess, Error error) : base(isSuccess, error)
{
_value = value;
}
public TValue Value => IsSuccess ? _value : throw new InvalidOperationException("No value for failure result");
}

View File

@@ -0,0 +1,12 @@
namespace Application.DTOs;
public class PagedResult<T>
{
public List<T> Items { get; set; } = [];
public int TotalCount { get; set; }
public int PageNumber { get; set; }
public int PageSize { get; set; }
public int TotalPages => (int)Math.Ceiling((double)TotalCount / PageSize);
public bool HasPreviousPage => PageNumber > 1;
public bool HasNext => PageNumber < TotalPages;
}

View File

@@ -0,0 +1,8 @@
namespace Application.DTOs;
public record ResetPasswordDto
{
public required string Email { get; init; }
public required string EmailToken { get; init; }
public required string NewPassword { get; init; }
}

View File

@@ -0,0 +1,10 @@
namespace Application.DTOs;
public record UserDto
{
public int Id { get; init; }
public required string Email { get; init; }
public required string Username { get; init; }
public DateTime LastLogin { get; init; }
public List<string> Roles { get; init; } = [];
}

View File

@@ -0,0 +1,29 @@
using Application.Common.Results;
namespace Application.Errors;
public static class AuthError
{
public static Error InvalidRegisterRequest => new(ErrorTypeConstant.ValidationError, "Invalid register request");
public static Error EmailAlreadyExists => new(ErrorTypeConstant.ValidationError, "E-Mail already exists");
public static Error UsernameAlreadyExists => new(ErrorTypeConstant.ValidationError, "Username already exists");
public static Error InvalidLoginRequest => new(ErrorTypeConstant.ValidationError, "Invalid login request");
public static Error UserNotFound => new(ErrorTypeConstant.NotFound, "User not found");
public static Error InvalidPassword => new(ErrorTypeConstant.ValidationError, "Invalid Password");
public static Error InvalidResetLink => new(ErrorTypeConstant.ValidationError, "Invalid reset link");
public static Error CreateInvalidLoginRequestError(IEnumerable<string> errors)
{
return new Error(ErrorTypeConstant.ValidationError, string.Join(", ", errors));
}
public static Error CreateInvalidRegisterRequestError(IEnumerable<string> errors)
{
return new Error(ErrorTypeConstant.ValidationError, string.Join(", ", errors));
}
}

View File

@@ -0,0 +1,54 @@
using Application.Common.Results;
namespace Application.Errors;
public static class UserError
{
public static Error InternalServerError =>
new(ErrorTypeConstant.InternalServerError, "something went wrong");
public static Error UserNotFound =>
new(ErrorTypeConstant.NotFound, "User not found");
public static Error UserCookieConsentNotFound =>
new(ErrorTypeConstant.NotFound, "Cookie consent not found for user");
public static Error FailedToAssignRole =>
new(ErrorTypeConstant.InternalServerError, "failed to assign role");
public static Error FailedToRevokeRole =>
new(ErrorTypeConstant.InternalServerError, "failed to revoke role");
public static Error UserAlreadyHasRole =>
new(ErrorTypeConstant.ValidationError, "User already has role");
public static Error UserHasNoRole =>
new(ErrorTypeConstant.ValidationError, "User already has no role");
public static Error FailedToRevokeCookieConsent =>
new(ErrorTypeConstant.InternalServerError, "failed to revoke cookie consent");
public static Error UserAlreadyHasCookieConsent =>
new(ErrorTypeConstant.ValidationError, "User already has cookie consent");
public static Error UserHasNoCookieConsent =>
new(ErrorTypeConstant.ValidationError, "User already has no cookie consent");
public static Error CannotDeleteYourself =>
new(ErrorTypeConstant.ValidationError, "You cannot delete your own account");
public static Error CreateInvalidUserUpdateRequestError(IEnumerable<string> errors)
{
return new Error(ErrorTypeConstant.ValidationError, string.Join(", ", errors));
}
public static Error CreateInvalidCookieConsentError(IEnumerable<string> errors)
{
return new Error(ErrorTypeConstant.ValidationError, string.Join(", ", errors));
}
public static Error CreateInvalidLoginRequestError(IEnumerable<string> errors)
{
return new Error(ErrorTypeConstant.ValidationError, string.Join(", ", errors));
}
}

View File

@@ -0,0 +1,20 @@
using Application.Interfaces;
using Application.Services;
using FluentValidation;
using Microsoft.Extensions.DependencyInjection;
namespace Application.Extensions;
public static class ServiceCollectionExtensions
{
public static IServiceCollection AddApplication(this IServiceCollection services)
{
services.AddValidatorsFromAssembly(typeof(ServiceCollectionExtensions).Assembly);
services.AddScoped<IAuthenticationService, AuthenticationService>();
services.AddScoped<IJwtService, JwtService>();
services.AddScoped<IEmailService, EmailService>();
services.AddScoped<IUserService, UserService>();
return services;
}
}

View File

@@ -0,0 +1,14 @@
using Application.Common.Results;
using Application.DTOs;
using Application.Models;
namespace Application.Interfaces;
public interface IAuthenticationService
{
Task<Result> RegisterAsync(RegisterRequest request);
Task<Result> LoginAsync(LoginRequest request);
Task<Result> RefreshTokensAsync(RefreshTokenRequest request);
Task<Result> SendResetEmailAsync(string email);
Task<Result> ResetPasswordAsync(ResetPasswordDto resetPasswordDto);
}

View File

@@ -0,0 +1,8 @@
using Application.Models;
namespace Application.Interfaces;
public interface IEmailService
{
void SendEmailAsync(EmailRequest emailRequest);
}

View File

@@ -0,0 +1,10 @@
using Domain.Entities;
namespace Application.Interfaces;
public interface IJwtService
{
Task<string> GenerateTokenAsync(User user);
Task<string> GenerateAndSaveRefreshTokenAsync(User user);
Task<User?> ValidateRefreshTokenAsync(int userId, string refreshToken);
}

View File

@@ -0,0 +1,15 @@
using Application.Common.Results;
using Application.DTOs;
using Application.Models;
namespace Application.Interfaces;
public interface IUserService
{
Task<Result<PagedResult<UserDto>>> GetAsync(int pageNumber = 1, int pageSize = 10);
Task<Result<string>> UpdateAsync(UserUpdateRequest user);
Task<Result<string>> DeleteAsync(int id, int currentUserId);
Task<Result<UserDto>> GetUserByIdAsync(int id);
Task<Result<string>> AssignRoleAsync(AssingRoleRequest roleRequest);
Task<Result<string>> RevokeRoleAsync(AssingRoleRequest roleRequest);
}

View File

@@ -0,0 +1,3 @@
namespace Application.Models;
public record AssingRoleRequest(int UserId, int RoleId);

View File

@@ -0,0 +1,15 @@
namespace Application.Models;
public class EmailRequest
{
public EmailRequest(string to, string subject, string content)
{
To = to;
Subject = subject;
Content = content;
}
public string To { get; set; }
public string Subject { get; set; }
public string Content { get; set; }
}

View File

@@ -0,0 +1,3 @@
namespace Application.Models;
public record LoginRequest(string Email, string Password);

View File

@@ -0,0 +1,7 @@
namespace Application.Models;
public class RefreshTokenRequest
{
public int UserId { get; set; }
public required string RefreshToken { get; set; }
}

View File

@@ -0,0 +1,7 @@
namespace Application.Models;
public record RegisterRequest(
string Username,
string Email,
string Password
);

View File

@@ -0,0 +1,7 @@
namespace Application.Models;
public class TokenResponse
{
public required string AccessToken { get; set; }
public required string RefreshToken { get; set; }
}

View File

@@ -0,0 +1,3 @@
namespace Application.Models;
public record UserUpdateRequest(int Id, string Username, string Email);

View File

@@ -0,0 +1,147 @@
using System.Security.Cryptography;
using Application.Common.Results;
using Application.DTOs;
using Application.Errors;
using Application.Interfaces;
using Application.Models;
using Application.Validators;
using Domain.Entities;
using Domain.Interface;
using Infrastructure.Utilities;
using Microsoft.AspNetCore.Identity;
namespace Application.Services;
public class AuthenticationService(
IUnitOfWork unitOfWork,
IUserRepository iUserRepository,
LoginRequestValidator loginRequestValidator,
RegisterRequestValidator registerRequestValidator,
IJwtService jwtService,
IEmailService emailService) : IAuthenticationService
{
public async Task<Result> RegisterAsync(RegisterRequest registerRequest)
{
var validationResult = await registerRequestValidator.ValidateAsync(registerRequest);
if (!validationResult.IsValid)
{
var errors = validationResult.Errors.Select(x => x.ErrorMessage);
return Result.Failure(AuthError.CreateInvalidRegisterRequestError(errors));
}
var emailExists = await iUserRepository.GetUserByEmailAsync(registerRequest.Email);
if (emailExists is not null) return Result.Failure(AuthError.EmailAlreadyExists);
var usernameExists = await iUserRepository.GetUserByUsernameAsync(registerRequest.Username);
if (usernameExists is not null) return Result.Failure(AuthError.UsernameAlreadyExists);
var user = new User
{
Username = registerRequest.Username,
Email = registerRequest.Email,
Password = registerRequest.Password,
UserRoles = [new UserRole { RoleId = 3 }]
};
var passwordHasher = new PasswordHasher<User>();
var hashedPassword = passwordHasher.HashPassword(user, registerRequest.Password);
user.Password = hashedPassword;
await iUserRepository.AddAsync(user);
await unitOfWork.CommitAsync();
return Result.Success("User registered successfully.");
}
public async Task<Result> LoginAsync(LoginRequest loginRequest)
{
var validationResult = await loginRequestValidator.ValidateAsync(loginRequest);
if (!validationResult.IsValid)
{
var errors = validationResult.Errors.Select(x => x.ErrorMessage);
return Result.Failure(AuthError.CreateInvalidLoginRequestError(errors));
}
var (email, password) = loginRequest;
var user = await iUserRepository.GetUserByEmailAsync(email);
if (user is null) return Result.Failure(AuthError.UserNotFound);
var passwordHasher = new PasswordHasher<User>();
var verificationResult = passwordHasher.VerifyHashedPassword(user, user.Password, password);
if (verificationResult == PasswordVerificationResult.Failed)
return Result.Failure(AuthError.InvalidPassword);
user.LastLogin = DateTime.UtcNow;
iUserRepository.Update(user);
var token = new TokenResponse
{
AccessToken = await jwtService.GenerateTokenAsync(user),
RefreshToken = await jwtService.GenerateAndSaveRefreshTokenAsync(user)
};
var result = new
{
Token = token, user.Username
};
return Result.Success(result);
}
public async Task<Result> RefreshTokensAsync(RefreshTokenRequest request)
{
var user = await jwtService.ValidateRefreshTokenAsync(request.UserId, request.RefreshToken);
if (user is null) return Result.Failure(AuthError.UserNotFound);
var result = new TokenResponse
{
AccessToken = await jwtService.GenerateTokenAsync(user),
RefreshToken = await jwtService.GenerateAndSaveRefreshTokenAsync(user)
};
return Result.Success(result);
}
public async Task<Result> SendResetEmailAsync(string email)
{
var user = await iUserRepository.GetUserByEmailAsync(email);
if (user is null) return Result.Failure(AuthError.UserNotFound);
var tokenBytes = RandomNumberGenerator.GetBytes(64);
var emailToken = Convert.ToBase64String(tokenBytes);
user.ResetPasswordToken = emailToken;
user.ResetPasswordTokenExpiryTime = DateTime.UtcNow.AddMinutes(15);
var emailModel = new EmailRequest(email, "Reset Password!!", EmailBody.EmailStringBody(email, emailToken));
emailService.SendEmailAsync(emailModel);
iUserRepository.Update(user);
await unitOfWork.CommitAsync();
return Result.Success("Reset email sent successfully");
}
public async Task<Result> ResetPasswordAsync(ResetPasswordDto resetPasswordDto)
{
// Normalize the incoming token if '+' was converted to space by transport layers.
var normalizedToken = (resetPasswordDto.EmailToken ?? string.Empty).Replace(" ", "+");
var user = await iUserRepository.GetUserByEmailAsync(resetPasswordDto.Email);
if (user is null) return Result.Failure(AuthError.UserNotFound);
var tokenCode = user.ResetPasswordToken;
var emailTokenExpiryTime = user.ResetPasswordTokenExpiryTime;
// Validate token and expiration using UTC to match stored times
if (string.IsNullOrWhiteSpace(normalizedToken) || tokenCode != normalizedToken ||
emailTokenExpiryTime < DateTime.UtcNow) return Result.Failure(AuthError.InvalidResetLink);
var passwordHasher = new PasswordHasher<User>();
var hashedPassword = passwordHasher.HashPassword(user, resetPasswordDto.NewPassword);
user.Password = hashedPassword;
// Invalidate the reset token after successful use
user.ResetPasswordToken = null;
user.ResetPasswordTokenExpiryTime = default;
iUserRepository.Update(user);
await unitOfWork.CommitAsync();
return Result.Success("Password reset successfully");
}
}

View File

@@ -0,0 +1,49 @@
using Application.Interfaces;
using Application.Models;
using MailKit.Net.Smtp;
using Microsoft.Extensions.Configuration;
using MimeKit;
using MimeKit.Text;
namespace Application.Services;
public class EmailService(IConfiguration config, Func<ISmtpClient>? smtpClientFactory)
: IEmailService
{
private readonly Func<ISmtpClient> _smtpClientFactory = smtpClientFactory ?? (() => new SmtpClient());
public EmailService(IConfiguration config) : this(config, null)
{
}
public void SendEmailAsync(EmailRequest emailRequest)
{
var emailMessage = new MimeMessage();
var from = config["EmailSettings:From"];
emailMessage.From.Add(new MailboxAddress("RssReader", from));
emailMessage.To.Add(new MailboxAddress(emailRequest.To, emailRequest.To));
emailMessage.Subject = emailRequest.Subject;
emailMessage.Body = new TextPart(TextFormat.Html)
{
Text = string.Format(emailRequest.Content)
};
var client = _smtpClientFactory();
try
{
client.Connect(config["EmailSettings:SmtpServer"], 465, true);
client.Authenticate(config["EmailSettings:From"], config["EmailSettings:Password"]);
client.Send(emailMessage);
}
catch (Exception e)
{
Console.WriteLine(e);
throw;
}
finally
{
client.Disconnect(true);
client.Dispose();
}
}
}

View File

@@ -0,0 +1,65 @@
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Text;
using Application.Interfaces;
using Domain.Entities;
using Domain.Interface;
using Infrastructure.Utilities;
using Microsoft.Extensions.Configuration;
using Microsoft.IdentityModel.Tokens;
namespace Application.Services;
public class JwtService(
IConfiguration configuration,
IUserRepository userRepository,
IUnitOfWork unitOfWork) : IJwtService
{
public async Task<string> GenerateTokenAsync(User user)
{
var secretKey = configuration["Jwt:Key"];
var securityKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(secretKey!));
var credentials = new SigningCredentials(securityKey, SecurityAlgorithms.HmacSha256);
var roles = await userRepository.GetUserRolesByEmailAsync(user.Email);
var claims = new List<Claim>
{
new(ClaimTypes.Email, user.Email),
new("UserId", user.Id.ToString()),
new("username", user.Username)
};
claims.AddRange(roles.Select(role => new Claim(ClaimTypes.Role, role)));
var tokenDescriptor = new SecurityTokenDescriptor
{
Subject = new ClaimsIdentity(claims),
Expires = DateTime.UtcNow.AddDays(1), // set a token expiration time
SigningCredentials = credentials,
Issuer = configuration["Jwt:Issuer"],
Audience = configuration["Jwt:Audience"]
};
var tokenHandler = new JwtSecurityTokenHandler();
var securityToken = tokenHandler.CreateToken(tokenDescriptor);
return tokenHandler.WriteToken(securityToken);
}
public async Task<string> GenerateAndSaveRefreshTokenAsync(User user)
{
var refreshToken = GenerateRefreshTokenHelper.GenerateRefreshToken();
user.RefreshToken = refreshToken;
user.RefreshTokenExpiryTime = DateTime.UtcNow.AddDays(5);
userRepository.Update(user);
await unitOfWork.CommitAsync();
return refreshToken;
}
public async Task<User?> ValidateRefreshTokenAsync(int userId, string refreshToken)
{
var user = await userRepository.GetUserByIdAsync(userId);
if (user is null || user.RefreshToken != refreshToken
|| user.RefreshTokenExpiryTime <= DateTime.UtcNow)
return null;
return user;
}
}

View File

@@ -0,0 +1,163 @@
using Application.Common.Results;
using Application.DTOs;
using Application.Errors;
using Application.Interfaces;
using Application.Models;
using Application.Validators;
using Domain.Interface;
namespace Application.Services;
public class UserService(
IUnitOfWork unitOfWork,
UserUpdateRequestValidator userUpdateRequestValidator,
IUserRepository userRepository,
IUserRoleRepository userRoleRepository) : IUserService
{
public async Task<Result<PagedResult<UserDto>>> GetAsync(int pageNumber = 1, int pageSize = 10)
{
try
{
// fetch all users
var users = await userRepository.GetAllAsync();
var totalCount = users.Count;
var pagedItems = users
.Skip((pageNumber - 1) * pageSize)
.Take(pageSize)
.Select(user => new UserDto
{
Id = user.Id,
Email = user.Email,
Username = user.Username,
LastLogin = user.LastLogin,
Roles = user.UserRoles.Select(x => x.Role?.Name ?? "Unknown").ToList()
})
.ToList();
var pageResult = new PagedResult<UserDto>
{
Items = pagedItems,
TotalCount = totalCount,
PageNumber = pageNumber,
PageSize = pageSize
};
return Result.Success(pageResult);
}
catch (Exception e)
{
Console.WriteLine(e);
throw;
}
}
public async Task<Result<string>> UpdateAsync(UserUpdateRequest userUpdateRequest)
{
try
{
// validate request
var validationResult = await userUpdateRequestValidator.ValidateAsync(userUpdateRequest);
if (!validationResult.IsValid)
{
var errors = validationResult.Errors.Select(a => a.ErrorMessage);
return Result.Failure<string>(UserError.CreateInvalidUserUpdateRequestError(errors));
}
// check if a user exists
var user = await userRepository.GetByIdAsync(userUpdateRequest.Id);
if (user == null) return Result.Failure<string>(UserError.UserNotFound);
// update user
user.Username = userUpdateRequest.Username;
user.Email = userUpdateRequest.Email;
userRepository.Update(user);
await unitOfWork.CommitAsync();
return Result.Success("User updated successfully");
}
catch (Exception e)
{
Console.WriteLine(e);
throw;
}
}
public async Task<Result<string>> DeleteAsync(int id, int currentUserId)
{
try
{
// Prevent users from deleting themselves
if (id == currentUserId) return Result.Failure<string>(UserError.CannotDeleteYourself);
var user = await userRepository.GetByIdAsync(id);
if (user == null) return Result.Failure<string>(UserError.UserNotFound);
userRepository.Delete(user);
await unitOfWork.CommitAsync();
return Result.Success("User deleted successfully");
}
catch (Exception e)
{
Console.WriteLine(e);
throw;
}
}
public async Task<Result<UserDto>> GetUserByIdAsync(int id)
{
try
{
var user = await userRepository.GetByIdAsync(id);
if (user is null) return Result.Failure<UserDto>(UserError.UserNotFound);
var userDetails = new UserDto
{
Id = user.Id,
Email = user.Email,
Username = user.Username,
LastLogin = user.LastLogin,
Roles = user.UserRoles.Select(x => x.Role?.Name ?? "Unknown").ToList()
};
return Result.Success(userDetails);
}
catch (Exception e)
{
Console.WriteLine(e);
throw;
}
}
public async Task<Result<string>> AssignRoleAsync(AssingRoleRequest roleRequest)
{
try
{
var isUserHasRole = userRoleRepository.HasRoleAsync(roleRequest.UserId, roleRequest.RoleId);
if (isUserHasRole.Result) return Result.Failure<string>(UserError.UserAlreadyHasRole);
var result = await userRoleRepository.AddRoleAsync(roleRequest.UserId, roleRequest.RoleId);
return result
? Result.Success("Role assigned successfully")
: Result.Failure<string>(UserError.FailedToAssignRole);
}
catch (Exception e)
{
Console.WriteLine(e);
throw;
}
}
public async Task<Result<string>> RevokeRoleAsync(AssingRoleRequest roleRequest)
{
try
{
var isUserHasRole = userRoleRepository.HasRoleAsync(roleRequest.UserId, roleRequest.RoleId);
if (!isUserHasRole.Result) return Result.Failure<string>(UserError.UserHasNoRole);
var result = await userRoleRepository.RemoveRoleAsync(roleRequest.UserId, roleRequest.RoleId);
return result
? Result.Success("Role revoked successfully")
: Result.Failure<string>(UserError.FailedToRevokeRole);
}
catch (Exception e)
{
Console.WriteLine(e);
throw;
}
}
}

View File

@@ -0,0 +1,17 @@
using Application.Models;
using FluentValidation;
namespace Application.Validators;
public class LoginRequestValidator : AbstractValidator<LoginRequest>
{
public LoginRequestValidator()
{
RuleFor(x => x.Email)
.NotEmpty().WithMessage("Email is required.")
.EmailAddress().WithMessage("Email is not valid.");
RuleFor(x => x.Password)
.NotEmpty().WithMessage("Password is required.");
}
}

View File

@@ -0,0 +1,29 @@
using Application.Models;
using FluentValidation;
namespace Application.Validators;
public class RegisterRequestValidator : AbstractValidator<RegisterRequest>
{
public RegisterRequestValidator()
{
RuleFor(x => x.Email)
.NotEmpty().WithMessage("{PropertyName} is required.")
.EmailAddress().WithMessage("{PropertyName} is not valid.");
RuleFor(x => x.Password)
.NotEmpty().WithMessage("{PropertyName} is required.")
.MinimumLength(10).WithMessage("{PropertyName} must be at least 10 characters long.")
.Matches("[A-Z]").WithMessage("{PropertyName} must contain at least one uppercase letter.")
.Matches("[a-z]").WithMessage("{PropertyName} must contain at least one lowercase letter.")
.Matches("[0-9]").WithMessage("{PropertyName} must contain at least one digit.")
.Matches("[^a-zA-Z0-9]").WithMessage("{PropertyName} must contain at least one special character.");
RuleFor(x => x.Username)
.NotEmpty().WithMessage("{PropertyName} is required.")
.MinimumLength(3).WithMessage("{PropertyName} must be at least 3 characters long.")
.MaximumLength(20).WithMessage("{PropertyName} must not exceed 20 characters.")
.Matches("^[a-zA-Z0-9_]*$")
.WithMessage("{PropertyName} can only contain letters, numbers, and underscores.");
}
}

View File

@@ -0,0 +1,21 @@
using Application.Models;
using FluentValidation;
namespace Application.Validators;
public class UserUpdateRequestValidator : AbstractValidator<UserUpdateRequest>
{
public UserUpdateRequestValidator()
{
RuleFor(x => x.Username)
.NotEmpty().WithMessage("Username is required.")
.MaximumLength(50).WithMessage(user => "Username must not exceed 50 characters.");
RuleFor(x => x.Email)
.NotEmpty().WithMessage("Email is required.")
.EmailAddress().WithMessage("Email is invalid.");
RuleFor(x => x.Id)
.NotEmpty().GreaterThan(0).WithMessage("Id is required.");
}
}

View File

@@ -41,8 +41,8 @@
"budgets": [
{
"type": "initial",
"maximumWarning": "500kB",
"maximumError": "1MB"
"maximumWarning": "2MB",
"maximumError": "5MB"
},
{
"type": "anyComponentStyle",
@@ -50,12 +50,24 @@
"maximumError": "4kB"
}
],
"outputHashing": "all"
"outputHashing": "all",
"fileReplacements": [
{
"replace": "src/environments/environment.development.ts",
"with": "src/environments/environment.prod.ts"
}
]
},
"development": {
"optimization": false,
"extractLicenses": false,
"sourceMap": true
"sourceMap": true,
"fileReplacements": [
{
"replace": "src/environments/environment.ts",
"with": "src/environments/environment.development.ts"
}
]
}
},
"defaultConfiguration": "production"

File diff suppressed because it is too large Load Diff

View File

@@ -12,22 +12,26 @@
},
"private": true,
"dependencies": {
"@angular/animations": "^21.1.2",
"@angular/common": "^21.1.2",
"@angular/compiler": "^21.1.2",
"@angular/core": "^21.1.2",
"@angular/forms": "^21.1.2",
"@angular/platform-browser": "^21.1.2",
"@angular/platform-browser-dynamic": "^21.1.2",
"@angular/router": "^21.1.2",
"@angular/animations": "^21.2.4",
"@angular/common": "^21.2.4",
"@angular/compiler": "^21.2.4",
"@angular/core": "^21.2.4",
"@angular/forms": "^21.2.4",
"@angular/platform-browser": "^21.2.4",
"@angular/platform-browser-dynamic": "^21.2.4",
"@angular/router": "^21.2.4",
"@auth0/angular-jwt": "^5.2.0",
"@ng-bootstrap/ng-bootstrap": "^20.0.0",
"bootstrap": "^5.3.8",
"bootstrap-icons": "^1.13.1",
"rxjs": "~7.8.0",
"tslib": "^2.3.0",
"zone.js": "~0.15.0"
},
"devDependencies": {
"@angular/build": "^21.1.2",
"@angular/cli": "^21.1.2",
"@angular/compiler-cli": "^21.1.2",
"@angular/build": "^21.2.2",
"@angular/cli": "^21.2.2",
"@angular/compiler-cli": "^21.2.4",
"@types/jasmine": "~5.1.0",
"jasmine-core": "~5.2.0",
"karma": "~6.4.0",

View File

@@ -0,0 +1,41 @@
/* Variables */
$gradient-color1: #fda400; /* Dunkles Orange */
$gradient-color2: #4d82c3; /* Dunkles Blau */
$gradient-color3: #605d5d; /* Dunkles Grau */
$gradient-color4: #A685E2; /* Pastell-Lila */
.card {
margin-top: 4rem;
border-radius: 12px;
box-shadow: 10px 10px 30px rgba(0, 0, 0, 0.8);
background: linear-gradient($gradient-color2, $gradient-color3, $gradient-color1);
color: black;
}
.error {
border-color: red;
}
h1 {
font-weight: 600;
}
i {
font-size: 20px;
color: black;
}
u {
color: black;
}
.form-check-input {
background-color: white;
}
span {
background-color: white;
}

View File

@@ -1,336 +1,9 @@
<!-- * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * -->
<!-- * * * * * * * * * * * The content below * * * * * * * * * * * -->
<!-- * * * * * * * * * * is only a placeholder * * * * * * * * * * -->
<!-- * * * * * * * * * * and can be replaced. * * * * * * * * * * -->
<!-- * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * -->
<!-- * * * * * * * * * Delete the template below * * * * * * * * * -->
<!-- * * * * * * * to get started with your project! * * * * * * * -->
<!-- * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * -->
<div [attr.data-bs-theme]="darkModeService.darkModeSignal()" class="main-container bg-body-tertiary">
<app-header></app-header>
<router-outlet></router-outlet>
<style>
:host {
--bright-blue: oklch(51.01% 0.274 263.83);
--electric-violet: oklch(53.18% 0.28 296.97);
--french-violet: oklch(47.66% 0.246 305.88);
--vivid-pink: oklch(69.02% 0.277 332.77);
--hot-red: oklch(61.42% 0.238 15.34);
--orange-red: oklch(63.32% 0.24 31.68);
<app-footer></app-footer>
--gray-900: oklch(19.37% 0.006 300.98);
--gray-700: oklch(36.98% 0.014 302.71);
--gray-400: oklch(70.9% 0.015 304.04);
<app-toast></app-toast>
--red-to-pink-to-purple-vertical-gradient: linear-gradient(
180deg,
var(--orange-red) 0%,
var(--vivid-pink) 50%,
var(--electric-violet) 100%
);
--red-to-pink-to-purple-horizontal-gradient: linear-gradient(
90deg,
var(--orange-red) 0%,
var(--vivid-pink) 50%,
var(--electric-violet) 100%
);
--pill-accent: var(--bright-blue);
font-family: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji",
"Segoe UI Symbol";
box-sizing: border-box;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
h1 {
font-size: 3.125rem;
color: var(--gray-900);
font-weight: 500;
line-height: 100%;
letter-spacing: -0.125rem;
margin: 0;
font-family: "Inter Tight", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji",
"Segoe UI Symbol";
}
p {
margin: 0;
color: var(--gray-700);
}
main {
width: 100%;
min-height: 100%;
display: flex;
justify-content: center;
align-items: center;
padding: 1rem;
box-sizing: inherit;
position: relative;
}
.angular-logo {
max-width: 9.2rem;
}
.content {
display: flex;
justify-content: space-around;
width: 100%;
max-width: 700px;
margin-bottom: 3rem;
}
.content h1 {
margin-top: 1.75rem;
}
.content p {
margin-top: 1.5rem;
}
.divider {
width: 1px;
background: var(--red-to-pink-to-purple-vertical-gradient);
margin-inline: 0.5rem;
}
.pill-group {
display: flex;
flex-direction: column;
align-items: start;
flex-wrap: wrap;
gap: 1.25rem;
}
.pill {
display: flex;
align-items: center;
--pill-accent: var(--bright-blue);
background: color-mix(in srgb, var(--pill-accent) 5%, transparent);
color: var(--pill-accent);
padding-inline: 0.75rem;
padding-block: 0.375rem;
border-radius: 2.75rem;
border: 0;
transition: background 0.3s ease;
font-family: var(--inter-font);
font-size: 0.875rem;
font-style: normal;
font-weight: 500;
line-height: 1.4rem;
letter-spacing: -0.00875rem;
text-decoration: none;
}
.pill:hover {
background: color-mix(in srgb, var(--pill-accent) 15%, transparent);
}
.pill-group .pill:nth-child(6n + 1) {
--pill-accent: var(--bright-blue);
}
.pill-group .pill:nth-child(6n + 2) {
--pill-accent: var(--french-violet);
}
.pill-group .pill:nth-child(6n + 3),
.pill-group .pill:nth-child(6n + 4),
.pill-group .pill:nth-child(6n + 5) {
--pill-accent: var(--hot-red);
}
.pill-group svg {
margin-inline-start: 0.25rem;
}
.social-links {
display: flex;
align-items: center;
gap: 0.73rem;
margin-top: 1.5rem;
}
.social-links path {
transition: fill 0.3s ease;
fill: var(--gray-400);
}
.social-links a:hover svg path {
fill: var(--gray-900);
}
@media screen and (max-width: 650px) {
.content {
flex-direction: column;
width: max-content;
}
.divider {
height: 1px;
width: 100%;
background: var(--red-to-pink-to-purple-horizontal-gradient);
margin-block: 1.5rem;
}
}
</style>
<main class="main">
<div class="content">
<div class="left-side">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 982 239"
fill="none"
class="angular-logo"
>
<g clip-path="url(#a)">
<path
fill="url(#b)"
d="M388.676 191.625h30.849L363.31 31.828h-35.758l-56.215 159.797h30.848l13.174-39.356h60.061l13.256 39.356Zm-65.461-62.675 21.602-64.311h1.227l21.602 64.311h-44.431Zm126.831-7.527v70.202h-28.23V71.839h27.002v20.374h1.392c2.782-6.71 7.2-12.028 13.255-15.956 6.056-3.927 13.584-5.89 22.503-5.89 8.264 0 15.465 1.8 21.684 5.318 6.137 3.518 10.964 8.673 14.319 15.382 3.437 6.71 5.074 14.81 4.992 24.383v76.175h-28.23v-71.92c0-8.019-2.046-14.237-6.219-18.819-4.173-4.5-9.819-6.791-17.102-6.791-4.91 0-9.328 1.063-13.174 3.272-3.846 2.128-6.792 5.237-9.001 9.328-2.046 4.009-3.191 8.918-3.191 14.728ZM589.233 239c-10.147 0-18.82-1.391-26.103-4.091-7.282-2.7-13.092-6.382-17.511-10.964-4.418-4.582-7.528-9.655-9.164-15.219l25.448-6.136c1.145 2.372 2.782 4.663 4.991 6.954 2.209 2.291 5.155 4.255 8.837 5.81 3.683 1.554 8.428 2.291 14.074 2.291 8.019 0 14.647-1.964 19.884-5.81 5.237-3.845 7.856-10.227 7.856-19.064v-22.665h-1.391c-1.473 2.946-3.601 5.892-6.383 9.001-2.782 3.109-6.464 5.645-10.965 7.691-4.582 2.046-10.228 3.109-17.101 3.109-9.165 0-17.511-2.209-25.039-6.545-7.446-4.337-13.42-10.883-17.757-19.474-4.418-8.673-6.628-19.473-6.628-32.565 0-13.091 2.21-24.301 6.628-33.383 4.419-9.082 10.311-15.955 17.839-20.7 7.528-4.746 15.874-7.037 25.039-7.037 7.037 0 12.846 1.145 17.347 3.518 4.582 2.373 8.182 5.236 10.883 8.51 2.7 3.272 4.746 6.382 6.137 9.327h1.554v-19.8h27.821v121.749c0 10.228-2.454 18.737-7.364 25.447-4.91 6.709-11.538 11.7-20.048 15.055-8.509 3.355-18.165 4.991-28.884 4.991Zm.245-71.266c5.974 0 11.047-1.473 15.302-4.337 4.173-2.945 7.446-7.118 9.573-12.519 2.21-5.482 3.274-12.027 3.274-19.637 0-7.609-1.064-14.155-3.274-19.8-2.127-5.646-5.318-10.064-9.491-13.255-4.174-3.11-9.329-4.746-15.384-4.746s-11.537 1.636-15.792 4.91c-4.173 3.272-7.365 7.772-9.492 13.418-2.128 5.727-3.191 12.191-3.191 19.392 0 7.2 1.063 13.745 3.273 19.228 2.127 5.482 5.318 9.736 9.573 12.764 4.174 3.027 9.41 4.582 15.629 4.582Zm141.56-26.51V71.839h28.23v119.786h-27.412v-21.273h-1.227c-2.7 6.709-7.119 12.191-13.338 16.446-6.137 4.255-13.747 6.382-22.748 6.382-7.855 0-14.81-1.718-20.783-5.237-5.974-3.518-10.72-8.591-14.075-15.382-3.355-6.709-5.073-14.891-5.073-24.464V71.839h28.312v71.921c0 7.609 2.046 13.664 6.219 18.083 4.173 4.5 9.655 6.709 16.365 6.709 4.173 0 8.183-.982 12.111-3.028 3.927-2.045 7.118-5.072 9.655-9.082 2.537-4.091 3.764-9.164 3.764-15.218Zm65.707-109.395v159.796h-28.23V31.828h28.23Zm44.841 162.169c-7.61 0-14.402-1.391-20.457-4.091-6.055-2.7-10.883-6.791-14.32-12.109-3.518-5.319-5.237-11.946-5.237-19.801 0-6.791 1.228-12.355 3.765-16.773 2.536-4.419 5.891-7.937 10.228-10.637 4.337-2.618 9.164-4.664 14.647-6.055 5.4-1.391 11.046-2.373 16.856-3.027 7.037-.737 12.683-1.391 17.102-1.964 4.337-.573 7.528-1.555 9.574-2.782 1.963-1.309 3.027-3.273 3.027-5.973v-.491c0-5.891-1.718-10.391-5.237-13.664-3.518-3.191-8.51-4.828-15.056-4.828-6.955 0-12.356 1.473-16.447 4.5-4.009 3.028-6.71 6.546-8.183 10.719l-26.348-3.764c2.046-7.282 5.483-13.336 10.31-18.328 4.746-4.909 10.638-8.59 17.511-11.045 6.955-2.455 14.565-3.682 22.912-3.682 5.809 0 11.537.654 17.265 2.045s10.965 3.6 15.711 6.71c4.746 3.109 8.51 7.282 11.455 12.6 2.864 5.318 4.337 11.946 4.337 19.883v80.184h-27.166v-16.446h-.9c-1.719 3.355-4.092 6.464-7.201 9.328-3.109 2.864-6.955 5.237-11.619 6.955-4.828 1.718-10.229 2.536-16.529 2.536Zm7.364-20.701c5.646 0 10.556-1.145 14.729-3.354 4.173-2.291 7.364-5.237 9.655-9.001 2.292-3.763 3.355-7.854 3.355-12.273v-14.155c-.9.737-2.373 1.391-4.5 2.046-2.128.654-4.419 1.145-7.037 1.636-2.619.491-5.155.9-7.692 1.227-2.537.328-4.746.655-6.628.901-4.173.572-8.019 1.472-11.292 2.781-3.355 1.31-5.973 3.11-7.855 5.401-1.964 2.291-2.864 5.318-2.864 8.918 0 5.237 1.882 9.164 5.728 11.782 3.682 2.782 8.51 4.091 14.401 4.091Zm64.643 18.328V71.839h27.412v19.965h1.227c2.21-6.955 5.974-12.274 11.292-16.038 5.319-3.763 11.456-5.645 18.329-5.645 1.555 0 3.355.082 5.237.163 1.964.164 3.601.328 4.91.573v25.938c-1.227-.41-3.109-.819-5.646-1.146a58.814 58.814 0 0 0-7.446-.49c-5.155 0-9.738 1.145-13.829 3.354-4.091 2.209-7.282 5.236-9.655 9.164-2.373 3.927-3.519 8.427-3.519 13.5v70.448h-28.312ZM222.077 39.192l-8.019 125.923L137.387 0l84.69 39.192Zm-53.105 162.825-57.933 33.056-57.934-33.056 11.783-28.556h92.301l11.783 28.556ZM111.039 62.675l30.357 73.803H80.681l30.358-73.803ZM7.937 165.115 0 39.192 84.69 0 7.937 165.115Z"
/>
<path
fill="url(#c)"
d="M388.676 191.625h30.849L363.31 31.828h-35.758l-56.215 159.797h30.848l13.174-39.356h60.061l13.256 39.356Zm-65.461-62.675 21.602-64.311h1.227l21.602 64.311h-44.431Zm126.831-7.527v70.202h-28.23V71.839h27.002v20.374h1.392c2.782-6.71 7.2-12.028 13.255-15.956 6.056-3.927 13.584-5.89 22.503-5.89 8.264 0 15.465 1.8 21.684 5.318 6.137 3.518 10.964 8.673 14.319 15.382 3.437 6.71 5.074 14.81 4.992 24.383v76.175h-28.23v-71.92c0-8.019-2.046-14.237-6.219-18.819-4.173-4.5-9.819-6.791-17.102-6.791-4.91 0-9.328 1.063-13.174 3.272-3.846 2.128-6.792 5.237-9.001 9.328-2.046 4.009-3.191 8.918-3.191 14.728ZM589.233 239c-10.147 0-18.82-1.391-26.103-4.091-7.282-2.7-13.092-6.382-17.511-10.964-4.418-4.582-7.528-9.655-9.164-15.219l25.448-6.136c1.145 2.372 2.782 4.663 4.991 6.954 2.209 2.291 5.155 4.255 8.837 5.81 3.683 1.554 8.428 2.291 14.074 2.291 8.019 0 14.647-1.964 19.884-5.81 5.237-3.845 7.856-10.227 7.856-19.064v-22.665h-1.391c-1.473 2.946-3.601 5.892-6.383 9.001-2.782 3.109-6.464 5.645-10.965 7.691-4.582 2.046-10.228 3.109-17.101 3.109-9.165 0-17.511-2.209-25.039-6.545-7.446-4.337-13.42-10.883-17.757-19.474-4.418-8.673-6.628-19.473-6.628-32.565 0-13.091 2.21-24.301 6.628-33.383 4.419-9.082 10.311-15.955 17.839-20.7 7.528-4.746 15.874-7.037 25.039-7.037 7.037 0 12.846 1.145 17.347 3.518 4.582 2.373 8.182 5.236 10.883 8.51 2.7 3.272 4.746 6.382 6.137 9.327h1.554v-19.8h27.821v121.749c0 10.228-2.454 18.737-7.364 25.447-4.91 6.709-11.538 11.7-20.048 15.055-8.509 3.355-18.165 4.991-28.884 4.991Zm.245-71.266c5.974 0 11.047-1.473 15.302-4.337 4.173-2.945 7.446-7.118 9.573-12.519 2.21-5.482 3.274-12.027 3.274-19.637 0-7.609-1.064-14.155-3.274-19.8-2.127-5.646-5.318-10.064-9.491-13.255-4.174-3.11-9.329-4.746-15.384-4.746s-11.537 1.636-15.792 4.91c-4.173 3.272-7.365 7.772-9.492 13.418-2.128 5.727-3.191 12.191-3.191 19.392 0 7.2 1.063 13.745 3.273 19.228 2.127 5.482 5.318 9.736 9.573 12.764 4.174 3.027 9.41 4.582 15.629 4.582Zm141.56-26.51V71.839h28.23v119.786h-27.412v-21.273h-1.227c-2.7 6.709-7.119 12.191-13.338 16.446-6.137 4.255-13.747 6.382-22.748 6.382-7.855 0-14.81-1.718-20.783-5.237-5.974-3.518-10.72-8.591-14.075-15.382-3.355-6.709-5.073-14.891-5.073-24.464V71.839h28.312v71.921c0 7.609 2.046 13.664 6.219 18.083 4.173 4.5 9.655 6.709 16.365 6.709 4.173 0 8.183-.982 12.111-3.028 3.927-2.045 7.118-5.072 9.655-9.082 2.537-4.091 3.764-9.164 3.764-15.218Zm65.707-109.395v159.796h-28.23V31.828h28.23Zm44.841 162.169c-7.61 0-14.402-1.391-20.457-4.091-6.055-2.7-10.883-6.791-14.32-12.109-3.518-5.319-5.237-11.946-5.237-19.801 0-6.791 1.228-12.355 3.765-16.773 2.536-4.419 5.891-7.937 10.228-10.637 4.337-2.618 9.164-4.664 14.647-6.055 5.4-1.391 11.046-2.373 16.856-3.027 7.037-.737 12.683-1.391 17.102-1.964 4.337-.573 7.528-1.555 9.574-2.782 1.963-1.309 3.027-3.273 3.027-5.973v-.491c0-5.891-1.718-10.391-5.237-13.664-3.518-3.191-8.51-4.828-15.056-4.828-6.955 0-12.356 1.473-16.447 4.5-4.009 3.028-6.71 6.546-8.183 10.719l-26.348-3.764c2.046-7.282 5.483-13.336 10.31-18.328 4.746-4.909 10.638-8.59 17.511-11.045 6.955-2.455 14.565-3.682 22.912-3.682 5.809 0 11.537.654 17.265 2.045s10.965 3.6 15.711 6.71c4.746 3.109 8.51 7.282 11.455 12.6 2.864 5.318 4.337 11.946 4.337 19.883v80.184h-27.166v-16.446h-.9c-1.719 3.355-4.092 6.464-7.201 9.328-3.109 2.864-6.955 5.237-11.619 6.955-4.828 1.718-10.229 2.536-16.529 2.536Zm7.364-20.701c5.646 0 10.556-1.145 14.729-3.354 4.173-2.291 7.364-5.237 9.655-9.001 2.292-3.763 3.355-7.854 3.355-12.273v-14.155c-.9.737-2.373 1.391-4.5 2.046-2.128.654-4.419 1.145-7.037 1.636-2.619.491-5.155.9-7.692 1.227-2.537.328-4.746.655-6.628.901-4.173.572-8.019 1.472-11.292 2.781-3.355 1.31-5.973 3.11-7.855 5.401-1.964 2.291-2.864 5.318-2.864 8.918 0 5.237 1.882 9.164 5.728 11.782 3.682 2.782 8.51 4.091 14.401 4.091Zm64.643 18.328V71.839h27.412v19.965h1.227c2.21-6.955 5.974-12.274 11.292-16.038 5.319-3.763 11.456-5.645 18.329-5.645 1.555 0 3.355.082 5.237.163 1.964.164 3.601.328 4.91.573v25.938c-1.227-.41-3.109-.819-5.646-1.146a58.814 58.814 0 0 0-7.446-.49c-5.155 0-9.738 1.145-13.829 3.354-4.091 2.209-7.282 5.236-9.655 9.164-2.373 3.927-3.519 8.427-3.519 13.5v70.448h-28.312ZM222.077 39.192l-8.019 125.923L137.387 0l84.69 39.192Zm-53.105 162.825-57.933 33.056-57.934-33.056 11.783-28.556h92.301l11.783 28.556ZM111.039 62.675l30.357 73.803H80.681l30.358-73.803ZM7.937 165.115 0 39.192 84.69 0 7.937 165.115Z"
/>
</g>
<defs>
<radialGradient
id="c"
cx="0"
cy="0"
r="1"
gradientTransform="rotate(118.122 171.182 60.81) scale(205.794)"
gradientUnits="userSpaceOnUse"
>
<stop stop-color="#FF41F8" />
<stop offset=".707" stop-color="#FF41F8" stop-opacity=".5" />
<stop offset="1" stop-color="#FF41F8" stop-opacity="0" />
</radialGradient>
<linearGradient
id="b"
x1="0"
x2="982"
y1="192"
y2="192"
gradientUnits="userSpaceOnUse"
>
<stop stop-color="#F0060B" />
<stop offset="0" stop-color="#F0070C" />
<stop offset=".526" stop-color="#CC26D5" />
<stop offset="1" stop-color="#7702FF" />
</linearGradient>
<clipPath id="a"><path fill="#fff" d="M0 0h982v239H0z" /></clipPath>
</defs>
</svg>
<h1>Hello, {{ title }}</h1>
<p>Congratulations! Your app is running. 🎉</p>
</div>
<div class="divider" role="separator" aria-label="Divider"></div>
<div class="right-side">
<div class="pill-group">
@for (item of [
{ title: 'Explore the Docs', link: 'https://angular.dev' },
{ title: 'Learn with Tutorials', link: 'https://angular.dev/tutorials' },
{ title: 'CLI Docs', link: 'https://angular.dev/tools/cli' },
{ title: 'Angular Language Service', link: 'https://angular.dev/tools/language-service' },
{ title: 'Angular DevTools', link: 'https://angular.dev/tools/devtools' },
]; track item.title) {
<a
class="pill"
[href]="item.link"
target="_blank"
rel="noopener"
>
<span>{{ item.title }}</span>
<svg
xmlns="http://www.w3.org/2000/svg"
height="14"
viewBox="0 -960 960 960"
width="14"
fill="currentColor"
>
<path
d="M200-120q-33 0-56.5-23.5T120-200v-560q0-33 23.5-56.5T200-840h280v80H200v560h560v-280h80v280q0 33-23.5 56.5T760-120H200Zm188-212-56-56 372-372H560v-80h280v280h-80v-144L388-332Z"
/>
</svg>
</a>
}
</div>
<div class="social-links">
<a
href="https://github.com/angular/angular"
aria-label="Github"
target="_blank"
rel="noopener"
>
<svg
width="25"
height="24"
viewBox="0 0 25 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
alt="Github"
>
<path
d="M12.3047 0C5.50634 0 0 5.50942 0 12.3047C0 17.7423 3.52529 22.3535 8.41332 23.9787C9.02856 24.0946 9.25414 23.7142 9.25414 23.3871C9.25414 23.0949 9.24389 22.3207 9.23876 21.2953C5.81601 22.0377 5.09414 19.6444 5.09414 19.6444C4.53427 18.2243 3.72524 17.8449 3.72524 17.8449C2.61064 17.082 3.81137 17.0973 3.81137 17.0973C5.04697 17.1835 5.69604 18.3647 5.69604 18.3647C6.79321 20.2463 8.57636 19.7029 9.27978 19.3881C9.39052 18.5924 9.70736 18.0499 10.0591 17.7423C7.32641 17.4347 4.45429 16.3765 4.45429 11.6618C4.45429 10.3185 4.9311 9.22133 5.72065 8.36C5.58222 8.04931 5.16694 6.79833 5.82831 5.10337C5.82831 5.10337 6.85883 4.77319 9.2121 6.36459C10.1965 6.09082 11.2424 5.95546 12.2883 5.94931C13.3342 5.95546 14.3801 6.09082 15.3644 6.36459C17.7023 4.77319 18.7328 5.10337 18.7328 5.10337C19.3942 6.79833 18.9789 8.04931 18.8559 8.36C19.6403 9.22133 20.1171 10.3185 20.1171 11.6618C20.1171 16.3888 17.2409 17.4296 14.5031 17.7321C14.9338 18.1012 15.3337 18.8559 15.3337 20.0084C15.3337 21.6552 15.3183 22.978 15.3183 23.3779C15.3183 23.7009 15.5336 24.0854 16.1642 23.9623C21.0871 22.3484 24.6094 17.7341 24.6094 12.3047C24.6094 5.50942 19.0999 0 12.3047 0Z"
/>
</svg>
</a>
<a
href="https://twitter.com/angular"
aria-label="Twitter"
target="_blank"
rel="noopener"
>
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
alt="Twitter"
>
<path
d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z"
/>
</svg>
</a>
<a
href="https://www.youtube.com/channel/UCbn1OgGei-DV7aSRo_HaAiw"
aria-label="Youtube"
target="_blank"
rel="noopener"
>
<svg
width="29"
height="20"
viewBox="0 0 29 20"
fill="none"
xmlns="http://www.w3.org/2000/svg"
alt="Youtube"
>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M27.4896 1.52422C27.9301 1.96749 28.2463 2.51866 28.4068 3.12258C29.0004 5.35161 29.0004 10 29.0004 10C29.0004 10 29.0004 14.6484 28.4068 16.8774C28.2463 17.4813 27.9301 18.0325 27.4896 18.4758C27.0492 18.9191 26.5 19.2389 25.8972 19.4032C23.6778 20 14.8068 20 14.8068 20C14.8068 20 5.93586 20 3.71651 19.4032C3.11363 19.2389 2.56449 18.9191 2.12405 18.4758C1.68361 18.0325 1.36732 17.4813 1.20683 16.8774C0.613281 14.6484 0.613281 10 0.613281 10C0.613281 10 0.613281 5.35161 1.20683 3.12258C1.36732 2.51866 1.68361 1.96749 2.12405 1.52422C2.56449 1.08095 3.11363 0.76113 3.71651 0.596774C5.93586 0 14.8068 0 14.8068 0C14.8068 0 23.6778 0 25.8972 0.596774C26.5 0.76113 27.0492 1.08095 27.4896 1.52422ZM19.3229 10L11.9036 5.77905V14.221L19.3229 10Z"
/>
</svg>
</a>
</div>
</div>
</div>
</main>
<!-- * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * -->
<!-- * * * * * * * * * * * The content above * * * * * * * * * * * * -->
<!-- * * * * * * * * * * is only a placeholder * * * * * * * * * * * -->
<!-- * * * * * * * * * * and can be replaced. * * * * * * * * * * * -->
<!-- * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * -->
<!-- * * * * * * * * * * End of Placeholder * * * * * * * * * * * * -->
<!-- * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * -->
<router-outlet />

View File

@@ -1,12 +1,18 @@
import { Component } from '@angular/core';
import {Component, inject} from '@angular/core';
import { RouterOutlet } from '@angular/router';
import {FooterComponent} from "./presentation/components/footer/footer.component";
import {ToastComponent} from "./presentation/shared/toast/toast.component";
import {HeaderComponent} from "./presentation/components/header/header.component";
import {DarkModeService} from "./infrastructure/services/dark-mode.service";
@Component({
selector: 'app-root',
imports: [RouterOutlet],
imports: [RouterOutlet, FooterComponent, ToastComponent, HeaderComponent],
templateUrl: './app.component.html',
standalone: true,
styleUrl: './app.component.scss'
})
export class AppComponent {
title = 'ClientApp';
darkModeService = inject(DarkModeService);
}

View File

@@ -2,7 +2,12 @@ import { ApplicationConfig, provideZoneChangeDetection } from '@angular/core';
import { provideRouter } from '@angular/router';
import { routes } from './app.routes';
import {provideHttpClient, withFetch, withInterceptors} from "@angular/common/http";
import {tokenInterceptor} from "./presentation/interceptors/token.interceptor";
import {provideClientHydration, withEventReplay} from "@angular/platform-browser";
export const appConfig: ApplicationConfig = {
providers: [provideZoneChangeDetection({ eventCoalescing: true }), provideRouter(routes)]
providers: [provideZoneChangeDetection({ eventCoalescing: true }), provideRouter(routes),
provideHttpClient(withFetch(), withInterceptors([tokenInterceptor
])), provideClientHydration(withEventReplay())]
};

View File

@@ -1,3 +1,22 @@
import { Routes } from '@angular/router';
import {RegisterComponent} from "./presentation/authentication/register/register.component";
import {LoginComponent} from "./presentation/authentication/login/login.component";
import {StartpageComponent} from "./presentation/components/startpage/startpage.component";
import {AdminDashboardComponent} from "./presentation/components/admin-dashboard/admin-dashboard.component";
import {UserDashboardComponent} from "./presentation/components/user-dashboard/user-dashboard.component";
import {UnauthorizedComponent} from "./presentation/components/unauthorized/unauthorized.component";
import {guestGuard} from "./presentation/guards/guest.guard";
import {AuthenticationGuard} from "./presentation/guards/authentication.guard";
import {adminGuard} from "./presentation/guards/admin.guard";
import {UserTableComponent} from "./presentation/components/user-table/user-table.component";
export const routes: Routes = [];
export const routes: Routes = [
{path: '', redirectTo: 'startpage', pathMatch: 'full'},
{path: 'register', component: RegisterComponent, canActivate: [guestGuard]},
{path: 'login', component: LoginComponent, canActivate: [guestGuard]},
{path: 'startpage', component: StartpageComponent, canActivate: [guestGuard]},
{path: 'user-table', component: UserTableComponent, canActivate: [adminGuard]},
{path: 'admin-dashboard', component: AdminDashboardComponent, canActivate: [adminGuard]},
{path: 'user-dashboard', component: UserDashboardComponent, canActivate: [AuthenticationGuard]},
{path: 'unauthorized', component: UnauthorizedComponent, canActivate: [AuthenticationGuard]}
];

View File

@@ -0,0 +1,7 @@
import {UserRole} from "./user-role";
export interface Role {
id: number;
name: string;
userRoles: UserRole[];
}

View File

@@ -0,0 +1,10 @@
import {User} from "./user";
import {Role} from "./role";
export interface UserRole {
userId: number;
user: User;
roleId: number;
role: Role;
}

View File

@@ -0,0 +1,11 @@
import {UserRole} from "./user-role";
export interface User {
id: number;
username: string;
email: string;
password: string;
lastLogin: Date;
UserRoles: UserRole[];
}

View File

@@ -0,0 +1,22 @@
import {AuthService} from './auth-service';
import {TestBed} from "@angular/core/testing";
import {provideHttpClient} from "@angular/common/http";
import {ActivatedRoute} from "@angular/router";
import {of} from "rxjs";
describe('AuthService', () => {
let service: AuthService;
beforeEach(() => {
TestBed.configureTestingModule({
providers: [provideHttpClient(),
{ provide: ActivatedRoute, useValue: { data: of({}), firstChild: null } }
]
});
service = TestBed.inject(AuthService);
})
it('should create an instance', () => {
expect(service).toBeTruthy();
});
});

View File

@@ -0,0 +1,101 @@
import {IAuthService} from "../../presentation/interfaces/iauth-service";
import {LoginRequest} from "../../presentation/models/login-request";
import {Observable} from "rxjs";
import {RegisterRequest} from "../../presentation/models/register-request";
import {inject, Injectable, PLATFORM_ID} from "@angular/core";
import {HttpClient} from "@angular/common/http";
import {environment} from "../../../environments/environment.development";
import {Router} from "@angular/router";
import {JwtHelperService} from '@auth0/angular-jwt';
import {RefreshTokenRequest} from "../../presentation/models/refresh-token-request";
import {isPlatformBrowser} from "@angular/common";
@Injectable({
providedIn: 'root'
})
export class AuthService implements IAuthService {
private httpClient = inject(HttpClient)
private router = inject(Router)
private baseUrl = environment.baseUrl
private readonly userPayload: any;
private platformId = inject(PLATFORM_ID);
private isBrowser = isPlatformBrowser(this.platformId);
constructor() {
if (this.isBrowser) {
this.userPayload = this.decodedToken();
}
}
register(registerRequest: RegisterRequest): Observable<any> {
return this.httpClient.post<any>(`${this.baseUrl}auth/register`, registerRequest);
}
login(loginRequest: LoginRequest): Observable<any> {
return this.httpClient.post<any>(`${this.baseUrl}auth/login`, loginRequest);
}
signOut() {
if (this.isBrowser) {
localStorage.clear();
this.router.navigate(['/login']).catch(error => {
console.error('Navigation error:', error);
});
}
}
storeToken(tokenValue: string) {
if (!this.isBrowser) return;
return localStorage.setItem('token', tokenValue)
}
getToken() {
if (!this.isBrowser) return null;
return localStorage.getItem('token')
}
storeRefreshToken(tokenValue: string) {
return localStorage.setItem('auth/refresh-token', tokenValue)
}
getRefreshToken() {
return localStorage.getItem('auth/refresh-token')
}
isLoggedIn(): boolean {
if (!this.isBrowser) return false;
return !!localStorage.getItem('token')
}
decodedToken() {
if (!this.isBrowser) return null;
const token = this.getToken();
if (!token) return null;
const jwtHelper = new JwtHelperService();
return jwtHelper.decodeToken(token)
}
getUsernameFromToken() {
if (this.userPayload)
return this.userPayload.username;
}
getRoleFromToken() {
if (this.userPayload)
return this.userPayload.role;
}
getUserIdFromToken() {
if (this.userPayload)
return this.userPayload.UserId;
}
getEmailFromToken() {
if (this.userPayload)
return this.userPayload.email;
}
renewToken(refreshTokenRequest: RefreshTokenRequest) {
return this.httpClient.post<any>(`${this.baseUrl}auth/refresh-token`, refreshTokenRequest)
}
}

View File

@@ -0,0 +1,16 @@
import {TestBed} from '@angular/core/testing';
import {DarkModeService} from './dark-mode.service';
describe('DarkModeService', () => {
let service: DarkModeService;
beforeEach(() => {
TestBed.configureTestingModule({});
service = TestBed.inject(DarkModeService);
});
it('should be created', () => {
expect(service).toBeTruthy();
});
});

View File

@@ -0,0 +1,37 @@
import {effect, inject, Injectable, PLATFORM_ID, signal} from '@angular/core';
import {isPlatformBrowser} from "@angular/common";
@Injectable({
providedIn: 'root'
})
export class DarkModeService {
private platformId = inject(PLATFORM_ID);
private isBrowser = isPlatformBrowser(this.platformId);
darkModeSignal = signal<string | null>(null);
constructor() {
if (!this.isBrowser) return;
// Initialwert nur im Browser lesen
const stored = window.localStorage.getItem('darkModeSignal');
this.darkModeSignal.set(stored ? JSON.parse(stored) : null);
// Persistenz nur im Browser
effect(() => {
window.localStorage.setItem(
'darkModeSignal',
JSON.stringify(this.darkModeSignal())
);
});
}
updateDarkMode(): void {
this.darkModeSignal.update(value =>
value === 'dark' ? null : 'dark'
);
}
}

View File

@@ -0,0 +1,16 @@
import { TestBed } from '@angular/core/testing';
import { LoadingService } from './loading.service';
describe('LoadingService', () => {
let service: LoadingService;
beforeEach(() => {
TestBed.configureTestingModule({});
service = TestBed.inject(LoadingService);
});
it('should be created', () => {
expect(service).toBeTruthy();
});
});

View File

@@ -0,0 +1,33 @@
import {Injectable, signal} from '@angular/core';
@Injectable({
providedIn: 'root',
})
export class LoadingService {
private _loading = signal(false);
private _itemLoading = signal<Record<string, boolean>>({}); // für einzelne Items
get loading() {
return this._loading;
}
get itemLoading() {
return this._itemLoading;
}
show() {
this._loading.set(true);
}
hide() {
this._loading.set(false);
}
showItem(id: string) {
this._itemLoading.update(state => ({ ...state, [id]: true }));
}
hideItem(id: string) {
this._itemLoading.update(state => ({ ...state, [id]: false }));
}
}

View File

@@ -0,0 +1,23 @@
import { TestBed } from '@angular/core/testing';
import { ResetPasswordService } from './reset-password.service';
import {provideHttpClient} from "@angular/common/http";
import {ActivatedRoute} from "@angular/router";
import {of} from "rxjs";
describe('ResetPasswordService', () => {
let service: ResetPasswordService;
beforeEach(() => {
TestBed.configureTestingModule({
providers: [provideHttpClient(),
{ provide: ActivatedRoute, useValue: { data: of({}), firstChild: null } }
]
});
service = TestBed.inject(ResetPasswordService);
});
it('should be created', () => {
expect(service).toBeTruthy();
});
});

View File

@@ -0,0 +1,19 @@
import {inject, Injectable} from '@angular/core';
import {environment} from "../../../environments/environment.development";
import {HttpClient} from "@angular/common/http";
import {ResetPassword} from "../../presentation/models/reset-password";
@Injectable({
providedIn: 'root'
})
export class ResetPasswordService {
httpClient = inject(HttpClient)
baseUrl = environment.baseUrl
sendResetPasswordLink(email : string){
return this.httpClient.post<any>(`${this.baseUrl}auth/send-reset-email/${email}`,{})
}
resetPassword(resetPasswordObj : ResetPassword){
return this.httpClient.post<any>(`${this.baseUrl}auth/reset-password` ,resetPasswordObj);
}
}

View File

@@ -0,0 +1,16 @@
import {TestBed} from '@angular/core/testing';
import {ToastService} from './toast.service';
describe('ToastService', () => {
let service: ToastService;
beforeEach(() => {
TestBed.configureTestingModule({});
service = TestBed.inject(ToastService);
});
it('should be created', () => {
expect(service).toBeTruthy();
});
});

View File

@@ -0,0 +1,17 @@
import {Injectable} from '@angular/core';
import {Toast} from "../../presentation/interfaces/toast";
@Injectable({
providedIn: 'root'
})
export class ToastService {
toasts: Toast[] = [];
show(message: string, options: Partial<Toast> = {}) {
this.toasts.push({message, ...options});
}
remove(toast: Toast) {
this.toasts = this.toasts.filter(t => t !== toast);
}
}

View File

@@ -0,0 +1,16 @@
import {TestBed} from '@angular/core/testing';
import {UserStoreService} from './user-store.service';
describe('UserStoreService', () => {
let service: UserStoreService;
beforeEach(() => {
TestBed.configureTestingModule({});
service = TestBed.inject(UserStoreService);
});
it('should be created', () => {
expect(service).toBeTruthy();
});
});

View File

@@ -0,0 +1,34 @@
import {Injectable} from '@angular/core';
import {BehaviorSubject} from 'rxjs';
@Injectable({
providedIn: 'root'
})
export class UserStoreService {
private username$ = new BehaviorSubject<string>("");
private role$ = new BehaviorSubject<string>("");
private email$ = new BehaviorSubject<string>("");
public getRoleFromStore() {
return this.role$.asObservable();
}
public setRoleForStore(role: string) {
this.role$.next(role);
}
public getUsernameFromStore() {
return this.username$.asObservable();
}
public setUsernameForStore(username: string) {
this.username$.next(username);
}
public getEmailFromStore() {
return this.email$.asObservable();
}
public setEmailForStore(email: string) {
this.email$.next(email);
}
}

View File

@@ -0,0 +1,23 @@
import {TestBed} from '@angular/core/testing';
import {UserService} from './user.service';
import {provideHttpClient} from "@angular/common/http";
import {ActivatedRoute} from "@angular/router";
import {of} from "rxjs";
describe('UserService', () => {
let service: UserService;
beforeEach(() => {
TestBed.configureTestingModule({
providers: [provideHttpClient(),
{ provide: ActivatedRoute, useValue: { data: of({}), firstChild: null } }
]
});
service = TestBed.inject(UserService);
});
it('should be created', () => {
expect(service).toBeTruthy();
});
});

View File

@@ -0,0 +1,22 @@
import {inject, Injectable} from '@angular/core';
import {HttpClient} from "@angular/common/http";
import {environment} from "../../../environments/environment.development";
import {IUserService} from "../../presentation/interfaces/iuser-service";
import { Observable } from "rxjs";
@Injectable({
providedIn: 'root'
})
export class UserService implements IUserService {
private httpClient = inject(HttpClient)
private baseUrl = environment.baseUrl
deleteUser(id: number): Observable<any> {
return this.httpClient.delete<any>(this.baseUrl + 'user/' + id);
}
getAllUsers(pageNumber: number, pageSize: number): Observable<any> {
const params = {
pageNumber: pageNumber.toString(),
pageSize: pageSize.toString()
};
return this.httpClient.get<any>(this.baseUrl + 'user', {params});
}
}

View File

@@ -0,0 +1,7 @@
import { PasswordValidator } from './password-validator';
describe('PasswordValidator', () => {
it('should create an instance', () => {
expect(new PasswordValidator()).toBeTruthy();
});
});

View File

@@ -0,0 +1,26 @@
import {AbstractControl, ValidationErrors} from "@angular/forms";
export class PasswordValidator {
static strong(control: AbstractControl): ValidationErrors | null {
const value = control.value || '';
const errors: any = {};
if (!/[A-Z]/.test(value)) {
errors.uppercase = true;
}
if (!/[a-z]/.test(value)) {
errors.lowercase = true;
}
if (!/[0-9]/.test(value)) {
errors.number = true;
}
if (!/[^a-zA-Z0-9]/.test(value)) {
errors.special = true;
}
if (value.length < 10) {
errors.minlength = true;
}
return Object.keys(errors).length ? errors : null;
}
}

View File

@@ -0,0 +1,16 @@
import { FormGroup } from "@angular/forms";
export function ConfirmPasswordValidator (controlName:string, matchControlName : string) {
return(formGroup : FormGroup)=>{
const passwordControl = formGroup.controls[controlName];
const confirmPasswordControl = formGroup.controls[matchControlName];
if (confirmPasswordControl.errors && confirmPasswordControl.errors['confirmPasswordValidator']) {
return;
}
if (passwordControl.value !== confirmPasswordControl.value) {
confirmPasswordControl.setErrors({ ConfirmPasswordValidator :true})
}else{
confirmPasswordControl.setErrors(null)
}
}
}

View File

@@ -0,0 +1,7 @@
import ValidateForm from './validate-form';
describe('ValidateForm', () => {
it('should create an instance', () => {
expect(new ValidateForm()).toBeTruthy();
});
});

View File

@@ -0,0 +1,14 @@
import {FormControl, FormGroup} from "@angular/forms";
export default class ValidateForm {
static validateAllFormFields(formGroup: FormGroup) {
Object.keys(formGroup.controls).forEach(field => {
const control = formGroup.get(field);
if (control instanceof FormControl) {
control.markAsDirty({onlySelf: true});
} else if (control instanceof FormGroup) {
this.validateAllFormFields(control)
}
})
}
}

View File

@@ -0,0 +1,26 @@
<div class="modal-content">
<div class="modal-header justify-content-center">
<h3 class="modal-title m-3">Forget your Password?</h3>
</div>
<div class="modal-body">
<p>Please provide your E-Mail we will send the reset link</p>
<div class="form-group">
<input type="email" class="form-control" placeholder="E-Mail" id="resetPasswordEmail" [(ngModel)]="resetPasswordEmail"
(ngModelChange)="checkValidEmail($event)" autofocus>
@if (!resetPasswordEmail) {
<small class="text-danger">*E-Mail is required</small>
} @else if (!isValidEmail && resetPasswordEmail){
<small class="text-danger">*E-Mail is invalid</small>
}
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-primary" (click)="confirmToSend()">
@if (loadingService.loading()) {
<span class="spinner-border spinner-border-sm" aria-hidden="true"></span>
<span class="visually-hidden" role="status">Loading...</span>
}
Send</button>
<button type="button" class="btn btn-light" (click)="activeModal.close()">Close</button>
</div>
</div>

View File

@@ -0,0 +1,31 @@
import {ComponentFixture, TestBed} from '@angular/core/testing';
import {ForgetPasswordPopupComponent} from './forget-password-popup.component';
import {NgbActiveModal} from "@ng-bootstrap/ng-bootstrap";
import {provideHttpClient} from "@angular/common/http";
import {ActivatedRoute} from "@angular/router";
import {of} from "rxjs";
describe('ForgetPasswordPopupComponent', () => {
let component: ForgetPasswordPopupComponent;
let fixture: ComponentFixture<ForgetPasswordPopupComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [ForgetPasswordPopupComponent],
providers: [provideHttpClient(),
{ provide: ActivatedRoute, useValue: { data: of({}), firstChild: null } },
NgbActiveModal
]
})
.compileComponents();
fixture = TestBed.createComponent(ForgetPasswordPopupComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create ForgetPasswordPopupComponent', () => {
expect(component).toBeTruthy();
});
});

View File

@@ -0,0 +1,64 @@
import {Component, inject} from '@angular/core';
import {FormsModule} from "@angular/forms";
import {NgbActiveModal} from "@ng-bootstrap/ng-bootstrap";
import {ResetPasswordService} from "../../../infrastructure/services/reset-password.service";
import {ToastService} from "../../../infrastructure/services/toast.service";
import {LoadingService} from "../../../infrastructure/services/loading.service";
@Component({
selector: 'app-forget-password-popup',
imports: [
FormsModule
],
templateUrl: './forget-password-popup.component.html',
styleUrl: './forget-password-popup.component.scss'
})
export class ForgetPasswordPopupComponent {
public resetPasswordEmail!: string;
public isValidEmail!: boolean;
activeModal = inject(NgbActiveModal);
resetService = inject(ResetPasswordService);
toastService = inject(ToastService);
loadingService = inject(LoadingService);
constructor() { }
checkValidEmail(event: string) {
const value = event;
const emailPattern = /^[\w-\.]+@([\w-]+\.)+[\w-]{2,3}$/;
this.isValidEmail = emailPattern.test(value);
return this.isValidEmail;
}
confirmToSend() {
if (this.checkValidEmail(this.resetPasswordEmail)) {
console.log(this.resetPasswordEmail)
this.loadingService.show();
// API call to be done
this.resetService.sendResetPasswordLink(this.resetPasswordEmail)
.subscribe({
next: (res) => {
this.loadingService.hide();
this.toastService.show(res.value, {
classname: 'bg-success text-light',
delay: 3000
});
this.resetPasswordEmail = "";
this.activeModal.close();
},
error: (err) => {
this.loadingService.hide();
console.error("reset password error ", err.error.error.message);
this.toastService.show(err.error.error.message, {
classname: 'bg-danger text-light',
delay: 3000
});
this.resetPasswordEmail = "";
}
})
}
}
protected readonly LoadingService = LoadingService;
}

View File

@@ -0,0 +1,74 @@
<div class="d-flex align-items-center justify-content-center h-100">
<div class="container h-100">
<div class="row d-flex justify-content-center align-items-center h-100">
<div class="col-12 col-md-9 col-lg-7 col-xl-6">
<div class="card start-card">
<div class="card-body p-5">
<h1 class="text-center mb-5">Log in</h1>
<form [formGroup]="loginForm">
<!-- E-Mail -->
<div class="input-group">
<span class="input-group-text"><i class="bi bi-envelope-fill"></i></span>
<input [class.error]="loginForm.controls['email'].dirty && loginForm.hasError('required' , 'email')"
autocomplete="email" class="form-control" formControlName="email" id="email"
placeholder="E-Mail"
type="email">
</div>
@if (loginForm.controls['email'].dirty && loginForm.hasError('pattern' , 'email')) {
<small
class="text-danger">*invalid E-Mail</small>
}
<!-- Password -->
<div class="input-group mt-5">
<span class="input-group-text"><i class="bi bi-lock-fill"></i></span>
<input
[class.error]="loginForm.controls['password'].dirty && loginForm.hasError('required' , 'password')"
[type]="type" autocomplete="current-password" class="form-control"
formControlName="password" id="password"
placeholder="Password">
<span (click)="hideShowPassword()" class="input-group-text"><i class="bi {{eyeIcon}}"></i></span>
</div>
@if (loginForm.controls['password'].dirty && loginForm.hasError('required' , 'password')) {
<small
class="text-danger">*Password is required</small>
}
<!-- Redirect links -->
<p class="m-1"><a (click)="openPopup()" class="fw-bold text-body" style="cursor: pointer;"><u>Forgot Password?</u></a></p>
<div class="d-flex justify-content-center">
<button (click)="onLogin()" [disabled]="loginForm.invalid || loadingService.loading()" class="btn btn-lg btn-light"
type="submit">
@if (loadingService.loading()) {
<span class="spinner-border spinner-border-sm" aria-hidden="true"></span>
<span class="visually-hidden" role="status">Loading...</span>
}
Sign in
</button>
</div>
<p class="text-center text-black-50 mt-5 mb-0">You don´t have an account? <a [routerLink]="['/register']"
class="fw-bold text-body"><u>Sign
up here!</u></a></p>
</form>
</div>
<!-- other Sign in -->
<!-- <div class="text-center py-3">-->
<!-- <p class="text-center">or Sign in with</p>-->
<!-- <a class="px-2" href="https://www.google.com" target="_blank"><img-->
<!-- alt=""-->
<!-- height="40"-->
<!-- ngSrc="https://www.freepnglogos.com/uploads/google-logo-png/google-logo-png-suite-everything-you-need-know-about-google-newest-0.png"-->
<!-- width="40"/></a>-->
<!-- <a class="px-2" href="https://www.github.com" target="_blank"><img-->
<!-- alt="" height="40"-->
<!-- ngSrc="https://www.freepnglogos.com/uploads/512x512-logo-png/512x512-logo-github-icon-35.png"-->
<!-- width="40"/></a>-->
<!-- <a class="px-2" href="https://www.facebook.com" target="_blank"><img-->
<!-- alt=""-->
<!-- height="40"-->
<!-- ngSrc="https://www.freepnglogos.com/uploads/facebook-logo-icon/facebook-logo-icon-facebook-icon-png-images-icons-and-png-backgrounds-1.png"-->
<!-- width="40"/></a>-->
<!-- </div>-->
</div>
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1 @@
@import url('../../../../../public/styles/common.scss');

View File

@@ -0,0 +1,30 @@
import {ComponentFixture, TestBed} from '@angular/core/testing';
import {LoginComponent} from './login.component';
import {ActivatedRoute, provideRouter, Router} from "@angular/router";
import {Title} from "@angular/platform-browser";
import {of} from "rxjs";
import {provideHttpClient} from "@angular/common/http";
describe('LoginComponent', () => {
let component: LoginComponent;
let fixture: ComponentFixture<LoginComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [LoginComponent],
providers: [provideHttpClient(),
{ provide: ActivatedRoute, useValue: { data: of({}), firstChild: null } }
]
})
.compileComponents();
fixture = TestBed.createComponent(LoginComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create LoginComponent', () => {
expect(component).toBeTruthy();
});
});

View File

@@ -0,0 +1,90 @@
import {Component, inject} from '@angular/core';
import {FormBuilder, FormGroup, ReactiveFormsModule, Validators} from "@angular/forms";
import {Router, RouterLink} from "@angular/router";
import ValidateForm from "../../../infrastructure/utilities/validate-form";
import {AuthService} from "../../../infrastructure/services/auth-service";
import {ToastService} from "../../../infrastructure/services/toast.service";
import {UserStoreService} from "../../../infrastructure/services/user-store.service";
import {NgbModal} from "@ng-bootstrap/ng-bootstrap";
import {ForgetPasswordPopupComponent} from "../forget-password-popup/forget-password-popup.component";
import {LoadingService} from "../../../infrastructure/services/loading.service";
import {Title} from "@angular/platform-browser";
@Component({
selector: 'app-login',
imports: [
ReactiveFormsModule,
RouterLink
],
templateUrl: './login.component.html',
standalone: true,
styleUrl: './login.component.scss'
})
export class LoginComponent {
emailPattern = "^[a-z0-9._%+-]+@[a-z0-9.-]+\\.[a-z]{2,4}$"
loginForm: FormGroup;
type: string = "password";
isText: boolean = true;
eyeIcon: string = "bi-eye-slash"
loadingService = inject(LoadingService);
authenticateService = inject(AuthService)
toastService = inject(ToastService);
userStore = inject(UserStoreService);
private router = inject(Router)
modalService = inject(NgbModal)
title = inject(Title)
constructor(private formBuilder: FormBuilder) {
this.loginForm = this.formBuilder.group({
email: ['', [Validators.required, Validators.pattern(this.emailPattern)]],
password: ['', Validators.required],
});
}
hideShowPassword() {
this.isText = !this.isText;
this.isText ? this.eyeIcon = "bi-eye" : this.eyeIcon = "bi-eye-slash";
this.isText ? this.type = "text" : this.type = "password";
}
openPopup() {
this.modalService.open(ForgetPasswordPopupComponent, {centered: true ,backdrop: 'static' });
}
onLogin() {
if (this.loginForm.valid) {
console.log(this.loginForm.value)
this.loadingService.show();
// Send the obj to a database
this.authenticateService.login(this.loginForm.value).subscribe({
next: (result) => {
this.authenticateService.storeToken(result.value.token.accessToken);
this.authenticateService.storeRefreshToken(result.value.token.refreshToken)
const tokenPayload = this.authenticateService.decodedToken();
this.userStore.setUsernameForStore(tokenPayload.username);
this.userStore.setEmailForStore(tokenPayload.email);
this.userStore.setRoleForStore(tokenPayload.role);
this.loadingService.hide()
// TODO redirect to...
this.router.navigate(['user-dashboard']).then(success => {
if (success) {
this.loginForm.reset();
}
})
},
error: (err) => {
this.loadingService.hide()
console.error(err.error.error.message);
this.toastService.show(err.error.error.message, {
classname: 'bg-danger text-light',
delay: 7000
});
}
})
} else {
ValidateForm.validateAllFormFields(this.loginForm)
}
}
}

View File

@@ -0,0 +1,128 @@
<div class="d-flex align-items-center h-100">
<div class="container h-100">
<div class="row d-flex justify-content-center align-items-center h-100">
<div class="col-12 col-md-9 col-lg-7 col-xl-6">
<div class="card">
<div class="card-body p-5">
<h1 class="text-center mb-5">Create an account</h1>
<form [formGroup]="registerForm">
<!-- Username -->
<div class="input-group">
<span class="input-group-text"><i class="bi bi-person-fill"></i></span>
<input
[class.error]="registerForm.controls['username'].dirty && registerForm.hasError('required' , 'username')"
autocomplete="username" class="form-control" formControlName="username" id="username"
name="username" placeholder="Username"
type="text">
</div>
<div>
@if (registerForm.controls['username'].touched && registerForm.controls['username'].errors) {
@if (registerForm.controls['username'].errors['minlength']) {
<small class="text-danger">
*Username must be at least 3 characters long
</small>
}
@if (registerForm.controls['username'].errors['maxlength']) {
<small class="text-danger">
*Username could contain max 20 characters long
</small>
}
@if (registerForm.controls['username'].errors['pattern']) {
<small class="text-danger">
*Username could contain only letters, numbers and underscore
</small>
}
}
</div>
<!-- E-Mail -->
<div class="input-group mt-5">
<span class="input-group-text"><i class="bi bi-envelope-fill"></i></span>
<input
[class.error]="registerForm.controls['email'].dirty && registerForm.hasError('required' , 'email' )"
autocomplete="email" class="form-control" formControlName="email" id="email"
name="email" placeholder="E-Mail"
type="email">
</div>
@if (registerForm.controls['email'].dirty && registerForm.hasError('pattern' , 'email')) {
<small
class="text-danger">*invalid E-Mail</small>
}
<!-- Password -->
<div class="input-group mt-5">
<span class="input-group-text"><i class="bi bi-lock-fill"></i></span>
<input
[class.error]="registerForm.controls['password'].dirty && registerForm.hasError('required' , 'password')"
[type]="type" autocomplete="new-password" class="form-control"
formControlName="password" id="password" name="password"
placeholder="Password">
<span (click)="hideShowPassword()" class="input-group-text"><i class="bi {{eyeIcon}}"></i></span>
</div>
<div>
@if (registerForm.controls['password'].touched && registerForm.controls['password'].errors) {
@if (registerForm.controls['password'].errors['minlength']) {
<small class="text-danger">
*Password must be at least 10 characters long
</small>
}
@if (registerForm.controls['password'].errors['uppercase']) {
<small class="text-danger">
*Password must contain at least one uppercase letter
</small>
}
@if (registerForm.controls['password'].errors['lowercase']) {
<small class="text-danger">
*Password must contain at least one lowercase letter
</small>
}
@if (registerForm.controls['password'].errors['number']) {
<small class="text-danger">
*Password must contain at least one number
</small>
}
@if (registerForm.controls['password'].errors['special']) {
<small class="text-danger">
*Password must contain at least one special character
</small>
}
}
</div>
<!-- Checkbox Terms -->
<!-- <div class="form-check d-flex justify-content-center mt-5">-->
<!-- <input class="checkbox form-check-input me-2" formControlName="terms" id="terms" type="checkbox">-->
<!-- <label class="form-check-label" for="terms">-->
<!-- I agree to the <a [routerLink]="['/terms-of-service']" class="text-body"><u>Terms of service</u></a>-->
<!-- </label>-->
<!-- </div>-->
<!-- <div class="text-center">-->
<!-- @if (registerForm.controls['terms'].untouched && registerForm.controls['terms'].invalid) {-->
<!-- <small-->
<!-- class="text-danger">-->
<!-- *You must agree to the terms-->
<!-- </small>-->
<!-- }-->
<!-- </div>-->
<!-- Button -->
<div class="d-flex justify-content-center">
<button (click)="onSignUp()" [disabled]="registerForm.invalid || loadingService.loading()" class="btn btn-lg btn-light mt-5"
type="submit">
@if (loadingService.loading()) {
<span class="spinner-border spinner-border-sm" aria-hidden="true"></span>
<span class="visually-hidden" role="status">Loading...</span>
}
Sign up
</button>
</div>
<p class="text-center text-black-50 mt-5 mb-0">Have already an account? <a [routerLink]="['/login']"
class="fw-bold text-body"><u>Sign
in here!</u></a></p>
</form>
</div>
</div>
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,12 @@
@import url('../../../../../public/styles/common.scss');
.checkbox {
border-color: black;
cursor: pointer;
}
.form-check-input:checked {
background-color: #716969;
}

View File

@@ -0,0 +1,28 @@
import {ComponentFixture, TestBed} from '@angular/core/testing';
import {RegisterComponent} from './register.component';
import {HttpClient, provideHttpClient} from "@angular/common/http";
import {ActivatedRoute} from "@angular/router";
import {of} from "rxjs";
describe('RegisterComponent', () => {
let component: RegisterComponent;
let fixture: ComponentFixture<RegisterComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [RegisterComponent],
providers: [provideHttpClient(),
{ provide: ActivatedRoute, useValue: { data: of({}), firstChild: null } }
]
}).compileComponents();
fixture = TestBed.createComponent(RegisterComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create RegisterComponent', () => {
expect(component).toBeTruthy();
});
});

View File

@@ -0,0 +1,94 @@
import {Component, inject} from '@angular/core';
import {Router, RouterLink} from "@angular/router";
import {FormBuilder, FormGroup, ReactiveFormsModule, Validators} from "@angular/forms";
import ValidateForm from "../../../infrastructure/utilities/validate-form";
import {AuthService} from "../../../infrastructure/services/auth-service";
import {ToastService} from "../../../infrastructure/services/toast.service";
import {PasswordValidator} from "../../../infrastructure/utilities/password-validator";
import {LoadingService} from "../../../infrastructure/services/loading.service";
import {Title} from "@angular/platform-browser";
@Component({
selector: 'app-register',
imports: [
RouterLink,
ReactiveFormsModule
],
templateUrl: './register.component.html',
standalone: true,
styleUrl: './register.component.scss'
})
export class RegisterComponent {
emailPattern = "^[a-z0-9._%+-]+@[a-z0-9.-]+\\.[a-z]{2,4}$"
usernamePattern = "^[a-zA-Z0-9_]*$"
type: string = "password";
isText: boolean = true;
eyeIcon: string = "bi-eye-slash"
registerForm: FormGroup;
loadingService = inject(LoadingService);
authenticateService = inject(AuthService)
toastService = inject(ToastService);
private router = inject(Router);
title = inject(Title)
constructor(private formBuilder: FormBuilder) {
this.registerForm = this.formBuilder.group({
username: ['', [Validators.required, Validators.minLength(3), Validators.maxLength(20), Validators.pattern(this.usernamePattern)]],
email: ['', [Validators.required, Validators.pattern(this.emailPattern)]],
password: ['', [Validators.required, PasswordValidator.strong]],
// terms: [false, Validators.requiredTrue]
});
this.title.setTitle('Register');
}
hideShowPassword() {
this.isText = !this.isText;
this.isText ? this.eyeIcon = "bi-eye" : this.eyeIcon = "bi-eye-slash";
this.isText ? this.type = "text" : this.type = "password";
}
onSignUp() {
if (this.registerForm.valid) {
console.log(this.registerForm.value);
// Payload: Formular + Consent
const payload = this.registerForm.value;
console.log(payload);
this.loadingService.show();
// Send the obj to a database
this.authenticateService.register(payload).subscribe({
next: (result) => {
this.loadingService.hide()
if (result.isSuccess === true) {
console.log("Result success",result);
this.registerForm.reset();
this.router.navigate(['/login']).then(success => {
if (success) {
this.toastService.show(result.value, {
classname: 'bg-success text-light',
delay: 2000
});
}
})
}
},
error: (err) => {
this.loadingService.hide()
console.error("register error",err.error?.error?.message);
this.toastService.show(err.error?.error?.message, {
classname: 'bg-danger text-light',
delay: 2000
});
}
})
} else {
ValidateForm.validateAllFormFields(this.registerForm)
}
}
protected readonly LoadingService = LoadingService;
}

View File

@@ -0,0 +1,67 @@
<div class="d-flex align-items-center h-100">
<div class="container h-100">
<div class="row d-flex justify-content-center align-items-center h-100">
<div class="col-12 col-md-9 col-lg-7 col-xl-6">
<div class="card">
<div class="card-body p-5">
<h1 class="text-center mb-5">Reset your Password</h1>
<form [formGroup]="resetForm">
<!-- Password -->
<div class="input-group">
<span class="input-group-text"><i class="bi bi-lock-fill"></i></span>
<input
[class.error]="resetForm.controls['password'].dirty && resetForm.hasError('required' , 'password')"
[type]="type" autocomplete="new-password" class="form-control"
formControlName="password" id="password"
placeholder="Password">
<span (click)="hideShowPassword()" class="input-group-text"><i class="bi {{eyeIcon}}"></i></span>
</div>
@if (resetForm.controls['password'].touched && resetForm.controls['password'].errors) {
@if (resetForm.controls['password'].dirty && resetForm.hasError('required' , 'password')){
<small class="text-danger">*New Password is required</small>
}
@if (resetForm.hasError('minlength' , 'password')){
<small class="text-danger">*Password must be at least 10 characters long</small>
}
@if (resetForm.hasError('uppercase' , 'password')){
<small class="text-danger">*Password must contain at least one uppercase letter</small>
}
@if (resetForm.hasError('lowercase' , 'password')){
<small class="text-danger">*Password must contain at least one lowercase letter</small>
}
@if (resetForm.hasError('number' , 'password')){
<small class="text-danger">*Password must contain at least one number</small>
}
@if (resetForm.hasError('special' , 'password')){
<small class="text-danger">*Password must contain at least one special character</small>
}
}
<!-- ConfirmPassword -->
<div class="input-group mt-5">
<span class="input-group-text"><i class="bi bi-lock-fill"></i></span>
<input
[class.error]="resetForm.controls['confirmPassword'].dirty && resetForm.hasError('required' , 'confirmPassword')"
[type]="type" autocomplete="current-password" class="form-control"
formControlName="confirmPassword" id="ConfirmPassword"
placeholder="Confirm Password">
<span (click)="hideShowPassword()" class="input-group-text"><i class="bi {{eyeIcon}}"></i></span>
</div>
@if (resetForm.controls['confirmPassword'].dirty && resetForm.hasError('required' , 'confirmPassword')){
<small class="text-danger">*Confirm Password is required</small>
}
@if (resetForm.hasError('ConfirmPasswordValidator' , 'confirmPassword')){
<small class="text-danger">*Password doesn't match</small>
}
<div class="d-flex justify-content-center mt-5">
<button (click)="reset()" type="submit" class="btn btn-lg btn-light">Reset Password</button>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1 @@
@import url('../../../../../public/styles/common.scss');

View File

@@ -0,0 +1,39 @@
import {ComponentFixture, TestBed} from '@angular/core/testing';
import {ResetPasswordComponent} from './reset-password.component';
import {provideHttpClient} from "@angular/common/http";
import {ActivatedRoute} from "@angular/router";
import {of} from "rxjs";
import {ReactiveFormsModule} from "@angular/forms";
describe('ResetPasswordComponent', () => {
let component: ResetPasswordComponent;
let fixture: ComponentFixture<ResetPasswordComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [ResetPasswordComponent, ReactiveFormsModule],
providers: [provideHttpClient(),
{ provide: ActivatedRoute, useValue: { data: of({}), firstChild: null } },
{
provide: ActivatedRoute,
useValue: {
queryParams: of({
email: 'test@example.com',
code: 'test-token'
})
}
}
]
})
.compileComponents();
fixture = TestBed.createComponent(ResetPasswordComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@@ -0,0 +1,88 @@
import {Component, inject, OnInit} from '@angular/core';
import {FormBuilder, FormGroup, ReactiveFormsModule, Validators} from "@angular/forms";
import {ResetPasswordService} from "../../../infrastructure/services/reset-password.service";
import {ActivatedRoute, Router} from "@angular/router";
import {ResetPassword} from "../../models/reset-password";
import {ConfirmPasswordValidator} from "../../../infrastructure/utilities/reset-password-validator";
import ValidateForm from "../../../infrastructure/utilities/validate-form";
import {ToastService} from "../../../infrastructure/services/toast.service";
import {PasswordValidator} from "../../../infrastructure/utilities/password-validator";
@Component({
selector: 'app-reset-password',
imports: [
ReactiveFormsModule
],
templateUrl: './reset-password.component.html',
styleUrl: './reset-password.component.scss'
})
export class ResetPasswordComponent implements OnInit {
type: string = "password";
isText: boolean = true;
eyeIcon: string = "bi-eye-slash"
resetForm!: FormGroup;
emailToReset!: string;
emailToken!: string;
resetPasswordObj = new ResetPassword();
constructor() { }
private formBuilder = inject(FormBuilder);
private activatedRoute= inject(ActivatedRoute);
private resetService = inject(ResetPasswordService);
private router = inject(Router);
private toastService = inject(ToastService);
ngOnInit() : void {
this.resetForm = this.formBuilder.group({
password: ['', [Validators.required, PasswordValidator.strong]],
confirmPassword: ['' , Validators.required],
}, {
validator: ConfirmPasswordValidator("password", "confirmPassword")
});
this.activatedRoute.queryParams.subscribe(val => {
this.emailToReset = val ['email'];
let uriToken = val ['code'];
this.emailToken = uriToken.replace(/ /g,'+');
console.log(this.emailToken)
console.log(this.emailToReset)
})
}
hideShowPassword(){
this.isText = !this.isText;
this.isText ? this.eyeIcon = "bi-eye" : this.eyeIcon = "bi-eye-slash";
this.isText ? this.type = "text" : this.type = "password";
}
reset() {
if (this.resetForm.valid) {
this.resetPasswordObj.email = this.emailToReset;
this.resetPasswordObj.newPassword = this.resetForm.value.password;
this.resetPasswordObj.confirmPassword = this.resetForm.value.confirmPassword;
this.resetPasswordObj.emailToken = this.emailToken;
this.resetService.resetPassword(this.resetPasswordObj).subscribe({
next:(res)=>{
alert(res.value);
this.router.navigate(['/login']).then(success => {
if (success) {
this.toastService.show(res.value, {
classname: 'bg-success text-light',
delay: 2000
});
}
})
},
error:(err)=>{
alert(err.error.error.message);
}
})
} else {
ValidateForm.validateAllFormFields(this.resetForm);
}
}
}

View File

@@ -0,0 +1,10 @@
<div class="container-fluid d-flex justify-content-center">
<div class="card mt-2 col-2 me-2" [routerLink]="['/user-table']" style="cursor: pointer;">
<div class="card-body text-center">
<svg xmlns="http://www.w3.org/2000/svg" width="150" height="150" fill="blue" class="bi bi-people-fill " viewBox="0 0 16 16">
<path d="M7 14s-1 0-1-1 1-4 5-4 5 3 5 4-1 1-1 1zm4-6a3 3 0 1 0 0-6 3 3 0 0 0 0 6m-5.784 6A2.24 2.24 0 0 1 5 13c0-1.355.68-2.75 1.936-3.72A6.3 6.3 0 0 0 5 9c-4 0-5 3-5 4s1 1 1 1zM4.5 8a2.5 2.5 0 1 0 0-5 2.5 2.5 0 0 0 0 5"/>
</svg>
<h4 class="my-3">Users</h4>
</div>
</div>
</div>

View File

@@ -0,0 +1,23 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { AdminDashboardComponent } from './admin-dashboard.component';
describe('AdminDashboardComponent', () => {
let component: AdminDashboardComponent;
let fixture: ComponentFixture<AdminDashboardComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [AdminDashboardComponent]
})
.compileComponents();
fixture = TestBed.createComponent(AdminDashboardComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@@ -0,0 +1,15 @@
import { Component } from '@angular/core';
import {RouterLink} from "@angular/router";
@Component({
selector: 'app-admin-dashboard',
imports: [
RouterLink
],
templateUrl: './admin-dashboard.component.html',
styleUrl: './admin-dashboard.component.scss',
standalone: true
})
export class AdminDashboardComponent {
}

View File

@@ -0,0 +1,24 @@
<footer class="footer mt-auto py-3 bg-body-tertiary">
<!-- Footer-Navigation -->
<ul class="navbar-nav flex-row justify-content-center gap-4 mb-2">
<li class="nav-item">
<a class="nav-link p-0 link-body-emphasis" [routerLink]="['/legal']" target="_blank">Legal notice & Privacy policy</a>
</li>
<li class="nav-item">
<a class="nav-link p-0 link-body-emphasis"
href='mailto:info@rss.wenske-services-development.de?subject=RssReader'>Contact</a>
</li>
<li class="nav-item">
<a class="nav-link p-0 link-body-emphasis" style="cursor: pointer">Cookie settings</a>
</li>
</ul>
<!-- Copyright -->
<div class="container text-center">
<span class="text-secondary">&copy; 2026 Wenske Services & Developments. All rights reserved.</span>
</div>
<!-- Version Anzeige -->
<div class="container text-center">
<span class="text-secondary small">Version {{ version }}</span>
</div>
</footer>

Some files were not shown because too many files have changed in this diff Show More