Compare commits

..

2 Commits

Author SHA1 Message Date
kushal-saha
e0cef27f51 wip: add facades, password confirmation 2026-05-06 13:20:12 +00:00
kushal-saha
fdc458e167 chore: add store state to simplify manageing validationErros and isLoading 2026-05-06 06:22:27 +00:00
31 changed files with 1034 additions and 99 deletions

View File

@ -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.

View File

@ -60,6 +60,7 @@ 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:

View File

@ -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": {

View File

@ -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),
},
]; ];

View File

@ -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`);
} }
} }

View File

@ -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',
};
},
},
],
},
];

View File

@ -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;
} }

View File

@ -0,0 +1,16 @@
@keyframes slideUpFade {
from {
opacity: 0;
transform: translateY(40px) scale(0.98);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
@keyframes pulse {
0% { box-shadow: 0 0 0 0 rgba(79, 172, 254, 0.4); }
70% { box-shadow: 0 0 0 10px rgba(79, 172, 254, 0); }
100% { box-shadow: 0 0 0 0 rgba(79, 172, 254, 0); }
}

View File

@ -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>

View File

@ -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();
});
});

View File

@ -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());
}
}
}

View 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');

View File

@ -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>
} }

View File

@ -0,0 +1,4 @@
export interface HttpStoreState {
isLoading: boolean;
validationErrors: Record<string, string[]> | null;
}

View 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();
});
});

View 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`);
}
}

View File

@ -0,0 +1,3 @@
:host {
display: block;
}

View File

@ -0,0 +1,27 @@
<div class="settings-section">
<div class="section-header">
<h3>Privacy &amp; 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>

View 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();
});
});

View 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 });
}
},
});
},
};
}),
);

View 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']);
}
}

View 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;
}

View 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();
});
});

View File

@ -0,0 +1,6 @@
import { Injectable } from '@angular/core';
@Injectable({
providedIn: 'root',
})
export class SettingsService {}

View 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;
}
}

View 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>

View 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();
});
});

View 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.',
});
}
},
})),
);

View 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;
}

View 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;
}

View File

@ -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);