diff --git a/README.md b/README.md index e4df759a..9a3d21d5 100644 --- a/README.md +++ b/README.md @@ -178,9 +178,9 @@ To run the application from source, follow these steps: 1. Open the `src/Turnierplan.sln` solution and navigate to the docker compose file located under `Solution Items`. Run the `turnierplan.database` docker compose service. This will start up the PostgreSQL database for local development. 2. Navigate to the `Turnierplan.App` project and run the `Turnierplan.App` launch configuration. This will start the backend using port `45000`. 3. Open a terminal and navigate to the `src/Turnierplan.App/Client` directory. Run `npm install` to install the node dependencies. Next, you can start the client application by typing `npm run start`. Note that this will only work if the backend application has previously been run because the client app startup depends on OpenAPI files generated by the backend build process. -4. Access the client application using [http://localhost:45001](http://localhost:45001) and log in using default credentials. The email address is `admin@example.com` and the password is `P@ssw0rd`. +4. Access the client application using [http://localhost:45001](http://localhost:45001) and log in using default credentials. The user name is `admin` and the password is `P@ssw0rd`. -When running locally, the API documentation can be viewed by opening [http://localhost:45000/scalar]( http://localhost:45000/scalar). +When running locally, the API documentation can be viewed by opening [http://localhost:45000/scalar](http://localhost:45000/scalar). ## Screenshots diff --git a/src/Turnierplan.App/Client/cypress/docker/docker-compose.yaml b/src/Turnierplan.App/Client/cypress/docker/docker-compose.yaml index 0de5b9fb..d610b64c 100644 --- a/src/Turnierplan.App/Client/cypress/docker/docker-compose.yaml +++ b/src/Turnierplan.App/Client/cypress/docker/docker-compose.yaml @@ -1,4 +1,4 @@ -# This docker compose file is used for running the E2E tests during the CI pipeline. +# This docker compose file is used for running the E2E tests during the CI workflow services: turnierplan.e2e.database: @@ -19,7 +19,7 @@ services: - "45001:8080" environment: - Database__ConnectionString=Host=turnierplan.e2e.database;Database=turnierplan;Username=postgres;Password=P@ssw0rd - - Turnierplan__ApplicationUrl=http://localhost:45009 + - Turnierplan__ApplicationUrl=http://localhost:45001 - Turnierplan__InitialUserPassword=P@ssw0rd networks: diff --git a/src/Turnierplan.App/Client/cypress/support/e2e.js b/src/Turnierplan.App/Client/cypress/support/e2e.js index 76ef10d8..4491bd7f 100644 --- a/src/Turnierplan.App/Client/cypress/support/e2e.js +++ b/src/Turnierplan.App/Client/cypress/support/e2e.js @@ -11,7 +11,7 @@ Cypress.Commands.add('getx', (id) => { Cypress.Commands.add('login', () => { cy.visit('/portal/login'); - cy.getx(turnierplan.loginPage.emailField).type('admin@example.com'); + cy.getx(turnierplan.loginPage.userNameField).type('admin'); cy.getx(turnierplan.loginPage.passwordField).type('P@ssw0rd'); cy.getx(turnierplan.loginPage.loginButton).click(); }); diff --git a/src/Turnierplan.App/Client/cypress/support/turnierplan.js b/src/Turnierplan.App/Client/cypress/support/turnierplan.js index 83cdf1bf..b381423b 100644 --- a/src/Turnierplan.App/Client/cypress/support/turnierplan.js +++ b/src/Turnierplan.App/Client/cypress/support/turnierplan.js @@ -15,7 +15,7 @@ export const turnierplan = { newOrganizationButton: 'landing-page-new-organization-button' }, loginPage: { - emailField: 'login-page-email-field', + userNameField: 'login-page-user-name-field', loginButton: 'login-page-login-button', passwordField: 'login-page-password-field' }, diff --git a/src/Turnierplan.App/Client/src/app/core/models/identity.ts b/src/Turnierplan.App/Client/src/app/core/models/identity.ts index aff44a47..d5cd8cb0 100644 --- a/src/Turnierplan.App/Client/src/app/core/models/identity.ts +++ b/src/Turnierplan.App/Client/src/app/core/models/identity.ts @@ -1,5 +1,6 @@ export interface AuthenticatedUser { id: string; - displayName: string; - emailAddress: string; + userName: string; + fullName?: string; + emailAddress?: string; } diff --git a/src/Turnierplan.App/Client/src/app/core/services/authentication.service.ts b/src/Turnierplan.App/Client/src/app/core/services/authentication.service.ts index f8402017..c11affbc 100644 --- a/src/Turnierplan.App/Client/src/app/core/services/authentication.service.ts +++ b/src/Turnierplan.App/Client/src/app/core/services/authentication.service.ts @@ -8,14 +8,14 @@ import { TurnierplanApi } from '../../api/turnierplan-api'; import { login } from '../../api/fn/identity/login'; import { NullableOfChangePasswordFailedReason } from '../../api/models/nullable-of-change-password-failed-reason'; import { changePassword } from '../../api/fn/identity/change-password'; -import { updateUserData } from '../../api/fn/identity/update-user-data'; import { refresh } from '../../api/fn/identity/refresh'; import { logout } from '../../api/fn/identity/logout'; interface TurnierplanAccessToken { exp: number; mail: string; - name: string; + userName: string; + fullName: string; adm?: string; uid: string; } @@ -29,11 +29,22 @@ interface TurnierplanRefreshToken { export class AuthenticationService implements OnDestroy { private static readonly localStorageUserIdKey = 'tp_id_userId'; private static readonly localStorageUserNameKey = 'tp_id_userName'; + private static readonly localStorageUserFullNameKey = 'tp_id_userFullName'; private static readonly localStorageUserEMailKey = 'tp_id_userEMail'; private static readonly localStorageUserAdministratorKey = 'tp_id_userAdmin'; private static readonly localStorageAccessTokenExpiryKey = 'tp_id_accTokenExp'; private static readonly localStorageRefreshTokenExpiryKey = 'tp_id_rfsTokenExp'; + private static readonly allLocalStorageKeys = [ + AuthenticationService.localStorageUserIdKey, + AuthenticationService.localStorageUserNameKey, + AuthenticationService.localStorageUserFullNameKey, + AuthenticationService.localStorageUserEMailKey, + AuthenticationService.localStorageUserAdministratorKey, + AuthenticationService.localStorageAccessTokenExpiryKey, + AuthenticationService.localStorageRefreshTokenExpiryKey + ]; + // Clock skew should be as short as possible, but still long enough that a request to the token // refresh endpoint can complete even in the case of a bad connection or unfavorable conditions. private static readonly tokenExpiryCheckClockSkewSeconds = 10; @@ -50,10 +61,16 @@ export class AuthenticationService implements OnDestroy { ) { const storedUserId = this.readUserIdFromLocalStorage(); const storedUserName = this.readUserNameFromLocalStorage(); + const storedUserFullName = this.readUserFullNameFromLocalStorage(); const storedUserEMail = this.readUserEMailFromLocalStorage(); - if (storedUserId && storedUserName && storedUserEMail) { - this.authentication$.next({ id: storedUserId, displayName: storedUserName, emailAddress: storedUserEMail }); + if (storedUserId && storedUserName) { + this.authentication$.next({ + id: storedUserId, + userName: storedUserName, + fullName: storedUserFullName, + emailAddress: storedUserEMail + }); } } @@ -62,8 +79,8 @@ export class AuthenticationService implements OnDestroy { this.destroyed$.complete(); } - public login(email: string, password: string): Observable<'success' | 'failure'> { - return this.turnierplanApi.invoke(login, { body: { eMail: email, password: password } }).pipe( + public login(userName: string, password: string): Observable<'success' | 'failure'> { + return this.turnierplanApi.invoke(login, { body: { userName: userName, password: password } }).pipe( catchError(() => of(undefined)), map((result) => { if (result && result.success && result.accessToken && result.refreshToken) { @@ -72,7 +89,8 @@ export class AuthenticationService implements OnDestroy { this.updateLocalStorageCache( decodedAccessToken.uid, - decodedAccessToken.name, + decodedAccessToken.userName, + decodedAccessToken.fullName, decodedAccessToken.mail, decodedAccessToken.adm === 'true', decodedAccessToken.exp, @@ -81,7 +99,8 @@ export class AuthenticationService implements OnDestroy { this.authentication$.next({ id: decodedAccessToken.uid, - displayName: decodedAccessToken.name, + userName: decodedAccessToken.userName, + fullName: decodedAccessToken.fullName, emailAddress: decodedAccessToken.mail }); @@ -97,10 +116,6 @@ export class AuthenticationService implements OnDestroy { this.logoutAndClearData(() => window.location.assign('/')).subscribe(); } - public openEditUserInfoForm(): void { - void this.router.navigate(['portal/user-info'], { queryParams: { redirect_to: this.router.url } }); - } - public openChangePasswordForm(): void { void this.router.navigate(['portal/change-password'], { queryParams: { redirect_to: this.router.url } }); } @@ -132,14 +147,14 @@ export class AuthenticationService implements OnDestroy { } public changePassword( - userEmail: string, + userName: string, newPassword: string, currentPassword: string ): Observable<'success' | 'failure' | NullableOfChangePasswordFailedReason> { return this.turnierplanApi .invoke(changePassword, { body: { - eMail: userEmail, + userName: userName, newPassword: newPassword, currentPassword: currentPassword } @@ -163,22 +178,6 @@ export class AuthenticationService implements OnDestroy { ); } - public changeUserInformation(userName: string, emailAddress: string): Observable<'success' | 'emailVerificationPending' | 'failure'> { - return this.turnierplanApi.invoke(updateUserData, { body: { userName: userName, eMail: emailAddress } }).pipe( - catchError(() => of(undefined)), - map((result) => { - if (result?.success !== true) { - return 'failure'; - } - - this.updateLocalStorageCacheUserInformationOnly(userName, emailAddress); - this.authentication$.next({ id: this.readUserIdFromLocalStorage()!, displayName: userName, emailAddress: emailAddress }); - - return 'success'; - }) - ); - } - private ensureAccessTokenUnprotected(): Observable { const accessTokenExpiry = this.readAccessTokenExpiryFromLocalStorage(); @@ -189,9 +188,12 @@ export class AuthenticationService implements OnDestroy { } const logoutWithRedirect = (): Observable => { + // Store userName in variable because logoutAndClearData() removes it from local storage + const userName = this.readUserNameFromLocalStorage(); + return this.logoutAndClearData(() => { void this.router.navigate(['/portal/login'], { - queryParams: { redirect_to: this.router.url, email: this.readUserEMailFromLocalStorage() } + queryParams: { redirect_to: this.router.url, user_name: userName } }); }).pipe(map(() => false)); }; @@ -212,7 +214,8 @@ export class AuthenticationService implements OnDestroy { this.updateLocalStorageCache( decodedAccessToken.uid, - decodedAccessToken.name, + decodedAccessToken.userName, + decodedAccessToken.fullName, decodedAccessToken.mail, decodedAccessToken.adm === 'true', decodedAccessToken.exp, @@ -221,7 +224,8 @@ export class AuthenticationService implements OnDestroy { this.authentication$.next({ id: decodedAccessToken.uid, - displayName: decodedAccessToken.name, + userName: decodedAccessToken.userName, + fullName: decodedAccessToken.fullName, emailAddress: decodedAccessToken.mail }); @@ -269,6 +273,10 @@ export class AuthenticationService implements OnDestroy { return localStorage.getItem(AuthenticationService.localStorageUserNameKey) ?? undefined; } + private readUserFullNameFromLocalStorage(): string | undefined { + return localStorage.getItem(AuthenticationService.localStorageUserFullNameKey) ?? undefined; + } + private readUserEMailFromLocalStorage(): string | undefined { return localStorage.getItem(AuthenticationService.localStorageUserEMailKey) ?? undefined; } @@ -292,25 +300,22 @@ export class AuthenticationService implements OnDestroy { private updateLocalStorageCache( userId: string, userName: string, - userEMail: string, + userFullName: string | undefined, + userEMail: string | undefined, userIsAdmin: boolean, accessTokenExpiry: number, refreshTokenExpiry: number ): void { localStorage.setItem(AuthenticationService.localStorageUserIdKey, userId); localStorage.setItem(AuthenticationService.localStorageUserNameKey, userName); - localStorage.setItem(AuthenticationService.localStorageUserEMailKey, userEMail); + localStorage.setItem(AuthenticationService.localStorageUserFullNameKey, userFullName ?? ''); + localStorage.setItem(AuthenticationService.localStorageUserEMailKey, userEMail ?? ''); localStorage.setItem(AuthenticationService.localStorageUserAdministratorKey, `${userIsAdmin}`); localStorage.setItem(AuthenticationService.localStorageAccessTokenExpiryKey, `${accessTokenExpiry}`); localStorage.setItem(AuthenticationService.localStorageRefreshTokenExpiryKey, `${refreshTokenExpiry}`); } - private updateLocalStorageCacheUserInformationOnly(userName: string, userEMail: string): void { - localStorage.setItem(AuthenticationService.localStorageUserNameKey, userName); - localStorage.setItem(AuthenticationService.localStorageUserEMailKey, userEMail); - } - private updateLocalStorageCacheRefreshTokenExpiryOnly(refreshTokenExpiry: number): void { localStorage.setItem(AuthenticationService.localStorageRefreshTokenExpiryKey, `${refreshTokenExpiry}`); } @@ -318,12 +323,7 @@ export class AuthenticationService implements OnDestroy { private logoutAndClearData(navigateTo?: () => void): Observable { const logout$ = this.turnierplanApi.invoke(logout).pipe( catchError(() => of(undefined)), - tap(() => { - localStorage.removeItem(AuthenticationService.localStorageUserNameKey); - localStorage.removeItem(AuthenticationService.localStorageUserEMailKey); - localStorage.removeItem(AuthenticationService.localStorageAccessTokenExpiryKey); - localStorage.removeItem(AuthenticationService.localStorageRefreshTokenExpiryKey); - }), + tap(() => AuthenticationService.allLocalStorageKeys.forEach((key) => localStorage.removeItem(key))), map(() => void 0) ); diff --git a/src/Turnierplan.App/Client/src/app/i18n/de.ts b/src/Turnierplan.App/Client/src/app/i18n/de.ts index 588c4605..3c92f15c 100644 --- a/src/Turnierplan.App/Client/src/app/i18n/de.ts +++ b/src/Turnierplan.App/Client/src/app/i18n/de.ts @@ -25,7 +25,7 @@ export const de = { EmptyOrExtraWhitespace: 'Geben Sie ein neues Passwort an, welches keine Leerzeichen am Anfang oder Ende hat.', PasswordsDoNotMatch: 'Die beiden Passwörter stimmen nicht überein.', InsecurePassword: - 'Das neue Passwort muss folgende Kriterien erfüllen:
  • min. 10 Zeichen lang
  • min. 1 Großbuchstabe
  • min. 1 Kleinbuchstabe
  • min. 1 Ziffer
  • min. 1 Sonderzeichen
', + 'Das neue Passwort muss folgende Kriterien erfüllen:
  • min. 8 Zeichen lang
  • min. 1 Buchstabe
  • min. 1 Ziffer
  • min. 1 Sonderzeichen
', InvalidCredentials: 'Das aktuelle Passwort ist falsch.', NewPasswordEqualsCurrent: 'Das neue Passwort darf nicht dem bisherigen Passwort entsprechen.', UnexpectedError: 'Bei der Bearbeitung der Anfrage ist ein Fehler aufgetreten.' @@ -37,27 +37,9 @@ export const de = { Back: 'Abbrechen', Submit: 'Speichern' }, - ChangeUserInfo: { - Title: 'Benutzerprofil', - UserName: 'Nutzername:', - EMail: 'E-Mail Adresse:', - ChangeEmailNotice: 'Wenn Sie Ihre E-Mail Adresse ändern, müssen Sie sich ab sofort mit dieser neuen E-Mail Adresse anmelden.', - RequestFailed: 'Bei der Bearbeitung der Anfrage ist ein Fehler aufgetreten.', - SuccessToast: { - Title: 'Nutzerdaten aktualisiert', - Message: 'Ihr Benutzerinformationen wurden gespeichert.' - }, - EmailVerificationPendingToast: { - Title: 'E-Mail muss bestätigt werden', - Message: - 'Ihr Benutzerinformationen wurden gespeichert. Klicken Sie auf den Link in der Mail, welche wir Ihnen geschickt haben, um die neue E-Mail zu bestätigen.' - }, - Back: 'Abbrechen', - Submit: 'Speichern' - }, Login: { Title: 'Anmelden', - EMail: 'E-Mail Adresse:', + UserName: 'Benutzername:', Password: 'Passwort:', CookieNotice: 'Wenn Sie sich anmelden, wird ihre aktive Sitzung in einem Cookie gespeichert.', Submit: 'Anmelden', @@ -85,7 +67,6 @@ export const de = { UserInfoPopover: { Text: 'Sie sind angemeldet als:\n{{userName}}', Administration: 'Administration', - EditUserInfo: 'Benutzerinformation', ChangePassword: 'Passwort ändern', Logout: 'Abmelden' }, @@ -117,8 +98,8 @@ export const de = { NewUser: 'Neuer Benutzer', Users: { TableLabel: 'Benutzer in dieser turnierplan.NET-Instanz', - Id: 'ID', - Name: 'Name', + UserName: 'Benutzername', + FullName: 'Name', EMail: 'E-Mail', CreatedAt: 'Erstellt am', LastPasswordChange: 'Letzte Passwortänderung', @@ -127,8 +108,9 @@ export const de = { EditUser: { Title: 'Benutzer bearbeiten', Info: 'Ändern Sie die Informationen eines bestehenden Benutzers. Beachten Sie, dass der betroffene Nutzer diese Änderungen unter Umständen nicht direkt sieht.', - Name: 'Name:', - NameInvalid: 'Der Name eines neuen Nutzers darf nicht leer sein.', + UserName: 'Benutzername:', + UserNameInvalid: 'Der Benutzername eines Nutzers darf nicht leer sein.', + FullName: 'Name:', Email: 'E-Mailadresse', EmailInvalid: 'Die eingegebene E-Mailadresse ist ungültig.', IsAdministrator: 'Administrator', @@ -155,14 +137,15 @@ export const de = { Title: 'Neuen Benutzer', LongTitle: 'Neuen Benutzer erstellen', Form: { - UserName: 'Name', + UserName: 'Benutzername:', UserNameInvalid: 'Der Name eines neuen Nutzers darf nicht leer sein.', - Email: 'E-Mailadresse', + FullName: 'Name:', + Email: 'E-Mailadresse:', EmailInvalid: 'Die eingegebene E-Mailadresse ist ungültig.', - Password: 'Passwort', + Password: 'Passwort:', PasswordInvalid: 'Das eingegebene Passwort ist ungültig.' }, - UserNotice: 'Der erstellte Nutzer kann sich unmittelbar danach mit E-Mail und Passwort anmelden.', + UserNotice: 'Der erstellte Nutzer kann sich unmittelbar danach mit Benutzername und Passwort anmelden.', Submit: 'Erstellen' }, CreateOrganization: { @@ -1247,7 +1230,7 @@ export const de = { SelectedRole: 'Gewählte Rolle:', SearchPrincipalPlaceholder: { ApiKey: 'ID des API-Schlüssels eingeben', - User: 'E-Mailadresse des Nutzers eingeben' + User: 'Benutzername oder E-Mailadresse des Nutzers eingeben' }, SearchPrincipalButton: 'Suchen & hinzufügen', CreatingRoleAssignment: 'Die Rollenzuweisung wird erstellt', diff --git a/src/Turnierplan.App/Client/src/app/identity/identity.routes.ts b/src/Turnierplan.App/Client/src/app/identity/identity.routes.ts index 09d657fa..6634c8da 100644 --- a/src/Turnierplan.App/Client/src/app/identity/identity.routes.ts +++ b/src/Turnierplan.App/Client/src/app/identity/identity.routes.ts @@ -2,7 +2,6 @@ import { Routes } from '@angular/router'; import { IdentityComponent } from './identity.component'; import { ChangePasswordComponent } from './pages/change-password/change-password.component'; import { LoginComponent } from './pages/login/login.component'; -import { ChangeUserInfoComponent } from './pages/change-user-info/change-user-info.component'; export const identityRoutes: Routes = [ { @@ -16,10 +15,6 @@ export const identityRoutes: Routes = [ { path: 'login', component: LoginComponent - }, - { - path: 'user-info', - component: ChangeUserInfoComponent } ] } diff --git a/src/Turnierplan.App/Client/src/app/identity/pages/change-password/change-password.component.ts b/src/Turnierplan.App/Client/src/app/identity/pages/change-password/change-password.component.ts index 6c3e890d..d7838c70 100644 --- a/src/Turnierplan.App/Client/src/app/identity/pages/change-password/change-password.component.ts +++ b/src/Turnierplan.App/Client/src/app/identity/pages/change-password/change-password.component.ts @@ -82,7 +82,7 @@ export class ChangePasswordComponent implements OnInit, OnDestroy { .pipe( take(1), switchMap((user) => { - return this.authenticationService.changePassword(user.emailAddress, this.newPassword, this.oldPassword); + return this.authenticationService.changePassword(user.userName, this.newPassword, this.oldPassword); }) ) .subscribe((result) => { diff --git a/src/Turnierplan.App/Client/src/app/identity/pages/change-user-info/change-user-info.component.html b/src/Turnierplan.App/Client/src/app/identity/pages/change-user-info/change-user-info.component.html deleted file mode 100644 index 74066c29..00000000 --- a/src/Turnierplan.App/Client/src/app/identity/pages/change-user-info/change-user-info.component.html +++ /dev/null @@ -1,35 +0,0 @@ -
- -
- - -
- -
- - -
- -@if (emailAddress !== previousEmailAddress) { -
- - -
-} - -
- -
- @if (isLoading) { - - } - -
- -@if (!isLoading && isRequestFailed) { -
-} diff --git a/src/Turnierplan.App/Client/src/app/identity/pages/change-user-info/change-user-info.component.ts b/src/Turnierplan.App/Client/src/app/identity/pages/change-user-info/change-user-info.component.ts deleted file mode 100644 index 2cc71ff7..00000000 --- a/src/Turnierplan.App/Client/src/app/identity/pages/change-user-info/change-user-info.component.ts +++ /dev/null @@ -1,91 +0,0 @@ -import { Component, OnDestroy, OnInit } from '@angular/core'; -import { ActivatedRoute, Router } from '@angular/router'; -import { Subject, take, takeUntil } from 'rxjs'; - -import { AuthenticationService } from '../../../core/services/authentication.service'; -import { NotificationService } from '../../../core/services/notification.service'; -import { TranslateDirective } from '@ngx-translate/core'; -import { FormsModule } from '@angular/forms'; -import { SmallSpinnerComponent } from '../../../core/components/small-spinner/small-spinner.component'; -import { NgClass } from '@angular/common'; - -@Component({ - templateUrl: './change-user-info.component.html', - imports: [TranslateDirective, FormsModule, SmallSpinnerComponent, NgClass] -}) -export class ChangeUserInfoComponent implements OnInit, OnDestroy { - protected previousEmailAddress: string = ''; - - protected userName: string = ''; - protected emailAddress: string = ''; - - protected isLoading = false; - protected isRequestFailed = false; - protected readonly history = history; - - private redirectTarget = '/portal'; - private readonly destroyed$ = new Subject(); - - constructor( - private readonly authenticationService: AuthenticationService, - private readonly notificationService: NotificationService, - private readonly router: Router, - private readonly route: ActivatedRoute - ) {} - - public ngOnInit(): void { - this.route.queryParamMap.pipe(takeUntil(this.destroyed$)).subscribe((params) => { - this.redirectTarget = params.get('redirect_to') ?? '/portal'; - }); - - this.authenticationService.authentication$.pipe(take(1)).subscribe((result) => { - this.userName = result.displayName; - this.previousEmailAddress = result.emailAddress; - this.emailAddress = result.emailAddress; - }); - } - - public ngOnDestroy(): void { - this.destroyed$.next(); - this.destroyed$.complete(); - } - - protected submit(): void { - if (this.isLoading) { - return; - } - - this.isLoading = true; - - this.authenticationService.changeUserInformation(this.userName, this.emailAddress).subscribe({ - next: (result) => { - switch (result) { - case 'success': - this.notificationService.showNotification( - 'success', - 'Identity.ChangeUserInfo.SuccessToast.Title', - 'Identity.ChangeUserInfo.SuccessToast.Message' - ); - - break; - case 'emailVerificationPending': - this.notificationService.showNotification( - 'success', - 'Identity.ChangeUserInfo.EmailVerificationPendingToast.Title', - 'Identity.ChangeUserInfo.EmailVerificationPendingToast.Message', - 60000 - ); - - break; - case 'failure': - this.isRequestFailed = true; - this.isLoading = false; - - return; - } - - void this.router.navigate([this.redirectTarget]); - } - }); - } -} diff --git a/src/Turnierplan.App/Client/src/app/identity/pages/login/login.component.html b/src/Turnierplan.App/Client/src/app/identity/pages/login/login.component.html index 72a8b3c4..0c04ecad 100644 --- a/src/Turnierplan.App/Client/src/app/identity/pages/login/login.component.html +++ b/src/Turnierplan.App/Client/src/app/identity/pages/login/login.component.html @@ -1,13 +1,13 @@
- +
diff --git a/src/Turnierplan.App/Client/src/app/identity/pages/login/login.component.ts b/src/Turnierplan.App/Client/src/app/identity/pages/login/login.component.ts index 2a04098e..e8fd338e 100644 --- a/src/Turnierplan.App/Client/src/app/identity/pages/login/login.component.ts +++ b/src/Turnierplan.App/Client/src/app/identity/pages/login/login.component.ts @@ -14,7 +14,7 @@ import { E2eDirective } from '../../../core/directives/e2e.directive'; imports: [TranslateDirective, FormsModule, SmallSpinnerComponent, NgClass, E2eDirective] }) export class LoginComponent implements OnInit, OnDestroy { - protected email: string = ''; + protected userName: string = ''; protected password: string = ''; protected isLoading = false; @@ -35,7 +35,7 @@ export class LoginComponent implements OnInit, OnDestroy { } else { this.route.queryParamMap.pipe(takeUntil(this.destroyed$)).subscribe((params) => { this.redirectTarget = params.get('redirect_to') ?? '/portal'; - this.email = params.get('email') ?? this.email; + this.userName = params.get('user_name') ?? params.get('email') ?? ''; }); } } @@ -46,13 +46,13 @@ export class LoginComponent implements OnInit, OnDestroy { } protected attemptLogin(): void { - if (this.isLoading || this.email.trim().length === 0 || this.password.length === 0) { + if (this.isLoading || this.userName.trim().length === 0 || this.password.length === 0) { return; } this.isLoading = true; - this.authenticationService.login(this.email, this.password).subscribe((result) => { + this.authenticationService.login(this.userName, this.password).subscribe((result) => { switch (result) { case 'success': void this.router.navigate([this.redirectTarget]); diff --git a/src/Turnierplan.App/Client/src/app/portal/components/rbac-add-assignment/rbac-add-assignment.component.ts b/src/Turnierplan.App/Client/src/app/portal/components/rbac-add-assignment/rbac-add-assignment.component.ts index 7f2f7a3c..de834abb 100644 --- a/src/Turnierplan.App/Client/src/app/portal/components/rbac-add-assignment/rbac-add-assignment.component.ts +++ b/src/Turnierplan.App/Client/src/app/portal/components/rbac-add-assignment/rbac-add-assignment.component.ts @@ -79,7 +79,7 @@ export class RbacAddAssignmentComponent implements OnDestroy { scopeId: this.targetScopeId, role: this.selectedRole, apiKeyId: this.selectedPrincipalKind === PrincipalKind.ApiKey ? this.searchPrincipalInput.trim() : null, - userEmail: this.selectedPrincipalKind === PrincipalKind.User ? this.searchPrincipalInput.trim() : null + userNameOrEmail: this.selectedPrincipalKind === PrincipalKind.User ? this.searchPrincipalInput.trim() : null }; this.turnierplanApi diff --git a/src/Turnierplan.App/Client/src/app/portal/pages/administration-page/administration-page.component.html b/src/Turnierplan.App/Client/src/app/portal/pages/administration-page/administration-page.component.html index dda94487..9d6f29c6 100644 --- a/src/Turnierplan.App/Client/src/app/portal/pages/administration-page/administration-page.component.html +++ b/src/Turnierplan.App/Client/src/app/portal/pages/administration-page/administration-page.component.html @@ -16,8 +16,8 @@ - - + + @@ -29,9 +29,9 @@ @for (user of users; track user) { @let isCurrentUser = user.id === currentUserId; - - - + + + @@ -74,14 +75,26 @@
@let userNameControl = editUserForm.get('userName')!; - + -
+
+
+ +
+ @let fullNameControl = editUserForm.get('fullName')!; + + +
@@ -154,7 +167,7 @@

diff --git a/src/Turnierplan.App/Client/src/app/portal/pages/administration-page/administration-page.component.ts b/src/Turnierplan.App/Client/src/app/portal/pages/administration-page/administration-page.component.ts index 12f4e350..4b1e5e46 100644 --- a/src/Turnierplan.App/Client/src/app/portal/pages/administration-page/administration-page.component.ts +++ b/src/Turnierplan.App/Client/src/app/portal/pages/administration-page/administration-page.component.ts @@ -53,7 +53,8 @@ export class AdministrationPageComponent implements OnInit { protected editUserForm = new FormGroup({ userName: new FormControl('', { nonNullable: true, validators: [Validators.required] }), - eMail: new FormControl('', { nonNullable: true, validators: [Validators.required, Validators.email] }), + fullName: new FormControl('', { nonNullable: false }), + eMail: new FormControl('', { nonNullable: false, validators: [Validators.email] }), isAdministrator: new FormControl(false, { nonNullable: true }), updatePassword: new FormControl(false, { nonNullable: true }), password: new FormControl('', { validators: [Validators.required] }) @@ -104,16 +105,13 @@ export class AdministrationPageComponent implements OnInit { } protected editButtonClicked(id: string, template: TemplateRef): void { - if (id === this.currentUserId) { - return; - } - this.userSelectedForEditing = this.users.find((x) => x.id === id); if (this.userSelectedForEditing) { this.editUserForm.setValue({ - userName: this.userSelectedForEditing.name, - eMail: this.userSelectedForEditing.eMail, + userName: this.userSelectedForEditing.userName, + fullName: this.userSelectedForEditing.fullName ?? '', + eMail: this.userSelectedForEditing.eMail ?? '', isAdministrator: this.userSelectedForEditing.isAdministrator, updatePassword: false, password: '' @@ -122,6 +120,12 @@ export class AdministrationPageComponent implements OnInit { this.editUserForm.get('password')!.disable(); this.editUserForm.markAsPristine({ onlySelf: false }); + if (id === this.currentUserId) { + this.editUserForm.get('isAdministrator')!.disable(); + } else { + this.editUserForm.get('isAdministrator')!.enable(); + } + this.currentOffcanvas = this.offcanvasService.open(template, { position: 'end' }); } } @@ -145,7 +149,8 @@ export class AdministrationPageComponent implements OnInit { const formValue = this.editUserForm.getRawValue(); const request: UpdateUserEndpointRequest = { userName: formValue.userName, - eMail: formValue.eMail, + fullName: (formValue.fullName ?? '').trim().length > 0 ? formValue.fullName : null, + eMail: (formValue.eMail ?? '').trim().length > 0 ? formValue.eMail : null, isAdministrator: formValue.isAdministrator, updatePassword: formValue.updatePassword, password: formValue.updatePassword ? formValue.password : undefined diff --git a/src/Turnierplan.App/Client/src/app/portal/pages/create-user/create-user.component.html b/src/Turnierplan.App/Client/src/app/portal/pages/create-user/create-user.component.html index 8022ab23..9fd7615d 100644 --- a/src/Turnierplan.App/Client/src/app/portal/pages/create-user/create-user.component.html +++ b/src/Turnierplan.App/Client/src/app/portal/pages/create-user/create-user.component.html @@ -13,6 +13,17 @@
+
+ + +
+
+
diff --git a/src/Turnierplan.App/Client/src/app/portal/pages/create-user/create-user.component.ts b/src/Turnierplan.App/Client/src/app/portal/pages/create-user/create-user.component.ts index 253774bc..5db79dc2 100644 --- a/src/Turnierplan.App/Client/src/app/portal/pages/create-user/create-user.component.ts +++ b/src/Turnierplan.App/Client/src/app/portal/pages/create-user/create-user.component.ts @@ -11,6 +11,7 @@ import { NgClass } from '@angular/common'; import { ActionButtonComponent } from '../../components/action-button/action-button.component'; import { TurnierplanApi } from '../../../api/turnierplan-api'; import { createUser } from '../../../api/fn/users/create-user'; +import { CreateUserEndpointRequest } from '../../../api/models/create-user-endpoint-request'; @Component({ templateUrl: './create-user.component.html', @@ -30,7 +31,8 @@ export class CreateUserComponent implements OnInit { protected form = new FormGroup({ userName: new FormControl('', { nonNullable: true, validators: [Validators.required] }), - eMail: new FormControl('', { nonNullable: true, validators: [Validators.required, Validators.email] }), + fullName: new FormControl('', { nonNullable: false }), + eMail: new FormControl('', { nonNullable: false, validators: [Validators.email] }), password: new FormControl('', { nonNullable: true, validators: [Validators.required] }) }); @@ -45,6 +47,10 @@ export class CreateUserComponent implements OnInit { return this.form.get('userName')!; } + protected get fullNameControl(): AbstractControl { + return this.form.get('fullName')!; + } + protected get eMailControl(): AbstractControl { return this.form.get('eMail')!; } @@ -60,8 +66,17 @@ export class CreateUserComponent implements OnInit { protected confirmButtonClicked(): void { if (this.form.valid && !this.loadingState.isLoading) { this.loadingState = { isLoading: true }; + + const formValue = this.form.getRawValue(); + const body: CreateUserEndpointRequest = { + userName: formValue.userName, + fullName: (formValue.fullName ?? '').trim().length > 0 ? formValue.fullName : null, + eMail: (formValue.eMail ?? '').trim().length > 0 ? formValue.eMail : null, + password: formValue.password + }; + this.turnierplanApi - .invoke(createUser, { body: this.form.getRawValue() }) + .invoke(createUser, { body: body }) .pipe(switchMap(() => from(this.router.navigate(['../..'], { relativeTo: this.route })))) .subscribe({ next: () => { diff --git a/src/Turnierplan.App/Client/src/app/portal/portal.component.html b/src/Turnierplan.App/Client/src/app/portal/portal.component.html index 6b16a1a2..da0c4c84 100644 --- a/src/Turnierplan.App/Client/src/app/portal/portal.component.html +++ b/src/Turnierplan.App/Client/src/app/portal/portal.component.html @@ -1,5 +1,5 @@
-
+
@@ -8,22 +8,34 @@ @if (currentUser) {
- - - + +
diff --git a/src/Turnierplan.App/Client/src/app/portal/portal.component.ts b/src/Turnierplan.App/Client/src/app/portal/portal.component.ts index 80a069eb..ced6475c 100644 --- a/src/Turnierplan.App/Client/src/app/portal/portal.component.ts +++ b/src/Turnierplan.App/Client/src/app/portal/portal.component.ts @@ -19,7 +19,7 @@ import { E2eDirective } from '../core/directives/e2e.directive'; echarts.use([BarChart, GridComponent, CanvasRenderer, TooltipComponent]); -type UserInfoAction = 'EditUserInfo' | 'ChangePassword' | 'Logout'; +type UserInfoAction = 'ChangePassword' | 'Logout'; @Component({ templateUrl: './portal.component.html', @@ -83,8 +83,6 @@ export class PortalComponent implements OnInit, OnDestroy { protected getUserInfoActionIcon(action: UserInfoAction): string { switch (action) { - case 'EditUserInfo': - return 'bi-person-vcard'; case 'ChangePassword': return 'bi-key'; case 'Logout': @@ -94,9 +92,6 @@ export class PortalComponent implements OnInit, OnDestroy { protected userInfoActionClicked(action: UserInfoAction): void { switch (action) { - case 'EditUserInfo': - this.authenticationService.openEditUserInfoForm(); - break; case 'ChangePassword': this.authenticationService.openChangePasswordForm(); break; diff --git a/src/Turnierplan.App/Endpoints/Identity/ChangePasswordEndpoint.cs b/src/Turnierplan.App/Endpoints/Identity/ChangePasswordEndpoint.cs index d052f8ba..47546701 100644 --- a/src/Turnierplan.App/Endpoints/Identity/ChangePasswordEndpoint.cs +++ b/src/Turnierplan.App/Endpoints/Identity/ChangePasswordEndpoint.cs @@ -55,7 +55,7 @@ private async Task Handle( }); } - var user = await userRepository.GetByEmailAsync(request.EMail); + var user = await userRepository.GetByUserNameAsync(request.UserName); if (user is null) { @@ -95,7 +95,7 @@ private async Task Handle( public sealed record ChangePasswordEndpointRequest { - public required string EMail { get; init; } + public required string UserName { get; init; } public required string CurrentPassword { get; init; } @@ -124,9 +124,8 @@ private sealed class Validator : AbstractValidator x.EMail) - .NotEmpty() - .EmailAddress(); + RuleFor(x => x.UserName) + .NotEmpty(); RuleFor(x => x.CurrentPassword) .NotEmpty(); diff --git a/src/Turnierplan.App/Endpoints/Identity/IdentityEndpointBase.cs b/src/Turnierplan.App/Endpoints/Identity/IdentityEndpointBase.cs index 4abdd0ca..e387bf25 100644 --- a/src/Turnierplan.App/Endpoints/Identity/IdentityEndpointBase.cs +++ b/src/Turnierplan.App/Endpoints/Identity/IdentityEndpointBase.cs @@ -34,12 +34,21 @@ protected string CreateTokenForUser(User user, bool isRefreshToken) else { claims.Add(new Claim(ClaimTypes.TokenType, JwtTokenTypes.Access)); - claims.Add(new Claim(ClaimTypes.DisplayName, user.Name)); - claims.Add(new Claim(ClaimTypes.EMailAddress, user.EMail)); claims.Add(new Claim(ClaimTypes.UserId, user.Id.ToString())); + claims.Add(new Claim(ClaimTypes.UserName, user.UserName)); claims.Add(new Claim(ClaimTypes.PrincipalId, user.PrincipalId.ToString())); claims.Add(new Claim(ClaimTypes.PrincipalKind, nameof(PrincipalKind.User))); + if (!string.IsNullOrWhiteSpace(user.FullName)) + { + claims.Add(new Claim(ClaimTypes.FullName, user.FullName)); + } + + if (!string.IsNullOrWhiteSpace(user.EMail)) + { + claims.Add(new Claim(ClaimTypes.EMailAddress, user.EMail)); + } + if (user.IsAdministrator) { claims.Add(new Claim(ClaimTypes.Administrator, "true")); @@ -86,9 +95,8 @@ void AddCookie(string path) /// Password is considered secure only if all the following conditions are met: ///
    ///
  • Does not start or end with white-space
  • - ///
  • Length is at least 10 characters
  • - ///
  • Contains at least one upper-case ASCII letter
  • - ///
  • Contains at least one lower-case ASCII letter
  • + ///
  • Length is at least 8 characters
  • + ///
  • Contains at least one letter
  • ///
  • Contains at least one digit
  • ///
  • Contains at least one punctuation or symbol character
  • ///
@@ -99,17 +107,16 @@ protected static bool IsPasswordInsecure(string password) password = password.Trim(); - if (lengthBeforeTrim != password.Length || password.Length < 10) + if (lengthBeforeTrim != password.Length || password.Length < 8) { return true; } - var upper = password.Count(char.IsAsciiLetterUpper); - var lower = password.Count(char.IsAsciiLetterLower); + var letter = password.Count(char.IsAsciiLetter); var digits = password.Count(char.IsDigit); var punctuation = password.Count(char.IsPunctuation); var symbols = password.Count(char.IsSymbol); - return upper == 0 || lower == 0 || digits == 0 || (punctuation == 0 && symbols == 0); + return letter == 0 || digits == 0 || (punctuation == 0 && symbols == 0); } } diff --git a/src/Turnierplan.App/Endpoints/Identity/LoginEndpoint.cs b/src/Turnierplan.App/Endpoints/Identity/LoginEndpoint.cs index 4905f99a..e2fafc75 100644 --- a/src/Turnierplan.App/Endpoints/Identity/LoginEndpoint.cs +++ b/src/Turnierplan.App/Endpoints/Identity/LoginEndpoint.cs @@ -40,7 +40,7 @@ private async Task Handle( return result; } - var user = await userRepository.GetByEmailAsync(request.EMail); + var user = await userRepository.GetByUserNameAsync(request.UserName); if (user is null) { @@ -80,7 +80,7 @@ private async Task Handle( public sealed record LoginEndpointRequest { - public required string EMail { get; init; } + public required string UserName { get; init; } public required string Password { get; init; } } @@ -100,9 +100,8 @@ private sealed class Validator : AbstractValidator private Validator() { - RuleFor(x => x.EMail) - .NotEmpty() - .EmailAddress(); + RuleFor(x => x.UserName) + .NotEmpty(); RuleFor(x => x.Password) .NotEmpty(); diff --git a/src/Turnierplan.App/Endpoints/Identity/UpdateUserDataEndpoint.cs b/src/Turnierplan.App/Endpoints/Identity/UpdateUserDataEndpoint.cs deleted file mode 100644 index 9c4b3420..00000000 --- a/src/Turnierplan.App/Endpoints/Identity/UpdateUserDataEndpoint.cs +++ /dev/null @@ -1,104 +0,0 @@ -using FluentValidation; -using Microsoft.AspNetCore.Mvc; -using Microsoft.Extensions.Options; -using Turnierplan.App.Extensions; -using Turnierplan.App.Security; -using Turnierplan.Core.User; -using IdentityOptions = Turnierplan.App.Options.IdentityOptions; - -namespace Turnierplan.App.Endpoints.Identity; - -internal sealed class UpdateUserDataEndpoint : IdentityEndpointBase -{ - public UpdateUserDataEndpoint(IOptionsMonitor options, ISigningKeyProvider signingKeyProvider) - : base(options, signingKeyProvider) - { - } - - protected override HttpMethod Method => HttpMethod.Post; - - protected override string Route => "/api/identity/user-data"; - - protected override Delegate Handler => Handle; - - private async Task Handle( - [FromBody] UpdateUserDataEndpointRequest request, - HttpContext context, - IUserRepository userRepository, - CancellationToken cancellationToken) - { - if (!Validator.Instance.ValidateAndGetResult(request, out var result)) - { - return result; - } - - var user = await userRepository.GetByIdAsync(context.GetCurrentUserIdOrThrow()); - - if (user is null) - { - return Results.Unauthorized(); - } - - user.Name = request.UserName; - - if (!user.NormalizedEMail.Equals(User.NormalizeEmail(request.EMail))) - { - // Check if the email address is already taken - - var existingUser = await userRepository.GetByEmailAsync(request.EMail); - - if (existingUser is not null) - { - return Results.Ok(new UpdateUserDataEndpointResponse - { - Success = false - }); - } - - user.UpdateEmail(request.EMail); - } - - await userRepository.UnitOfWork.SaveChangesAsync(cancellationToken); - - // Give the user a new - // - access token which includes the updated username & email claims - // - refresh token because the one he currently holds is invalidated due to the updated security stamp - var accessToken = CreateTokenForUser(user, false); - var refreshToken = CreateTokenForUser(user, true); - - AddResponseCookieForToken(context, accessToken, false); - AddResponseCookieForToken(context, refreshToken, true); - - return Results.Ok(new UpdateUserDataEndpointResponse - { - Success = true - }); - } - - public sealed record UpdateUserDataEndpointRequest - { - public required string UserName { get; init; } - - public required string EMail { get; init; } - } - - public sealed record UpdateUserDataEndpointResponse - { - public required bool Success { get; init; } - } - - private sealed class Validator : AbstractValidator - { - public static readonly Validator Instance = new(); - - private Validator() - { - RuleFor(x => x.UserName) - .NotEmpty(); - - RuleFor(x => x.EMail) - .NotEmpty() - .EmailAddress(); - } - } -} diff --git a/src/Turnierplan.App/Endpoints/Principals/GetPrincipalNameEndpoint.cs b/src/Turnierplan.App/Endpoints/Principals/GetPrincipalNameEndpoint.cs index 3caac4b6..5244094c 100644 --- a/src/Turnierplan.App/Endpoints/Principals/GetPrincipalNameEndpoint.cs +++ b/src/Turnierplan.App/Endpoints/Principals/GetPrincipalNameEndpoint.cs @@ -29,7 +29,7 @@ private static async Task Handle( break; case PrincipalKind.User: var user = await userRepository.GetByPrincipalIdAsync(principalId); - name = user?.Name; + name = user?.UserName; break; default: return Results.BadRequest("Invalid principal kind specified."); diff --git a/src/Turnierplan.App/Endpoints/RoleAssignments/CreateRoleAssignmentEndpoint.cs b/src/Turnierplan.App/Endpoints/RoleAssignments/CreateRoleAssignmentEndpoint.cs index bcbd7774..cdcb2f76 100644 --- a/src/Turnierplan.App/Endpoints/RoleAssignments/CreateRoleAssignmentEndpoint.cs +++ b/src/Turnierplan.App/Endpoints/RoleAssignments/CreateRoleAssignmentEndpoint.cs @@ -126,9 +126,9 @@ private static async Task CreateRoleAssignmentAsync( return apiKey?.AsPrincipal(); } - if (request.UserEmail is not null) + if (request.UserNameOrEmail is not null) { - var user = await userRepository.GetByEmailAsync(request.UserEmail); + var user = await userRepository.GetByUserNameOrEmailAsync(request.UserNameOrEmail); return user?.AsPrincipal(); } @@ -144,7 +144,7 @@ public sealed record CreateRoleAssignmentEndpointRequest public required PublicId? ApiKeyId { get; init; } - public required string? UserEmail { get; init; } + public required string? UserNameOrEmail { get; init; } } private sealed class Validator : AbstractValidator @@ -160,8 +160,8 @@ private Validator() .IsInEnum(); RuleFor(x => x) - .Must(x => x.ApiKeyId is null ^ x.UserEmail is null) - .WithMessage($"Exactly only one of {nameof(CreateRoleAssignmentEndpointRequest.ApiKeyId)} and {nameof(CreateRoleAssignmentEndpointRequest.UserEmail)} must be specified."); + .Must(x => x.ApiKeyId is null ^ x.UserNameOrEmail is null) + .WithMessage($"Exactly only one of {nameof(CreateRoleAssignmentEndpointRequest.ApiKeyId)} and {nameof(CreateRoleAssignmentEndpointRequest.UserNameOrEmail)} must be specified."); } } } diff --git a/src/Turnierplan.App/Endpoints/Users/CreateUserEndpoint.cs b/src/Turnierplan.App/Endpoints/Users/CreateUserEndpoint.cs index 4142544d..2e00fb77 100644 --- a/src/Turnierplan.App/Endpoints/Users/CreateUserEndpoint.cs +++ b/src/Turnierplan.App/Endpoints/Users/CreateUserEndpoint.cs @@ -30,14 +30,28 @@ private static async Task Handle( return result; } - var existingUser = await repository.GetByEmailAsync(request.EMail); + if (await repository.GetByUserNameAsync(request.UserName) is not null) + { + return Results.BadRequest("The specified user name is already taken."); + } - if (existingUser is not null) + if (!string.IsNullOrWhiteSpace(request.EMail)) { - return Results.BadRequest("The specified email address is already taken."); + if (await repository.GetByEmailAsync(request.EMail) is not null) + { + return Results.BadRequest("The specified email address is already taken."); + } } - var user = new User(request.UserName, request.EMail); + var user = new User(request.UserName.Trim()) + { + FullName = request.FullName?.Trim() + }; + + if (request.EMail is not null) + { + user.SetEmailAddress(request.EMail); + } user.UpdatePassword(passwordHasher.HashPassword(user, request.Password)); @@ -51,7 +65,9 @@ public sealed record CreateUserEndpointRequest { public required string UserName { get; init; } - public required string EMail { get; init; } + public string? FullName { get; init; } + + public string? EMail { get; init; } public required string Password { get; init; } } @@ -65,8 +81,14 @@ private Validator() RuleFor(x => x.UserName) .NotEmpty(); + RuleFor(x => x.FullName) + .NotEmpty() + .When(x => x.FullName is not null); + RuleFor(x => x.EMail) - .EmailAddress(); + .NotEmpty() + .EmailAddress() + .When(x => x.EMail is not null); RuleFor(x => x.Password) .NotEmpty(); diff --git a/src/Turnierplan.App/Endpoints/Users/UpdateUserEndpoint.cs b/src/Turnierplan.App/Endpoints/Users/UpdateUserEndpoint.cs index 7139158e..6fe53120 100644 --- a/src/Turnierplan.App/Endpoints/Users/UpdateUserEndpoint.cs +++ b/src/Turnierplan.App/Endpoints/Users/UpdateUserEndpoint.cs @@ -21,6 +21,7 @@ private static async Task Handle( [FromBody] UpdateUserEndpointRequest request, IPasswordHasher passwordHasher, IUserRepository repository, + HttpContext httpContext, CancellationToken cancellationToken) { if (!Validator.Instance.ValidateAndGetResult(request, out var result)) @@ -35,22 +36,31 @@ private static async Task Handle( return Results.NotFound(); } - if (!user.NormalizedEMail.Equals(User.NormalizeEmail(request.EMail))) + if (!user.NormalizedUserName.Equals(User.Normalize(request.UserName)) && await repository.GetByUserNameAsync(request.UserName) is not null) { - // If the email address ought to be changed, check that no other user uses that email address + return Results.BadRequest("The specified user name is already taken."); + } - var existingUserWithNewEmail = await repository.GetByEmailAsync(request.EMail); + if (request.EMail is not null && !Equals(user.NormalizedEMail, User.Normalize(request.EMail))) + { + // If the email address ought to be changed, check that no other user uses that email address - if (existingUserWithNewEmail is not null) + if (await repository.GetByEmailAsync(request.EMail) is not null) { return Results.BadRequest("The specified email address is already taken."); } } - user.Name = request.UserName; + if (httpContext.GetCurrentUserIdOrThrow() == user.Id && !request.IsAdministrator) + { + return Results.BadRequest("Cannot take away the administrator privilege of the requesting user."); + } + + user.FullName = request.FullName?.Trim(); user.IsAdministrator = request.IsAdministrator; - user.UpdateEmail(request.EMail); + user.SetUserName(request.UserName); + user.SetEmailAddress(request.EMail); if (request.UpdatePassword) { @@ -66,7 +76,9 @@ public sealed record UpdateUserEndpointRequest { public required string UserName { get; init; } - public required string EMail { get; init; } + public string? FullName { get; init; } + + public string? EMail { get; init; } public bool IsAdministrator { get; init; } @@ -84,8 +96,14 @@ private Validator() RuleFor(x => x.UserName) .NotEmpty(); + RuleFor(x => x.FullName) + .NotEmpty() + .When(x => x.FullName is not null); + RuleFor(x => x.EMail) - .EmailAddress(); + .NotEmpty() + .EmailAddress() + .When(x => x.EMail is not null); RuleFor(x => x.Password) .Null() diff --git a/src/Turnierplan.App/Extensions/WebApplicationExtensions.cs b/src/Turnierplan.App/Extensions/WebApplicationExtensions.cs index 6377dbdf..6b5e0ec6 100644 --- a/src/Turnierplan.App/Extensions/WebApplicationExtensions.cs +++ b/src/Turnierplan.App/Extensions/WebApplicationExtensions.cs @@ -36,14 +36,14 @@ public static async Task InitializeDatabaseAsync(this WebApplication app) var overwriteInitialUserPassword = !string.IsNullOrWhiteSpace(options.InitialUserPassword); - var initialUserName = string.IsNullOrWhiteSpace(options.InitialUserName) ? "Administrator" : options.InitialUserName; - var initialUserEmail = string.IsNullOrWhiteSpace(options.InitialUserEmail) ? "admin@example.com" : options.InitialUserEmail; + var initialUserName = string.IsNullOrWhiteSpace(options.InitialUserName) ? "admin" : options.InitialUserName; var initialUserPassword = overwriteInitialUserPassword ? options.InitialUserPassword! : Guid.NewGuid().ToString(); var passwordHasher = scope.ServiceProvider.GetRequiredService>(); - var initialUser = new User(initialUserName, initialUserEmail) + var initialUser = new User(initialUserName) { + FullName = "Administrator", IsAdministrator = true }; @@ -55,11 +55,11 @@ public static async Task InitializeDatabaseAsync(this WebApplication app) if (overwriteInitialUserPassword) { // Don't log the password if it was specified using an environment variable - logger.LogInformation("An initial user \"{Name}\" was created. You can log in using \"{Email}\" and the password \"****\" (set by environment variable). This is NOT recommended in a production environment!", initialUserName, initialUserEmail); + logger.LogInformation("An initial user was created: You can log in using \"{Name}\" and the password \"****\" (set by environment variable). This is NOT recommended in a production environment!", initialUserName); } else { - logger.LogInformation("An initial user \"{Name}\" was created. You can log in using \"{Email}\" and the password \"{Password}\". IMMEDIATELY change this password when running in a production environment!", initialUserName, initialUserEmail, initialUserPassword); + logger.LogInformation("An initial user was created: You can log in using \"{Name}\" and the password \"{Password}\". IMMEDIATELY change this password when running in a production environment!", initialUserName, initialUserPassword); } } else diff --git a/src/Turnierplan.App/Mapping/Rules/UserMappingRule.cs b/src/Turnierplan.App/Mapping/Rules/UserMappingRule.cs index 120f2516..e5b3dce6 100644 --- a/src/Turnierplan.App/Mapping/Rules/UserMappingRule.cs +++ b/src/Turnierplan.App/Mapping/Rules/UserMappingRule.cs @@ -11,7 +11,8 @@ protected override UserDto Map(IMapper mapper, MappingContext context, User sour { Id = source.Id, CreatedAt = source.CreatedAt, - Name = source.Name, + UserName = source.UserName, + FullName = source.FullName, EMail = source.EMail, LastPasswordChange = source.LastPasswordChange, IsAdministrator = source.IsAdministrator diff --git a/src/Turnierplan.App/Models/UserDto.cs b/src/Turnierplan.App/Models/UserDto.cs index caf371ee..08b503f1 100644 --- a/src/Turnierplan.App/Models/UserDto.cs +++ b/src/Turnierplan.App/Models/UserDto.cs @@ -6,9 +6,11 @@ public sealed record UserDto public required DateTime CreatedAt { get; init; } - public required string Name { get; init; } + public required string UserName { get; init; } - public required string EMail { get; init; } + public string? FullName { get; init; } + + public string? EMail { get; init; } public required DateTime LastPasswordChange { get; init; } diff --git a/src/Turnierplan.App/Options/TurnierplanOptions.cs b/src/Turnierplan.App/Options/TurnierplanOptions.cs index 2784dd8e..aac4dcb2 100644 --- a/src/Turnierplan.App/Options/TurnierplanOptions.cs +++ b/src/Turnierplan.App/Options/TurnierplanOptions.cs @@ -14,7 +14,5 @@ internal sealed record TurnierplanOptions public string? InitialUserName { get; init; } - public string? InitialUserEmail { get; init; } - public string? InitialUserPassword { get; init; } } diff --git a/src/Turnierplan.App/Security/ClaimTypes.cs b/src/Turnierplan.App/Security/ClaimTypes.cs index 64ad9898..553b6a12 100644 --- a/src/Turnierplan.App/Security/ClaimTypes.cs +++ b/src/Turnierplan.App/Security/ClaimTypes.cs @@ -3,11 +3,12 @@ namespace Turnierplan.App.Security; internal static class ClaimTypes { public const string Administrator = "adm"; - public const string DisplayName = "name"; public const string EMailAddress = "mail"; + public const string FullName = "fullName"; public const string PrincipalKind = "principalkind"; public const string PrincipalId = "principalid"; public const string SecurityStamp = "sst"; public const string TokenType = "typ"; public const string UserId = "uid"; + public const string UserName = "userName"; } diff --git a/src/Turnierplan.Core/User/IUserRepository.cs b/src/Turnierplan.Core/User/IUserRepository.cs index e3aaa86c..b3a65fee 100644 --- a/src/Turnierplan.Core/User/IUserRepository.cs +++ b/src/Turnierplan.Core/User/IUserRepository.cs @@ -10,5 +10,9 @@ public interface IUserRepository : IRepository Task GetByPrincipalIdAsync(Guid id); + Task GetByUserNameAsync(string userName); + Task GetByEmailAsync(string email); + + Task GetByUserNameOrEmailAsync(string userNameOrEmail); } diff --git a/src/Turnierplan.Core/User/User.cs b/src/Turnierplan.Core/User/User.cs index 2a94a13d..617cc2f2 100644 --- a/src/Turnierplan.Core/User/User.cs +++ b/src/Turnierplan.Core/User/User.cs @@ -4,28 +4,31 @@ namespace Turnierplan.Core.User; public sealed class User : Entity { - public User(string name, string email) + public User(string userName) { - email = email.Trim(); Id = Guid.NewGuid(); PrincipalId = Guid.NewGuid(); CreatedAt = DateTime.UtcNow; - Name = name; - EMail = email; - NormalizedEMail = NormalizeEmail(email); + UserName = userName; + NormalizedUserName = Normalize(userName); + FullName = null; + EMail = null; + NormalizedEMail = null; PasswordHash = string.Empty; IsAdministrator = false; LastPasswordChange = DateTime.MinValue; SecurityStamp = Guid.Empty; } - internal User(Guid id, Guid principalId, DateTime createdAt, string name, string eMail, string normalizedEMail, string passwordHash, bool isAdministrator, DateTime lastPasswordChange, Guid securityStamp) + internal User(Guid id, Guid principalId, DateTime createdAt, string userName, string normalizedUserName, string? fullName, string? eMail, string? normalizedEMail, string passwordHash, bool isAdministrator, DateTime lastPasswordChange, Guid securityStamp) { Id = id; PrincipalId = principalId; CreatedAt = createdAt; - Name = name; + UserName = userName; + NormalizedUserName = normalizedUserName; + FullName = fullName; EMail = eMail; NormalizedEMail = normalizedEMail; PasswordHash = passwordHash; @@ -40,11 +43,15 @@ internal User(Guid id, Guid principalId, DateTime createdAt, string name, string public DateTime CreatedAt { get; } - public string Name { get; set; } + public string UserName { get; private set; } - public string EMail { get; private set; } + public string NormalizedUserName { get; private set; } - public string NormalizedEMail { get; private set; } + public string? FullName { get; set; } + + public string? EMail { get; private set; } + + public string? NormalizedEMail { get; private set; } public string PasswordHash { get; private set; } @@ -68,12 +75,29 @@ public void UpdatePassword(string passwordHash) SecurityStamp = Guid.NewGuid(); } - public void UpdateEmail(string newEmail) + public void SetUserName(string userName) + { + UserName = userName.Trim(); + NormalizedUserName = Normalize(userName); + } + + public void SetEmailAddress(string? newEmail) { - EMail = newEmail; - NormalizedEMail = NormalizeEmail(newEmail); + if (newEmail is null) + { + EMail = null; + NormalizedEMail = null; + } + else + { + ArgumentException.ThrowIfNullOrWhiteSpace(newEmail); + + EMail = newEmail.Trim(); + NormalizedEMail = Normalize(newEmail); + } + SecurityStamp = Guid.NewGuid(); } - public static string NormalizeEmail(string email) => email.Trim().ToUpper(); + public static string Normalize(string value) => value.Trim().ToUpper(); } diff --git a/src/Turnierplan.Dal/EntityConfigurations/UserEntityTypeConfiguration.cs b/src/Turnierplan.Dal/EntityConfigurations/UserEntityTypeConfiguration.cs index 68595986..3e8109d1 100644 --- a/src/Turnierplan.Dal/EntityConfigurations/UserEntityTypeConfiguration.cs +++ b/src/Turnierplan.Dal/EntityConfigurations/UserEntityTypeConfiguration.cs @@ -24,14 +24,23 @@ public void Configure(EntityTypeBuilder builder) builder.Property(x => x.CreatedAt) .IsRequired(); - builder.Property(x => x.Name) + builder.Property(x => x.UserName) .IsRequired(); - builder.Property(x => x.EMail) + builder.Property(x => x.NormalizedUserName) .IsRequired(); + builder.HasIndex(x => x.NormalizedUserName) + .IsUnique(); + + builder.Property(x => x.FullName) + .IsRequired(false); + + builder.Property(x => x.EMail) + .IsRequired(false); + builder.Property(x => x.NormalizedEMail) - .IsRequired(); + .IsRequired(false); builder.HasIndex(x => x.NormalizedEMail) .IsUnique(); diff --git a/src/Turnierplan.Dal/Migrations/20250924200327_Add_UserName.Designer.cs b/src/Turnierplan.Dal/Migrations/20250924200327_Add_UserName.Designer.cs new file mode 100644 index 00000000..79d16abb --- /dev/null +++ b/src/Turnierplan.Dal/Migrations/20250924200327_Add_UserName.Designer.cs @@ -0,0 +1,1739 @@ +// +using System; +using System.Collections.Generic; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using Turnierplan.Dal; + +#nullable disable + +namespace Turnierplan.Dal.Migrations +{ + [DbContext(typeof(TurnierplanContext))] + [Migration("20250924200327_Add_UserName")] + partial class Add_UserName + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "9.0.9") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Turnierplan.Core.ApiKey.ApiKey", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .IsRequired() + .HasColumnType("text"); + + b.Property("ExpiryDate") + .HasColumnType("timestamp with time zone"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("OrganizationId") + .HasColumnType("bigint"); + + b.Property("PrincipalId") + .HasColumnType("uuid"); + + b.Property("PublicId") + .HasColumnType("bigint"); + + b.Property("SecretHash") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.HasIndex("PrincipalId") + .IsUnique(); + + b.HasIndex("PublicId") + .IsUnique(); + + b.ToTable("ApiKeys", "turnierplan"); + }); + + modelBuilder.Entity("Turnierplan.Core.ApiKey.ApiKeyRequest", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ApiKeyId") + .HasColumnType("bigint"); + + b.Property("Path") + .IsRequired() + .HasColumnType("text"); + + b.Property("Timestamp") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("ApiKeyId"); + + b.HasIndex("Timestamp"); + + b.ToTable("ApiKeyRequests", "turnierplan"); + }); + + modelBuilder.Entity("Turnierplan.Core.Document.Document", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Configuration") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("GenerationCount") + .HasColumnType("integer"); + + b.Property("LastGeneration") + .HasColumnType("timestamp with time zone"); + + b.Property("LastModifiedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("PublicId") + .HasColumnType("bigint"); + + b.Property("TournamentId") + .HasColumnType("bigint"); + + b.Property("Type") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("PublicId") + .IsUnique(); + + b.HasIndex("TournamentId"); + + b.ToTable("Documents", "turnierplan"); + }); + + modelBuilder.Entity("Turnierplan.Core.Folder.Folder", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("OrganizationId") + .HasColumnType("bigint"); + + b.Property("PublicId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.HasIndex("PublicId") + .IsUnique(); + + b.ToTable("Folders", "turnierplan"); + }); + + modelBuilder.Entity("Turnierplan.Core.Image.Image", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FileSize") + .HasColumnType("bigint"); + + b.Property("FileType") + .IsRequired() + .HasColumnType("text"); + + b.Property("Height") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("OrganizationId") + .HasColumnType("bigint"); + + b.Property("PublicId") + .HasColumnType("bigint"); + + b.Property("ResourceIdentifier") + .HasColumnType("uuid"); + + b.Property("Type") + .HasColumnType("integer"); + + b.Property("Width") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.HasIndex("PublicId") + .IsUnique(); + + b.HasIndex("ResourceIdentifier") + .IsUnique(); + + b.ToTable("Images", "turnierplan"); + }); + + modelBuilder.Entity("Turnierplan.Core.Organization.Organization", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("PublicId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("PublicId") + .IsUnique(); + + b.ToTable("Organizations", "turnierplan"); + }); + + modelBuilder.Entity("Turnierplan.Core.PlanningRealm.Application", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Comment") + .HasColumnType("text"); + + b.Property("Contact") + .IsRequired() + .HasColumnType("text"); + + b.Property("ContactEmail") + .HasColumnType("text"); + + b.Property("ContactTelephone") + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Notes") + .IsRequired() + .HasColumnType("text"); + + b.Property("PlanningRealmId") + .HasColumnType("bigint"); + + b.Property("SourceLinkId") + .HasColumnType("bigint"); + + b.Property("Tag") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("PlanningRealmId"); + + b.HasIndex("SourceLinkId"); + + b.ToTable("Applications", "turnierplan"); + }); + + modelBuilder.Entity("Turnierplan.Core.PlanningRealm.ApplicationTeam", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ApplicationId") + .HasColumnType("bigint"); + + b.Property("ClassId") + .HasColumnType("bigint"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("ApplicationId"); + + b.HasIndex("ClassId"); + + b.ToTable("ApplicationTeams", "turnierplan"); + }); + + modelBuilder.Entity("Turnierplan.Core.PlanningRealm.InvitationLink", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ColorCode") + .IsRequired() + .HasColumnType("text"); + + b.Property("ContactEmail") + .HasColumnType("text"); + + b.Property("ContactPerson") + .HasColumnType("text"); + + b.Property("ContactTelephone") + .HasColumnType("text"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("PlanningRealmId") + .HasColumnType("bigint"); + + b.Property("PrimaryLogoId") + .HasColumnType("bigint"); + + b.Property("PublicId") + .HasColumnType("bigint"); + + b.Property("SecondaryLogoId") + .HasColumnType("bigint"); + + b.Property("Title") + .HasColumnType("text"); + + b.Property("ValidUntil") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("PlanningRealmId"); + + b.HasIndex("PrimaryLogoId"); + + b.HasIndex("PublicId") + .IsUnique(); + + b.HasIndex("SecondaryLogoId"); + + b.ToTable("InvitationLinks", "turnierplan"); + }); + + modelBuilder.Entity("Turnierplan.Core.PlanningRealm.InvitationLinkEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AllowNewRegistrations") + .HasColumnType("boolean"); + + b.Property("ClassId") + .HasColumnType("bigint"); + + b.Property("InvitationLinkId") + .HasColumnType("bigint"); + + b.Property("MaxTeamsPerRegistration") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ClassId"); + + b.HasIndex("InvitationLinkId"); + + b.ToTable("InvitationLinkEntries", "turnierplan"); + }); + + modelBuilder.Entity("Turnierplan.Core.PlanningRealm.PlanningRealm", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("OrganizationId") + .HasColumnType("bigint"); + + b.Property("PublicId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.HasIndex("PublicId") + .IsUnique(); + + b.ToTable("PlanningRealms", "turnierplan"); + }); + + modelBuilder.Entity("Turnierplan.Core.PlanningRealm.TeamLink", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ApplicationTeamId") + .HasColumnType("bigint"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("TeamId") + .HasColumnType("integer"); + + b.Property("TeamTournamentId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("ApplicationTeamId") + .IsUnique(); + + b.HasIndex("TeamTournamentId", "TeamId") + .IsUnique(); + + b.ToTable("TeamLinks", "turnierplan"); + }); + + modelBuilder.Entity("Turnierplan.Core.PlanningRealm.TournamentClass", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("PlanningRealmId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("PlanningRealmId"); + + b.ToTable("TournamentClasses", "turnierplan"); + }); + + modelBuilder.Entity("Turnierplan.Core.RoleAssignment.RoleAssignment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ApiKeyId") + .HasColumnType("bigint"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Principal") + .IsRequired() + .HasColumnType("text"); + + b.Property("Role") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ApiKeyId"); + + b.ToTable("IAM_ApiKey", "turnierplan"); + }); + + modelBuilder.Entity("Turnierplan.Core.RoleAssignment.RoleAssignment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FolderId") + .HasColumnType("bigint"); + + b.Property("Principal") + .IsRequired() + .HasColumnType("text"); + + b.Property("Role") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("FolderId"); + + b.ToTable("IAM_Folder", "turnierplan"); + }); + + modelBuilder.Entity("Turnierplan.Core.RoleAssignment.RoleAssignment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ImageId") + .HasColumnType("bigint"); + + b.Property("Principal") + .IsRequired() + .HasColumnType("text"); + + b.Property("Role") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ImageId"); + + b.ToTable("IAM_Image", "turnierplan"); + }); + + modelBuilder.Entity("Turnierplan.Core.RoleAssignment.RoleAssignment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("OrganizationId") + .HasColumnType("bigint"); + + b.Property("Principal") + .IsRequired() + .HasColumnType("text"); + + b.Property("Role") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("IAM_Organization", "turnierplan"); + }); + + modelBuilder.Entity("Turnierplan.Core.RoleAssignment.RoleAssignment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("PlanningRealmId") + .HasColumnType("bigint"); + + b.Property("Principal") + .IsRequired() + .HasColumnType("text"); + + b.Property("Role") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("PlanningRealmId"); + + b.ToTable("IAM_PlanningRealm", "turnierplan"); + }); + + modelBuilder.Entity("Turnierplan.Core.RoleAssignment.RoleAssignment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Principal") + .IsRequired() + .HasColumnType("text"); + + b.Property("Role") + .HasColumnType("integer"); + + b.Property("TournamentId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("TournamentId"); + + b.ToTable("IAM_Tournament", "turnierplan"); + }); + + modelBuilder.Entity("Turnierplan.Core.RoleAssignment.RoleAssignment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Principal") + .IsRequired() + .HasColumnType("text"); + + b.Property("Role") + .HasColumnType("integer"); + + b.Property("VenueId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("VenueId"); + + b.ToTable("IAM_Venue", "turnierplan"); + }); + + modelBuilder.Entity("Turnierplan.Core.Tournament.Group", b => + { + b.Property("TournamentId") + .HasColumnType("bigint"); + + b.Property("Id") + .HasColumnType("integer"); + + b.Property("AlphabeticalId") + .HasColumnType("character(1)"); + + b.Property("DisplayName") + .HasColumnType("text"); + + b.HasKey("TournamentId", "Id"); + + b.ToTable("Groups", "turnierplan"); + }); + + modelBuilder.Entity("Turnierplan.Core.Tournament.GroupParticipant", b => + { + b.Property("TournamentId") + .HasColumnType("bigint"); + + b.Property("GroupId") + .HasColumnType("integer"); + + b.Property("TeamId") + .HasColumnType("integer"); + + b.Property("Order") + .HasColumnType("integer"); + + b.Property("Priority") + .HasColumnType("integer"); + + b.HasKey("TournamentId", "GroupId", "TeamId"); + + b.HasIndex("TournamentId", "TeamId"); + + b.ToTable("GroupParticipants", "turnierplan"); + }); + + modelBuilder.Entity("Turnierplan.Core.Tournament.Match", b => + { + b.Property("TournamentId") + .HasColumnType("bigint"); + + b.Property("Id") + .HasColumnType("integer"); + + b.Property("Court") + .HasColumnType("smallint"); + + b.Property("FinalsRound") + .HasColumnType("integer"); + + b.Property("GroupId") + .HasColumnType("integer"); + + b.Property("Index") + .HasColumnType("integer"); + + b.Property("IsCurrentlyPlaying") + .HasColumnType("boolean"); + + b.Property("Kickoff") + .HasColumnType("timestamp with time zone"); + + b.Property("OutcomeType") + .HasColumnType("integer"); + + b.Property("PlayoffPosition") + .HasColumnType("integer"); + + b.Property("ScoreA") + .HasColumnType("integer"); + + b.Property("ScoreB") + .HasColumnType("integer"); + + b.Property("TeamSelectorA") + .IsRequired() + .HasColumnType("text"); + + b.Property("TeamSelectorB") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("TournamentId", "Id"); + + b.HasIndex("TournamentId", "GroupId"); + + b.ToTable("Matches", "turnierplan"); + }); + + modelBuilder.Entity("Turnierplan.Core.Tournament.Team", b => + { + b.Property("TournamentId") + .HasColumnType("bigint"); + + b.Property("Id") + .HasColumnType("integer"); + + b.Property("EntryFeePaidAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("OutOfCompetition") + .HasColumnType("boolean"); + + b.HasKey("TournamentId", "Id"); + + b.ToTable("Teams", "turnierplan"); + }); + + modelBuilder.Entity("Turnierplan.Core.Tournament.Tournament", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("BannerImageId") + .HasColumnType("bigint"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FolderId") + .HasColumnType("bigint"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("OrganizationId") + .HasColumnType("bigint"); + + b.Property("PrimaryLogoId") + .HasColumnType("bigint"); + + b.Property("PublicId") + .HasColumnType("bigint"); + + b.Property("PublicPageViews") + .HasColumnType("integer"); + + b.Property("SecondaryLogoId") + .HasColumnType("bigint"); + + b.Property("VenueId") + .HasColumnType("bigint"); + + b.Property("Visibility") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("BannerImageId"); + + b.HasIndex("FolderId"); + + b.HasIndex("OrganizationId"); + + b.HasIndex("PrimaryLogoId"); + + b.HasIndex("PublicId") + .IsUnique(); + + b.HasIndex("SecondaryLogoId"); + + b.HasIndex("VenueId"); + + b.ToTable("Tournaments", "turnierplan"); + }); + + modelBuilder.Entity("Turnierplan.Core.User.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EMail") + .HasColumnType("text"); + + b.Property("FullName") + .HasColumnType("text"); + + b.Property("IsAdministrator") + .HasColumnType("boolean"); + + b.Property("LastLogin") + .HasColumnType("timestamp with time zone"); + + b.Property("LastPasswordChange") + .HasColumnType("timestamp with time zone"); + + b.Property("NormalizedEMail") + .HasColumnType("text"); + + b.Property("NormalizedUserName") + .IsRequired() + .HasColumnType("text"); + + b.Property("PasswordHash") + .IsRequired() + .HasColumnType("text"); + + b.Property("PrincipalId") + .HasColumnType("uuid"); + + b.Property("SecurityStamp") + .HasColumnType("uuid"); + + b.Property("UserName") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEMail") + .IsUnique(); + + b.HasIndex("NormalizedUserName") + .IsUnique(); + + b.HasIndex("PrincipalId") + .IsUnique(); + + b.ToTable("Users", "turnierplan"); + }); + + modelBuilder.Entity("Turnierplan.Core.Venue.Venue", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.PrimitiveCollection>("AddressDetails") + .IsRequired() + .HasColumnType("text[]"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .IsRequired() + .HasColumnType("text"); + + b.PrimitiveCollection>("ExternalLinks") + .IsRequired() + .HasColumnType("text[]"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("OrganizationId") + .HasColumnType("bigint"); + + b.Property("PublicId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.HasIndex("PublicId") + .IsUnique(); + + b.ToTable("Venues", "turnierplan"); + }); + + modelBuilder.Entity("Turnierplan.Core.ApiKey.ApiKey", b => + { + b.HasOne("Turnierplan.Core.Organization.Organization", "Organization") + .WithMany("ApiKeys") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Turnierplan.Core.ApiKey.ApiKeyRequest", b => + { + b.HasOne("Turnierplan.Core.ApiKey.ApiKey", "ApiKey") + .WithMany("Requests") + .HasForeignKey("ApiKeyId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("ApiKey"); + }); + + modelBuilder.Entity("Turnierplan.Core.Document.Document", b => + { + b.HasOne("Turnierplan.Core.Tournament.Tournament", "Tournament") + .WithMany("Documents") + .HasForeignKey("TournamentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tournament"); + }); + + modelBuilder.Entity("Turnierplan.Core.Folder.Folder", b => + { + b.HasOne("Turnierplan.Core.Organization.Organization", "Organization") + .WithMany("Folders") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Turnierplan.Core.Image.Image", b => + { + b.HasOne("Turnierplan.Core.Organization.Organization", "Organization") + .WithMany("Images") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Turnierplan.Core.PlanningRealm.Application", b => + { + b.HasOne("Turnierplan.Core.PlanningRealm.PlanningRealm", "PlanningRealm") + .WithMany("Applications") + .HasForeignKey("PlanningRealmId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Turnierplan.Core.PlanningRealm.InvitationLink", "SourceLink") + .WithMany() + .HasForeignKey("SourceLinkId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("PlanningRealm"); + + b.Navigation("SourceLink"); + }); + + modelBuilder.Entity("Turnierplan.Core.PlanningRealm.ApplicationTeam", b => + { + b.HasOne("Turnierplan.Core.PlanningRealm.Application", "Application") + .WithMany("Teams") + .HasForeignKey("ApplicationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Turnierplan.Core.PlanningRealm.TournamentClass", "Class") + .WithMany() + .HasForeignKey("ClassId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Application"); + + b.Navigation("Class"); + }); + + modelBuilder.Entity("Turnierplan.Core.PlanningRealm.InvitationLink", b => + { + b.HasOne("Turnierplan.Core.PlanningRealm.PlanningRealm", "PlanningRealm") + .WithMany("InvitationLinks") + .HasForeignKey("PlanningRealmId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Turnierplan.Core.Image.Image", "PrimaryLogo") + .WithMany() + .HasForeignKey("PrimaryLogoId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Turnierplan.Core.Image.Image", "SecondaryLogo") + .WithMany() + .HasForeignKey("SecondaryLogoId") + .OnDelete(DeleteBehavior.SetNull); + + b.OwnsMany("Turnierplan.Core.PlanningRealm.InvitationLink+ExternalLink", "ExternalLinks", b1 => + { + b1.Property("InvitationLinkId") + .HasColumnType("bigint"); + + b1.Property("__synthesizedOrdinal") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + b1.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b1.Property("Url") + .IsRequired() + .HasColumnType("text"); + + b1.HasKey("InvitationLinkId", "__synthesizedOrdinal"); + + b1.ToTable("InvitationLinks", "turnierplan"); + + b1.ToJson("ExternalLinks"); + + b1.WithOwner() + .HasForeignKey("InvitationLinkId"); + }); + + b.Navigation("ExternalLinks"); + + b.Navigation("PlanningRealm"); + + b.Navigation("PrimaryLogo"); + + b.Navigation("SecondaryLogo"); + }); + + modelBuilder.Entity("Turnierplan.Core.PlanningRealm.InvitationLinkEntry", b => + { + b.HasOne("Turnierplan.Core.PlanningRealm.TournamentClass", "Class") + .WithMany() + .HasForeignKey("ClassId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Turnierplan.Core.PlanningRealm.InvitationLink", null) + .WithMany("Entries") + .HasForeignKey("InvitationLinkId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Class"); + }); + + modelBuilder.Entity("Turnierplan.Core.PlanningRealm.PlanningRealm", b => + { + b.HasOne("Turnierplan.Core.Organization.Organization", "Organization") + .WithMany("PlanningRealms") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Turnierplan.Core.PlanningRealm.TeamLink", b => + { + b.HasOne("Turnierplan.Core.PlanningRealm.ApplicationTeam", "ApplicationTeam") + .WithOne("TeamLink") + .HasForeignKey("Turnierplan.Core.PlanningRealm.TeamLink", "ApplicationTeamId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Turnierplan.Core.Tournament.Team", "Team") + .WithOne("TeamLink") + .HasForeignKey("Turnierplan.Core.PlanningRealm.TeamLink", "TeamTournamentId", "TeamId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("ApplicationTeam"); + + b.Navigation("Team"); + }); + + modelBuilder.Entity("Turnierplan.Core.PlanningRealm.TournamentClass", b => + { + b.HasOne("Turnierplan.Core.PlanningRealm.PlanningRealm", null) + .WithMany("TournamentClasses") + .HasForeignKey("PlanningRealmId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Turnierplan.Core.RoleAssignment.RoleAssignment", b => + { + b.HasOne("Turnierplan.Core.ApiKey.ApiKey", "Scope") + .WithMany("RoleAssignments") + .HasForeignKey("ApiKeyId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Scope"); + }); + + modelBuilder.Entity("Turnierplan.Core.RoleAssignment.RoleAssignment", b => + { + b.HasOne("Turnierplan.Core.Folder.Folder", "Scope") + .WithMany("RoleAssignments") + .HasForeignKey("FolderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Scope"); + }); + + modelBuilder.Entity("Turnierplan.Core.RoleAssignment.RoleAssignment", b => + { + b.HasOne("Turnierplan.Core.Image.Image", "Scope") + .WithMany("RoleAssignments") + .HasForeignKey("ImageId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Scope"); + }); + + modelBuilder.Entity("Turnierplan.Core.RoleAssignment.RoleAssignment", b => + { + b.HasOne("Turnierplan.Core.Organization.Organization", "Scope") + .WithMany("RoleAssignments") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Scope"); + }); + + modelBuilder.Entity("Turnierplan.Core.RoleAssignment.RoleAssignment", b => + { + b.HasOne("Turnierplan.Core.PlanningRealm.PlanningRealm", "Scope") + .WithMany("RoleAssignments") + .HasForeignKey("PlanningRealmId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Scope"); + }); + + modelBuilder.Entity("Turnierplan.Core.RoleAssignment.RoleAssignment", b => + { + b.HasOne("Turnierplan.Core.Tournament.Tournament", "Scope") + .WithMany("RoleAssignments") + .HasForeignKey("TournamentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Scope"); + }); + + modelBuilder.Entity("Turnierplan.Core.RoleAssignment.RoleAssignment", b => + { + b.HasOne("Turnierplan.Core.Venue.Venue", "Scope") + .WithMany("RoleAssignments") + .HasForeignKey("VenueId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Scope"); + }); + + modelBuilder.Entity("Turnierplan.Core.Tournament.Group", b => + { + b.HasOne("Turnierplan.Core.Tournament.Tournament", null) + .WithMany("Groups") + .HasForeignKey("TournamentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Turnierplan.Core.Tournament.GroupParticipant", b => + { + b.HasOne("Turnierplan.Core.Tournament.Group", "Group") + .WithMany("Participants") + .HasForeignKey("TournamentId", "GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Turnierplan.Core.Tournament.Team", "Team") + .WithMany() + .HasForeignKey("TournamentId", "TeamId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Group"); + + b.Navigation("Team"); + }); + + modelBuilder.Entity("Turnierplan.Core.Tournament.Match", b => + { + b.HasOne("Turnierplan.Core.Tournament.Tournament", null) + .WithMany("Matches") + .HasForeignKey("TournamentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Turnierplan.Core.Tournament.Group", "Group") + .WithMany() + .HasForeignKey("TournamentId", "GroupId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Group"); + }); + + modelBuilder.Entity("Turnierplan.Core.Tournament.Team", b => + { + b.HasOne("Turnierplan.Core.Tournament.Tournament", "Tournament") + .WithMany("Teams") + .HasForeignKey("TournamentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tournament"); + }); + + modelBuilder.Entity("Turnierplan.Core.Tournament.Tournament", b => + { + b.HasOne("Turnierplan.Core.Image.Image", "BannerImage") + .WithMany() + .HasForeignKey("BannerImageId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Turnierplan.Core.Folder.Folder", "Folder") + .WithMany("Tournaments") + .HasForeignKey("FolderId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Turnierplan.Core.Organization.Organization", "Organization") + .WithMany("Tournaments") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Turnierplan.Core.Image.Image", "PrimaryLogo") + .WithMany() + .HasForeignKey("PrimaryLogoId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Turnierplan.Core.Image.Image", "SecondaryLogo") + .WithMany() + .HasForeignKey("SecondaryLogoId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Turnierplan.Core.Venue.Venue", "Venue") + .WithMany("Tournaments") + .HasForeignKey("VenueId") + .OnDelete(DeleteBehavior.SetNull); + + b.OwnsOne("Turnierplan.Core.Tournament.ComputationConfiguration", "ComputationConfiguration", b1 => + { + b1.Property("TournamentId") + .HasColumnType("bigint"); + + b1.PrimitiveCollection("ComparisonModes") + .IsRequired() + .HasColumnType("integer[]") + .HasAnnotation("Relational:JsonPropertyName", "cmp"); + + b1.Property("HigherScoreLoses") + .HasColumnType("boolean") + .HasAnnotation("Relational:JsonPropertyName", "r"); + + b1.Property("MatchDrawnPoints") + .HasColumnType("integer") + .HasAnnotation("Relational:JsonPropertyName", "d"); + + b1.Property("MatchLostPoints") + .HasColumnType("integer") + .HasAnnotation("Relational:JsonPropertyName", "l"); + + b1.Property("MatchWonPoints") + .HasColumnType("integer") + .HasAnnotation("Relational:JsonPropertyName", "w"); + + b1.HasKey("TournamentId"); + + b1.ToTable("Tournaments", "turnierplan"); + + b1.ToJson("ComputationConfiguration"); + + b1.WithOwner() + .HasForeignKey("TournamentId"); + }); + + b.OwnsOne("Turnierplan.Core.Tournament.MatchPlanConfiguration", "MatchPlanConfiguration", b1 => + { + b1.Property("TournamentId") + .HasColumnType("bigint"); + + b1.HasKey("TournamentId"); + + b1.ToTable("Tournaments", "turnierplan"); + + b1.ToJson("MatchPlanConfiguration"); + + b1.WithOwner() + .HasForeignKey("TournamentId"); + + b1.OwnsOne("Turnierplan.Core.Tournament.FinalsRoundConfig", "FinalsRoundConfig", b2 => + { + b2.Property("MatchPlanConfigurationTournamentId") + .HasColumnType("bigint"); + + b2.Property("EnableThirdPlacePlayoff") + .HasColumnType("boolean") + .HasAnnotation("Relational:JsonPropertyName", "3rd"); + + b2.Property("FirstFinalsRoundOrder") + .HasColumnType("integer") + .HasAnnotation("Relational:JsonPropertyName", "fo"); + + b2.PrimitiveCollection>("TeamSelectors") + .HasColumnType("text[]") + .HasAnnotation("Relational:JsonPropertyName", "ts"); + + b2.HasKey("MatchPlanConfigurationTournamentId"); + + b2.ToTable("Tournaments", "turnierplan"); + + b2.HasAnnotation("Relational:JsonPropertyName", "fr"); + + b2.WithOwner() + .HasForeignKey("MatchPlanConfigurationTournamentId"); + + b2.OwnsMany("Turnierplan.Core.Tournament.AdditionalPlayoffConfig", "AdditionalPlayoffs", b3 => + { + b3.Property("FinalsRoundConfigMatchPlanConfigurationTournamentId") + .HasColumnType("bigint"); + + b3.Property("__synthesizedOrdinal") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + b3.Property("PlayoffPosition") + .HasColumnType("integer") + .HasAnnotation("Relational:JsonPropertyName", "p"); + + b3.Property("TeamSelectorA") + .IsRequired() + .HasColumnType("text") + .HasAnnotation("Relational:JsonPropertyName", "a"); + + b3.Property("TeamSelectorB") + .IsRequired() + .HasColumnType("text") + .HasAnnotation("Relational:JsonPropertyName", "b"); + + b3.HasKey("FinalsRoundConfigMatchPlanConfigurationTournamentId", "__synthesizedOrdinal"); + + b3.ToTable("Tournaments", "turnierplan"); + + b3.HasAnnotation("Relational:JsonPropertyName", "ap"); + + b3.WithOwner() + .HasForeignKey("FinalsRoundConfigMatchPlanConfigurationTournamentId"); + }); + + b2.Navigation("AdditionalPlayoffs"); + }); + + b1.OwnsOne("Turnierplan.Core.Tournament.GroupRoundConfig", "GroupRoundConfig", b2 => + { + b2.Property("MatchPlanConfigurationTournamentId") + .HasColumnType("bigint"); + + b2.Property("GroupMatchOrder") + .HasColumnType("integer") + .HasAnnotation("Relational:JsonPropertyName", "o"); + + b2.Property("GroupPhaseRounds") + .HasColumnType("integer") + .HasAnnotation("Relational:JsonPropertyName", "r"); + + b2.HasKey("MatchPlanConfigurationTournamentId"); + + b2.ToTable("Tournaments", "turnierplan"); + + b2.HasAnnotation("Relational:JsonPropertyName", "gr"); + + b2.WithOwner() + .HasForeignKey("MatchPlanConfigurationTournamentId"); + }); + + b1.OwnsOne("Turnierplan.Core.Tournament.ScheduleConfig", "ScheduleConfig", b2 => + { + b2.Property("MatchPlanConfigurationTournamentId") + .HasColumnType("bigint"); + + b2.Property("FinalsPhaseNumberOfCourts") + .HasColumnType("smallint") + .HasAnnotation("Relational:JsonPropertyName", "fc"); + + b2.Property("FinalsPhasePauseTime") + .HasColumnType("interval") + .HasAnnotation("Relational:JsonPropertyName", "fp"); + + b2.Property("FinalsPhasePlayTime") + .HasColumnType("interval") + .HasAnnotation("Relational:JsonPropertyName", "fd"); + + b2.Property("FirstMatchKickoff") + .HasColumnType("timestamp with time zone") + .HasAnnotation("Relational:JsonPropertyName", "f"); + + b2.Property("GroupPhaseNumberOfCourts") + .HasColumnType("smallint") + .HasAnnotation("Relational:JsonPropertyName", "gc"); + + b2.Property("GroupPhasePauseTime") + .HasColumnType("interval") + .HasAnnotation("Relational:JsonPropertyName", "gp"); + + b2.Property("GroupPhasePlayTime") + .HasColumnType("interval") + .HasAnnotation("Relational:JsonPropertyName", "gd"); + + b2.Property("PauseBetweenGroupAndFinalsPhase") + .HasColumnType("interval") + .HasAnnotation("Relational:JsonPropertyName", "p"); + + b2.HasKey("MatchPlanConfigurationTournamentId"); + + b2.ToTable("Tournaments", "turnierplan"); + + b2.HasAnnotation("Relational:JsonPropertyName", "sc"); + + b2.WithOwner() + .HasForeignKey("MatchPlanConfigurationTournamentId"); + }); + + b1.Navigation("FinalsRoundConfig"); + + b1.Navigation("GroupRoundConfig"); + + b1.Navigation("ScheduleConfig"); + }); + + b.OwnsOne("Turnierplan.Core.Tournament.PresentationConfiguration", "PresentationConfiguration", b1 => + { + b1.Property("TournamentId") + .HasColumnType("bigint"); + + b1.Property("ShowPrimaryLogo") + .HasColumnType("boolean") + .HasAnnotation("Relational:JsonPropertyName", "ol"); + + b1.Property("ShowResults") + .HasColumnType("integer") + .HasAnnotation("Relational:JsonPropertyName", "o"); + + b1.Property("ShowSecondaryLogo") + .HasColumnType("boolean") + .HasAnnotation("Relational:JsonPropertyName", "sl"); + + b1.HasKey("TournamentId"); + + b1.ToTable("Tournaments", "turnierplan"); + + b1.ToJson("PresentationConfiguration"); + + b1.WithOwner() + .HasForeignKey("TournamentId"); + + b1.OwnsOne("Turnierplan.Core.Tournament.PresentationConfiguration+HeaderLine", "Header1", b2 => + { + b2.Property("PresentationConfigurationTournamentId") + .HasColumnType("bigint"); + + b2.Property("Content") + .HasColumnType("integer") + .HasAnnotation("Relational:JsonPropertyName", "c"); + + b2.Property("CustomContent") + .HasColumnType("text") + .HasAnnotation("Relational:JsonPropertyName", "cc"); + + b2.HasKey("PresentationConfigurationTournamentId"); + + b2.ToTable("Tournaments", "turnierplan"); + + b2.HasAnnotation("Relational:JsonPropertyName", "h1"); + + b2.WithOwner() + .HasForeignKey("PresentationConfigurationTournamentId"); + }); + + b1.OwnsOne("Turnierplan.Core.Tournament.PresentationConfiguration+HeaderLine", "Header2", b2 => + { + b2.Property("PresentationConfigurationTournamentId") + .HasColumnType("bigint"); + + b2.Property("Content") + .HasColumnType("integer") + .HasAnnotation("Relational:JsonPropertyName", "c"); + + b2.Property("CustomContent") + .HasColumnType("text") + .HasAnnotation("Relational:JsonPropertyName", "cc"); + + b2.HasKey("PresentationConfigurationTournamentId"); + + b2.ToTable("Tournaments", "turnierplan"); + + b2.HasAnnotation("Relational:JsonPropertyName", "h2"); + + b2.WithOwner() + .HasForeignKey("PresentationConfigurationTournamentId"); + }); + + b1.Navigation("Header1") + .IsRequired(); + + b1.Navigation("Header2") + .IsRequired(); + }); + + b.Navigation("BannerImage"); + + b.Navigation("ComputationConfiguration") + .IsRequired(); + + b.Navigation("Folder"); + + b.Navigation("MatchPlanConfiguration"); + + b.Navigation("Organization"); + + b.Navigation("PresentationConfiguration") + .IsRequired(); + + b.Navigation("PrimaryLogo"); + + b.Navigation("SecondaryLogo"); + + b.Navigation("Venue"); + }); + + modelBuilder.Entity("Turnierplan.Core.Venue.Venue", b => + { + b.HasOne("Turnierplan.Core.Organization.Organization", "Organization") + .WithMany("Venues") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Turnierplan.Core.ApiKey.ApiKey", b => + { + b.Navigation("Requests"); + + b.Navigation("RoleAssignments"); + }); + + modelBuilder.Entity("Turnierplan.Core.Folder.Folder", b => + { + b.Navigation("RoleAssignments"); + + b.Navigation("Tournaments"); + }); + + modelBuilder.Entity("Turnierplan.Core.Image.Image", b => + { + b.Navigation("RoleAssignments"); + }); + + modelBuilder.Entity("Turnierplan.Core.Organization.Organization", b => + { + b.Navigation("ApiKeys"); + + b.Navigation("Folders"); + + b.Navigation("Images"); + + b.Navigation("PlanningRealms"); + + b.Navigation("RoleAssignments"); + + b.Navigation("Tournaments"); + + b.Navigation("Venues"); + }); + + modelBuilder.Entity("Turnierplan.Core.PlanningRealm.Application", b => + { + b.Navigation("Teams"); + }); + + modelBuilder.Entity("Turnierplan.Core.PlanningRealm.ApplicationTeam", b => + { + b.Navigation("TeamLink"); + }); + + modelBuilder.Entity("Turnierplan.Core.PlanningRealm.InvitationLink", b => + { + b.Navigation("Entries"); + }); + + modelBuilder.Entity("Turnierplan.Core.PlanningRealm.PlanningRealm", b => + { + b.Navigation("Applications"); + + b.Navigation("InvitationLinks"); + + b.Navigation("RoleAssignments"); + + b.Navigation("TournamentClasses"); + }); + + modelBuilder.Entity("Turnierplan.Core.Tournament.Group", b => + { + b.Navigation("Participants"); + }); + + modelBuilder.Entity("Turnierplan.Core.Tournament.Team", b => + { + b.Navigation("TeamLink"); + }); + + modelBuilder.Entity("Turnierplan.Core.Tournament.Tournament", b => + { + b.Navigation("Documents"); + + b.Navigation("Groups"); + + b.Navigation("Matches"); + + b.Navigation("RoleAssignments"); + + b.Navigation("Teams"); + }); + + modelBuilder.Entity("Turnierplan.Core.Venue.Venue", b => + { + b.Navigation("RoleAssignments"); + + b.Navigation("Tournaments"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Turnierplan.Dal/Migrations/20250924200327_Add_UserName.cs b/src/Turnierplan.Dal/Migrations/20250924200327_Add_UserName.cs new file mode 100644 index 00000000..65035a22 --- /dev/null +++ b/src/Turnierplan.Dal/Migrations/20250924200327_Add_UserName.cs @@ -0,0 +1,165 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Turnierplan.Dal.Migrations +{ + /// + public partial class Add_UserName : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + // Note: This migration was modified manually! + + // pre-migration: Name & EMail are both required columns + // post-migration: Only UserName is required column, FullName and EMail are optional + + // migration steps: + // 1. Create new required "UserName" and "NormalizedUserName" columns with a placeholder default value + // 2. Update all users and set the "UserName" to the current value of the "EMail" column & set "NormalizedUserName" to upper-case variant + // 3. Rename the previous "Name" column to "FullName" and make that column optional + // 4. Make the "EMail" and "NormalizedEMail" columns optional + // 5. Create the unique index on the "NormalizedUserName" column + + // step 1: + migrationBuilder.AddColumn( + name: "UserName", + schema: "turnierplan", + table: "Users", + type: "text", + nullable: false, + defaultValue: "x"); + + migrationBuilder.AddColumn( + name: "NormalizedUserName", + schema: "turnierplan", + table: "Users", + type: "text", + nullable: false, + defaultValue: "x"); + + // step 2: + migrationBuilder.Sql(""" +UPDATE turnierplan."Users" SET "UserName" = "EMail"; +UPDATE turnierplan."Users" SET "NormalizedUserName" = UPPER("EMail"); +"""); + + // step 3: + migrationBuilder.RenameColumn( + name: "Name", + schema: "turnierplan", + table: "Users", + newName: "FullName"); + + migrationBuilder.AlterColumn( + name: "FullName", + schema: "turnierplan", + table: "Users", + type: "text", + nullable: true, + oldClrType: typeof(string), + oldType: "text"); + + // step 4: + migrationBuilder.AlterColumn( + name: "EMail", + schema: "turnierplan", + table: "Users", + type: "text", + nullable: true, + oldClrType: typeof(string), + oldType: "text"); + + migrationBuilder.AlterColumn( + name: "NormalizedEMail", + schema: "turnierplan", + table: "Users", + type: "text", + nullable: true, + oldClrType: typeof(string), + oldType: "text"); + + // step 5: + migrationBuilder.CreateIndex( + name: "IX_Users_NormalizedUserName", + schema: "turnierplan", + table: "Users", + column: "NormalizedUserName", + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + // Note: This migration was modified manually! + + // pre-migration: Only UserName is required column, FullName and EMail are optional + // post-migration: Name & EMail are both required columns + + // migration steps: + // 1. Delete the unique index on the "NormalizedUserName" column + // 2. Make the "EMail" and "NormalizedEMail" columns required + // 3. Rename the previous "FullName" column to "Name" and make that column required + // 4. Delete the "UserName" and "NormalizedUserName" columns + + // step 1: + migrationBuilder.DropIndex( + name: "IX_Users_NormalizedUserName", + schema: "turnierplan", + table: "Users"); + + // step 2: + migrationBuilder.AlterColumn( + name: "EMail", + schema: "turnierplan", + table: "Users", + type: "text", + nullable: false, + defaultValue: "", + oldClrType: typeof(string), + oldType: "text", + oldNullable: true); + + migrationBuilder.AlterColumn( + name: "NormalizedEMail", + schema: "turnierplan", + table: "Users", + type: "text", + nullable: false, + defaultValue: "", + oldClrType: typeof(string), + oldType: "text", + oldNullable: true); + + // step 3: + migrationBuilder.RenameColumn( + name: "FullName", + schema: "turnierplan", + table: "Users", + newName: "Name"); + + migrationBuilder.AlterColumn( + name: "Name", + schema: "turnierplan", + table: "Users", + type: "text", + nullable: false, + defaultValue: "", + oldClrType: typeof(string), + oldType: "text", + oldNullable: true); + + // step 4: + migrationBuilder.DropColumn( + name: "UserName", + schema: "turnierplan", + table: "Users"); + + migrationBuilder.DropColumn( + name: "NormalizedUserName", + schema: "turnierplan", + table: "Users"); + } + } +} diff --git a/src/Turnierplan.Dal/Migrations/TurnierplanContextModelSnapshot.cs b/src/Turnierplan.Dal/Migrations/TurnierplanContextModelSnapshot.cs index a46184bc..d9826518 100644 --- a/src/Turnierplan.Dal/Migrations/TurnierplanContextModelSnapshot.cs +++ b/src/Turnierplan.Dal/Migrations/TurnierplanContextModelSnapshot.cs @@ -18,7 +18,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) { #pragma warning disable 612, 618 modelBuilder - .HasAnnotation("ProductVersion", "9.0.8") + .HasAnnotation("ProductVersion", "9.0.9") .HasAnnotation("Relational:MaxIdentifierLength", 63); NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); @@ -880,7 +880,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasColumnType("timestamp with time zone"); b.Property("EMail") - .IsRequired() + .HasColumnType("text"); + + b.Property("FullName") .HasColumnType("text"); b.Property("IsAdministrator") @@ -892,11 +894,10 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("LastPasswordChange") .HasColumnType("timestamp with time zone"); - b.Property("Name") - .IsRequired() + b.Property("NormalizedEMail") .HasColumnType("text"); - b.Property("NormalizedEMail") + b.Property("NormalizedUserName") .IsRequired() .HasColumnType("text"); @@ -910,11 +911,18 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("SecurityStamp") .HasColumnType("uuid"); + b.Property("UserName") + .IsRequired() + .HasColumnType("text"); + b.HasKey("Id"); b.HasIndex("NormalizedEMail") .IsUnique(); + b.HasIndex("NormalizedUserName") + .IsUnique(); + b.HasIndex("PrincipalId") .IsUnique(); diff --git a/src/Turnierplan.Dal/Repositories/UserRepository.cs b/src/Turnierplan.Dal/Repositories/UserRepository.cs index 3a0d2e9b..5d2f27f8 100644 --- a/src/Turnierplan.Dal/Repositories/UserRepository.cs +++ b/src/Turnierplan.Dal/Repositories/UserRepository.cs @@ -21,12 +21,30 @@ public Task> GetAllUsersAsync() return DbSet.Where(x => x.PrincipalId == id).FirstOrDefaultAsync(); } + public Task GetByUserNameAsync(string userName) + { + var normalizedUserName = User.Normalize(userName); + + return DbSet + .Where(x => x.NormalizedUserName.Equals(normalizedUserName)) + .FirstOrDefaultAsync(); + } + public Task GetByEmailAsync(string email) { - var normalizedEMail = User.NormalizeEmail(email); + var normalizedEMail = User.Normalize(email); + + return DbSet + .Where(x => x.NormalizedEMail != null && x.NormalizedEMail.Equals(normalizedEMail)) + .FirstOrDefaultAsync(); + } + + public Task GetByUserNameOrEmailAsync(string userNameOrEmail) + { + var normalizedUserNameOrEmail = User.Normalize(userNameOrEmail); return DbSet - .Where(x => x.NormalizedEMail.Equals(normalizedEMail)) + .Where(x => x.NormalizedUserName.Equals(normalizedUserNameOrEmail) || (x.NormalizedEMail != null && x.NormalizedEMail.Equals(normalizedUserNameOrEmail))) .FirstOrDefaultAsync(); } }
{{ user.id }}{{ user.name }}{{ user.eMail }}{{ user.userName }}{{ user.fullName ?? '' }}{{ user.eMail ?? '' }} {{ user.createdAt | translateDate: 'medium' }} {{ user.lastPasswordChange | translateDate: 'medium' }} @@ -40,19 +40,20 @@ } -
+
- +
+ +