generated from flo/template-frontend
first base version
This commit is contained in:
parent
bdb6299692
commit
550f8bb209
@ -1,5 +1,6 @@
|
|||||||
import { Component, OnInit } from '@angular/core';
|
import { Component, OnInit } from '@angular/core';
|
||||||
import { AuthService } from './core/services/auth.service';
|
import { AuthService } from './core/services/auth.service';
|
||||||
|
import { AppService } from './core/services/app.service';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-root',
|
selector: 'app-root',
|
||||||
@ -8,7 +9,8 @@ import { AuthService } from './core/services/auth.service';
|
|||||||
})
|
})
|
||||||
export class AppComponent implements OnInit {
|
export class AppComponent implements OnInit {
|
||||||
constructor(
|
constructor(
|
||||||
private authService: AuthService
|
private authService: AuthService,
|
||||||
|
private appService: AppService,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -11,8 +11,8 @@ import { CoreModule } from './core/core.module';
|
|||||||
import { AuthGuard } from './core/guards/auth.guard';
|
import { AuthGuard } from './core/guards/auth.guard';
|
||||||
|
|
||||||
const routes: Routes = [
|
const routes: Routes = [
|
||||||
{ path: 'auth', loadChildren: () => import('./core/auth/auth.module').then(m => m.AuthModule) },
|
|
||||||
{ path: 'home', component: HomeComponent, canActivate: [AuthGuard] },
|
{ path: 'home', component: HomeComponent, canActivate: [AuthGuard] },
|
||||||
|
{ path: 'auth', loadChildren: () => import('./core/auth/auth.module').then(m => m.AuthModule) },
|
||||||
{ path: '', redirectTo: 'home', pathMatch: 'full' },
|
{ path: '', redirectTo: 'home', pathMatch: 'full' },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@ -1,21 +0,0 @@
|
|||||||
import { Injectable } from '@angular/core';
|
|
||||||
|
|
||||||
@Injectable({
|
|
||||||
providedIn: 'root'
|
|
||||||
})
|
|
||||||
export class AppService {
|
|
||||||
|
|
||||||
private username: string|null;
|
|
||||||
|
|
||||||
constructor() {
|
|
||||||
this.username = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
public getUsername(): string|null {
|
|
||||||
return this.username;
|
|
||||||
}
|
|
||||||
|
|
||||||
public setUsername(name: string|null): void {
|
|
||||||
this.username = name;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -9,7 +9,7 @@ import { ReactiveFormsModule } from '@angular/forms';
|
|||||||
const routes: Routes = [
|
const routes: Routes = [
|
||||||
{ path: 'login', component: LoginComponent },
|
{ path: 'login', component: LoginComponent },
|
||||||
{ path: 'registration', component: RegistrationComponent },
|
{ path: 'registration', component: RegistrationComponent },
|
||||||
{ path: 'registration/:id', component: ConfirmRegistrationComponent },
|
{ path: 'registration/:registrationId', component: ConfirmRegistrationComponent },
|
||||||
{ path: '', redirectTo: 'login', pathMatch: 'full' },
|
{ path: '', redirectTo: 'login', pathMatch: 'full' },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@ -1 +1,23 @@
|
|||||||
<p>confirm-registration works!</p>
|
<div class="mb-10">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="max-w-sm mx-auto mb-10">
|
||||||
|
<h1 class="font-bold text-center text-skin-primary text-5xl mb-5">
|
||||||
|
Beekeeper
|
||||||
|
</h1>
|
||||||
|
<h1 class="font-bold text-center text-skin-accent text-xl">
|
||||||
|
Registrierung Abschließen
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form class="max-w-sm mx-auto" [formGroup]="confirmRegistrationForm">
|
||||||
|
<div class="mb-5">
|
||||||
|
<label for="password" class="block mb-2 text-sm font-medium text-skin-primary-muted">Passwort</label>
|
||||||
|
<input formControlName="password" type="password" id="password" class="bg-skin-primary border border-gray-300 text-skin-primary text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5" required />
|
||||||
|
</div>
|
||||||
|
<div class="mb-5">
|
||||||
|
<label for="passwordConfirmation" class="block mb-2 text-sm font-medium text-skin-primary-muted">Passwort wiederholen</label>
|
||||||
|
<input formControlName="passwordConfirmation" type="password" id="passwordConfirmation" class="bg-skin-primary border border-gray-300 text-skin-primary text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5" required />
|
||||||
|
</div>
|
||||||
|
<button (click)="confirm()" [disabled]="!confirmRegistrationForm.valid" type="submit" class="w-full 9xl:w-auto font-bold text-skin-secondary bg-skin-accent hover:text-skin-primary rounded-lg text-sm px-5 py-2.5 text-center">Registrieren</button>
|
||||||
|
</form>
|
||||||
|
|||||||
@ -1,4 +1,8 @@
|
|||||||
import { Component } from '@angular/core';
|
import { Component } from '@angular/core';
|
||||||
|
import { FormControl, FormGroup, Validators } from '@angular/forms';
|
||||||
|
import { ActivatedRoute, Router } from '@angular/router';
|
||||||
|
import { filter } from 'rxjs';
|
||||||
|
import { AuthService } from 'src/app/core/services/auth.service';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-confirm-registration',
|
selector: 'app-confirm-registration',
|
||||||
@ -6,5 +10,32 @@ import { Component } from '@angular/core';
|
|||||||
styleUrls: ['./confirm-registration.component.scss']
|
styleUrls: ['./confirm-registration.component.scss']
|
||||||
})
|
})
|
||||||
export class ConfirmRegistrationComponent {
|
export class ConfirmRegistrationComponent {
|
||||||
|
confirmRegistrationForm = new FormGroup({
|
||||||
|
password: new FormControl('', [Validators.required]),
|
||||||
|
passwordConfirmation: new FormControl('', [Validators.required]),
|
||||||
|
});
|
||||||
|
|
||||||
|
registrationId: string | undefined;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private authService: AuthService,
|
||||||
|
private activatedRoute: ActivatedRoute
|
||||||
|
) {
|
||||||
|
this.activatedRoute.params.subscribe(
|
||||||
|
(params) => {
|
||||||
|
this.registrationId = params['registrationId'];
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
confirm(): void {
|
||||||
|
if (this.registrationId === undefined)
|
||||||
|
return;
|
||||||
|
|
||||||
|
this.authService.confirmRegistration({
|
||||||
|
id: this.registrationId!,
|
||||||
|
password: this.confirmRegistrationForm.value.password!,
|
||||||
|
passwordConfirmation: this.confirmRegistrationForm.value.passwordConfirmation!
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -20,5 +20,5 @@
|
|||||||
<input formControlName="password" type="password" id="password" class="bg-skin-primary border border-gray-300 text-skin-primary text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5" required />
|
<input formControlName="password" type="password" id="password" class="bg-skin-primary border border-gray-300 text-skin-primary text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5" required />
|
||||||
<p id="helper-text-explanation" class="mt-2 text-sm text-skin-primary-muted">Neu hier? <a routerLink="/auth/registration" class="font-medium text-skin-accent hover:underline hover:font-bold">Jetzt registrieren!</a></p>
|
<p id="helper-text-explanation" class="mt-2 text-sm text-skin-primary-muted">Neu hier? <a routerLink="/auth/registration" class="font-medium text-skin-accent hover:underline hover:font-bold">Jetzt registrieren!</a></p>
|
||||||
</div>
|
</div>
|
||||||
<button (click)="login()" [disabled]="!loginForm.valid" type="submit" class="w-full 9xl:w-auto font-bold text-skin-primary bg-skin-secondary hover:text-skin-secondary rounded-lg text-sm px-5 py-2.5 text-center">Anmelden</button>
|
<button (click)="login()" [disabled]="!loginForm.valid" type="submit" class="w-full 9xl:w-auto font-bold text-skin-secondary bg-skin-accent hover:text-skin-primary rounded-lg text-sm px-5 py-2.5 text-center">Anmelden</button>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@ -1 +1,24 @@
|
|||||||
<p>registration works!</p>
|
<div class="mb-10">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="max-w-sm mx-auto mb-10">
|
||||||
|
<h1 class="font-bold text-center text-skin-primary text-5xl mb-5">
|
||||||
|
Beekeeper
|
||||||
|
</h1>
|
||||||
|
<h1 class="font-bold text-center text-skin-accent text-xl">
|
||||||
|
Registrierung
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form class="max-w-sm mx-auto" [formGroup]="registrationForm">
|
||||||
|
<div class="mb-5">
|
||||||
|
<label for="mail" class="block mb-2 text-sm font-medium text-skin-primary-muted">E-Mail</label>
|
||||||
|
<input formControlName="mail" type="mail" id="mail" class="bg-skin-primary border border-gray-300 text-skin-primary text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5" placeholder="your@email.com" required />
|
||||||
|
</div>
|
||||||
|
<div class="mb-5">
|
||||||
|
<label for="username" class="block mb-2 text-sm font-medium text-skin-primary-muted">Benutzername</label>
|
||||||
|
<input formControlName="username" type="string" id="username" class="bg-skin-primary border border-gray-300 text-skin-primary text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5" required />
|
||||||
|
<p id="helper-text-explanation" class="mt-2 text-sm text-skin-primary-muted">Bereits registiert? <a routerLink="/auth/login" class="font-medium text-skin-accent hover:underline hover:font-bold">Jetzt anmelden!</a></p>
|
||||||
|
</div>
|
||||||
|
<button (click)="login()" [disabled]="!registrationForm.valid" type="submit" class="w-full 9xl:w-auto font-bold text-skin-secondary bg-skin-accent hover:text-skin-primary rounded-lg text-sm px-5 py-2.5 text-center">Registrieren</button>
|
||||||
|
</form>
|
||||||
|
|||||||
@ -1,4 +1,8 @@
|
|||||||
import { Component } from '@angular/core';
|
import { Component } from '@angular/core';
|
||||||
|
import { FormControl, FormGroup, Validators } from '@angular/forms';
|
||||||
|
import { Router } from '@angular/router';
|
||||||
|
import { filter } from 'rxjs';
|
||||||
|
import { AuthService } from 'src/app/core/services/auth.service';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-registration',
|
selector: 'app-registration',
|
||||||
@ -6,5 +10,26 @@ import { Component } from '@angular/core';
|
|||||||
styleUrls: ['./registration.component.scss']
|
styleUrls: ['./registration.component.scss']
|
||||||
})
|
})
|
||||||
export class RegistrationComponent {
|
export class RegistrationComponent {
|
||||||
|
registrationForm = new FormGroup({
|
||||||
|
mail: new FormControl('', [Validators.required]),
|
||||||
|
username: new FormControl('', [Validators.required]),
|
||||||
|
});
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private authService: AuthService,
|
||||||
|
private router: Router
|
||||||
|
) {
|
||||||
|
this.authService.currentState$.pipe(
|
||||||
|
filter(state => state !== undefined)
|
||||||
|
).subscribe(state => {
|
||||||
|
this.router.navigateByUrl('/home');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
login(): void {
|
||||||
|
this.authService.register({
|
||||||
|
mail: this.registrationForm.value.mail!,
|
||||||
|
username: this.registrationForm.value.username!
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,5 +1,24 @@
|
|||||||
<app-navigation></app-navigation>
|
<app-navigation></app-navigation>
|
||||||
|
|
||||||
<div>
|
<div class="max-w-screen-xl mx-auto p-4">
|
||||||
<iframe src="/api/health"></iframe>
|
<shared-card icon="/assets/icon.png" header="Test" subHeader="lol noch ein test">
|
||||||
|
I'm working
|
||||||
|
</shared-card>
|
||||||
|
|
||||||
|
<shared-card icon="/assets/icon.png" header="Test" >
|
||||||
|
I'm working
|
||||||
|
</shared-card>
|
||||||
|
|
||||||
|
<shared-card>
|
||||||
|
I'm working
|
||||||
|
</shared-card>
|
||||||
|
|
||||||
|
<div class="mb-5">
|
||||||
|
<shared-table [items]="colonies" [columns]="columns">
|
||||||
|
|
||||||
|
</shared-table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<shared-paginator total="20" perPage="5" />
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
@ -1,5 +1,11 @@
|
|||||||
import { Component } from '@angular/core';
|
import { Component } from '@angular/core';
|
||||||
import { RequestService } from 'src/app/core/services/request.service';
|
import { RequestService } from 'src/app/core/services/request.service';
|
||||||
|
import { ColumnDefinition } from 'src/app/shared/components/table/table.component';
|
||||||
|
|
||||||
|
interface Colony {
|
||||||
|
name: string;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-home',
|
selector: 'app-home',
|
||||||
@ -7,6 +13,28 @@ import { RequestService } from 'src/app/core/services/request.service';
|
|||||||
styleUrls: ['./home.component.scss'],
|
styleUrls: ['./home.component.scss'],
|
||||||
})
|
})
|
||||||
export class HomeComponent {
|
export class HomeComponent {
|
||||||
|
|
||||||
|
columns: ColumnDefinition[] = [
|
||||||
|
{
|
||||||
|
header: 'Name',
|
||||||
|
columnFunction: (colony:Colony) => colony.name,
|
||||||
|
routerLink: (colony:Colony) => '#',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: 'Name2',
|
||||||
|
columnFunction: (colony:Colony) => colony.name,
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
colonies: Colony[] = [
|
||||||
|
{
|
||||||
|
name: 'Die Römer'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Die Griechen'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
constructor(public requestService: RequestService) {
|
constructor(public requestService: RequestService) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,17 +1,17 @@
|
|||||||
|
|
||||||
|
|
||||||
<nav class="bg-skin-secondary">
|
<nav class="bg-skin-accent">
|
||||||
<div class="max-w-screen-xl flex flex-wrap items-center justify-between mx-auto p-4">
|
<div class="max-w-screen-xl flex flex-wrap items-center justify-between mx-auto p-4">
|
||||||
|
|
||||||
<!-- Title -->
|
<!-- Title -->
|
||||||
<a href="#" class="flex items-center space-x-3">
|
<a href="#" class="flex items-center space-x-3">
|
||||||
<img src="assets/icon.png" class="h-10" alt="Beekeeper Logo" />
|
<img src="assets/icon.png" class="h-10" alt="Beekeeper Logo" />
|
||||||
<span class="text-skin-primary self-center text-2xl font-semibold whitespace-nowrap">Beekeeper</span>
|
<span class="text-skin-primary self-center text-4xl font-semibold whitespace-nowrap">Beekeeper</span>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
<div class="flex items-center md:order-2 space-x-3 md:space-x-0">
|
<div class="flex items-center md:order-2 space-x-3 md:space-x-0">
|
||||||
<!--User Bubble-->
|
<!--User Bubble-->
|
||||||
<button type="button" class="block w-10 h-10 text-sm bg-skin-primary rounded-full md:me-0 focus:ring-4 focus:ring-gray-300" id="user-menu-button" aria-expanded="false" data-dropdown-toggle="user-dropdown" data-dropdown-placement="bottom">
|
<button type="button" class="block w-10 h-10 text-sm bg-skin-primary rounded-full md:me-0 focus:ring-4 focus:ring-skin-primary" id="user-menu-button" aria-expanded="false" data-dropdown-toggle="user-dropdown" data-dropdown-placement="bottom">
|
||||||
<span class="text-skin-accent rounded-full font-bold p-2">AA</span>
|
<span class="text-skin-accent rounded-full font-bold p-2">AA</span>
|
||||||
<!-- <img class="w-8 h-8 rounded-full" src="/docs/images/people/profile-picture-3.jpg" alt="user photo"> -->
|
<!-- <img class="w-8 h-8 rounded-full" src="/docs/images/people/profile-picture-3.jpg" alt="user photo"> -->
|
||||||
</button>
|
</button>
|
||||||
@ -26,29 +26,32 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- User dropdown -->
|
<!-- User dropdown -->
|
||||||
<div class="z-50 hidden my-4 text-base list-none divide-y divide-gray-100 rounded-lg shadow-sm shadow-skin-primary bg-skin-primary" id="user-dropdown">
|
<div class="z-50 hidden my-4 text-base list-none divide-y divide-skin-primary rounded-lg shadow-sm shadow-skin-primary bg-skin-secondary" id="user-dropdown">
|
||||||
<div class="px-4 py-3">
|
<div class="px-4 py-3">
|
||||||
<span class="block text-sm text-skin-accent font-bold">{{state?.username}}</span>
|
<span class="block text-sm text-skin-accent font-bold">{{state?.username}}</span>
|
||||||
<span class="block text-sm text-skin-primary-muted truncate">{{state?.roleIdentifier}}</span>
|
<span class="block text-sm text-skin-primary-muted truncate">{{state?.roleIdentifier}}</span>
|
||||||
</div>
|
</div>
|
||||||
<ul class="p-4" aria-labelledby="user-menu-button">
|
<ul class="p-4" aria-labelledby="user-menu-button">
|
||||||
<li>
|
<li>
|
||||||
<a href="#" class="block py-2 px-3 rounded text-skin-primary hover:bg-skin-secondary">Einstellungen</a>
|
<a href="#" class="block py-2 px-3 rounded text-skin-primary hover:bg-skin-accent">Einstellungen</a>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<button class="w-full block py-2 px-3 rounded text-skin-primary hover:bg-skin-secondary" (click)="logout()">Ausloggen</button>
|
<button class="w-full block py-2 px-3 rounded text-skin-primary hover:bg-skin-accent" (click)="toggleDarkmode()">{{ darkMode ? 'Hell' : 'Dunkel' }}</button>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<button class="w-full block py-2 px-3 rounded text-skin-primary hover:bg-skin-accent" (click)="logout()">Ausloggen</button>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Navigatoin -->
|
<!-- Navigatoin -->
|
||||||
<div class="items-center justify-between hidden w-full md:flex md:w-auto md:order-1" id="navbar-user">
|
<div class="items-center justify-between hidden w-full md:flex md:w-auto md:order-1" id="navbar-user">
|
||||||
<ul class="flex flex-col font-medium p-4 md:p-0 mt-4 rounded-lg bg-skin-primary md:bg-skin-secondary md:space-x-8 md:flex-row md:mt-0 md:border-0 md:bg-skin-fill">
|
<ul class="flex flex-col text-xl p-4 md:p-0 mt-4 rounded-lg bg-skin-primary md:bg-skin-accent md:space-x-8 md:flex-row md:mt-0 md:border-0">
|
||||||
<li>
|
<li>
|
||||||
<a routerLink="/home" class="block py-2 px-3 rounded text-skin-primary md:p-0" aria-current="page">Home</a>
|
<a routerLink="/home" class="block py-2 px-3 rounded text-skin-primary md:p-0" aria-current="page">Home</a>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<a href="#" class="block py-2 px-3 rounded text-skin-primary-muted hover:text-skin-primary hover:bg-skin-secondary md:p-0">About</a>
|
<a href="#" class="block py-2 px-3 rounded text-skin-primary-muted hover:text-skin-primary hover:bg-skin-accent md:p-0">About</a>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import { AuthService } from '../../services/auth.service';
|
|||||||
import { UserStateResponse } from '../../models/user-state-request.model';
|
import { UserStateResponse } from '../../models/user-state-request.model';
|
||||||
import { Router } from '@angular/router';
|
import { Router } from '@angular/router';
|
||||||
import { filter, map } from 'rxjs';
|
import { filter, map } from 'rxjs';
|
||||||
|
import { AppService } from '../../services/app.service';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-navigation',
|
selector: 'app-navigation',
|
||||||
@ -13,11 +14,14 @@ import { filter, map } from 'rxjs';
|
|||||||
export class NavigationComponent implements OnInit {
|
export class NavigationComponent implements OnInit {
|
||||||
|
|
||||||
state: UserStateResponse | undefined | null;
|
state: UserStateResponse | undefined | null;
|
||||||
|
darkMode: boolean;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private authService: AuthService,
|
private authService: AuthService,
|
||||||
|
private appService: AppService,
|
||||||
private router: Router,
|
private router: Router,
|
||||||
) {
|
) {
|
||||||
|
this.darkMode = this.appService.darkMode;
|
||||||
this.state = this.authService.currentState$.value;
|
this.state = this.authService.currentState$.value;
|
||||||
this.authService.currentState$.pipe(
|
this.authService.currentState$.pipe(
|
||||||
filter(state => state === undefined)
|
filter(state => state === undefined)
|
||||||
@ -33,4 +37,9 @@ export class NavigationComponent implements OnInit {
|
|||||||
logout(): void {
|
logout(): void {
|
||||||
this.authService.logout();
|
this.authService.logout();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
toggleDarkmode() {
|
||||||
|
this.appService.toggleDarkMode();
|
||||||
|
this.darkMode = this.appService.darkMode;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -5,6 +5,8 @@ import { NavigationComponent } from './components/navigation/navigation.componen
|
|||||||
import { AuthGuard } from './guards/auth.guard';
|
import { AuthGuard } from './guards/auth.guard';
|
||||||
import { AuthService } from './services/auth.service';
|
import { AuthService } from './services/auth.service';
|
||||||
import { RequestService } from './services/request.service';
|
import { RequestService } from './services/request.service';
|
||||||
|
import { AppService } from './services/app.service';
|
||||||
|
import { SharedModule } from '../shared/shared.module';
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@ -12,8 +14,9 @@ import { RequestService } from './services/request.service';
|
|||||||
declarations: [HomeComponent, NavigationComponent],
|
declarations: [HomeComponent, NavigationComponent],
|
||||||
exports: [HomeComponent, NavigationComponent],
|
exports: [HomeComponent, NavigationComponent],
|
||||||
imports: [
|
imports: [
|
||||||
CommonModule
|
CommonModule,
|
||||||
|
SharedModule
|
||||||
],
|
],
|
||||||
providers: [AuthGuard, AuthService, RequestService]
|
providers: [AuthGuard, AuthService, RequestService, AppService]
|
||||||
})
|
})
|
||||||
export class CoreModule { }
|
export class CoreModule { }
|
||||||
|
|||||||
14
src/app/core/models/confirm-registration-request.model.ts
Normal file
14
src/app/core/models/confirm-registration-request.model.ts
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
export interface ConfirmRegistrationRequest {
|
||||||
|
id: string,
|
||||||
|
password: string,
|
||||||
|
passwordConfirmation: string,
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ConfirmRegistrationResponse {
|
||||||
|
id: string,
|
||||||
|
username: string,
|
||||||
|
roleIdentifier: string,
|
||||||
|
createdAt: string,
|
||||||
|
updatedAt: string,
|
||||||
|
permissions: string[]
|
||||||
|
}
|
||||||
7
src/app/core/models/register-user-request.model.ts
Normal file
7
src/app/core/models/register-user-request.model.ts
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
export interface RegisterUserRequest {
|
||||||
|
mail: string,
|
||||||
|
username: string,
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RegisterUserResponse {
|
||||||
|
}
|
||||||
30
src/app/core/services/app.service.ts
Normal file
30
src/app/core/services/app.service.ts
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
import { Injectable } from "@angular/core";
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class AppService {
|
||||||
|
|
||||||
|
darkMode: boolean;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
) {
|
||||||
|
this.darkMode = (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches);
|
||||||
|
this.applyDarkMode();
|
||||||
|
}
|
||||||
|
|
||||||
|
private applyDarkMode(): void {
|
||||||
|
if (this.darkMode) {
|
||||||
|
document.documentElement.classList.add('theme-dark');
|
||||||
|
} else {
|
||||||
|
document.documentElement.classList.remove('theme-dark');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleDarkMode() {
|
||||||
|
this.darkMode = !this.darkMode;
|
||||||
|
this.applyDarkMode();
|
||||||
|
}
|
||||||
|
|
||||||
|
getDarkMode(): boolean {
|
||||||
|
return this.darkMode;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -4,6 +4,8 @@ import { UserStateResponse } from "../models/user-state-request.model";
|
|||||||
import { BehaviorSubject, Observable } from "rxjs";
|
import { BehaviorSubject, Observable } from "rxjs";
|
||||||
import { LoginRequest, LoginResponse } from "../models/login-request.model copy";
|
import { LoginRequest, LoginResponse } from "../models/login-request.model copy";
|
||||||
import { Router } from "@angular/router";
|
import { Router } from "@angular/router";
|
||||||
|
import { RegisterUserRequest, RegisterUserResponse } from "../models/register-user-request.model";
|
||||||
|
import { ConfirmRegistrationRequest, ConfirmRegistrationResponse } from "../models/confirm-registration-request.model";
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class AuthService {
|
export class AuthService {
|
||||||
@ -44,6 +46,36 @@ export class AuthService {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
register(body: RegisterUserRequest): RegisterUserResponse|null {
|
||||||
|
let result = null;
|
||||||
|
|
||||||
|
this.requestService.post(
|
||||||
|
'auth/register-user',
|
||||||
|
body,
|
||||||
|
(response: LoginResponse) => {
|
||||||
|
result = response;
|
||||||
|
this.readUserState();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
confirmRegistration(body: ConfirmRegistrationRequest): ConfirmRegistrationResponse|null {
|
||||||
|
let result = null;
|
||||||
|
|
||||||
|
this.requestService.post(
|
||||||
|
'auth/confirm-registration',
|
||||||
|
body,
|
||||||
|
(response: LoginResponse) => {
|
||||||
|
result = response;
|
||||||
|
this.readUserState();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
logout(): void {
|
logout(): void {
|
||||||
this.requestService.post(
|
this.requestService.post(
|
||||||
'auth/logout-user',
|
'auth/logout-user',
|
||||||
|
|||||||
@ -1,29 +1,32 @@
|
|||||||
<div
|
<div
|
||||||
id="card"
|
id="card"
|
||||||
class="grow p-3 my-2 rounded-xl shadow-lg border border-gray-100 dark:bg-zinc-800 dark:border-zinc-900"
|
class="grow p-3 my-2 rounded-xl shadow-sm shadow-skin-primary border border-skin-primary divide-y divide-skin-primary"
|
||||||
>
|
>
|
||||||
<div id="header">
|
<div *ngIf="icon !== null || header !== null || subHeader !== null" id="header" class="mb-5">
|
||||||
<div class="flex flex-row">
|
<div class="w-full flex flex-row">
|
||||||
<div *ngIf="icon !== null">
|
<!--Icon-->
|
||||||
<img class="h-6 w-6 m-2" [src]="icon" />
|
<div *ngIf="icon !== null" class="basis-auto">
|
||||||
|
<img class="h-12 w-12 m-2" [src]="icon" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!--Header-->
|
||||||
|
<div class="basis-full truncate">
|
||||||
<div
|
<div
|
||||||
*ngIf="header !== null"
|
*ngIf="header !== null"
|
||||||
class="my-auto text-xl font-medium text-black dark:text-white truncate"
|
class="text-xl font-medium text-skin-primary mb-2">
|
||||||
>
|
|
||||||
{{ header }}
|
{{ header }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
<div
|
<div
|
||||||
*ngIf="subHeader !== null"
|
*ngIf="subHeader !== null"
|
||||||
class="text-slate-500 dark:text-slate-400 mx-2"
|
class="text-skin-primary-muted pl-2">
|
||||||
>
|
|
||||||
{{ subHeader }}
|
{{ subHeader }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div id="content" class="mx-5 text-black dark:text-white">
|
<div id="content" class="text-skin-primary p-5">
|
||||||
<div *ngIf="content !== null" class="my-2 italic">"{{ content }}"</div>
|
<div *ngIf="content !== null" class="italic">"{{ content }}"</div>
|
||||||
<div *ngIf="content === null">
|
<div *ngIf="content === null">
|
||||||
<ng-content></ng-content>
|
<ng-content></ng-content>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,21 +0,0 @@
|
|||||||
<div
|
|
||||||
*ngIf="caption !== null"
|
|
||||||
class="text-lg font-bold mb-3 text-black dark:text-white"
|
|
||||||
>
|
|
||||||
{{ caption }}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex flex-col md:space-x-4">
|
|
||||||
<div id="form-body" class="flex-1 basis-full">
|
|
||||||
<ng-content />
|
|
||||||
</div>
|
|
||||||
<div class="flex flex-row">
|
|
||||||
<div class="flex-1"></div>
|
|
||||||
<button
|
|
||||||
(click)="submit.emit()"
|
|
||||||
class="flex-0 p-2 min-w-24 bg-green-700 hover:bg-green-800 dark:bg-green-800 dark:hover:bg-green-900 rounded-lg"
|
|
||||||
>
|
|
||||||
<p class="text-lg text-white text-md font-bold">Submit</p>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
@ -1,12 +0,0 @@
|
|||||||
import { Component, EventEmitter, Input, Output } from '@angular/core';
|
|
||||||
|
|
||||||
@Component({
|
|
||||||
selector: 'shared-form',
|
|
||||||
templateUrl: './form.component.html',
|
|
||||||
styleUrls: ['./form.component.scss']
|
|
||||||
})
|
|
||||||
export class FormComponent {
|
|
||||||
@Output() submit = new EventEmitter<any>();
|
|
||||||
@Input() caption: string|null = null;
|
|
||||||
|
|
||||||
}
|
|
||||||
@ -3,7 +3,7 @@
|
|||||||
<li>
|
<li>
|
||||||
<button
|
<button
|
||||||
(click)="setActivePage(-1)"
|
(click)="setActivePage(-1)"
|
||||||
class="flex items-center justify-center px-3 h-8 ms-0 leading-tight text-gray-500 dark:text-white bg-white dark:bg-zinc-800 border border-e-0 border-gray-500 dark:border-zinc-800 rounded-s-lg hover:bg-gray-100 hover:text-gray-700"
|
class="flex items-center justify-center px-3 h-8 ms-0 leading-tight text-skin-primary bg-skin-secondary border border-skin-primary rounded-s-lg hover:bg-skin-primary"
|
||||||
>
|
>
|
||||||
<span class="sr-only">Previous</span>
|
<span class="sr-only">Previous</span>
|
||||||
<svg
|
<svg
|
||||||
@ -23,18 +23,19 @@
|
|||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
|
|
||||||
<li *ngFor="let item of items" class="w-full">
|
<li *ngFor="let item of items" class="w-full">
|
||||||
<div
|
<div
|
||||||
class="flex items-center justify-center h-8 leading-tight border border-gray-500 dark:border-zinc-800"
|
class="flex items-center justify-center h-8 leading-tight border border-skin-primary text-skin-primary"
|
||||||
[ngClass]="{
|
[ngClass]="{
|
||||||
'bg-green-700 dark:bg-green-800 text-white': item.page === page,
|
'bg-skin-accent font-bold text-skin-secondary hover:text-skin-primary': item.page === page,
|
||||||
'bg-white dark:bg-zinc-700 text-black dark:text-white':
|
'bg-skin-secondary':
|
||||||
item.page !== page
|
item.page !== page
|
||||||
}"
|
}"
|
||||||
>
|
>
|
||||||
<div *ngIf="item.page === null" class="px-3">...</div>
|
<div *ngIf="item.page === null" class="px-3">...</div>
|
||||||
<button
|
<button
|
||||||
class="px-3 w-full h-full hover:bg-gray-100 hover:text-gray-700"
|
class="px-3 w-full h-full hover:bg-skin-primary"
|
||||||
*ngIf="item.page !== null"
|
*ngIf="item.page !== null"
|
||||||
(click)="setActivePage(item.page)"
|
(click)="setActivePage(item.page)"
|
||||||
>
|
>
|
||||||
@ -42,10 +43,11 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
|
|
||||||
<li>
|
<li>
|
||||||
<button
|
<button
|
||||||
(click)="setActivePage(-2)"
|
(click)="setActivePage(-2)"
|
||||||
class="flex items-center justify-center px-3 h-8 leading-tight text-gray-500 dark:text-white bg-white dark:bg-zinc-800 border border-gray-500 dark:border-zinc-800 rounded-e-lg hover:bg-gray-100 hover:text-gray-700"
|
class="flex items-center justify-center px-3 h-8 leading-tight text-skin-primary bg-skin-secondary border border-skin-primary rounded-e-lg hover:bg-skin-primary"
|
||||||
>
|
>
|
||||||
<span class="sr-only">Next</span>
|
<span class="sr-only">Next</span>
|
||||||
<svg
|
<svg
|
||||||
|
|||||||
@ -1,20 +0,0 @@
|
|||||||
<div class="text-sm font-medium text-center text-gray-500">
|
|
||||||
<ul class="flex flex-wrap -mb-px">
|
|
||||||
<li *ngFor="let tab of tabs; index as currentIndex" class="grow lg:grow-0">
|
|
||||||
<button
|
|
||||||
(click)="setActiveTab(tab)"
|
|
||||||
class="w-full lg:w-auto text-xl font-bold inline-block px-4 pt-4 pb-3 border-b-2 rounded-t-lg hover:text-green-700 hover:border-green-700 hover:no-underline dark:hover:text-green-800 dark:hover:border-green-800"
|
|
||||||
[ngClass]="{
|
|
||||||
'text-black border-black dark:text-green-800 dark:border-green-800 hover:text-green-700 dark:hover:border-white dark:hover:text-white':
|
|
||||||
currentIndex === activeIndex
|
|
||||||
}"
|
|
||||||
>
|
|
||||||
{{ tab.title }}
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="tab-content">
|
|
||||||
<ng-content></ng-content>
|
|
||||||
</div>
|
|
||||||
@ -1,30 +0,0 @@
|
|||||||
import { Component, ContentChildren, EventEmitter, Input, Output, QueryList } from '@angular/core';
|
|
||||||
import { TabItem } from '../../models/TabItem';
|
|
||||||
|
|
||||||
@Component({
|
|
||||||
selector: 'shared-tab-control',
|
|
||||||
templateUrl: './tab-control.component.html',
|
|
||||||
styleUrls: ['./tab-control.component.scss']
|
|
||||||
})
|
|
||||||
export class TabControlComponent {
|
|
||||||
@Input() public tabs: TabItem[] = [];
|
|
||||||
@Input() public activeTabId: string = '';
|
|
||||||
@Output() public activeTabIdChange = new EventEmitter<string>();
|
|
||||||
|
|
||||||
@ContentChildren('tabContent') tabContents!: QueryList<any>;
|
|
||||||
|
|
||||||
public activeIndex = 0;
|
|
||||||
|
|
||||||
public ngOnChanges() {
|
|
||||||
this.setActiveTab(null);
|
|
||||||
}
|
|
||||||
|
|
||||||
public setActiveTab(tab: TabItem|null) {
|
|
||||||
if (tab !== null) {
|
|
||||||
this.activeTabId = tab.id;
|
|
||||||
this.activeTabIdChange.emit(this.activeTabId);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.activeIndex = this.tabs.findIndex((i) => i.id === this.activeTabId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,30 +1,29 @@
|
|||||||
<table class="w-full" [ngClass]="[background, foreground, border]">
|
<table class="w-full text-skin-primary">
|
||||||
<thead class="uppercase border-b text-sm" [ngClass]="[border]">
|
|
||||||
|
<thead class="uppercase bg-skin-accent border-b border-skin-primary text-left">
|
||||||
<tr>
|
<tr>
|
||||||
<th
|
<th
|
||||||
*ngFor="let column of columns; index as currentIndex"
|
*ngFor="let column of columns"
|
||||||
[class]="'px-5 py-2 ' + column.headerCss"
|
class="px-5 py-2"
|
||||||
[ngClass]="{
|
|
||||||
backgroundColumn: currentIndex % 2 === 0,
|
|
||||||
backgroundColumnAlternate: currentIndex % 2 === 1
|
|
||||||
}"
|
|
||||||
>
|
>
|
||||||
<div [innerHtml]="column.header"></div>
|
{{column.header}}
|
||||||
</th>
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
|
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr *ngFor="let item of items" class="border-b" [ngClass]="[border]">
|
<tr *ngFor="let item of items" class="border-b border-skin-primary">
|
||||||
|
|
||||||
<td
|
<td
|
||||||
*ngFor="let column of columns; index as currentIndex"
|
*ngFor="let column of columns; index as currentIndex"
|
||||||
[class]="'px-5 py-2 ' + column.columnCss"
|
class="px-5 py-2"
|
||||||
[ngClass]="{
|
[ngClass]="{
|
||||||
backgroundColumn: currentIndex % 2 === 0,
|
'bg-skin-primary': currentIndex % 2 === 0,
|
||||||
backgroundColumnAlternate: currentIndex % 2 === 1
|
'bg-skin-secondary': currentIndex % 2 === 1,
|
||||||
}"
|
}"
|
||||||
>
|
>
|
||||||
<div *ngIf="column.routerLink !== undefined">
|
<div *ngIf="column.routerLink !== undefined">
|
||||||
<a [routerLink]="column.routerLink(item)">
|
<a [routerLink]="column.routerLink(item)" class="font-bold hover:text-skin-accent hover:underline">
|
||||||
<div *ngIf="column.columnContent !== undefined">
|
<div *ngIf="column.columnContent !== undefined">
|
||||||
{{ column.columnContent }}
|
{{ column.columnContent }}
|
||||||
</div>
|
</div>
|
||||||
@ -49,6 +48,8 @@
|
|||||||
></div>
|
></div>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|
||||||
</table>
|
</table>
|
||||||
|
|||||||
@ -5,8 +5,6 @@ export interface ColumnDefinition {
|
|||||||
columnContent?: string | undefined;
|
columnContent?: string | undefined;
|
||||||
columnFunction?: Function | undefined;
|
columnFunction?: Function | undefined;
|
||||||
routerLink?: Function | undefined;
|
routerLink?: Function | undefined;
|
||||||
headerCss?: string | undefined;
|
|
||||||
columnCss?: string | undefined;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
@ -15,12 +13,6 @@ export interface ColumnDefinition {
|
|||||||
styleUrls: ['./table.component.scss'],
|
styleUrls: ['./table.component.scss'],
|
||||||
})
|
})
|
||||||
export class TableComponent {
|
export class TableComponent {
|
||||||
@Input() foreground: string = 'text-white';
|
|
||||||
@Input() background: string = 'bg-zinc-800';
|
|
||||||
@Input() backgroundColumn: string = 'bg-zinc-800';
|
|
||||||
@Input() backgroundColumnAlternate: string = 'bg-zinc-700';
|
|
||||||
@Input() border: string = 'border-zinc-600';
|
|
||||||
|
|
||||||
@Input() items: any[] = [];
|
@Input() items: any[] = [];
|
||||||
@Input() columns: ColumnDefinition[] = [];
|
@Input() columns: ColumnDefinition[] = [];
|
||||||
|
|
||||||
|
|||||||
@ -1,26 +1,20 @@
|
|||||||
import { NgModule } from '@angular/core';
|
import { NgModule } from '@angular/core';
|
||||||
import { RouterModule } from '@angular/router';
|
import { RouterModule } from '@angular/router';
|
||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import { FormComponent } from './components/form/form.component';
|
|
||||||
import { CardComponent } from './components/card/card.component';
|
import { CardComponent } from './components/card/card.component';
|
||||||
import { PaginatorComponent } from './components/paginator/paginator.component';
|
import { PaginatorComponent } from './components/paginator/paginator.component';
|
||||||
import { TabControlComponent } from './components/tab-control/tab-control.component';
|
|
||||||
import { TableComponent } from './components/table/table.component';
|
import { TableComponent } from './components/table/table.component';
|
||||||
import { FormsModule } from '@angular/forms';
|
import { FormsModule } from '@angular/forms';
|
||||||
|
|
||||||
@NgModule({
|
@NgModule({
|
||||||
declarations: [
|
declarations: [
|
||||||
CardComponent,
|
CardComponent,
|
||||||
TabControlComponent,
|
|
||||||
PaginatorComponent,
|
PaginatorComponent,
|
||||||
FormComponent,
|
|
||||||
TableComponent,
|
TableComponent,
|
||||||
],
|
],
|
||||||
exports: [
|
exports: [
|
||||||
CardComponent,
|
CardComponent,
|
||||||
TabControlComponent,
|
|
||||||
PaginatorComponent,
|
PaginatorComponent,
|
||||||
FormComponent,
|
|
||||||
TableComponent,
|
TableComponent,
|
||||||
|
|
||||||
FormsModule,
|
FormsModule,
|
||||||
|
|||||||
@ -11,10 +11,8 @@
|
|||||||
--color-text-accent: 255, 199, 44;
|
--color-text-accent: 255, 199, 44;
|
||||||
--color-text-accent-muted: 255, 199, 44;
|
--color-text-accent-muted: 255, 199, 44;
|
||||||
|
|
||||||
--color-shadow-primary: 100,100,100;
|
--color-primary: 244, 244, 245;
|
||||||
|
--color-secondary: 238, 238, 240;
|
||||||
--color-primary: 255, 255, 255;
|
|
||||||
--color-secondary: 255, 199, 44;
|
|
||||||
--color-accent: 255, 199, 44;
|
--color-accent: 255, 199, 44;
|
||||||
}
|
}
|
||||||
.theme-dark {
|
.theme-dark {
|
||||||
@ -25,11 +23,8 @@
|
|||||||
--color-text-accent: 250, 183, 0;
|
--color-text-accent: 250, 183, 0;
|
||||||
--color-text-accent-muted: 250, 183, 0;
|
--color-text-accent-muted: 250, 183, 0;
|
||||||
|
|
||||||
--color-shadow-primary: 170,170,170;
|
--color-primary: 24, 24, 27;
|
||||||
|
--color-secondary: 39, 39, 42;
|
||||||
|
|
||||||
--color-primary: 10, 10, 10;
|
|
||||||
--color-secondary: 250, 183, 0;
|
|
||||||
--color-accent: 250, 183, 0;
|
--color-accent: 250, 183, 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -34,7 +34,22 @@ module.exports = {
|
|||||||
},
|
},
|
||||||
boxShadowColor: {
|
boxShadowColor: {
|
||||||
skin: {
|
skin: {
|
||||||
primary: withOpacity('--color-shadow-primary'),
|
primary: withOpacity('--color-text-secondary-muted'),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
ringColor: {
|
||||||
|
skin: {
|
||||||
|
primary: withOpacity('--color-text-secondary-muted'),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
divideColor: {
|
||||||
|
skin: {
|
||||||
|
primary: withOpacity('--color-text-secondary-muted'),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
borderColor: {
|
||||||
|
skin: {
|
||||||
|
primary: withOpacity('--color-text-secondary-muted'),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user