Compare commits
2 Commits
master
...
feature/2f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e0cef27f51 | ||
|
|
fdc458e167 |
@ -10,6 +10,7 @@
|
|||||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
use Illuminate\Foundation\Auth\User as Authenticatable;
|
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||||
use Illuminate\Notifications\Notifiable;
|
use Illuminate\Notifications\Notifiable;
|
||||||
|
use Laravel\Fortify\TwoFactorAuthenticatable;
|
||||||
use Laravel\Sanctum\HasApiTokens;
|
use Laravel\Sanctum\HasApiTokens;
|
||||||
|
|
||||||
#[Fillable(['name', 'email', 'password'])]
|
#[Fillable(['name', 'email', 'password'])]
|
||||||
@ -17,7 +18,7 @@
|
|||||||
class User extends Authenticatable
|
class User extends Authenticatable
|
||||||
{
|
{
|
||||||
/** @use HasFactory<UserFactory> */
|
/** @use HasFactory<UserFactory> */
|
||||||
use HasApiTokens, HasFactory, Notifiable;
|
use HasApiTokens, HasFactory, Notifiable, TwoFactorAuthenticatable;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the attributes that should be cast.
|
* Get the attributes that should be cast.
|
||||||
|
|||||||
@ -1,69 +1,70 @@
|
|||||||
services:
|
services:
|
||||||
# Laravel App (PHP-FPM)
|
# Laravel App (PHP-FPM)
|
||||||
app:
|
app:
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: docker/php/Dockerfile
|
dockerfile: docker/php/Dockerfile
|
||||||
container_name: laravel_app
|
container_name: laravel_app
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
working_dir: /var/www/html/backend
|
working_dir: /var/www/html/backend
|
||||||
volumes:
|
volumes:
|
||||||
- ./backend:/var/www/html/backend
|
- ./backend:/var/www/html/backend
|
||||||
networks:
|
networks:
|
||||||
- app-network
|
- app-network
|
||||||
depends_on:
|
depends_on:
|
||||||
- db
|
- db
|
||||||
|
|
||||||
# Nginx Web Server
|
# Nginx Web Server
|
||||||
webserver:
|
webserver:
|
||||||
image: nginx:alpine
|
image: nginx:alpine
|
||||||
container_name: laravel_nginx
|
container_name: laravel_nginx
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
- "8000:80"
|
- "8000:80"
|
||||||
volumes:
|
volumes:
|
||||||
- ./backend:/var/www/html/backend
|
- ./backend:/var/www/html/backend
|
||||||
- ./docker/nginx/default.conf:/etc/nginx/conf.d/default.conf
|
- ./docker/nginx/default.conf:/etc/nginx/conf.d/default.conf
|
||||||
networks:
|
networks:
|
||||||
- app-network
|
- app-network
|
||||||
depends_on:
|
depends_on:
|
||||||
- app
|
- app
|
||||||
|
|
||||||
# PostgreSQL Database
|
# PostgreSQL Database
|
||||||
# (Note: Set to PostgreSQL per your 8.5/PostgreSQL/Node 24 requirement)
|
# (Note: Set to PostgreSQL per your 8.5/PostgreSQL/Node 24 requirement)
|
||||||
db:
|
db:
|
||||||
image: postgres:16-alpine
|
image: postgres:16-alpine
|
||||||
container_name: laravel_db
|
container_name: laravel_db
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_DB: neoban
|
POSTGRES_DB: neoban
|
||||||
POSTGRES_USER: neoban
|
POSTGRES_USER: neoban
|
||||||
POSTGRES_PASSWORD: secret
|
POSTGRES_PASSWORD: secret
|
||||||
ports:
|
ports:
|
||||||
- "5432:5432"
|
- "5432:5432"
|
||||||
volumes:
|
volumes:
|
||||||
- dbdata:/var/lib/postgresql/data
|
- dbdata:/var/lib/postgresql/data
|
||||||
networks:
|
networks:
|
||||||
- app-network
|
- app-network
|
||||||
|
|
||||||
# Angular Frontend
|
# Angular Frontend
|
||||||
frontend:
|
frontend:
|
||||||
image: node:24-alpine
|
image: node:24-alpine
|
||||||
container_name: angular_frontend
|
container_name: angular_frontend
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
working_dir: /app/frontend
|
working_dir: /app/frontend
|
||||||
volumes:
|
volumes:
|
||||||
- ./frontend:/app/frontend
|
- ./frontend:/app/frontend
|
||||||
ports:
|
ports:
|
||||||
- "4200:4200"
|
- "4200:4200"
|
||||||
# Runs install and starts Angular bound to 0.0.0.0 so it is accessible outside the container
|
# 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"
|
command: sh -c "npm install && npx ng serve --host 0.0.0.0 --poll 2000"
|
||||||
networks:
|
networks:
|
||||||
- app-network
|
- app-network
|
||||||
|
user: 1000:1000
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
app-network:
|
app-network:
|
||||||
driver: bridge
|
driver: bridge
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
dbdata:
|
dbdata:
|
||||||
|
|||||||
@ -2,7 +2,8 @@
|
|||||||
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
|
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
|
||||||
"version": 1,
|
"version": 1,
|
||||||
"cli": {
|
"cli": {
|
||||||
"packageManager": "npm"
|
"packageManager": "npm",
|
||||||
|
"analytics": false
|
||||||
},
|
},
|
||||||
"newProjectRoot": "projects",
|
"newProjectRoot": "projects",
|
||||||
"projects": {
|
"projects": {
|
||||||
|
|||||||
@ -6,11 +6,15 @@ export const routes: Routes = [
|
|||||||
loadComponent: () => import('./chat/chat').then((m) => m.Chat),
|
loadComponent: () => import('./chat/chat').then((m) => m.Chat),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: ':id',
|
path: 'settings',
|
||||||
loadComponent: () => import('./chat/chat').then((m) => m.Chat),
|
loadComponent: () => import('./settings/settings').then((m) => m.Settings),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'user',
|
path: 'user',
|
||||||
loadChildren: () => import('./auth/auth.routes').then((m) => m.authRoutes),
|
loadChildren: () => import('./auth/auth.routes').then((m) => m.authRoutes),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: ':id',
|
||||||
|
loadComponent: () => import('./chat/chat').then((m) => m.Chat),
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@ -1,9 +1,8 @@
|
|||||||
import {inject, Injectable } from '@angular/core';
|
import { inject, Injectable } from '@angular/core';
|
||||||
import {HttpClient, HttpHeaders} from '@angular/common/http';
|
import { HttpClient, HttpHeaders } from '@angular/common/http';
|
||||||
import {environment} from '../../environments/environment';
|
import { LoginRequest, PasswordConfirmationRequest, RegisterRequest, User } from './auth.types';
|
||||||
import {LoginRequest, RegisterRequest, User} from './auth.types';
|
import { Observable, switchMap } from 'rxjs';
|
||||||
import {Observable, switchMap} from 'rxjs';
|
import { API_URL, BASE_URL } from '../core/tokens/api-urls';
|
||||||
import {API_URL, BASE_URL} from '../core/tokens/api-urls';
|
|
||||||
|
|
||||||
@Injectable({
|
@Injectable({
|
||||||
providedIn: 'root',
|
providedIn: 'root',
|
||||||
@ -13,29 +12,33 @@ export class AuthService {
|
|||||||
private baseUrl = inject(BASE_URL);
|
private baseUrl = inject(BASE_URL);
|
||||||
private apiUrl = inject(API_URL);
|
private apiUrl = inject(API_URL);
|
||||||
|
|
||||||
public login (credentials: LoginRequest){
|
public login(credentials: LoginRequest) {
|
||||||
return this.getSanctumCookie().pipe(
|
return this.getSanctumCookie().pipe(
|
||||||
switchMap(() => this.http.post(`${this.baseUrl}/login`, credentials))
|
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(){
|
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`, {});
|
return this.http.post(`${this.baseUrl}/logout`, {});
|
||||||
}
|
}
|
||||||
|
|
||||||
public getCurrentUser():Observable<User>
|
public getCurrentUser(): Observable<User> {
|
||||||
{
|
|
||||||
return this.http.get<User>(`${this.apiUrl}/me`);
|
return this.http.get<User>(`${this.apiUrl}/me`);
|
||||||
}
|
}
|
||||||
|
|
||||||
private getSanctumCookie(): Observable<null>
|
public confirmPassword(data: PasswordConfirmationRequest): Observable<any> {
|
||||||
{
|
return this.getSanctumCookie().pipe(
|
||||||
|
switchMap(() => this.http.post(`${this.baseUrl}/user/confirm-password`, data)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private getSanctumCookie(): Observable<null> {
|
||||||
return this.http.get<null>(`${this.baseUrl}/sanctum/csrf-cookie`);
|
return this.http.get<null>(`${this.baseUrl}/sanctum/csrf-cookie`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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',
|
path: 'login',
|
||||||
loadComponent: () => import('./login/login').then(m => m.Login),
|
loadComponent: () => import('./login/login').then((m) => m.Login),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'register',
|
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',
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|||||||
@ -1,3 +1,5 @@
|
|||||||
|
import { HttpStoreState } from '../core/types/http';
|
||||||
|
|
||||||
export interface User {
|
export interface User {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
@ -22,9 +24,11 @@ export interface RegisterRequest {
|
|||||||
password_confirmation: string;
|
password_confirmation: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AuthState {
|
export interface PasswordConfirmationRequest {
|
||||||
user: User | null;
|
password: string;
|
||||||
isLoading: boolean;
|
}
|
||||||
error: string | null;
|
|
||||||
validationErrors: Record<string, string[]> | null;
|
export interface AuthState extends HttpStoreState {
|
||||||
|
user: User | null;
|
||||||
|
error: string | null;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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); }
|
||||||
|
}
|
||||||
@ -0,0 +1,65 @@
|
|||||||
|
<div
|
||||||
|
class="w-90 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-linear-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="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z"
|
||||||
|
></path>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h1
|
||||||
|
class="m-0 text-3xl font-semibold tracking-wide bg-linear-to-r from-white to-indigo-300 bg-clip-text text-transparent"
|
||||||
|
>
|
||||||
|
Confirm Password
|
||||||
|
</h1>
|
||||||
|
<p class="m-0 mt-2 text-sm text-slate-400">
|
||||||
|
This is a secure area. Please confirm your password before continuing.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<app-validation-errors [errors]="allErrors()" />
|
||||||
|
|
||||||
|
<form 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">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"
|
||||||
|
[formField]="passwordForm.password"
|
||||||
|
placeholder="••••••••"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
(click)="confirm()"
|
||||||
|
class="mt-4 bg-linear-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]="facade.isLoading() || passwordForm().invalid()"
|
||||||
|
>
|
||||||
|
@if (facade.isLoading()) {
|
||||||
|
<div class="w-full flex justify-center">
|
||||||
|
<app-loader class="w-8!" />
|
||||||
|
</div>
|
||||||
|
} @else { Confirm }
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<p class="mt-8 text-center text-sm text-slate-400">
|
||||||
|
<a
|
||||||
|
[routerLink]="facade.backLink"
|
||||||
|
class="text-[#4facfe] hover:text-[#00f2fe] font-medium transition-colors"
|
||||||
|
>← Back</a
|
||||||
|
>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
@ -0,0 +1,22 @@
|
|||||||
|
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||||
|
|
||||||
|
import { PasswordConfirmation } from './password-confirmation';
|
||||||
|
|
||||||
|
describe('PasswordConfirmation', () => {
|
||||||
|
let component: PasswordConfirmation;
|
||||||
|
let fixture: ComponentFixture<PasswordConfirmation>;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
imports: [PasswordConfirmation],
|
||||||
|
}).compileComponents();
|
||||||
|
|
||||||
|
fixture = TestBed.createComponent(PasswordConfirmation);
|
||||||
|
component = fixture.componentInstance;
|
||||||
|
await fixture.whenStable();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create', () => {
|
||||||
|
expect(component).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -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<PasswordConfirmationRequest>({ password: '' });
|
||||||
|
passwordForm = form(this.passwordModel, (schema) => {
|
||||||
|
required(schema.password);
|
||||||
|
});
|
||||||
|
|
||||||
|
private formErrors = signal<Record<string, string[]>>({});
|
||||||
|
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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
11
frontend/src/app/core/facades/passwordFlow.facade.ts
Normal file
11
frontend/src/app/core/facades/passwordFlow.facade.ts
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
import { InjectionToken, Signal } from '@angular/core';
|
||||||
|
import { HttpStoreState } from '../types/http';
|
||||||
|
|
||||||
|
export interface PasswordFlowFacade {
|
||||||
|
isLoading: Signal<boolean>;
|
||||||
|
validationErrors: Signal<Record<string, string[]> | null>;
|
||||||
|
action: (password: string) => void;
|
||||||
|
backLink: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PASSWORD_FLOW_FACADE = new InjectionToken<PasswordFlowFacade>('PASSWORD_FLOW_FACADE');
|
||||||
@ -230,14 +230,22 @@
|
|||||||
@if(isCogMenuOpen()){
|
@if(isCogMenuOpen()){
|
||||||
<div
|
<div
|
||||||
id="cog-menu"
|
id="cog-menu"
|
||||||
class="bg-black/9 backdrop-blur-sm text-xs text-gray-400 min-w-30 px-2 py-2 rounded-xl absolute right-0 bottom-4"
|
class="bg-black/9 backdrop-blur-sm shadow-2xl border border-[#2a3a5c] text-xs text-gray-400 min-w-30 px-2 py-2 rounded-xl absolute right-0 bottom-4"
|
||||||
>
|
>
|
||||||
<ul class="">
|
<ul class="space-y-1">
|
||||||
<li>
|
<li class="border-b border-[#2a3a5c]">
|
||||||
<button class="w-full py-2 rounded-xl hover:bg-white/4 hover:text-red-400">
|
<button class="w-full py-1.5 mb-1 rounded-xl hover:bg-white/4 hover:text-red-400">
|
||||||
Logout
|
Logout
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
|
<li>
|
||||||
|
<a
|
||||||
|
routerLink="/settings"
|
||||||
|
class="block text-center w-full py-1.5 rounded-xl hover:bg-white/4 hover:text-gray-100"
|
||||||
|
>
|
||||||
|
Settings
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|||||||
4
frontend/src/app/core/types/http.ts
Normal file
4
frontend/src/app/core/types/http.ts
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
export interface HttpStoreState {
|
||||||
|
isLoading: boolean;
|
||||||
|
validationErrors: Record<string, string[]> | null;
|
||||||
|
}
|
||||||
16
frontend/src/app/settings/privacy/privacy-service.spec.ts
Normal file
16
frontend/src/app/settings/privacy/privacy-service.spec.ts
Normal file
@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
20
frontend/src/app/settings/privacy/privacy-service.ts
Normal file
20
frontend/src/app/settings/privacy/privacy-service.ts
Normal file
@ -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<string>(BASE_URL);
|
||||||
|
|
||||||
|
public enableTwoFA() {
|
||||||
|
return this.http.get<void>(`${this.baseUrl}/user/two-factor-authentication`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public disableTwoFA() {
|
||||||
|
return this.http.delete<void>(`${this.baseUrl}/user/two-factor-authentication`);
|
||||||
|
}
|
||||||
|
}
|
||||||
3
frontend/src/app/settings/privacy/privacy.css
Normal file
3
frontend/src/app/settings/privacy/privacy.css
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
:host {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
27
frontend/src/app/settings/privacy/privacy.html
Normal file
27
frontend/src/app/settings/privacy/privacy.html
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
<div class="settings-section">
|
||||||
|
<div class="section-header">
|
||||||
|
<h3>Privacy & Security</h3>
|
||||||
|
<p>Control who can see your information and how your data is used.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<div class="toggle-row">
|
||||||
|
<div class="toggle-info">
|
||||||
|
<label>Two-Factor Authentication</label>
|
||||||
|
<span class="hint">Add an extra layer of security to your account.</span>
|
||||||
|
</div>
|
||||||
|
<label class="toggle">
|
||||||
|
<input type="checkbox" (change)="toggle2fa()" />
|
||||||
|
<span class="toggle-slider"></span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="danger-zone">
|
||||||
|
<h4>Danger Zone</h4>
|
||||||
|
<p>Irreversible actions. Please proceed with caution.</p>
|
||||||
|
<div class="danger-actions">
|
||||||
|
<button class="btn btn-danger-outline" type="button">Delete Account</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
22
frontend/src/app/settings/privacy/privacy.spec.ts
Normal file
22
frontend/src/app/settings/privacy/privacy.spec.ts
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||||
|
|
||||||
|
import { Privacy } from './privacy';
|
||||||
|
|
||||||
|
describe('Privacy', () => {
|
||||||
|
let component: Privacy;
|
||||||
|
let fixture: ComponentFixture<Privacy>;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
imports: [Privacy],
|
||||||
|
}).compileComponents();
|
||||||
|
|
||||||
|
fixture = TestBed.createComponent(Privacy);
|
||||||
|
component = fixture.componentInstance;
|
||||||
|
await fixture.whenStable();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create', () => {
|
||||||
|
expect(component).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
49
frontend/src/app/settings/privacy/privacy.store.ts
Normal file
49
frontend/src/app/settings/privacy/privacy.store.ts
Normal file
@ -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 });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
);
|
||||||
15
frontend/src/app/settings/privacy/privacy.ts
Normal file
15
frontend/src/app/settings/privacy/privacy.ts
Normal file
@ -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']);
|
||||||
|
}
|
||||||
|
}
|
||||||
11
frontend/src/app/settings/privacy/privacy.types.ts
Normal file
11
frontend/src/app/settings/privacy/privacy.types.ts
Normal file
@ -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;
|
||||||
|
}
|
||||||
16
frontend/src/app/settings/settings-service.spec.ts
Normal file
16
frontend/src/app/settings/settings-service.spec.ts
Normal file
@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
6
frontend/src/app/settings/settings-service.ts
Normal file
6
frontend/src/app/settings/settings-service.ts
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
import { Injectable } from '@angular/core';
|
||||||
|
|
||||||
|
@Injectable({
|
||||||
|
providedIn: 'root',
|
||||||
|
})
|
||||||
|
export class SettingsService {}
|
||||||
371
frontend/src/app/settings/settings.css
Normal file
371
frontend/src/app/settings/settings.css
Normal file
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
71
frontend/src/app/settings/settings.html
Normal file
71
frontend/src/app/settings/settings.html
Normal file
@ -0,0 +1,71 @@
|
|||||||
|
<div class="settings-container">
|
||||||
|
<!-- Left sidebar with tabs -->
|
||||||
|
<aside class="settings-sidebar">
|
||||||
|
<div class="sidebar-header">
|
||||||
|
<a routerLink="/" class="back-link">
|
||||||
|
<svg viewBox="0 0 24 24" fill="currentColor" class="back-icon">
|
||||||
|
<path d="M20 11H7.83l5.59-5.59L12 4l-8 8 8 8 1.41-1.41L7.83 13H20v-2z" />
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
<h2>Settings</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav class="sidebar-nav">
|
||||||
|
@for (tab of tabs; track tab.id) {
|
||||||
|
<button
|
||||||
|
class="nav-tab"
|
||||||
|
[class.active]="store.activeTabId() === tab.id"
|
||||||
|
(click)="store.setActiveTab(tab.id)"
|
||||||
|
type="button"
|
||||||
|
[attr.id]="'settings-tab-' + tab.id"
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 24 24" fill="currentColor" class="nav-icon">
|
||||||
|
<path [attr.d]="tab.icon" />
|
||||||
|
</svg>
|
||||||
|
<div class="nav-tab-text">
|
||||||
|
<span class="nav-label">{{ tab.label }}</span>
|
||||||
|
<span class="nav-description">{{ tab.description }}</span>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</nav>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<!-- Right content area -->
|
||||||
|
<main class="settings-content">
|
||||||
|
<div class="content-header">
|
||||||
|
<div>
|
||||||
|
<h2>{{ store.activeTab().label }}</h2>
|
||||||
|
<p>{{ store.activeTab().description }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Success / Error toast -->
|
||||||
|
@if (store.saveSuccess() === true) {
|
||||||
|
<div class="toast toast-success">
|
||||||
|
<svg viewBox="0 0 24 24" fill="currentColor" class="toast-icon">
|
||||||
|
<path
|
||||||
|
d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
Settings saved successfully!
|
||||||
|
</div>
|
||||||
|
} @if (store.error()) {
|
||||||
|
<div class="toast toast-error">
|
||||||
|
<svg viewBox="0 0 24 24" fill="currentColor" class="toast-icon">
|
||||||
|
<path
|
||||||
|
d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-2h2v2zm0-4h-2V7h2v6z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
{{ store.error() }}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- Tab panels -->
|
||||||
|
<div class="tab-panel">
|
||||||
|
@switch (store.activeTabId()) { @case ('privacy') {
|
||||||
|
<app-privacy />
|
||||||
|
}}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
22
frontend/src/app/settings/settings.spec.ts
Normal file
22
frontend/src/app/settings/settings.spec.ts
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||||
|
|
||||||
|
import { Settings } from './settings';
|
||||||
|
|
||||||
|
describe('Settings', () => {
|
||||||
|
let component: Settings;
|
||||||
|
let fixture: ComponentFixture<Settings>;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
imports: [Settings],
|
||||||
|
}).compileComponents();
|
||||||
|
|
||||||
|
fixture = TestBed.createComponent(Settings);
|
||||||
|
component = fixture.componentInstance;
|
||||||
|
await fixture.whenStable();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create', () => {
|
||||||
|
expect(component).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
46
frontend/src/app/settings/settings.store.ts
Normal file
46
frontend/src/app/settings/settings.store.ts
Normal file
@ -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.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
);
|
||||||
17
frontend/src/app/settings/settings.ts
Normal file
17
frontend/src/app/settings/settings.ts
Normal file
@ -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;
|
||||||
|
}
|
||||||
25
frontend/src/app/settings/settings.types.ts
Normal file
25
frontend/src/app/settings/settings.types.ts
Normal file
@ -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;
|
||||||
|
}
|
||||||
@ -12,7 +12,7 @@ body {
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
font-family: 'Outfit', sans-serif;
|
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 {
|
button:active {
|
||||||
transform: scale(0.98);
|
transform: scale(0.98);
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user