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">
|
||||
<map>
|
||||
<entry key="createEloquentScope:namespace" value="Models\Scopes" />
|
||||
<entry key="createFormRequestDto:namespace" value="Data" />
|
||||
<entry key="createModel:namespace" value="Models" />
|
||||
</map>
|
||||
</option>
|
||||
|
||||
1
.idea/prettier.xml
generated
1
.idea/prettier.xml
generated
@ -2,5 +2,6 @@
|
||||
<project version="4">
|
||||
<component name="PrettierConfiguration">
|
||||
<option name="myConfigurationMode" value="AUTOMATIC" />
|
||||
<option name="myRunOnSave" value="true" />
|
||||
</component>
|
||||
</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\UpdateUserPassword;
|
||||
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\Http\Request;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
use Illuminate\Support\Str;
|
||||
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;
|
||||
|
||||
class FortifyServiceProvider extends ServiceProvider
|
||||
@ -21,7 +27,9 @@ class FortifyServiceProvider extends ServiceProvider
|
||||
*/
|
||||
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\Configuration\Exceptions;
|
||||
use Illuminate\Foundation\Configuration\Middleware;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
return Application::configure(basePath: dirname(__DIR__))
|
||||
->withRouting(
|
||||
|
||||
@ -73,7 +73,7 @@
|
||||
|
|
||||
*/
|
||||
|
||||
'home' => '/home',
|
||||
'home' => env('FRONTEND_URL', 'http://localhost:4200/').'/',
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
|
||||
@ -24,9 +24,7 @@
|
||||
"input": "public"
|
||||
}
|
||||
],
|
||||
"styles": [
|
||||
"src/styles.css"
|
||||
]
|
||||
"styles": ["src/styles.css"]
|
||||
},
|
||||
"configurations": {
|
||||
"production": {
|
||||
@ -47,7 +45,13 @@
|
||||
"development": {
|
||||
"optimization": false,
|
||||
"extractLicenses": false,
|
||||
"sourceMap": true
|
||||
"sourceMap": true,
|
||||
"fileReplacements": [
|
||||
{
|
||||
"replace": "src/environments/environment.ts",
|
||||
"with": "src/environments/environment.development.ts"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"defaultConfiguration": "production"
|
||||
|
||||
@ -1,13 +1,14 @@
|
||||
import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core';
|
||||
import { provideRouter } from '@angular/router';
|
||||
import { provideHttpClient } from '@angular/common/http';
|
||||
import { provideHttpClient, withInterceptors} from '@angular/common/http';
|
||||
|
||||
import { routes } from './app.routes';
|
||||
import {sanctumInterceptor} from './core/interceptors/sanctum-interceptor';
|
||||
|
||||
export const appConfig: ApplicationConfig = {
|
||||
providers: [
|
||||
provideBrowserGlobalErrorListeners(),
|
||||
provideRouter(routes),
|
||||
provideHttpClient()
|
||||
provideHttpClient(withInterceptors([sanctumInterceptor])),
|
||||
]
|
||||
};
|
||||
|
||||
@ -1 +1,2 @@
|
||||
<app-header />
|
||||
<router-outlet />
|
||||
|
||||
@ -1,9 +1,10 @@
|
||||
import { Component, signal } from '@angular/core';
|
||||
import { RouterOutlet } from '@angular/router';
|
||||
import {Header} from './core/layout/header/header';
|
||||
|
||||
@Component({
|
||||
selector: 'app-root',
|
||||
imports: [RouterOutlet],
|
||||
imports: [RouterOutlet, Header],
|
||||
templateUrl: './app.html',
|
||||
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');
|
||||
|
||||
: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 {
|
||||
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="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>
|
||||
|
||||
@ -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">
|
||||
<link rel="icon" type="image/x-icon" href="favicon.ico">
|
||||
</head>
|
||||
<body>
|
||||
<body class="min-h-screen antialiased">
|
||||
<app-root></app-root>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@ -1,3 +1,15 @@
|
||||
/* 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';
|
||||
|
||||
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