feature: Fix Foritify error, implement logput on frontend. persist authentication on accross app
This commit is contained in:
parent
19a05a1a5b
commit
66e858e386
7
.idea/compiler.xml
generated
Normal file
7
.idea/compiler.xml
generated
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="TypeScriptCompiler">
|
||||||
|
<option name="memoryAutoIncrease" value="true" />
|
||||||
|
<option name="nodeInterpreterTextField" value="wsl://Ubuntu@/home/krbfx/.nvm/versions/node/v16.20.2/bin/node" />
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
6
.idea/jsLinters/eslint.xml
generated
Normal file
6
.idea/jsLinters/eslint.xml
generated
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="EslintConfiguration">
|
||||||
|
<option name="fix-on-save" value="true" />
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
1
.idea/laravel-idea.xml
generated
1
.idea/laravel-idea.xml
generated
@ -11,6 +11,7 @@
|
|||||||
<option name="generationStringSettings">
|
<option name="generationStringSettings">
|
||||||
<map>
|
<map>
|
||||||
<entry key="createEloquentScope:namespace" value="Models\Scopes" />
|
<entry key="createEloquentScope:namespace" value="Models\Scopes" />
|
||||||
|
<entry key="createFormRequestDto:namespace" value="Data" />
|
||||||
<entry key="createModel:namespace" value="Models" />
|
<entry key="createModel:namespace" value="Models" />
|
||||||
</map>
|
</map>
|
||||||
</option>
|
</option>
|
||||||
|
|||||||
1
.idea/prettier.xml
generated
1
.idea/prettier.xml
generated
@ -2,5 +2,6 @@
|
|||||||
<project version="4">
|
<project version="4">
|
||||||
<component name="PrettierConfiguration">
|
<component name="PrettierConfiguration">
|
||||||
<option name="myConfigurationMode" value="AUTOMATIC" />
|
<option name="myConfigurationMode" value="AUTOMATIC" />
|
||||||
|
<option name="myRunOnSave" value="true" />
|
||||||
</component>
|
</component>
|
||||||
</project>
|
</project>
|
||||||
25
backend/app/Http/Responses/LoginResponse.php
Normal file
25
backend/app/Http/Responses/LoginResponse.php
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Responses;
|
||||||
|
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Laravel\Fortify\Contracts\LoginResponse as LoginResponseContract;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
||||||
|
class LoginResponse implements LoginResponseContract
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Create an HTTP response that represents the object.
|
||||||
|
*
|
||||||
|
* @param Request $request
|
||||||
|
* @return Response
|
||||||
|
*/
|
||||||
|
public function toResponse($request): JsonResponse
|
||||||
|
{
|
||||||
|
return response()->json([
|
||||||
|
'message' => 'Logged in successfully.',
|
||||||
|
'two_factor' => false,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
24
backend/app/Http/Responses/LogoutResponse.php
Normal file
24
backend/app/Http/Responses/LogoutResponse.php
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Responses;
|
||||||
|
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Laravel\Fortify\Contracts\LogoutResponse as LogoutResponseContract;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
||||||
|
class LogoutResponse implements LogoutResponseContract
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Create an HTTP response that represents the object.
|
||||||
|
*
|
||||||
|
* @param Request $request
|
||||||
|
* @return Response
|
||||||
|
*/
|
||||||
|
public function toResponse($request): JsonResponse
|
||||||
|
{
|
||||||
|
return response()->json([
|
||||||
|
'message' => 'Logged out successfully.',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
24
backend/app/Http/Responses/RegisterResponse.php
Normal file
24
backend/app/Http/Responses/RegisterResponse.php
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Responses;
|
||||||
|
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Laravel\Fortify\Contracts\RegisterResponse as RegisterResponseContract;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
||||||
|
class RegisterResponse implements RegisterResponseContract
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Create an HTTP response that represents the object.
|
||||||
|
*
|
||||||
|
* @param Request $request
|
||||||
|
* @return Response
|
||||||
|
*/
|
||||||
|
public function toResponse($request): JsonResponse
|
||||||
|
{
|
||||||
|
return response()->json([
|
||||||
|
'message' => 'Registered successfully.',
|
||||||
|
], 201);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -6,12 +6,18 @@
|
|||||||
use App\Actions\Fortify\ResetUserPassword;
|
use App\Actions\Fortify\ResetUserPassword;
|
||||||
use App\Actions\Fortify\UpdateUserPassword;
|
use App\Actions\Fortify\UpdateUserPassword;
|
||||||
use App\Actions\Fortify\UpdateUserProfileInformation;
|
use App\Actions\Fortify\UpdateUserProfileInformation;
|
||||||
|
use App\Http\Responses\LoginResponse;
|
||||||
|
use App\Http\Responses\LogoutResponse;
|
||||||
|
use App\Http\Responses\RegisterResponse;
|
||||||
use Illuminate\Cache\RateLimiting\Limit;
|
use Illuminate\Cache\RateLimiting\Limit;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Facades\RateLimiter;
|
use Illuminate\Support\Facades\RateLimiter;
|
||||||
use Illuminate\Support\ServiceProvider;
|
use Illuminate\Support\ServiceProvider;
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
use Laravel\Fortify\Actions\RedirectIfTwoFactorAuthenticatable;
|
use Laravel\Fortify\Actions\RedirectIfTwoFactorAuthenticatable;
|
||||||
|
use Laravel\Fortify\Contracts\LoginResponse as LoginResponseContract;
|
||||||
|
use Laravel\Fortify\Contracts\LogoutResponse as LogoutResponseContract;
|
||||||
|
use Laravel\Fortify\Contracts\RegisterResponse as RegisterResponseContract;
|
||||||
use Laravel\Fortify\Fortify;
|
use Laravel\Fortify\Fortify;
|
||||||
|
|
||||||
class FortifyServiceProvider extends ServiceProvider
|
class FortifyServiceProvider extends ServiceProvider
|
||||||
@ -21,7 +27,9 @@ class FortifyServiceProvider extends ServiceProvider
|
|||||||
*/
|
*/
|
||||||
public function register(): void
|
public function register(): void
|
||||||
{
|
{
|
||||||
//
|
$this->app->singleton(LoginResponseContract::class, LoginResponse::class);
|
||||||
|
$this->app->singleton(RegisterResponseContract::class, RegisterResponse::class);
|
||||||
|
$this->app->singleton(LogoutResponseContract::class, LogoutResponse::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -3,6 +3,7 @@
|
|||||||
use Illuminate\Foundation\Application;
|
use Illuminate\Foundation\Application;
|
||||||
use Illuminate\Foundation\Configuration\Exceptions;
|
use Illuminate\Foundation\Configuration\Exceptions;
|
||||||
use Illuminate\Foundation\Configuration\Middleware;
|
use Illuminate\Foundation\Configuration\Middleware;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
return Application::configure(basePath: dirname(__DIR__))
|
return Application::configure(basePath: dirname(__DIR__))
|
||||||
->withRouting(
|
->withRouting(
|
||||||
|
|||||||
@ -73,7 +73,7 @@
|
|||||||
|
|
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
'home' => '/home',
|
'home' => env('FRONTEND_URL', 'http://localhost:4200/').'/',
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|--------------------------------------------------------------------------
|
|--------------------------------------------------------------------------
|
||||||
|
|||||||
@ -24,9 +24,7 @@
|
|||||||
"input": "public"
|
"input": "public"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"styles": [
|
"styles": ["src/styles.css"]
|
||||||
"src/styles.css"
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
"configurations": {
|
"configurations": {
|
||||||
"production": {
|
"production": {
|
||||||
@ -47,7 +45,13 @@
|
|||||||
"development": {
|
"development": {
|
||||||
"optimization": false,
|
"optimization": false,
|
||||||
"extractLicenses": false,
|
"extractLicenses": false,
|
||||||
"sourceMap": true
|
"sourceMap": true,
|
||||||
|
"fileReplacements": [
|
||||||
|
{
|
||||||
|
"replace": "src/environments/environment.ts",
|
||||||
|
"with": "src/environments/environment.development.ts"
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"defaultConfiguration": "production"
|
"defaultConfiguration": "production"
|
||||||
|
|||||||
@ -1,13 +1,14 @@
|
|||||||
import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core';
|
import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core';
|
||||||
import { provideRouter } from '@angular/router';
|
import { provideRouter } from '@angular/router';
|
||||||
import { provideHttpClient } from '@angular/common/http';
|
import { provideHttpClient, withInterceptors} from '@angular/common/http';
|
||||||
|
|
||||||
import { routes } from './app.routes';
|
import { routes } from './app.routes';
|
||||||
|
import {sanctumInterceptor} from './core/interceptors/sanctum-interceptor';
|
||||||
|
|
||||||
export const appConfig: ApplicationConfig = {
|
export const appConfig: ApplicationConfig = {
|
||||||
providers: [
|
providers: [
|
||||||
provideBrowserGlobalErrorListeners(),
|
provideBrowserGlobalErrorListeners(),
|
||||||
provideRouter(routes),
|
provideRouter(routes),
|
||||||
provideHttpClient()
|
provideHttpClient(withInterceptors([sanctumInterceptor])),
|
||||||
]
|
]
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1 +1,2 @@
|
|||||||
|
<app-header />
|
||||||
<router-outlet />
|
<router-outlet />
|
||||||
|
|||||||
@ -1,9 +1,10 @@
|
|||||||
import { Component, signal } from '@angular/core';
|
import { Component, signal } from '@angular/core';
|
||||||
import { RouterOutlet } from '@angular/router';
|
import { RouterOutlet } from '@angular/router';
|
||||||
|
import {Header} from './core/layout/header/header';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-root',
|
selector: 'app-root',
|
||||||
imports: [RouterOutlet],
|
imports: [RouterOutlet, Header],
|
||||||
templateUrl: './app.html',
|
templateUrl: './app.html',
|
||||||
styleUrl: './app.css'
|
styleUrl: './app.css'
|
||||||
})
|
})
|
||||||
|
|||||||
16
frontend/src/app/auth/auth-service.spec.ts
Normal file
16
frontend/src/app/auth/auth-service.spec.ts
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
import { TestBed } from '@angular/core/testing';
|
||||||
|
|
||||||
|
import { AuthService } from './auth-service';
|
||||||
|
|
||||||
|
describe('AuthService', () => {
|
||||||
|
let service: AuthService;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
TestBed.configureTestingModule({});
|
||||||
|
service = TestBed.inject(AuthService);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should be created', () => {
|
||||||
|
expect(service).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
41
frontend/src/app/auth/auth-service.ts
Normal file
41
frontend/src/app/auth/auth-service.ts
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
import {inject, Injectable } from '@angular/core';
|
||||||
|
import {HttpClient, HttpHeaders} from '@angular/common/http';
|
||||||
|
import {environment} from '../../environments/environment';
|
||||||
|
import {LoginRequest, RegisterRequest, User} from './auth.types';
|
||||||
|
import {Observable, switchMap} from 'rxjs';
|
||||||
|
import {API_URL, BASE_URL} from '../core/tokens/api-urls';
|
||||||
|
|
||||||
|
@Injectable({
|
||||||
|
providedIn: 'root',
|
||||||
|
})
|
||||||
|
export class AuthService {
|
||||||
|
private http: HttpClient = inject(HttpClient);
|
||||||
|
private baseUrl = inject(BASE_URL);
|
||||||
|
private apiUrl = inject(API_URL);
|
||||||
|
|
||||||
|
public login (credentials: LoginRequest){
|
||||||
|
return this.getSanctumCookie().pipe(
|
||||||
|
switchMap(() => this.http.post(`${this.baseUrl}/login`, credentials))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
public register (credentials: RegisterRequest ){
|
||||||
|
return this.getSanctumCookie().pipe(
|
||||||
|
switchMap(() => this.http.post(`${this.baseUrl}/register`, credentials))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public logout(){
|
||||||
|
return this.http.post(`${this.baseUrl}/logout`, {});
|
||||||
|
}
|
||||||
|
|
||||||
|
public getCurrentUser():Observable<User>
|
||||||
|
{
|
||||||
|
return this.http.get<User>(`${this.apiUrl}/me`);
|
||||||
|
}
|
||||||
|
|
||||||
|
private getSanctumCookie(): Observable<null>
|
||||||
|
{
|
||||||
|
return this.http.get<null>(`${this.baseUrl}/sanctum/csrf-cookie`);
|
||||||
|
}
|
||||||
|
}
|
||||||
12
frontend/src/app/auth/auth.routes.ts
Normal file
12
frontend/src/app/auth/auth.routes.ts
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
import {Routes} from '@angular/router';
|
||||||
|
|
||||||
|
export const authRoutes : Routes = [
|
||||||
|
{
|
||||||
|
path: 'login',
|
||||||
|
loadComponent: () => import('./login/login').then(m => m.Login),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'register',
|
||||||
|
loadComponent: () => import('./register/register').then(m => m.Register),
|
||||||
|
},
|
||||||
|
]
|
||||||
88
frontend/src/app/auth/auth.store.ts
Normal file
88
frontend/src/app/auth/auth.store.ts
Normal file
@ -0,0 +1,88 @@
|
|||||||
|
import {AuthState, LoginRequest, RegisterRequest, User} from './auth.types';
|
||||||
|
import {patchState, signalStore, withHooks, withMethods, withState} from '@ngrx/signals';
|
||||||
|
import {inject} from '@angular/core';
|
||||||
|
import {AuthService} from './auth-service';
|
||||||
|
import {rxMethod} from '@ngrx/signals/rxjs-interop';
|
||||||
|
import {catchError, Observable, of, pipe, switchMap, tap} from 'rxjs';
|
||||||
|
import {HttpErrorResponse} from '@angular/common/http';
|
||||||
|
import {Router} from '@angular/router';
|
||||||
|
|
||||||
|
const initialState : AuthState = {
|
||||||
|
user: null,
|
||||||
|
isLoading: true,
|
||||||
|
error: null,
|
||||||
|
validationErrors: null
|
||||||
|
};
|
||||||
|
|
||||||
|
export const AuthStore = signalStore(
|
||||||
|
{ providedIn: 'root' },
|
||||||
|
withState(initialState),
|
||||||
|
withMethods((store , authService = inject(AuthService), router = inject(Router))=> {
|
||||||
|
const handleAuthFlow = <T>(apiCall: (requestData: T) => Observable<any>, fallbackError: string ) => {
|
||||||
|
return pipe(
|
||||||
|
tap((_req: T) => patchState(store, {isLoading: true, error: null, validationErrors: null})),
|
||||||
|
switchMap((requestData : T) => {
|
||||||
|
return apiCall(requestData).pipe(
|
||||||
|
switchMap(() => authService.getCurrentUser()),
|
||||||
|
tap((user) => {
|
||||||
|
patchState(store, {user: user as User, isLoading: false});
|
||||||
|
router.navigate(['/']);
|
||||||
|
}),
|
||||||
|
catchError((error: HttpErrorResponse) => {
|
||||||
|
const errorMessage = error.error?.message || fallbackError;
|
||||||
|
const validationErrors = error.error?.errors || null;
|
||||||
|
patchState(store, {isLoading : false, error: errorMessage, validationErrors});
|
||||||
|
return of(null);
|
||||||
|
})
|
||||||
|
)
|
||||||
|
})
|
||||||
|
);
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
login: rxMethod<LoginRequest>(
|
||||||
|
handleAuthFlow((credentials : LoginRequest) => authService.login(credentials), 'Login Failed'),
|
||||||
|
),
|
||||||
|
register: rxMethod<RegisterRequest>(
|
||||||
|
handleAuthFlow((credentials: RegisterRequest) => authService.register(credentials), 'Registration Failed')
|
||||||
|
),
|
||||||
|
checkAuth: rxMethod<void>(
|
||||||
|
pipe(
|
||||||
|
switchMap(() => {
|
||||||
|
return authService.getCurrentUser().pipe(
|
||||||
|
tap((user) => patchState(store, {user: user as User, isLoading: false})),
|
||||||
|
catchError(() => {
|
||||||
|
patchState(store, {user: null, isLoading: false})
|
||||||
|
return of(null);
|
||||||
|
})
|
||||||
|
)
|
||||||
|
})
|
||||||
|
),
|
||||||
|
),
|
||||||
|
logout: rxMethod<void>(
|
||||||
|
pipe(
|
||||||
|
tap(() => patchState(store, { isLoading: true })),
|
||||||
|
switchMap(() => {
|
||||||
|
return authService.logout().pipe(
|
||||||
|
tap(() => {
|
||||||
|
patchState(store, { user: null, isLoading: false });
|
||||||
|
router.navigate(['/']);
|
||||||
|
}),
|
||||||
|
catchError(() => {
|
||||||
|
patchState(store, { user: null, isLoading: false });
|
||||||
|
router.navigate(['/']);
|
||||||
|
return of(null);
|
||||||
|
})
|
||||||
|
)
|
||||||
|
})
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
withHooks(
|
||||||
|
{
|
||||||
|
onInit(store){
|
||||||
|
store.checkAuth();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
30
frontend/src/app/auth/auth.types.ts
Normal file
30
frontend/src/app/auth/auth.types.ts
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
export interface User {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
email_verified_at: Date | null;
|
||||||
|
created_at: Date;
|
||||||
|
updated_at: Date;
|
||||||
|
two_factor_secret: string | null;
|
||||||
|
two_factor_recovery_codes: string[] | null;
|
||||||
|
two_factor_confirmed_at: Date | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LoginRequest {
|
||||||
|
email: string;
|
||||||
|
password: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RegisterRequest {
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
password: string;
|
||||||
|
password_confirmation: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuthState {
|
||||||
|
user: User | null;
|
||||||
|
isLoading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
validationErrors: Record<string, string[]> | null;
|
||||||
|
}
|
||||||
16
frontend/src/app/auth/login/login.css
Normal file
16
frontend/src/app/auth/login/login.css
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
@keyframes slideUpFade {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(40px) scale(0.98);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0) scale(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse {
|
||||||
|
0% { box-shadow: 0 0 0 0 rgba(79, 172, 254, 0.4); }
|
||||||
|
70% { box-shadow: 0 0 0 10px rgba(79, 172, 254, 0); }
|
||||||
|
100% { box-shadow: 0 0 0 0 rgba(79, 172, 254, 0); }
|
||||||
|
}
|
||||||
53
frontend/src/app/auth/login/login.html
Normal file
53
frontend/src/app/auth/login/login.html
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
<div class="w-full max-w-[450px] mx-auto mt-27 flex flex-col bg-white/5 backdrop-blur-[20px] border border-white/10 rounded-3xl shadow-[0_25px_50px_-12px_rgba(0,0,0,0.5),inset_0_1px_0_rgba(255,255,255,0.1)] overflow-hidden animate-[slideUpFade_0.8s_cubic-bezier(0.16,1,0.3,1)] p-8">
|
||||||
|
<div class="text-center mb-8">
|
||||||
|
<div class="w-16 h-16 mx-auto mb-4 rounded-full bg-gradient-to-br from-[#00f2fe] to-[#4facfe] flex items-center justify-center font-semibold text-2xl shadow-[0_0_20px_rgba(79,172,254,0.4)] animate-[pulse_2s_infinite]">
|
||||||
|
<svg class="w-8 h-8 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"></path>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h1 class="m-0 text-3xl font-semibold tracking-wide bg-gradient-to-r from-white to-indigo-300 bg-clip-text text-transparent">Welcome Back</h1>
|
||||||
|
<p class="m-0 mt-2 text-sm text-slate-400">Sign in to continue to Post Assistant.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<app-validation-errors [errors]="authStore.validationErrors()" />
|
||||||
|
|
||||||
|
<form [formGroup]="loginForm" (ngSubmit)="login()" class="flex flex-col gap-5">
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-1.5">
|
||||||
|
<label class="text-sm font-medium text-slate-300 ml-1">Email</label>
|
||||||
|
<div class="flex bg-white/5 px-4 py-3 rounded-xl border border-white/10 transition-all duration-300 focus-within:bg-white/10 focus-within:border-[#4facfe]/50 focus-within:shadow-[0_0_20px_rgba(79,172,254,0.2)]">
|
||||||
|
<input
|
||||||
|
class="flex-1 bg-transparent border-none text-white text-base outline-none placeholder-slate-500"
|
||||||
|
type="email"
|
||||||
|
formControlName="email"
|
||||||
|
placeholder="name@example.com"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-1.5">
|
||||||
|
<label class="text-sm font-medium text-slate-300 ml-1">Password</label>
|
||||||
|
<div class="flex bg-white/5 px-4 py-3 rounded-xl border border-white/10 transition-all duration-300 focus-within:bg-white/10 focus-within:border-[#4facfe]/50 focus-within:shadow-[0_0_20px_rgba(79,172,254,0.2)]">
|
||||||
|
<input
|
||||||
|
class="flex-1 bg-transparent border-none text-white text-base outline-none placeholder-slate-500"
|
||||||
|
type="password"
|
||||||
|
formControlName="password"
|
||||||
|
placeholder="••••••••"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="mt-4 bg-gradient-to-br from-[#00f2fe] to-[#4facfe] border-none py-3.5 rounded-xl font-semibold text-white text-lg transition-all duration-200 hover:scale-[1.02] hover:-translate-y-0.5 hover:shadow-[0_10px_20px_rgba(79,172,254,0.3)] active:scale-[0.98] disabled:opacity-70 disabled:cursor-not-allowed disabled:transform-none disabled:shadow-none"
|
||||||
|
[disabled]="loginForm.invalid"
|
||||||
|
>
|
||||||
|
Sign In
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<p class="mt-8 text-center text-sm text-slate-400">
|
||||||
|
Don't have an account?
|
||||||
|
<a routerLink="/user/register" class="text-[#4facfe] hover:text-[#00f2fe] font-medium transition-colors">Register</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
22
frontend/src/app/auth/login/login.spec.ts
Normal file
22
frontend/src/app/auth/login/login.spec.ts
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||||
|
|
||||||
|
import { Login } from './login';
|
||||||
|
|
||||||
|
describe('Login', () => {
|
||||||
|
let component: Login;
|
||||||
|
let fixture: ComponentFixture<Login>;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
imports: [Login],
|
||||||
|
}).compileComponents();
|
||||||
|
|
||||||
|
fixture = TestBed.createComponent(Login);
|
||||||
|
component = fixture.componentInstance;
|
||||||
|
await fixture.whenStable();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create', () => {
|
||||||
|
expect(component).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
33
frontend/src/app/auth/login/login.ts
Normal file
33
frontend/src/app/auth/login/login.ts
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
import { Component, inject} from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { FormBuilder, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms';
|
||||||
|
import { RouterLink } from '@angular/router';
|
||||||
|
import {AuthStore} from '../auth.store';
|
||||||
|
import {environment} from '../../../environments/environment';
|
||||||
|
import { ValidationErrors } from '../../shared/validation-errors/validation-errors';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-login',
|
||||||
|
standalone: true,
|
||||||
|
imports: [CommonModule, ReactiveFormsModule, RouterLink, ValidationErrors],
|
||||||
|
templateUrl: './login.html',
|
||||||
|
styleUrl: './login.css',
|
||||||
|
})
|
||||||
|
export class Login {
|
||||||
|
loginForm: FormGroup;
|
||||||
|
protected readonly authStore = inject(AuthStore);
|
||||||
|
|
||||||
|
constructor(private fb: FormBuilder) {
|
||||||
|
this.loginForm = this.fb.group({
|
||||||
|
email: ['', [Validators.required, Validators.email]],
|
||||||
|
password: ['', Validators.required]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
login() {
|
||||||
|
if (this.loginForm.valid) {
|
||||||
|
if(!environment.production) console.log('Login submitted:', this.loginForm.value);
|
||||||
|
this.authStore.login(this.loginForm.value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
17
frontend/src/app/auth/register/register.css
Normal file
17
frontend/src/app/auth/register/register.css
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
|
||||||
|
@keyframes slideUpFade {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(40px) scale(0.98);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0) scale(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse {
|
||||||
|
0% { box-shadow: 0 0 0 0 rgba(79, 172, 254, 0.4); }
|
||||||
|
70% { box-shadow: 0 0 0 10px rgba(79, 172, 254, 0); }
|
||||||
|
100% { box-shadow: 0 0 0 0 rgba(79, 172, 254, 0); }
|
||||||
|
}
|
||||||
75
frontend/src/app/auth/register/register.html
Normal file
75
frontend/src/app/auth/register/register.html
Normal file
@ -0,0 +1,75 @@
|
|||||||
|
<div class="w-full max-w-[450px] mx-auto mt-27 flex flex-col bg-white/5 backdrop-blur-[20px] border border-white/10 rounded-3xl shadow-[0_25px_50px_-12px_rgba(0,0,0,0.5),inset_0_1px_0_rgba(255,255,255,0.1)] overflow-hidden animate-[slideUpFade_0.8s_cubic-bezier(0.16,1,0.3,1)] p-8">
|
||||||
|
<div class="text-center mb-8">
|
||||||
|
<div class="w-16 h-16 mx-auto mb-4 rounded-full bg-gradient-to-br from-[#00f2fe] to-[#4facfe] flex items-center justify-center font-semibold text-2xl shadow-[0_0_20px_rgba(79,172,254,0.4)] animate-[pulse_2s_infinite]">
|
||||||
|
<svg class="w-8 h-8 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M18 9v3m0 0v3m0-3h3m-3 0h-3m-2-5a4 4 0 11-8 0 4 4 0 018 0zM3 20a6 6 0 0112 0v1H3v-1z"></path>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h1 class="m-0 text-3xl font-semibold tracking-wide bg-gradient-to-r from-white to-indigo-300 bg-clip-text text-transparent">Create Account</h1>
|
||||||
|
<p class="m-0 mt-2 text-sm text-slate-400">Join us and start using Post Assistant.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form [formGroup]="registerForm" (ngSubmit)="register()" class="flex flex-col gap-5">
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-1.5">
|
||||||
|
<label class="text-sm font-medium text-slate-300 ml-1">Name</label>
|
||||||
|
<div class="flex bg-white/5 px-4 py-3 rounded-xl border border-white/10 transition-all duration-300 focus-within:bg-white/10 focus-within:border-[#4facfe]/50 focus-within:shadow-[0_0_20px_rgba(79,172,254,0.2)]">
|
||||||
|
<input
|
||||||
|
class="flex-1 bg-transparent border-none text-white text-base outline-none placeholder-slate-500"
|
||||||
|
type="text"
|
||||||
|
formControlName="name"
|
||||||
|
placeholder="John Doe"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-1.5">
|
||||||
|
<label class="text-sm font-medium text-slate-300 ml-1">Email</label>
|
||||||
|
<div class="flex bg-white/5 px-4 py-3 rounded-xl border border-white/10 transition-all duration-300 focus-within:bg-white/10 focus-within:border-[#4facfe]/50 focus-within:shadow-[0_0_20px_rgba(79,172,254,0.2)]">
|
||||||
|
<input
|
||||||
|
class="flex-1 bg-transparent border-none text-white text-base outline-none placeholder-slate-500"
|
||||||
|
type="email"
|
||||||
|
formControlName="email"
|
||||||
|
placeholder="name@example.com"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-1.5">
|
||||||
|
<label class="text-sm font-medium text-slate-300 ml-1">Password</label>
|
||||||
|
<div class="flex bg-white/5 px-4 py-3 rounded-xl border border-white/10 transition-all duration-300 focus-within:bg-white/10 focus-within:border-[#4facfe]/50 focus-within:shadow-[0_0_20px_rgba(79,172,254,0.2)]">
|
||||||
|
<input
|
||||||
|
class="flex-1 bg-transparent border-none text-white text-base outline-none placeholder-slate-500"
|
||||||
|
type="password"
|
||||||
|
formControlName="password"
|
||||||
|
placeholder="••••••••"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-1.5">
|
||||||
|
<label class="text-sm font-medium text-slate-300 ml-1">Confirm Password</label>
|
||||||
|
<div class="flex bg-white/5 px-4 py-3 rounded-xl border border-white/10 transition-all duration-300 focus-within:bg-white/10 focus-within:border-[#4facfe]/50 focus-within:shadow-[0_0_20px_rgba(79,172,254,0.2)]">
|
||||||
|
<input
|
||||||
|
class="flex-1 bg-transparent border-none text-white text-base outline-none placeholder-slate-500"
|
||||||
|
type="password"
|
||||||
|
formControlName="password_confirmation"
|
||||||
|
placeholder="••••••••"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="mt-4 bg-gradient-to-br from-[#00f2fe] to-[#4facfe] border-none py-3.5 rounded-xl font-semibold text-white text-lg transition-all duration-200 hover:scale-[1.02] hover:-translate-y-0.5 hover:shadow-[0_10px_20px_rgba(79,172,254,0.3)] active:scale-[0.98] disabled:opacity-70 disabled:cursor-not-allowed disabled:transform-none disabled:shadow-none"
|
||||||
|
[disabled]="registerForm.invalid"
|
||||||
|
>
|
||||||
|
Sign Up
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<p class="mt-8 text-center text-sm text-slate-400">
|
||||||
|
Already have an account?
|
||||||
|
<a routerLink="/user/login" class="text-[#4facfe] hover:text-[#00f2fe] font-medium transition-colors">Sign in</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
22
frontend/src/app/auth/register/register.spec.ts
Normal file
22
frontend/src/app/auth/register/register.spec.ts
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||||
|
|
||||||
|
import { Register } from './register';
|
||||||
|
|
||||||
|
describe('Register', () => {
|
||||||
|
let component: Register;
|
||||||
|
let fixture: ComponentFixture<Register>;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
imports: [Register],
|
||||||
|
}).compileComponents();
|
||||||
|
|
||||||
|
fixture = TestBed.createComponent(Register);
|
||||||
|
component = fixture.componentInstance;
|
||||||
|
await fixture.whenStable();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create', () => {
|
||||||
|
expect(component).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
34
frontend/src/app/auth/register/register.ts
Normal file
34
frontend/src/app/auth/register/register.ts
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
import { Component, inject} from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { FormBuilder, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms';
|
||||||
|
import { RouterLink } from '@angular/router';
|
||||||
|
import {environment} from '../../../environments/environment';
|
||||||
|
import {AuthStore} from '../auth.store';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-register',
|
||||||
|
standalone: true,
|
||||||
|
imports: [CommonModule, ReactiveFormsModule, RouterLink],
|
||||||
|
templateUrl: './register.html',
|
||||||
|
styleUrl: './register.css',
|
||||||
|
})
|
||||||
|
export class Register {
|
||||||
|
registerForm: FormGroup;
|
||||||
|
private readonly authStore = inject(AuthStore);
|
||||||
|
|
||||||
|
constructor(private fb: FormBuilder) {
|
||||||
|
this.registerForm = this.fb.group({
|
||||||
|
name: ['', Validators.required],
|
||||||
|
email: ['', [Validators.required, Validators.email]],
|
||||||
|
password: ['', [Validators.required, Validators.minLength(8)]],
|
||||||
|
password_confirmation: ['', [Validators.required, Validators.minLength(8)]],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
register() {
|
||||||
|
if (this.registerForm.valid) {
|
||||||
|
if(!environment.production) console.log('Register submitted:', this.registerForm.value);
|
||||||
|
this.authStore.register(this.registerForm.value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,18 +1,5 @@
|
|||||||
@import url('https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600&display=swap');
|
@import url('https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600&display=swap');
|
||||||
|
|
||||||
:host {
|
|
||||||
display: block;
|
|
||||||
height: 100vh;
|
|
||||||
width: 100vw;
|
|
||||||
color: white;
|
|
||||||
overflow: hidden;
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
box-sizing: border-box;
|
|
||||||
font-family: 'Outfit', sans-serif;
|
|
||||||
background: radial-gradient(circle at top right, #1a1a2e 0%, #16213e 50%, #0f3460 100%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.custom-scrollbar::-webkit-scrollbar {
|
.custom-scrollbar::-webkit-scrollbar {
|
||||||
width: 6px;
|
width: 6px;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
<div class="max-w-[900px] h-[90vh] my-[5vh] mx-auto flex flex-col bg-white/5 backdrop-blur-[20px] border border-white/10 rounded-3xl shadow-[0_25px_50px_-12px_rgba(0,0,0,0.5),inset_0_1px_0_rgba(255,255,255,0.1)] overflow-hidden animate-[slideUpFade_0.8s_cubic-bezier(0.16,1,0.3,1)]">
|
<div class="max-w-[900px] mt-15 h-[80vh] mx-auto my-auto flex flex-col bg-white/5 backdrop-blur-[20px] border border-white/10 rounded-3xl shadow-[0_25px_50px_-12px_rgba(0,0,0,0.5),inset_0_1px_0_rgba(255,255,255,0.1)] overflow-hidden animate-[slideUpFade_0.8s_cubic-bezier(0.16,1,0.3,1)]">
|
||||||
<div class="px-8 py-6 border-b border-white/10 flex items-center gap-4 bg-black/20">
|
<div class="px-8 py-6 border-b border-white/10 flex items-center gap-4 bg-black/20">
|
||||||
<div class="w-12 h-12 rounded-full bg-gradient-to-br from-[#00f2fe] to-[#4facfe] flex items-center justify-center font-semibold text-xl shadow-[0_0_20px_rgba(79,172,254,0.4)] animate-[pulse_2s_infinite]">AI</div>
|
<div class="w-12 h-12 rounded-full bg-gradient-to-br from-[#00f2fe] to-[#4facfe] flex items-center justify-center font-semibold text-xl shadow-[0_0_20px_rgba(79,172,254,0.4)] animate-[pulse_2s_infinite]">AI</div>
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@ -0,0 +1,17 @@
|
|||||||
|
import { TestBed } from '@angular/core/testing';
|
||||||
|
import { HttpInterceptorFn } from '@angular/common/http';
|
||||||
|
|
||||||
|
import { sanctumInterceptor } from './sanctum-interceptor';
|
||||||
|
|
||||||
|
describe('sanctumInterceptor', () => {
|
||||||
|
const interceptor: HttpInterceptorFn = (req, next) =>
|
||||||
|
TestBed.runInInjectionContext(() => sanctumInterceptor(req, next));
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
TestBed.configureTestingModule({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should be created', () => {
|
||||||
|
expect(interceptor).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
20
frontend/src/app/core/interceptors/sanctum-interceptor.ts
Normal file
20
frontend/src/app/core/interceptors/sanctum-interceptor.ts
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
import { HttpInterceptorFn } from "@angular/common/http";
|
||||||
|
|
||||||
|
export const sanctumInterceptor: HttpInterceptorFn = (req, next) => {
|
||||||
|
const getCookie = (name: string): string | null => {
|
||||||
|
const match = document.cookie.match(new RegExp("(^|;\\s*)(" + name + ")=([^;]*)"));
|
||||||
|
return match ? decodeURIComponent(match[3]) : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
let headers = req.headers.set("Accept", "application/json");
|
||||||
|
const xsrfToken = getCookie("XSRF-TOKEN");
|
||||||
|
if (xsrfToken) {
|
||||||
|
headers = headers.set("X-XSRF-TOKEN", xsrfToken);
|
||||||
|
}
|
||||||
|
const clonedRequest = req.clone({
|
||||||
|
withCredentials: true,
|
||||||
|
headers: headers,
|
||||||
|
});
|
||||||
|
|
||||||
|
return next(clonedRequest);
|
||||||
|
};
|
||||||
4
frontend/src/app/core/layout/header/header.css
Normal file
4
frontend/src/app/core/layout/header/header.css
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
:host{
|
||||||
|
display: block;
|
||||||
|
width: 100svw;
|
||||||
|
}
|
||||||
41
frontend/src/app/core/layout/header/header.html
Normal file
41
frontend/src/app/core/layout/header/header.html
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
<nav
|
||||||
|
class="bg-gray-900/80 backdrop-blur-md border-b border-gray-800 shadow-sm fixed w-full top-0 z-50">
|
||||||
|
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
|
<div class="flex justify-between items-center h-16">
|
||||||
|
<!-- Logo -->
|
||||||
|
<div class="flex-shrink-0 flex items-center cursor-pointer" routerLink="/">
|
||||||
|
<span
|
||||||
|
class="text-2xl font-extrabold tracking-tight bg-clip-text text-transparent bg-gradient-to-r from-blue-500 to-indigo-700 dark:from-blue-400 dark:to-indigo-400">
|
||||||
|
NeoBan
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
@if (authStore.isLoading()) {
|
||||||
|
<p>Loading...</p>
|
||||||
|
}
|
||||||
|
@else if (authStore.user() === null) {
|
||||||
|
<!-- Actions -->
|
||||||
|
<div class="flex items-center space-x-3">
|
||||||
|
<a routerLink="/user/login"
|
||||||
|
class="text-sm font-semibold text-gray-400 hover:text-blue-600 transition-colors duration-200 px-3 py-2 rounded-md hover:bg-gray-100 dark:hover:bg-gray-800">
|
||||||
|
Login
|
||||||
|
</a>
|
||||||
|
<a routerLink="/user/register"
|
||||||
|
class="inline-flex items-center justify-center px-4 py-2 border border-transparent text-sm font-semibold rounded-lg text-white bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-700 hover:to-indigo-700 shadow-md hover:shadow-lg focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 transition-all duration-200 transform hover:-translate-y-0.5">
|
||||||
|
Register
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
@else{
|
||||||
|
<div class="flex font-normal items-center space-x-3">
|
||||||
|
<span class="text-sm text-gray-200 dark:text-gray-300">
|
||||||
|
{{ authStore.user()?.name }}
|
||||||
|
</span>
|
||||||
|
<button (click)="authStore.logout()"
|
||||||
|
class="inline-flex items-center justify-center px-4 py-2 border border-transparent text-sm font-semibold rounded-lg text-gray-400 bg-gray-200/9 shadow-md hover:shadow-lg focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 transition-all duration-200 transform hover:-translate-y-0.5">
|
||||||
|
Logout
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
22
frontend/src/app/core/layout/header/header.spec.ts
Normal file
22
frontend/src/app/core/layout/header/header.spec.ts
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||||
|
|
||||||
|
import { Header } from './header';
|
||||||
|
|
||||||
|
describe('Header', () => {
|
||||||
|
let component: Header;
|
||||||
|
let fixture: ComponentFixture<Header>;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
imports: [Header],
|
||||||
|
}).compileComponents();
|
||||||
|
|
||||||
|
fixture = TestBed.createComponent(Header);
|
||||||
|
component = fixture.componentInstance;
|
||||||
|
await fixture.whenStable();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create', () => {
|
||||||
|
expect(component).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
14
frontend/src/app/core/layout/header/header.ts
Normal file
14
frontend/src/app/core/layout/header/header.ts
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
import { Component, inject} from '@angular/core';
|
||||||
|
import { RouterLink } from '@angular/router';
|
||||||
|
import {AuthStore} from '../../../auth/auth.store';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-header',
|
||||||
|
imports: [RouterLink],
|
||||||
|
templateUrl: './header.html',
|
||||||
|
styleUrl: './header.css',
|
||||||
|
})
|
||||||
|
export class Header {
|
||||||
|
protected readonly authStore = inject(AuthStore);
|
||||||
|
|
||||||
|
}
|
||||||
12
frontend/src/app/core/tokens/api-urls.ts
Normal file
12
frontend/src/app/core/tokens/api-urls.ts
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
import { InjectionToken } from "@angular/core";
|
||||||
|
import { environment } from "../../../environments/environment";
|
||||||
|
|
||||||
|
export const API_URL = new InjectionToken<string>("API_URL", {
|
||||||
|
providedIn: "root",
|
||||||
|
factory: () => environment.apiUrl,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const BASE_URL = new InjectionToken<string>("BASE_URL", {
|
||||||
|
providedIn: "root",
|
||||||
|
factory: () => environment.baseApiUrl,
|
||||||
|
});
|
||||||
@ -0,0 +1,21 @@
|
|||||||
|
@if (errorMessages().length > 0) {
|
||||||
|
<div class="rounded-xl bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 p-4 mb-4">
|
||||||
|
<div class="flex">
|
||||||
|
<div class="flex-shrink-0">
|
||||||
|
<svg class="h-5 w-5 text-red-400 dark:text-red-500" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
||||||
|
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.28 7.22a.75.75 0 00-1.06 1.06L8.94 10l-1.72 1.72a.75.75 0 101.06 1.06L10 11.06l1.72 1.72a.75.75 0 101.06-1.06L11.06 10l1.72-1.72a.75.75 0 00-1.06-1.06L10 8.94 8.28 7.22z" clip-rule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="ml-3">
|
||||||
|
<h3 class="text-sm font-medium text-red-800 dark:text-red-400">There were errors with your submission</h3>
|
||||||
|
<div class="mt-2 text-sm text-red-700 dark:text-red-300">
|
||||||
|
<ul role="list" class="list-disc space-y-1 pl-5">
|
||||||
|
@for (error of errorMessages(); track error) {
|
||||||
|
<li>{{ error }}</li>
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
@ -0,0 +1,22 @@
|
|||||||
|
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||||
|
|
||||||
|
import { ValidationErrors } from './validation-errors';
|
||||||
|
|
||||||
|
describe('ValidationErrors', () => {
|
||||||
|
let component: ValidationErrors;
|
||||||
|
let fixture: ComponentFixture<ValidationErrors>;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
imports: [ValidationErrors],
|
||||||
|
}).compileComponents();
|
||||||
|
|
||||||
|
fixture = TestBed.createComponent(ValidationErrors);
|
||||||
|
component = fixture.componentInstance;
|
||||||
|
await fixture.whenStable();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create', () => {
|
||||||
|
expect(component).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -0,0 +1,18 @@
|
|||||||
|
import { Component, computed, input } from '@angular/core';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-validation-errors',
|
||||||
|
imports: [],
|
||||||
|
templateUrl: './validation-errors.html',
|
||||||
|
styleUrl: './validation-errors.css',
|
||||||
|
})
|
||||||
|
export class ValidationErrors {
|
||||||
|
errors = input<Record<string, string[]> | null | undefined>();
|
||||||
|
|
||||||
|
errorMessages = computed(() => {
|
||||||
|
const errs = this.errors();
|
||||||
|
if (!errs) return [];
|
||||||
|
|
||||||
|
return Object.values(errs).reduce((acc, curr) => acc.concat(curr), []);
|
||||||
|
});
|
||||||
|
}
|
||||||
5
frontend/src/environments/environment.development.ts
Normal file
5
frontend/src/environments/environment.development.ts
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
export const environment = {
|
||||||
|
production: false,
|
||||||
|
apiUrl: 'http://localhost:8000/api',
|
||||||
|
baseApiUrl: 'http://localhost:8000',
|
||||||
|
};
|
||||||
5
frontend/src/environments/environment.ts
Normal file
5
frontend/src/environments/environment.ts
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
export const environment = {
|
||||||
|
production: true,
|
||||||
|
apiUrl: 'my-deverything-api.com/api',
|
||||||
|
baseApiUrl: 'my-deverything-api.com',
|
||||||
|
};
|
||||||
@ -7,7 +7,7 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<link rel="icon" type="image/x-icon" href="favicon.ico">
|
<link rel="icon" type="image/x-icon" href="favicon.ico">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body class="min-h-screen antialiased">
|
||||||
<app-root></app-root>
|
<app-root></app-root>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@ -1,3 +1,15 @@
|
|||||||
/* You can add global styles to this file, and also import other style files */
|
/* You can add global styles to this file, and also import other style files */
|
||||||
|
@import url('https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600&display=swap');
|
||||||
@import 'tailwindcss';
|
@import 'tailwindcss';
|
||||||
|
|
||||||
|
body {
|
||||||
|
color: white;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
box-sizing: border-box;
|
||||||
|
font-family: 'Outfit', sans-serif;
|
||||||
|
background: radial-gradient(circle at top right, #1a1a2e 0%, #16213e 50%, #0f3460 100%);
|
||||||
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user