From e0cef27f5137214320b0564f29f8d4279d4e70ad Mon Sep 17 00:00:00 2001 From: kushal-saha Date: Wed, 6 May 2026 13:20:12 +0000 Subject: [PATCH] wip: add facades, password confirmation --- backend/app/Models/User.php | 3 +- docker-compose.yml | 123 +++--- frontend/angular.json | 3 +- frontend/src/app/app.routes.ts | 8 +- frontend/src/app/auth/auth-service.ts | 41 +- frontend/src/app/auth/auth.routes.ts | 32 +- frontend/src/app/auth/auth.types.ts | 14 +- .../password-confirmation.css | 16 + .../password-confirmation.html | 65 +++ .../password-confirmation.spec.ts | 22 ++ .../password-confirmation.ts | 36 ++ .../app/core/facades/passwordFlow.facade.ts | 11 + .../src/app/core/layout/sidebar/sidebar.html | 16 +- .../settings/privacy/privacy-service.spec.ts | 16 + .../app/settings/privacy/privacy-service.ts | 20 + frontend/src/app/settings/privacy/privacy.css | 3 + .../src/app/settings/privacy/privacy.html | 27 ++ .../src/app/settings/privacy/privacy.spec.ts | 22 ++ .../src/app/settings/privacy/privacy.store.ts | 49 +++ frontend/src/app/settings/privacy/privacy.ts | 15 + .../src/app/settings/privacy/privacy.types.ts | 11 + .../src/app/settings/settings-service.spec.ts | 16 + frontend/src/app/settings/settings-service.ts | 6 + frontend/src/app/settings/settings.css | 371 ++++++++++++++++++ frontend/src/app/settings/settings.html | 71 ++++ frontend/src/app/settings/settings.spec.ts | 22 ++ frontend/src/app/settings/settings.store.ts | 46 +++ frontend/src/app/settings/settings.ts | 17 + frontend/src/app/settings/settings.types.ts | 25 ++ frontend/src/styles.css | 2 +- 30 files changed, 1030 insertions(+), 99 deletions(-) create mode 100644 frontend/src/app/auth/password-confirmation/password-confirmation.css create mode 100644 frontend/src/app/auth/password-confirmation/password-confirmation.html create mode 100644 frontend/src/app/auth/password-confirmation/password-confirmation.spec.ts create mode 100644 frontend/src/app/auth/password-confirmation/password-confirmation.ts create mode 100644 frontend/src/app/core/facades/passwordFlow.facade.ts create mode 100644 frontend/src/app/settings/privacy/privacy-service.spec.ts create mode 100644 frontend/src/app/settings/privacy/privacy-service.ts create mode 100644 frontend/src/app/settings/privacy/privacy.css create mode 100644 frontend/src/app/settings/privacy/privacy.html create mode 100644 frontend/src/app/settings/privacy/privacy.spec.ts create mode 100644 frontend/src/app/settings/privacy/privacy.store.ts create mode 100644 frontend/src/app/settings/privacy/privacy.ts create mode 100644 frontend/src/app/settings/privacy/privacy.types.ts create mode 100644 frontend/src/app/settings/settings-service.spec.ts create mode 100644 frontend/src/app/settings/settings-service.ts create mode 100644 frontend/src/app/settings/settings.css create mode 100644 frontend/src/app/settings/settings.html create mode 100644 frontend/src/app/settings/settings.spec.ts create mode 100644 frontend/src/app/settings/settings.store.ts create mode 100644 frontend/src/app/settings/settings.ts create mode 100644 frontend/src/app/settings/settings.types.ts diff --git a/backend/app/Models/User.php b/backend/app/Models/User.php index 2d0857b..cb477c6 100644 --- a/backend/app/Models/User.php +++ b/backend/app/Models/User.php @@ -10,6 +10,7 @@ use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Notifications\Notifiable; +use Laravel\Fortify\TwoFactorAuthenticatable; use Laravel\Sanctum\HasApiTokens; #[Fillable(['name', 'email', 'password'])] @@ -17,7 +18,7 @@ class User extends Authenticatable { /** @use HasFactory */ - use HasApiTokens, HasFactory, Notifiable; + use HasApiTokens, HasFactory, Notifiable, TwoFactorAuthenticatable; /** * Get the attributes that should be cast. diff --git a/docker-compose.yml b/docker-compose.yml index b06e556..16b4dbd 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,69 +1,70 @@ services: - # Laravel App (PHP-FPM) - app: - build: - context: . - dockerfile: docker/php/Dockerfile - container_name: laravel_app - restart: unless-stopped - working_dir: /var/www/html/backend - volumes: - - ./backend:/var/www/html/backend - networks: - - app-network - depends_on: - - db + # Laravel App (PHP-FPM) + app: + build: + context: . + dockerfile: docker/php/Dockerfile + container_name: laravel_app + restart: unless-stopped + working_dir: /var/www/html/backend + volumes: + - ./backend:/var/www/html/backend + networks: + - app-network + depends_on: + - db - # Nginx Web Server - webserver: - image: nginx:alpine - container_name: laravel_nginx - restart: unless-stopped - ports: - - "8000:80" - volumes: - - ./backend:/var/www/html/backend - - ./docker/nginx/default.conf:/etc/nginx/conf.d/default.conf - networks: - - app-network - depends_on: - - app + # Nginx Web Server + webserver: + image: nginx:alpine + container_name: laravel_nginx + restart: unless-stopped + ports: + - "8000:80" + volumes: + - ./backend:/var/www/html/backend + - ./docker/nginx/default.conf:/etc/nginx/conf.d/default.conf + networks: + - app-network + depends_on: + - app - # PostgreSQL Database - # (Note: Set to PostgreSQL per your 8.5/PostgreSQL/Node 24 requirement) - db: - image: postgres:16-alpine - container_name: laravel_db - restart: unless-stopped - environment: - POSTGRES_DB: neoban - POSTGRES_USER: neoban - POSTGRES_PASSWORD: secret - ports: - - "5432:5432" - volumes: - - dbdata:/var/lib/postgresql/data - networks: - - app-network + # PostgreSQL Database + # (Note: Set to PostgreSQL per your 8.5/PostgreSQL/Node 24 requirement) + db: + image: postgres:16-alpine + container_name: laravel_db + restart: unless-stopped + environment: + POSTGRES_DB: neoban + POSTGRES_USER: neoban + POSTGRES_PASSWORD: secret + ports: + - "5432:5432" + volumes: + - dbdata:/var/lib/postgresql/data + networks: + - app-network - # Angular Frontend - frontend: - image: node:24-alpine - container_name: angular_frontend - restart: unless-stopped - working_dir: /app/frontend - volumes: - - ./frontend:/app/frontend - ports: - - "4200:4200" - # Runs install and starts Angular bound to 0.0.0.0 so it is accessible outside the container - command: sh -c "npm install && npx ng serve --host 0.0.0.0 --poll 2000" - networks: - - app-network + # Angular Frontend + frontend: + image: node:24-alpine + container_name: angular_frontend + restart: unless-stopped + working_dir: /app/frontend + volumes: + - ./frontend:/app/frontend + ports: + - "4200:4200" + # Runs install and starts Angular bound to 0.0.0.0 so it is accessible outside the container + command: sh -c "npm install && npx ng serve --host 0.0.0.0 --poll 2000" + networks: + - app-network + user: 1000:1000 networks: - app-network: - driver: bridge + app-network: + driver: bridge volumes: - dbdata: + dbdata: diff --git a/frontend/angular.json b/frontend/angular.json index 1dffa47..657f5d2 100644 --- a/frontend/angular.json +++ b/frontend/angular.json @@ -2,7 +2,8 @@ "$schema": "./node_modules/@angular/cli/lib/config/schema.json", "version": 1, "cli": { - "packageManager": "npm" + "packageManager": "npm", + "analytics": false }, "newProjectRoot": "projects", "projects": { diff --git a/frontend/src/app/app.routes.ts b/frontend/src/app/app.routes.ts index 62e28a3..b9963cc 100644 --- a/frontend/src/app/app.routes.ts +++ b/frontend/src/app/app.routes.ts @@ -6,11 +6,15 @@ export const routes: Routes = [ loadComponent: () => import('./chat/chat').then((m) => m.Chat), }, { - path: ':id', - loadComponent: () => import('./chat/chat').then((m) => m.Chat), + path: 'settings', + loadComponent: () => import('./settings/settings').then((m) => m.Settings), }, { path: 'user', loadChildren: () => import('./auth/auth.routes').then((m) => m.authRoutes), }, + { + path: ':id', + loadComponent: () => import('./chat/chat').then((m) => m.Chat), + }, ]; diff --git a/frontend/src/app/auth/auth-service.ts b/frontend/src/app/auth/auth-service.ts index 92bdf53..9d13da2 100644 --- a/frontend/src/app/auth/auth-service.ts +++ b/frontend/src/app/auth/auth-service.ts @@ -1,9 +1,8 @@ -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'; +import { inject, Injectable } from '@angular/core'; +import { HttpClient, HttpHeaders } from '@angular/common/http'; +import { LoginRequest, PasswordConfirmationRequest, RegisterRequest, User } from './auth.types'; +import { Observable, switchMap } from 'rxjs'; +import { API_URL, BASE_URL } from '../core/tokens/api-urls'; @Injectable({ providedIn: 'root', @@ -13,29 +12,33 @@ export class AuthService { private baseUrl = inject(BASE_URL); private apiUrl = inject(API_URL); - public login (credentials: LoginRequest){ + 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)) + switchMap(() => this.http.post(`${this.baseUrl}/login`, credentials)), ); } - public logout(){ + 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 - { + public getCurrentUser(): Observable { return this.http.get(`${this.apiUrl}/me`); } - private getSanctumCookie(): Observable - { + public confirmPassword(data: PasswordConfirmationRequest): Observable { + return this.getSanctumCookie().pipe( + switchMap(() => this.http.post(`${this.baseUrl}/user/confirm-password`, data)), + ); + } + + private getSanctumCookie(): Observable { return this.http.get(`${this.baseUrl}/sanctum/csrf-cookie`); } } diff --git a/frontend/src/app/auth/auth.routes.ts b/frontend/src/app/auth/auth.routes.ts index a5397c7..f3cde49 100644 --- a/frontend/src/app/auth/auth.routes.ts +++ b/frontend/src/app/auth/auth.routes.ts @@ -1,12 +1,34 @@ -import {Routes} from '@angular/router'; +import { Routes } from '@angular/router'; +import { PASSWORD_FLOW_FACADE } from '../core/facades/passwordFlow.facade'; +import { PrivayStore } from '../settings/privacy/privacy.store'; +import { inject } from '@angular/core'; -export const authRoutes : Routes = [ +export const authRoutes: Routes = [ { path: 'login', - loadComponent: () => import('./login/login').then(m => m.Login), + loadComponent: () => import('./login/login').then((m) => m.Login), }, { path: 'register', - loadComponent: () => import('./register/register').then(m => m.Register), + loadComponent: () => import('./register/register').then((m) => m.Register), }, -] + { + path: 'privacy/2fa/confirm-password', + loadComponent: () => + import('./password-confirmation/password-confirmation').then((m) => m.PasswordConfirmation), + providers: [ + { + provide: PASSWORD_FLOW_FACADE, + useFactory: () => { + const store = inject(PrivayStore); + return { + isLoading: store.isLoading, + validationErrors: store.validationErrors, + action: store.toggleTwoFA, + backLink: '/settings', + }; + }, + }, + ], + }, +]; diff --git a/frontend/src/app/auth/auth.types.ts b/frontend/src/app/auth/auth.types.ts index fcfd1fc..1f735ae 100644 --- a/frontend/src/app/auth/auth.types.ts +++ b/frontend/src/app/auth/auth.types.ts @@ -1,3 +1,5 @@ +import { HttpStoreState } from '../core/types/http'; + export interface User { id: string; name: string; @@ -22,9 +24,11 @@ export interface RegisterRequest { password_confirmation: string; } -export interface AuthState { - user: User | null; - isLoading: boolean; - error: string | null; - validationErrors: Record | null; +export interface PasswordConfirmationRequest { + password: string; +} + +export interface AuthState extends HttpStoreState { + user: User | null; + error: string | null; } diff --git a/frontend/src/app/auth/password-confirmation/password-confirmation.css b/frontend/src/app/auth/password-confirmation/password-confirmation.css new file mode 100644 index 0000000..04f7e98 --- /dev/null +++ b/frontend/src/app/auth/password-confirmation/password-confirmation.css @@ -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); } +} diff --git a/frontend/src/app/auth/password-confirmation/password-confirmation.html b/frontend/src/app/auth/password-confirmation/password-confirmation.html new file mode 100644 index 0000000..ed73e56 --- /dev/null +++ b/frontend/src/app/auth/password-confirmation/password-confirmation.html @@ -0,0 +1,65 @@ +
+
+
+ + + +
+

+ Confirm Password +

+

+ This is a secure area. Please confirm your password before continuing. +

+
+ + + +
+
+ +
+ +
+
+ + +
+ +

+ ← Back +

+
diff --git a/frontend/src/app/auth/password-confirmation/password-confirmation.spec.ts b/frontend/src/app/auth/password-confirmation/password-confirmation.spec.ts new file mode 100644 index 0000000..cda9237 --- /dev/null +++ b/frontend/src/app/auth/password-confirmation/password-confirmation.spec.ts @@ -0,0 +1,22 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { PasswordConfirmation } from './password-confirmation'; + +describe('PasswordConfirmation', () => { + let component: PasswordConfirmation; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [PasswordConfirmation], + }).compileComponents(); + + fixture = TestBed.createComponent(PasswordConfirmation); + component = fixture.componentInstance; + await fixture.whenStable(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/frontend/src/app/auth/password-confirmation/password-confirmation.ts b/frontend/src/app/auth/password-confirmation/password-confirmation.ts new file mode 100644 index 0000000..3d88df8 --- /dev/null +++ b/frontend/src/app/auth/password-confirmation/password-confirmation.ts @@ -0,0 +1,36 @@ +import { Component, inject, signal } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { RouterLink } from '@angular/router'; +import { ValidationErrors } from '../../shared/validation-errors/validation-errors'; +import { getFormValidationErrors, getMergedValidationErrors } from '../../core/helpers/forms'; +import { PasswordConfirmationRequest } from '../auth.types'; +import { form, required, FormField } from '@angular/forms/signals'; +import { Loader } from '../../shared/loader/loader'; +import { PASSWORD_FLOW_FACADE } from '../../core/facades/passwordFlow.facade'; + +@Component({ + selector: 'app-password-confirmation', + standalone: true, + imports: [CommonModule, RouterLink, ValidationErrors, Loader, FormField], + templateUrl: './password-confirmation.html', + styleUrl: './password-confirmation.css', +}) +export class PasswordConfirmation { + passwordModel = signal({ password: '' }); + passwordForm = form(this.passwordModel, (schema) => { + required(schema.password); + }); + + private formErrors = signal>({}); + protected facade = inject(PASSWORD_FLOW_FACADE); + protected readonly allErrors = getMergedValidationErrors( + this.formErrors, + this.facade.validationErrors, + ); + + confirm() { + if (this.passwordForm().valid()) { + this.facade.action(this.passwordForm.password().value()); + } + } +} diff --git a/frontend/src/app/core/facades/passwordFlow.facade.ts b/frontend/src/app/core/facades/passwordFlow.facade.ts new file mode 100644 index 0000000..d3f10cd --- /dev/null +++ b/frontend/src/app/core/facades/passwordFlow.facade.ts @@ -0,0 +1,11 @@ +import { InjectionToken, Signal } from '@angular/core'; +import { HttpStoreState } from '../types/http'; + +export interface PasswordFlowFacade { + isLoading: Signal; + validationErrors: Signal | null>; + action: (password: string) => void; + backLink: string; +} + +export const PASSWORD_FLOW_FACADE = new InjectionToken('PASSWORD_FLOW_FACADE'); diff --git a/frontend/src/app/core/layout/sidebar/sidebar.html b/frontend/src/app/core/layout/sidebar/sidebar.html index 4ba1833..c583424 100644 --- a/frontend/src/app/core/layout/sidebar/sidebar.html +++ b/frontend/src/app/core/layout/sidebar/sidebar.html @@ -230,14 +230,22 @@ @if(isCogMenuOpen()){
-
} diff --git a/frontend/src/app/settings/privacy/privacy-service.spec.ts b/frontend/src/app/settings/privacy/privacy-service.spec.ts new file mode 100644 index 0000000..d40048a --- /dev/null +++ b/frontend/src/app/settings/privacy/privacy-service.spec.ts @@ -0,0 +1,16 @@ +import { TestBed } from '@angular/core/testing'; + +import { PrivacyService } from './privacy-service'; + +describe('PrivacyService', () => { + let service: PrivacyService; + + beforeEach(() => { + TestBed.configureTestingModule({}); + service = TestBed.inject(PrivacyService); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); +}); diff --git a/frontend/src/app/settings/privacy/privacy-service.ts b/frontend/src/app/settings/privacy/privacy-service.ts new file mode 100644 index 0000000..6348eba --- /dev/null +++ b/frontend/src/app/settings/privacy/privacy-service.ts @@ -0,0 +1,20 @@ +import { HttpClient } from '@angular/common/http'; +import { Injectable } from '@angular/core'; +import { inject } from '@angular/core/primitives/di'; +import { BASE_URL } from '../../core/tokens/api-urls'; + +@Injectable({ + providedIn: 'root', +}) +export class PrivacyService { + private http = inject(HttpClient); + private baseUrl = inject(BASE_URL); + + public enableTwoFA() { + return this.http.get(`${this.baseUrl}/user/two-factor-authentication`); + } + + public disableTwoFA() { + return this.http.delete(`${this.baseUrl}/user/two-factor-authentication`); + } +} diff --git a/frontend/src/app/settings/privacy/privacy.css b/frontend/src/app/settings/privacy/privacy.css new file mode 100644 index 0000000..5d4e87f --- /dev/null +++ b/frontend/src/app/settings/privacy/privacy.css @@ -0,0 +1,3 @@ +:host { + display: block; +} diff --git a/frontend/src/app/settings/privacy/privacy.html b/frontend/src/app/settings/privacy/privacy.html new file mode 100644 index 0000000..c279f22 --- /dev/null +++ b/frontend/src/app/settings/privacy/privacy.html @@ -0,0 +1,27 @@ +
+
+

Privacy & Security

+

Control who can see your information and how your data is used.

+
+ +
+
+
+ + Add an extra layer of security to your account. +
+ +
+
+ +
+

Danger Zone

+

Irreversible actions. Please proceed with caution.

+
+ +
+
+
diff --git a/frontend/src/app/settings/privacy/privacy.spec.ts b/frontend/src/app/settings/privacy/privacy.spec.ts new file mode 100644 index 0000000..8185933 --- /dev/null +++ b/frontend/src/app/settings/privacy/privacy.spec.ts @@ -0,0 +1,22 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { Privacy } from './privacy'; + +describe('Privacy', () => { + let component: Privacy; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [Privacy], + }).compileComponents(); + + fixture = TestBed.createComponent(Privacy); + component = fixture.componentInstance; + await fixture.whenStable(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/frontend/src/app/settings/privacy/privacy.store.ts b/frontend/src/app/settings/privacy/privacy.store.ts new file mode 100644 index 0000000..eefb291 --- /dev/null +++ b/frontend/src/app/settings/privacy/privacy.store.ts @@ -0,0 +1,49 @@ +import { patchState, signalStore, withMethods, withState } from '@ngrx/signals'; +import { PrivacyState } from './privacy.types'; +import { Router } from '@angular/router'; +import { inject } from '@angular/core'; +import { PrivacyService } from './privacy-service'; +import { AuthService } from '../../auth/auth-service'; +import { HttpErrorResponse } from '@angular/common/http'; + +const initialState: PrivacyState = { + twoFactor: { + enabled: false, + qrCode: null, + recoveryCodes: [], + }, + validationErrors: null, + isLoading: false, +}; + +export const PrivayStore = signalStore( + { providedIn: 'root' }, + withState(initialState), + withMethods((store) => { + const router = inject(Router); + const authService = inject(AuthService); + + return { + toggleTwoFA: (password: string) => { + patchState(store, { isLoading: true }); + authService.confirmPassword({ password: password }).subscribe({ + next: () => { + const privacyService = inject(PrivacyService); + + if (store.twoFactor().enabled) { + privacyService.disableTwoFA(); + patchState(store, (state) => { + + }) + } + }, + error: (error: HttpErrorResponse) => { + if (error.status === 422) { + patchState(store, { validationErrors: error.error.errors, isLoading: false }); + } + }, + }); + }, + }; + }), +); diff --git a/frontend/src/app/settings/privacy/privacy.ts b/frontend/src/app/settings/privacy/privacy.ts new file mode 100644 index 0000000..309f8bb --- /dev/null +++ b/frontend/src/app/settings/privacy/privacy.ts @@ -0,0 +1,15 @@ +import { Component, inject } from '@angular/core'; +import { Router } from '@angular/router'; + +@Component({ + selector: 'app-privacy', + imports: [], + templateUrl: './privacy.html', + styleUrl: './privacy.css', +}) +export class Privacy { + private router = inject(Router); + protected toggle2fa() { + this.router.navigate(['/user/privacy/2fa/confirm-password']); + } +} diff --git a/frontend/src/app/settings/privacy/privacy.types.ts b/frontend/src/app/settings/privacy/privacy.types.ts new file mode 100644 index 0000000..b1af3fa --- /dev/null +++ b/frontend/src/app/settings/privacy/privacy.types.ts @@ -0,0 +1,11 @@ +import { HttpStoreState } from '../../core/types/http'; + +export interface TwoFactorAuth { + enabled: boolean; + qrCode: string | null; + recoveryCodes: string[]; +} + +export interface PrivacyState extends HttpStoreState { + twoFactor: TwoFactorAuth; +} diff --git a/frontend/src/app/settings/settings-service.spec.ts b/frontend/src/app/settings/settings-service.spec.ts new file mode 100644 index 0000000..c6b89fc --- /dev/null +++ b/frontend/src/app/settings/settings-service.spec.ts @@ -0,0 +1,16 @@ +import { TestBed } from '@angular/core/testing'; + +import { SettingsService } from './settings-service'; + +describe('SettingsService', () => { + let service: SettingsService; + + beforeEach(() => { + TestBed.configureTestingModule({}); + service = TestBed.inject(SettingsService); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); +}); diff --git a/frontend/src/app/settings/settings-service.ts b/frontend/src/app/settings/settings-service.ts new file mode 100644 index 0000000..bc6f3e4 --- /dev/null +++ b/frontend/src/app/settings/settings-service.ts @@ -0,0 +1,6 @@ +import { Injectable } from '@angular/core'; + +@Injectable({ + providedIn: 'root', +}) +export class SettingsService {} diff --git a/frontend/src/app/settings/settings.css b/frontend/src/app/settings/settings.css new file mode 100644 index 0000000..7068f90 --- /dev/null +++ b/frontend/src/app/settings/settings.css @@ -0,0 +1,371 @@ +@import 'tailwindcss'; +/* + Based on TailwindCSS recommendations, + consider using classes instead of the `@apply` directive + @see https://tailwindcss.com/docs/reusing-styles#avoiding-premature-abstraction +*/ +/* ============================================================ + Settings Layout + ============================================================ */ +:host { + @apply block w-full h-screen overflow-hidden; +} +.settings-container { + @apply flex w-full h-full; +} + +/* ============================================================ + Sidebar + ============================================================ */ +.settings-sidebar { + @apply w-80 min-w-[280px] flex flex-col backdrop-blur-[20px] border-r-[rgba(255,255,255,0.06)] border-r border-solid; + background: rgba(255, 255, 255, 0.02); +} +.sidebar-header { + @apply flex items-center gap-3 pt-7 pb-5 px-6; +} +.sidebar-header h2 { + @apply text-xl font-semibold text-slate-100 m-0; +} +.back-link { + @apply flex items-center justify-center w-[34px] h-[34px] border text-slate-400 no-underline transition-all duration-[0.2s] ease-[ease] rounded-[10px] border-solid border-[rgba(255,255,255,0.08)] hover:text-slate-200 hover:border-[rgba(255,255,255,0.15)]; + background: rgba(255, 255, 255, 0.05); +} +.back-link:hover { + background: rgba(255, 255, 255, 0.1); +} +.back-icon { + @apply w-[18px] h-[18px]; +} +.sidebar-nav { + @apply flex flex-col gap-1 flex-1 overflow-y-auto px-3 py-2; +} + +/* ============================================================ + Nav Tabs + ============================================================ */ +.nav-tab { + @apply flex items-center gap-3 border text-slate-400 cursor-pointer transition-all duration-[0.25s] ease-in-out text-left w-full px-4 py-3.5 rounded-xl border-solid border-transparent hover:text-slate-300; + background: transparent; + font-family: inherit; +} +.nav-tab:hover { + background: rgba(255, 255, 255, 0.05); +} +.nav-tab.active { + @apply text-slate-200 shadow-[0_0_20px_rgba(99,102,241,0.08)] border-[rgba(99,102,241,0.3)]; + background: linear-gradient(135deg, rgba(99, 102, 241, 0.15) 0%, rgba(139, 92, 246, 0.1) 100%); +} +.nav-tab.active .nav-icon { + @apply text-indigo-400; +} +.nav-icon { + @apply w-[22px] h-[22px] shrink-0 transition-[color] duration-[0.2s] ease-[ease]; +} +.nav-tab-text { + @apply flex flex-col min-w-0; +} +.nav-label { + @apply text-[0.9rem] font-medium leading-[1.3]; +} +.nav-description { + @apply text-[0.72rem] text-slate-500 whitespace-nowrap overflow-hidden text-ellipsis mt-0.5; +} + +/* ============================================================ + Content Area + ============================================================ */ +.settings-content { + @apply flex-1 flex flex-col overflow-y-auto px-10 py-8; + scrollbar-width: thin; + scrollbar-color: rgba(255, 255, 255, 0.1) transparent; +} +.content-header { + @apply flex items-start justify-between mb-8 pb-6 border-b-[rgba(255,255,255,0.06)] border-b border-solid; +} +.content-header h2 { + @apply text-2xl font-semibold text-slate-100 m-0; +} +.content-header p { + @apply text-[0.85rem] text-slate-500 mt-1 mb-0 mx-0; +} +.content-actions { + @apply flex gap-3 shrink-0; +} + +/* ============================================================ + Tab Panel + ============================================================ */ +.tab-panel { + @apply animate-[fadeSlideIn_0.35s_cubic-bezier(0.4,0,0.2,1)]; +} +@keyframes fadeSlideIn { + from { + opacity: 0; + transform: translateY(8px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +/* ============================================================ + Shared Form Styles (inherited by tab components via ::ng-deep) + ============================================================ */ +::ng-deep .settings-section { + @apply flex flex-col gap-6; +} +::ng-deep .section-header h3 { + @apply text-[1.1rem] font-semibold text-slate-200 m-0; +} +::ng-deep .section-header p { + @apply text-[0.82rem] text-slate-500 mt-1 mb-0 mx-0; +} +::ng-deep .form-group { + @apply flex flex-col gap-2; +} +::ng-deep .form-group > label { + @apply text-[0.85rem] font-medium text-slate-300; +} +::ng-deep .form-input { + @apply border text-slate-200 text-[0.9rem] transition-all duration-[0.2s] ease-[ease] w-full box-border px-4 py-[0.7rem] rounded-[10px] border-solid border-[rgba(255,255,255,0.1)] focus:shadow-[0_0_0_3px_rgba(99,102,241,0.1)] focus:border-[rgba(99,102,241,0.5)]; + background: rgba(255, 255, 255, 0.04); + font-family: inherit; + outline: none; +} +::ng-deep .form-input:focus { + background: rgba(255, 255, 255, 0.06); +} +::ng-deep .form-input::placeholder { + @apply text-slate-600; +} +::ng-deep .form-textarea { + @apply resize-y min-h-[80px]; +} +::ng-deep .form-select { + @apply appearance-none bg-[url("data:image/svg+xml,%3Csvg_xmlns='http://www.w3.org/2000/svg'_width='24'_height='24'_viewBox='0_0_24_24'_fill='%2394a3b8'%3E%3Cpath_d='M7_10l5_5_5-5z'/%3E%3C/svg%3E")] bg-no-repeat bg-[right_0.75rem_center] bg-[18px] pr-10; +} +::ng-deep .form-select option { + @apply text-slate-200; + background: #1e293b; +} +::ng-deep .hint { + @apply text-xs text-slate-500; +} +::ng-deep .char-count { + @apply text-[0.72rem] text-slate-600 text-right; +} + +/* ============================================================ + Toggle Switch + ============================================================ */ +::ng-deep .toggle-row { + @apply flex items-center justify-between border transition-all duration-[0.2s] ease-[ease] px-5 py-4 rounded-xl border-solid border-[rgba(255,255,255,0.06)] hover:border-[rgba(255,255,255,0.1)]; + background: rgba(255, 255, 255, 0.03); +} +::ng-deep .toggle-row:hover { + background: rgba(255, 255, 255, 0.05); +} +::ng-deep .toggle-info { + @apply flex flex-col gap-0.5; +} +::ng-deep .toggle-info label { + @apply text-[0.9rem] font-medium text-slate-200 cursor-pointer; +} +::ng-deep .toggle { + @apply relative inline-block w-12 h-[26px] shrink-0 cursor-pointer; +} +::ng-deep .toggle input { + @apply opacity-0 w-0 h-0; +} +::ng-deep .toggle-slider { + position: absolute; + cursor: pointer; + inset: 0; + background: rgba(255, 255, 255, 0.1); + border-radius: 26px; + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); +} + +::ng-deep .toggle-slider::before { + content: ''; + position: absolute; + height: 20px; + width: 20px; + left: 3px; + bottom: 3px; + background: #e2e8f0; + border-radius: 50%; + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); +} + +::ng-deep .toggle input:checked + .toggle-slider { + background: linear-gradient(135deg, #6366f1, #8b5cf6); +} + +::ng-deep .toggle input:checked + .toggle-slider::before { + transform: translateX(22px); + background: white; +} + +/* ============================================================ + Option Cards (e.g. Theme, Font Size) + ============================================================ */ +::ng-deep .option-cards { + @apply flex gap-3 flex-wrap; +} +::ng-deep .option-card { + @apply flex flex-col items-center gap-2 min-w-[100px] border text-slate-400 cursor-pointer transition-all duration-[0.25s] ease-[ease] text-[0.82rem] font-medium px-6 py-5 rounded-[14px] border-solid border-[rgba(255,255,255,0.08)] hover:-translate-y-0.5 hover:border-[rgba(255,255,255,0.12)]; + background: rgba(255, 255, 255, 0.03); + font-family: inherit; +} +::ng-deep .option-card:hover { + background: rgba(255, 255, 255, 0.06); +} +::ng-deep .option-card.active { + @apply text-indigo-200 shadow-[0_4px_16px_rgba(99,102,241,0.12)] border-[rgba(99,102,241,0.4)]; + background: linear-gradient(135deg, rgba(99, 102, 241, 0.12) 0%, rgba(139, 92, 246, 0.08) 100%); +} +::ng-deep .option-card-icon { + @apply w-7 h-7; +} +::ng-deep .option-card-icon svg { + @apply w-full h-full; +} + +/* ============================================================ + Avatar Section + ============================================================ */ +::ng-deep .avatar-section { + @apply flex items-center gap-5 border p-5 rounded-[14px] border-solid border-[rgba(255,255,255,0.06)]; + background: rgba(255, 255, 255, 0.03); +} +::ng-deep .avatar-preview { + @apply w-[72px] h-[72px] overflow-hidden shrink-0 rounded-[50%] border-2 border-solid border-[rgba(99,102,241,0.3)]; +} +::ng-deep .avatar-preview img { + @apply w-full h-full object-cover; +} +::ng-deep .avatar-placeholder { + @apply w-full h-full flex items-center justify-center text-indigo-400; + background: linear-gradient(135deg, rgba(99, 102, 241, 0.2), rgba(139, 92, 246, 0.2)); +} +::ng-deep .avatar-placeholder svg { + @apply w-9 h-9; +} +::ng-deep .avatar-actions { + @apply flex flex-col gap-[0.4rem]; +} + +/* ============================================================ + Danger Zone + ============================================================ */ +::ng-deep .danger-zone { + @apply border mt-6 p-6 rounded-[14px] border-solid border-[rgba(239,68,68,0.15)]; + background: rgba(239, 68, 68, 0.04); +} +::ng-deep .danger-zone h4 { + @apply text-red-300 text-base font-semibold m-0; +} +::ng-deep .danger-zone > p { + @apply text-[0.8rem] text-red-400 opacity-70 mt-1 mb-4 mx-0; +} +::ng-deep .danger-actions { + @apply flex gap-3 flex-wrap; +} + +/* ============================================================ + Buttons + ============================================================ */ +.btn, +::ng-deep .btn { + @apply inline-flex items-center justify-center gap-2 text-[0.85rem] font-medium cursor-pointer transition-all duration-[0.2s] ease-[ease] whitespace-nowrap px-5 py-[0.6rem] rounded-[10px] border-[none]; + font-family: inherit; +} +.btn-primary { + @apply text-[white] shadow-[0_4px_12px_rgba(99,102,241,0.25)] hover:shadow-[0_6px_20px_rgba(99,102,241,0.35)] hover:-translate-y-px disabled:opacity-60 disabled:cursor-not-allowed disabled:shadow-none; + background: linear-gradient(135deg, #6366f1, #8b5cf6); +} +.btn-primary:disabled { + transform: none; +} +.btn-outline, +::ng-deep .btn-outline { + @apply text-slate-400 border border-solid border-[rgba(255,255,255,0.1)]; + background: transparent; +} +.btn-outline:hover, +::ng-deep .btn-outline:hover { + @apply text-slate-200 border-[rgba(255,255,255,0.2)]; + background: rgba(255, 255, 255, 0.05); +} +::ng-deep .btn-danger { + @apply text-red-300 border border-solid border-[rgba(239,68,68,0.2)] hover:border-[rgba(239,68,68,0.35)]; + background: rgba(239, 68, 68, 0.1); +} +::ng-deep .btn-danger:hover { + background: rgba(239, 68, 68, 0.2); +} +::ng-deep .btn-danger-outline { + @apply text-red-400 border border-solid border-[rgba(239,68,68,0.25)] hover:border-[rgba(239,68,68,0.4)]; + background: transparent; +} +::ng-deep .btn-danger-outline:hover { + background: rgba(239, 68, 68, 0.08); +} + +/* ============================================================ + Spinner + ============================================================ */ +.spinner { + @apply inline-block w-4 h-4 animate-[spin_0.6s_linear_infinite] rounded-[50%] border-t-[white] border-2 border-solid border-[rgba(255,255,255,0.3)]; +} +@keyframes spin { + to { + transform: rotate(360deg); + } +} + +/* ============================================================ + Toasts + ============================================================ */ +.toast { + @apply flex items-center gap-[0.6rem] text-[0.85rem] animate-[fadeSlideIn_0.3s_ease] mb-6 px-4 py-3 rounded-[10px]; +} +.toast-icon { + @apply w-5 h-5 shrink-0; +} +.toast-success { + @apply border text-green-300 border-solid border-[rgba(34,197,94,0.2)]; + background: rgba(34, 197, 94, 0.08); +} +.toast-error { + @apply border text-red-300 border-solid border-[rgba(239,68,68,0.2)]; + background: rgba(239, 68, 68, 0.08); +} + +/* ============================================================ + Responsive + ============================================================ */ +@media (max-width: 768px) { + .settings-container { + @apply flex-col; + } + .settings-sidebar { + @apply w-full min-w-[unset] max-h-[40vh] border-r-[none] border-b-[rgba(255,255,255,0.06)] border-b border-solid; + } + .nav-description { + @apply hidden; + } + .settings-content { + @apply p-6; + } + .content-header { + @apply flex-col gap-4; + } + ::ng-deep .option-cards { + @apply flex-col; + } +} diff --git a/frontend/src/app/settings/settings.html b/frontend/src/app/settings/settings.html new file mode 100644 index 0000000..010b373 --- /dev/null +++ b/frontend/src/app/settings/settings.html @@ -0,0 +1,71 @@ +
+ + + + +
+
+
+

{{ store.activeTab().label }}

+

{{ store.activeTab().description }}

+
+
+ + + @if (store.saveSuccess() === true) { +
+ + + + Settings saved successfully! +
+ } @if (store.error()) { +
+ + + + {{ store.error() }} +
+ } + + +
+ @switch (store.activeTabId()) { @case ('privacy') { + + }} +
+
+
diff --git a/frontend/src/app/settings/settings.spec.ts b/frontend/src/app/settings/settings.spec.ts new file mode 100644 index 0000000..011fd8f --- /dev/null +++ b/frontend/src/app/settings/settings.spec.ts @@ -0,0 +1,22 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { Settings } from './settings'; + +describe('Settings', () => { + let component: Settings; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [Settings], + }).compileComponents(); + + fixture = TestBed.createComponent(Settings); + component = fixture.componentInstance; + await fixture.whenStable(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/frontend/src/app/settings/settings.store.ts b/frontend/src/app/settings/settings.store.ts new file mode 100644 index 0000000..7775359 --- /dev/null +++ b/frontend/src/app/settings/settings.store.ts @@ -0,0 +1,46 @@ +import { patchState, signalStore, withComputed, withMethods, withState } from '@ngrx/signals'; +import { computed } from '@angular/core'; +import { SettingsState, SETTINGS_TABS } from './settings.types'; + +const initialState: SettingsState = { + activeTabId: 'privacy', + isLoading: false, + isSaving: false, + saveSuccess: null, + error: null, +}; + +export const SettingsStore = signalStore( + { providedIn: 'root' }, + withState(initialState), + withComputed((store) => ({ + activeTab: computed(() => { + return SETTINGS_TABS.find((tab) => tab.id === store.activeTabId()) ?? SETTINGS_TABS[0]; + }), + tabs: computed(() => SETTINGS_TABS), + })), + withMethods((store) => ({ + setActiveTab(tabId: string) { + patchState(store, { activeTabId: tabId, saveSuccess: null, error: null }); + }, + async saveSettings() { + patchState(store, { isSaving: true, saveSuccess: null, error: null }); + try { + // Simulate API call — replace with actual SettingsService call + await new Promise((resolve) => setTimeout(resolve, 800)); + patchState(store, { isSaving: false, saveSuccess: true }); + + // Auto-dismiss success message + setTimeout(() => { + patchState(store, { saveSuccess: null }); + }, 3000); + } catch (err: any) { + patchState(store, { + isSaving: false, + saveSuccess: false, + error: err?.message ?? 'Failed to save settings.', + }); + } + }, + })), +); diff --git a/frontend/src/app/settings/settings.ts b/frontend/src/app/settings/settings.ts new file mode 100644 index 0000000..31f8d4d --- /dev/null +++ b/frontend/src/app/settings/settings.ts @@ -0,0 +1,17 @@ +import { Component, inject } from '@angular/core'; +import { SettingsStore } from './settings.store'; +import { SETTINGS_TABS } from './settings.types'; +import { RouterLink } from '@angular/router'; +import { Privacy } from "./privacy/privacy"; + +@Component({ + selector: 'app-settings', + standalone: true, + imports: [RouterLink, Privacy], + templateUrl: './settings.html', + styleUrl: './settings.css', +}) +export class Settings { + protected readonly store = inject(SettingsStore); + protected readonly tabs = SETTINGS_TABS; +} diff --git a/frontend/src/app/settings/settings.types.ts b/frontend/src/app/settings/settings.types.ts new file mode 100644 index 0000000..5355da7 --- /dev/null +++ b/frontend/src/app/settings/settings.types.ts @@ -0,0 +1,25 @@ +export interface SettingsTab { + id: string; + label: string; + icon: string; + description: string; +} + +export const SETTINGS_TABS: SettingsTab[] = [ + { + id: 'privacy', + label: 'Privacy & Security', + icon: 'M18 8h-1V6c0-2.76-2.24-5-5-5S7 3.24 7 6v2H6c-1.1 0-2 .9-2 2v10c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V10c0-1.1-.9-2-2-2zm-6 9c-1.1 0-2-.9-2-2s.9-2 2-2 2 .9 2 2-.9 2-2 2zm3.1-9H8.9V6c0-1.71 1.39-3.1 3.1-3.1 1.71 0 3.1 1.39 3.1 3.1v2z', + description: 'Manage security and data privacy', + }, +]; + +// --- Store state --- + +export interface SettingsState { + activeTabId: string; + isLoading: boolean; + isSaving: boolean; + saveSuccess: boolean | null; + error: string | null; +} diff --git a/frontend/src/styles.css b/frontend/src/styles.css index 17c9d95..b113058 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -12,7 +12,7 @@ body { 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%); + background: linear-gradient(135deg, #0f172a 0%, #1e1b4b 50%, #0f172a 100%); } button:active { transform: scale(0.98);