Compare commits
No commits in common. "feature/2fa" and "master" have entirely different histories.
feature/2f
...
master
@ -10,7 +10,6 @@
|
|||||||
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'])]
|
||||||
@ -18,7 +17,7 @@
|
|||||||
class User extends Authenticatable
|
class User extends Authenticatable
|
||||||
{
|
{
|
||||||
/** @use HasFactory<UserFactory> */
|
/** @use HasFactory<UserFactory> */
|
||||||
use HasApiTokens, HasFactory, Notifiable, TwoFactorAuthenticatable;
|
use HasApiTokens, HasFactory, Notifiable;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the attributes that should be cast.
|
* Get the attributes that should be cast.
|
||||||
|
|||||||
@ -60,7 +60,6 @@ services:
|
|||||||
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:
|
||||||
|
|||||||
@ -2,8 +2,7 @@
|
|||||||
"$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,15 +6,11 @@ export const routes: Routes = [
|
|||||||
loadComponent: () => import('./chat/chat').then((m) => m.Chat),
|
loadComponent: () => import('./chat/chat').then((m) => m.Chat),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'settings',
|
path: ':id',
|
||||||
loadComponent: () => import('./settings/settings').then((m) => m.Settings),
|
loadComponent: () => import('./chat/chat').then((m) => m.Chat),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
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,6 +1,7 @@
|
|||||||
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 { LoginRequest, PasswordConfirmationRequest, RegisterRequest, User } from './auth.types';
|
import {environment} from '../../environments/environment';
|
||||||
|
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';
|
||||||
|
|
||||||
@ -14,13 +15,13 @@ export class AuthService {
|
|||||||
|
|
||||||
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 ){
|
public register (credentials: RegisterRequest ){
|
||||||
return this.getSanctumCookie().pipe(
|
return this.getSanctumCookie().pipe(
|
||||||
switchMap(() => this.http.post(`${this.baseUrl}/register`, credentials)),
|
switchMap(() => this.http.post(`${this.baseUrl}/register`, credentials))
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -28,17 +29,13 @@ export class AuthService {
|
|||||||
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`);
|
||||||
}
|
}
|
||||||
|
|
||||||
public confirmPassword(data: PasswordConfirmationRequest): Observable<any> {
|
private getSanctumCookie(): Observable<null>
|
||||||
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,34 +1,12 @@
|
|||||||
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,5 +1,3 @@
|
|||||||
import { HttpStoreState } from '../core/types/http';
|
|
||||||
|
|
||||||
export interface User {
|
export interface User {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
@ -24,11 +22,9 @@ export interface RegisterRequest {
|
|||||||
password_confirmation: string;
|
password_confirmation: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PasswordConfirmationRequest {
|
export interface AuthState {
|
||||||
password: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AuthState extends HttpStoreState {
|
|
||||||
user: User | null;
|
user: User | null;
|
||||||
|
isLoading: boolean;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
|
validationErrors: Record<string, string[]> | null;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,16 +0,0 @@
|
|||||||
@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); }
|
|
||||||
}
|
|
||||||
@ -1,65 +0,0 @@
|
|||||||
<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>
|
|
||||||
@ -1,22 +0,0 @@
|
|||||||
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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,36 +0,0 @@
|
|||||||
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());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,11 +0,0 @@
|
|||||||
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,22 +230,14 @@
|
|||||||
@if(isCogMenuOpen()){
|
@if(isCogMenuOpen()){
|
||||||
<div
|
<div
|
||||||
id="cog-menu"
|
id="cog-menu"
|
||||||
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"
|
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"
|
||||||
>
|
>
|
||||||
<ul class="space-y-1">
|
<ul class="">
|
||||||
<li class="border-b border-[#2a3a5c]">
|
<li>
|
||||||
<button class="w-full py-1.5 mb-1 rounded-xl hover:bg-white/4 hover:text-red-400">
|
<button class="w-full py-2 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>
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,4 +0,0 @@
|
|||||||
export interface HttpStoreState {
|
|
||||||
isLoading: boolean;
|
|
||||||
validationErrors: Record<string, string[]> | null;
|
|
||||||
}
|
|
||||||
@ -1,16 +0,0 @@
|
|||||||
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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,20 +0,0 @@
|
|||||||
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`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,3 +0,0 @@
|
|||||||
:host {
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
@ -1,27 +0,0 @@
|
|||||||
<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>
|
|
||||||
@ -1,22 +0,0 @@
|
|||||||
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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,49 +0,0 @@
|
|||||||
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 });
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
@ -1,15 +0,0 @@
|
|||||||
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']);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,11 +0,0 @@
|
|||||||
import { HttpStoreState } from '../../core/types/http';
|
|
||||||
|
|
||||||
export interface TwoFactorAuth {
|
|
||||||
enabled: boolean;
|
|
||||||
qrCode: string | null;
|
|
||||||
recoveryCodes: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PrivacyState extends HttpStoreState {
|
|
||||||
twoFactor: TwoFactorAuth;
|
|
||||||
}
|
|
||||||
@ -1,16 +0,0 @@
|
|||||||
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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,6 +0,0 @@
|
|||||||
import { Injectable } from '@angular/core';
|
|
||||||
|
|
||||||
@Injectable({
|
|
||||||
providedIn: 'root',
|
|
||||||
})
|
|
||||||
export class SettingsService {}
|
|
||||||
@ -1,371 +0,0 @@
|
|||||||
@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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,71 +0,0 @@
|
|||||||
<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>
|
|
||||||
@ -1,22 +0,0 @@
|
|||||||
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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,46 +0,0 @@
|
|||||||
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.',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
})),
|
|
||||||
);
|
|
||||||
@ -1,17 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
@ -1,25 +0,0 @@
|
|||||||
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: linear-gradient(135deg, #0f172a 0%, #1e1b4b 50%, #0f172a 100%);
|
background: radial-gradient(circle at top right, #1a1a2e 0%, #16213e 50%, #0f3460 100%);
|
||||||
}
|
}
|
||||||
button:active {
|
button:active {
|
||||||
transform: scale(0.98);
|
transform: scale(0.98);
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user