Skip to content

Commit

Permalink
feat: make user Alias unique on create - prevent accidentally double … (
Browse files Browse the repository at this point in the history
#144)

…creation of a player
  • Loading branch information
AnSch1510 authored Jul 18, 2024
1 parent ebfb77c commit 6e4eee8
Show file tree
Hide file tree
Showing 19 changed files with 2,077 additions and 77 deletions.

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -29,30 +29,36 @@
</span>

@if (userToUpdate()) {
<p-iconField class="p-float-label min-w-0 grow sm:col-span-2" [iconPosition]="'right'">
@if (isTokenLoading()) {
<p-inputIcon styleClass="i-[mdi--loading] animate-spin" />
} @else if (!tokenVisible()) {
<p-inputIcon
styleClass="i-[mdi--eye-outline] cursor-pointer"
(click)="loadLoginToken()"
></p-inputIcon>
} @else {
<p-inputIcon
styleClass="i-[mdi--content-copy] cursor-pointer"
(click)="copyLoginToken(loginToken())"
></p-inputIcon>
}
<input
pInputText
[id]="id('loginToken')"
[type]="tokenVisible() ? 'text' : 'password'"
[value]="loginToken() ?? 'abcdefghijklmnop'"
[readOnly]="true"
autocomplete="off"
<div class="flex flex-row gap-2 sm:col-span-2">
<p-iconField class="p-float-label min-w-0 grow" [iconPosition]="'right'">
@if (isTokenLoading()) {
<p-inputIcon styleClass="i-[mdi--loading] animate-spin" />
} @else if (!tokenVisible()) {
<p-inputIcon
styleClass="i-[mdi--eye-outline] cursor-pointer"
(click)="loadLoginToken()"
></p-inputIcon>
} @else {
<p-inputIcon
styleClass="i-[mdi--content-copy] cursor-pointer"
(click)="copyLoginToken(loginToken())"
></p-inputIcon>
}
<input
pInputText
[id]="id('loginToken')"
[type]="tokenVisible() ? 'text' : 'password'"
[value]="loginToken() ?? 'abcdefghijklmnop'"
[readOnly]="true"
autocomplete="off"
/>
<label [htmlFor]="id('loginToken')">{{ translations.users_dialog_loginToken() }}</label>
</p-iconField>
<p-button
icon="i-[mdi--book-open-blank-variant-outline]"
(onClick)="openUserWelcomeDialog()"
/>
<label [htmlFor]="id('loginToken')">{{ translations.users_dialog_loginToken() }}</label>
</p-iconField>
</div>
}

<div class="sm:col-span-2">
Expand Down Expand Up @@ -193,4 +199,4 @@ <h4 class="m-0 mb-2">{{ translations.users_dialog_roles_title() }}</h4>
</ng-template>
</p-overlayPanel>

<app-user-created-dialog />
<app-user-welcome-dialog />
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import {
} from '@angular/core';
import { takeUntilDestroyed, toSignal } from '@angular/core/rxjs-interop';
import { FormBuilder, FormControl, ReactiveFormsModule, Validators } from '@angular/forms';
import { InterpolatePipe } from '@ngneers/signal-translate';
import { interpolate, InterpolatePipe } from '@ngneers/signal-translate';
import { Actions, ofType } from '@ngrx/effects';
import { Store } from '@ngrx/store';
import copyToClipboard from 'copy-to-clipboard';
Expand Down Expand Up @@ -41,8 +41,8 @@ import { TranslateService } from '../../../services/translate.service';
import { areArraysEqual } from '../../../utils/array.utils';
import { notNullish } from '../../../utils/common.utils';
import { selectSignal } from '../../../utils/ngrx.utils';
import { UserCreatedDialogComponent } from '../user-created-dialog/user-created-dialog.component';
import { UserItemComponent } from '../user-item/user-item.component';
import { UserWelcomeDialogComponent } from '../user-welcome-dialog/user-welcome-dialog.component';

@Component({
selector: 'app-user-dialog',
Expand All @@ -61,8 +61,8 @@ import { UserItemComponent } from '../user-item/user-item.component';
MessagesModule,
OverlayPanelModule,
ReactiveFormsModule,
UserCreatedDialogComponent,
UserItemComponent,
UserWelcomeDialogComponent,
],
templateUrl: './user-dialog.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
Expand All @@ -74,7 +74,7 @@ export class UserDialogComponent {
private readonly _allUsers = selectSignal(userSelectors.selectEntities);
private readonly _randomId = Math.random().toString(36).substring(2, 9);

private readonly _userCreatedDialog = viewChild.required(UserCreatedDialogComponent);
private readonly _userWelcomeDialog = viewChild.required(UserWelcomeDialogComponent);

protected readonly form = this._formBuilder.group({
id: new FormControl<string | null>(null),
Expand Down Expand Up @@ -131,7 +131,7 @@ export class UserDialogComponent {
.subscribe(({ type, response }) => {
this.close();
if (type === addUserAction.success.type) {
this._userCreatedDialog().open(response);
this._userWelcomeDialog().open(response);
}
});
actions$
Expand Down Expand Up @@ -229,6 +229,17 @@ export class UserDialogComponent {
}

protected submit() {
if (Object.values(this._allUsers()).some(x => x?.alias === this.form.value.alias)) {
this._messageService.add({
severity: 'error',
summary: interpolate(this.translations.users_dialog_error_exists(), {
user: this.form.value.alias,
}),
life: 2000,
});
return;
}

if (!this.form.valid) {
this.form.markAllAsTouched();
return;
Expand Down Expand Up @@ -274,6 +285,13 @@ export class UserDialogComponent {
return `${purpose}-${this._randomId}`;
}

protected openUserWelcomeDialog() {
const user = this.userToUpdate();
if (user) {
this._userWelcomeDialog().open(user);
}
}

private getUsersByIds(ids: string[]) {
return ids.map(id => this._allUsers()[id]).filter(notNullish);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
<p-dialog
[visible]="visible()"
(visibleChange)="visible.set($event)"
[modal]="true"
[draggable]="false"
[resizable]="false"
>
<ng-template pTemplate="header">
<h2 class="max-w-72 sm:max-w-none">
{{ translations.users_userWelcomeDialog_title() | interpolate: user() }}
</h2>
</ng-template>
@if (isLoading()) {
<div class="flex flex-row justify-center">
<p-progressSpinner class="m-4 self-center" styleClass="h-16 w-16" strokeWidth="4" />
</div>
} @else {
<div class="flex flex-col items-end gap-2">
<div class="grid grid-cols-1 gap-2 sm:grid-cols-2">
<p-button
styleClass="w-full"
[icon]="'i-[mdi--text-account]'"
[label]="translations.users_userWelcomeDialog_copyWelcomeMessage()"
(onClick)="copyWelcomeMessage()"
/>
@if (loginToken(); as loginToken) {
<p-button
styleClass="w-full"
[icon]="'i-[mdi--lock]'"
[label]="translations.users_userWelcomeDialog_copyPassword()"
(onClick)="copyPassword(loginToken)"
/>
}
</div>
</div>
}
<ng-template pTemplate="footer">
<p-button
styleClass="w-full"
[icon]="'i-[mdi--check]'"
[label]="translations.shared_done()"
(onClick)="visible.set(false)"
[text]="true"
/>
</ng-template>
</p-dialog>
Original file line number Diff line number Diff line change
@@ -1,41 +1,68 @@
import { CommonModule } from '@angular/common';
import { ChangeDetectionStrategy, Component, inject, signal } from '@angular/core';
import { ChangeDetectionStrategy, Component, computed, inject, signal } from '@angular/core';
import { interpolate, InterpolatePipe } from '@ngneers/signal-translate';
import { Store } from '@ngrx/store';
import copyToClipboard from 'copy-to-clipboard';
import { MessageService } from 'primeng/api';
import { ButtonModule } from 'primeng/button';
import { DialogModule } from 'primeng/dialog';
import { ProgressSpinnerModule } from 'primeng/progressspinner';

import { isActionBusy } from '../../../+state/action-state';
import {
loadUserLoginTokenAction,
selectUserLoginToken,
selectUsersActionState,
} from '../../../+state/users';
import { User } from '../../../models/parsed-models';
import { TranslateService } from '../../../services/translate.service';
import { selectSignal } from '../../../utils/ngrx.utils';

@Component({
selector: 'app-user-created-dialog',
selector: 'app-user-welcome-dialog',
standalone: true,
imports: [ButtonModule, CommonModule, DialogModule, InterpolatePipe],
templateUrl: './user-created-dialog.component.html',
imports: [ButtonModule, CommonModule, DialogModule, InterpolatePipe, ProgressSpinnerModule],
templateUrl: './user-welcome-dialog.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class UserCreatedDialogComponent {
export class UserWelcomeDialogComponent {
private readonly _messageService = inject(MessageService);
private readonly _store = inject(Store);
protected readonly translations = inject(TranslateService).translations;

protected readonly visible = signal(false);
protected readonly user = signal<User | undefined>(undefined);
private readonly _loadTokenState = selectSignal(selectUsersActionState('loadLoginToken'));
protected readonly isLoading = computed(() => isActionBusy(this._loadTokenState()));
protected readonly loginToken = selectSignal(
computed(() => selectUserLoginToken(this.user()?.id))
);

protected loadLoginToken() {
if (this.loginToken()) {
return;
}

const user = this.user();
if (!user) return;

this._store.dispatch(loadUserLoginTokenAction({ userId: user.id }));
}

public open(user: User) {
this.user.set(user);
this.loadLoginToken();
this.visible.set(true);
}

protected copyWelcomeMessage() {
const message = interpolate(this.translations.users_userCreatedDialog_welcomeMessage(), {
const message = interpolate(this.translations.users_userWelcomeDialog_welcomeMessage(), {
url: (document.head.querySelector('base') as HTMLBaseElement).href,
});
copyToClipboard(message);
this._messageService.add({
severity: 'success',
summary: this.translations.users_userCreatedDialog_welcomeMessageCopied(),
summary: this.translations.users_userWelcomeDialog_welcomeMessageCopied(),
life: 2000,
});
}
Expand Down
7 changes: 4 additions & 3 deletions src/client/src/app/i18n/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -195,7 +195,8 @@
"developer": "Entwickler"
},
"error": {
"save": "Fehler beim Speichern des Benutzers."
"save": "Fehler beim Speichern des Benutzers.",
"exists": "Benutzer \"{{user}}\" existiert bereits"
}
},
"error": {
Expand All @@ -209,8 +210,8 @@
"body": "Inhalt",
"bodyDefault": "Benachrichtigungen funktionieren!!! Wohoo ⛳"
},
"userCreatedDialog": {
"title": "User \"{{alias}}\" created",
"userWelcomeDialog": {
"title": "Willkommen {{alias}}",
"copyWelcomeMessage": "Willkommensnachricht kopieren",
"copyPassword": "Passwort kopieren",
"welcomeMessage": "Hallo Liebe Minigolffreunde!\nDie Anmeldung zum Minigolffreitag läuft ab jetzt über die Web-basierte App, welche du unter:\n{{url}}\naufrufen kannst.\nSpeichere sie dir als Lesezeichen, oder füge sie zu deinem Home-Bildschirm hinzu um jederzeit darauf zugreifen zu können.\nMelde dich dort bitte mit deinem Passwort an und registriere dich selber für deine Spielzeiten.\nBitte schreibe doch trotzdem im Facebook Beitrag, dass du dich registriert hast, damit dort die Konversation aufrecht erhalten wird.\nViel Spaß beim Minigolfen!\nDein Orga-Team",
Expand Down
7 changes: 4 additions & 3 deletions src/client/src/app/i18n/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -195,7 +195,8 @@
"developer": "Developer"
},
"error": {
"save": "Failed to save user."
"save": "Failed to save user.",
"exists": "User \"{{user}}\" already exists"
}
},
"error": {
Expand All @@ -209,8 +210,8 @@
"body": "body",
"bodyDefault": "Notifications are working!!! Wohoo ⛳"
},
"userCreatedDialog": {
"title": "User \"{{alias}}\" created",
"userWelcomeDialog": {
"title": "Welcome {{alias}}",
"copyWelcomeMessage": "Copy welcome message",
"copyPassword": "Copy password",
"welcomeMessage": "Hello dear minigolf friends!\nRegistration for Minigolf Friday is now possible via the web-based app, which you can download at:\n{{url}}.\nSave it as a bookmark or add it to your home screen so that you can access it at any time.\nPlease log in there with your password and register yourself for your game times.\nPlease still write in the Facebook post that you have registered so that the conversation is maintained there.\nHave fun playing mini golf!\nYour organization team",
Expand Down
1 change: 1 addition & 0 deletions src/server/data/Entities/UserEntity.cs
Original file line number Diff line number Diff line change
Expand Up @@ -58,5 +58,6 @@ public static void Configure(EntityTypeBuilder<UserEntity> builder)

builder.HasKey(x => x.Id);
builder.HasIndex(x => x.LoginToken).IsUnique();
builder.HasIndex(x => x.Alias).IsUnique();
}
}
Original file line number Diff line number Diff line change
@@ -1,10 +1,14 @@
using System.ComponentModel.DataAnnotations;
using FastEndpoints;
using FluentValidation;
using Microsoft.EntityFrameworkCore;
using MinigolfFriday.Data;
using MinigolfFriday.Data.Entities;
using MinigolfFriday.Domain.Models;
using MinigolfFriday.Domain.Models.RealtimeEvents;
using MinigolfFriday.Host.Common;
using MinigolfFriday.Host.Mappers;
using MinigolfFriday.Host.Options;
using MinigolfFriday.Host.Services;
using MinigolfFriday.Host.Utilities;

Expand Down Expand Up @@ -51,18 +55,30 @@ public CreateUserRequestValidator(IIdService idService)
public class CreateUserEndpoint(
DatabaseContext databaseContext,
IRealtimeEventsService realtimeEventsService,
IIdService idService
IIdService idService,
IUserMapper userMapper
) : Endpoint<CreateUserRequest, CreateUserResponse>
{
public override void Configure()
{
Post("");
Group<UserAdministrationGroup>();
Description(x => x.ClearDefaultProduces(200).Produces<CreateUserResponse>(201));
this.ProducesError(EndpointErrors.UserExists);
}

public override async Task HandleAsync(CreateUserRequest req, CancellationToken ct)
{
var existentUser = await databaseContext
.Users.Where(x => x.Alias == req.Alias)
.Select(userMapper.MapUserExpression)
.FirstOrDefaultAsync(ct);
if (existentUser != null)
{
await this.SendErrorAsync(EndpointErrors.UserExists, existentUser.Alias, ct);
return;
}

var user = new UserEntity()
{
Alias = req.Alias,
Expand Down
Loading

0 comments on commit 6e4eee8

Please sign in to comment.