feat: implement logout functionality and enhance contact layout

This commit is contained in:
Bangara Raju Kottedi 2025-11-17 13:35:33 +05:30
parent 38f305067a
commit e83e2b2161
13 changed files with 205 additions and 133 deletions

View File

@ -89,30 +89,37 @@
<ul class="social-list"> <ul class="social-list">
@if (model.socialLinks?.linkedin) { @if (model.socialLinks?.linkedin) {
<li class="social-item"> <li class="social-item">
<a href="{{model.socialLinks?.linkedin}}" target="_blank" class="social-link"> <a href="{{model.socialLinks?.linkedin}}" target="_blank" class="social-link">
<i class="fa-brands fa-linkedin"></i> <i class="fa-brands fa-linkedin"></i>
</a> </a>
</li> </li>
} }
@if (model.socialLinks?.gitHub) { @if (model.socialLinks?.gitHub) {
<li class="social-item"> <li class="social-item">
<a href="{{model.socialLinks?.gitHub}}" target="_blank" class="social-link"> <a href="{{model.socialLinks?.gitHub}}" target="_blank" class="social-link">
<i class="fa-brands fa-github"></i> <i class="fa-brands fa-github"></i>
</a> </a>
</li> </li>
} }
@if (model.socialLinks?.blogUrl) { @if (model.socialLinks?.blogUrl) {
<li class="social-item"> <li class="social-item">
<a href="{{model.socialLinks?.blogUrl}}" target="_blank" class="social-link"> <a href="{{model.socialLinks?.blogUrl}}" target="_blank" class="social-link">
<i class="fa-duotone fa-blog"></i> <i class="fa-duotone fa-blog"></i>
</a> </a>
</li> </li>
} }
</ul> </ul>
</div> </div>
<hr class="logout-divider" />
<div class="logout-section">
<button class="logout-btn" (click)="logout()">
<span class="icon">🔓</span>
<span>Logout</span>
</button>
</div>
</aside> </aside>

View File

@ -1,3 +1,62 @@
img { img {
border-radius: inherit; border-radius: inherit;
}
.logout-section {
margin-top: 14px;
padding-bottom: 8px;
display: flex;
justify-content: center;
}
.logout-btn {
width: 90%;
margin: 0 auto;
padding: 14px 18px;
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
background: #191919; // darker for better contrast
border: 1px solid rgba(227, 179, 65, 0.18); // subtle gold border
border-radius: 14px;
cursor: pointer;
color: #e3b341; // gold text
font-size: 15px;
font-weight: 500;
transition: 0.3s ease;
/* Subtle depth */
box-shadow:
inset 0 0 8px rgba(255, 255, 255, 0.03),
0 4px 18px rgba(0, 0, 0, 0.45);
&:hover {
background: rgba(227, 179, 65, 0.12);
border-color: rgba(227, 179, 65, 0.35);
box-shadow:
0 0 12px rgba(227, 179, 65, 0.25),
inset 0 0 10px rgba(227, 179, 65, 0.1);
color: #fff;
}
}
.icon {
font-size: 18px;
line-height: 0;
}
.logout-divider {
border: 0;
height: 1px;
background: rgba(255, 255, 255, 0.06);
margin: 18px 0 14px 0;
} }

View File

@ -3,6 +3,7 @@ import { IContactModel } from './contact.model';
import { AdminService } from '../services/admin.service'; import { AdminService } from '../services/admin.service';
import { BaseComponent } from '../base.component'; import { BaseComponent } from '../base.component';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { AuthService } from '../../auth/auth.service';
@Component({ @Component({
selector: 'app-contact', selector: 'app-contact',
@ -13,6 +14,7 @@ import { CommonModule } from '@angular/common';
export class Contact extends BaseComponent<IContactModel> implements OnInit { export class Contact extends BaseComponent<IContactModel> implements OnInit {
sideBarExpanded = false; sideBarExpanded = false;
displayName!: string; displayName!: string;
authSvc: AuthService = inject(AuthService);
constructor(){ constructor(){
const svc = inject(AdminService); const svc = inject(AdminService);
super(svc); super(svc);
@ -28,4 +30,8 @@ export class Contact extends BaseComponent<IContactModel> implements OnInit {
this.assignData(response); this.assignData(response);
}); });
} }
logout() {
this.authSvc.logout();
}
} }

View File

@ -56,9 +56,9 @@
<a> <a>
<figure class="project-img"> <figure class="project-img">
<!-- <div class="project-item-icon-box"> <div class="project-item-icon-box">
<ion-icon name="eye-outline"></ion-icon> <ion-icon name="eye-outline"></ion-icon>
</div> --> </div>
<img src="{{imagesOrigin + project.imagePath}}" alt="{{project.name}}" loading="lazy"> <img src="{{imagesOrigin + project.imagePath}}" alt="{{project.name}}" loading="lazy">
</figure> </figure>

View File

@ -1,4 +1,4 @@
import { Component, inject, OnInit } from '@angular/core'; import { Component, CUSTOM_ELEMENTS_SCHEMA, inject, OnInit } from '@angular/core';
import { IProject } from '../models/project.model'; import { IProject } from '../models/project.model';
import { BaseComponent } from '../base.component'; import { BaseComponent } from '../base.component';
import { AdminService } from '../services/admin.service'; import { AdminService } from '../services/admin.service';
@ -10,7 +10,8 @@ import { CommonModule } from '@angular/common';
selector: 'app-projects', selector: 'app-projects',
templateUrl: './projects.html', templateUrl: './projects.html',
styleUrl: './projects.scss', styleUrl: './projects.scss',
imports: [CommonModule] imports: [CommonModule],
schemas: [CUSTOM_ELEMENTS_SCHEMA]
}) })
export class Projects extends BaseComponent<IProjects> implements OnInit { export class Projects extends BaseComponent<IProjects> implements OnInit {
filter = 'All'; filter = 'All';

View File

@ -4,13 +4,12 @@ import { routes } from './app.routes';
import { provideHttpClient, withFetch, withInterceptors} from '@angular/common/http'; import { provideHttpClient, withFetch, withInterceptors} from '@angular/common/http';
import { loadingInterceptor } from './interceptors/loading-interceptor'; import { loadingInterceptor } from './interceptors/loading-interceptor';
import { AuthInterceptor } from './interceptors/auth-interceptor'; import { AuthInterceptor } from './interceptors/auth-interceptor';
import { provideClientHydration, withEventReplay } from '@angular/platform-browser';
export const appConfig: ApplicationConfig = { export const appConfig: ApplicationConfig = {
providers: [ providers: [
provideBrowserGlobalErrorListeners(), provideBrowserGlobalErrorListeners(),
provideZonelessChangeDetection(), provideZonelessChangeDetection(),
provideHttpClient(withInterceptors([loadingInterceptor, AuthInterceptor]), withFetch()), provideHttpClient(withInterceptors([loadingInterceptor, AuthInterceptor]), withFetch()),
provideRouter(routes), provideClientHydration(withEventReplay()) provideRouter(routes)
] ]
}; };

View File

@ -19,6 +19,11 @@ export const routes: Routes = [
redirectTo: 'admin/about', redirectTo: 'admin/about',
pathMatch: 'full' pathMatch: 'full'
}, },
{
path: 'admin',
redirectTo: 'admin/about',
pathMatch: 'full'
},
{ {
path: 'admin', path: 'admin',
component: AdminLayout, component: AdminLayout,

View File

@ -25,6 +25,7 @@ export class AuthService {
router = inject(Router); router = inject(Router);
private readonly storageKey = 'accessToken'; private readonly storageKey = 'accessToken';
tokenReady$ = new BehaviorSubject<boolean | null>(null);
constructor() { constructor() {
console.log('🔥 AuthService constructor started'); console.log('🔥 AuthService constructor started');
@ -36,31 +37,31 @@ export class AuthService {
return `${this.baseUrl}${path.startsWith('/') ? '' : '/'}${path}`; return `${this.baseUrl}${path.startsWith('/') ? '' : '/'}${path}`;
} }
// // Call on app start, or from guard // Call on app start, or from guard
// async ensureTokenReady(): Promise<void>{ async ensureTokenReady(): Promise<void>{
// if(this.tokenReady$.value) return; if(this.tokenReady$.value) return;
// const stored = this.safeGetToken(); const stored = this.safeGetToken();
// // try to restore from storage // try to restore from storage
// if(stored){ if(stored){
// this.accessTokenSub.next(stored); this.accessTokenSub.next(stored);
// this.tokenReady$.next(true); this.tokenReady$.next(true);
// return; return;
// } }
// // // Optionally: try a silent refresh on startup to restore session using HttpOnly cookie // // Optionally: try a silent refresh on startup to restore session using HttpOnly cookie
// // try { // try {
// // const res = await firstValueFrom(this.refreshToken()); // const res = await firstValueFrom(this.refreshToken());
// // this.safeSetToken(res.accessToken); // this.safeSetToken(res.accessToken);
// // } // }
// // catch{ // catch{
// // console.warn('Silent token refresh failed on startup'); // console.warn('Silent token refresh failed on startup');
// // } // }
// // finally{ // finally{
// // this.tokenReady$.next(true); // this.tokenReady$.next(true);
// // } // }
// } }
get currentToken(): string | null { get currentToken(): string | null {
return this.safeGetToken(); return this.safeGetToken();
@ -123,7 +124,7 @@ export class AuthService {
logout(): Observable<void> { logout(): Observable<void> {
this.accessToken = null; this.accessToken = null;
this.safeRemoveToken(); this.safeRemoveToken();
this.router.navigate(['/login']); this.router.navigate(['/admin/login']);
return of(); return of();
} }

View File

@ -1,7 +1,13 @@
<div class="verify-wrapper"> <div class="verify-wrapper">
<div class="verify-card"> <div class="verify-card">
<h2 class="title">🔐 OTP Verification</h2> @if(!isOtpSent()){
<h2 class="title">🔐 Login</h2>
}
@if(isOtpSent() && !isVerified()){
<h2 class="title">🔐 OTP Verification</h2>
}
<!-- Step 1: Enter Email --> <!-- Step 1: Enter Email -->
@if (!isOtpSent()) { @if (!isOtpSent()) {
@ -35,17 +41,5 @@
</button> </button>
</div> </div>
} }
<!-- Step 3: Success -->
<!-- @if (isVerified()) {
<div>
<p class="success-msg">{{ message() }}✅</p>
</div>
}
@if (isError()) {
<p class="error-message">{{ message() }}</p>
} -->
</div> </div>
</div> </div>

View File

@ -9,93 +9,94 @@ import {
import { BehaviorSubject, catchError, filter, Observable, switchMap, take, throwError } from 'rxjs'; import { BehaviorSubject, catchError, filter, Observable, switchMap, take, throwError } from 'rxjs';
import { AuthService } from '../auth/auth.service'; import { AuthService } from '../auth/auth.service';
let refreshInProgress = false;
const refreshSubject = new BehaviorSubject<string | null>(null);
export const AuthInterceptor: HttpInterceptorFn = ( export const AuthInterceptor: HttpInterceptorFn = (
req: HttpRequest<unknown>, req: HttpRequest<unknown>,
next: HttpHandlerFn next: HttpHandlerFn
): Observable<HttpEvent<unknown>> => { ): Observable<HttpEvent<unknown>> => {
let refreshInProgress = false;
const refreshSubject = new BehaviorSubject<string | null>(null);
const authSvc: AuthService = inject(AuthService); const authSvc: AuthService = inject(AuthService);
let headers = req.headers; let headers = req.headers;
// add API key // add API key
const apiKey = authSvc.getApiKey(); const apiKey = authSvc.getApiKey();
if (apiKey) { if (apiKey) {
headers = headers.set('XApiKey', apiKey); headers = headers.set('XApiKey', apiKey);
} }
// add content type if needed // add content type if needed
if (!(req.body instanceof FormData) && if (!(req.body instanceof FormData) &&
(req.method === 'POST' || req.method === 'PUT') && (req.method === 'POST' || req.method === 'PUT') &&
!headers.has('Content-Type')) { !headers.has('Content-Type')) {
headers = headers.set('Content-Type', 'application/json'); headers = headers.set('Content-Type', 'application/json');
} }
// add access token // add access token
const token = authSvc.currentToken; const token = authSvc.currentToken;
if (token) { if (token) {
headers = headers.set('Authorization', `Bearer ${token}`); headers = headers.set('Authorization', `Bearer ${token}`);
} }
// final cloned request // final cloned request
const clonedRequest = req.clone({ const clonedRequest = req.clone({
headers, headers,
withCredentials: true withCredentials: true
}); });
return next(clonedRequest).pipe( return next(clonedRequest).pipe(
catchError((err: HttpErrorResponse) => { catchError((err: HttpErrorResponse) => {
if (err.status !== 401) { if (err.status !== 401) {
return throwError(() => err); return throwError(() => err);
} }
// if refresh is already in progress // if refresh is already in progress
if (refreshInProgress) { if (refreshInProgress) {
return refreshSubject.pipe( return refreshSubject.pipe(
filter(t => t !== null), filter(t => t !== null),
take(1), take(1),
switchMap(newToken => { switchMap(newToken => {
const retryReq = clonedRequest.clone({
headers: clonedRequest.headers.set('Authorization', `Bearer ${newToken}`),
withCredentials: true
});
return next(retryReq);
})
);
}
// start refresh
refreshInProgress = true;
refreshSubject.next(null);
return authSvc.refreshToken()!.pipe(
switchMap(res => {
refreshInProgress = false;
const newToken = res.accessToken;
authSvc.safeSetToken(newToken);
refreshSubject.next(newToken);
const retryReq = clonedRequest.clone({ const retryReq = clonedRequest.clone({
headers: clonedRequest.headers.set('Authorization', `Bearer ${newToken}`), headers: clonedRequest.headers.set('Authorization', `Bearer ${newToken}`),
withCredentials: true withCredentials: true
}); });
return next(retryReq); return next(retryReq);
}),
catchError(refreshErr => {
refreshInProgress = false;
refreshSubject.next(null);
authSvc.logout();
return throwError(() => refreshErr);
}) })
); );
}) }
);
// start refresh
refreshInProgress = true;
refreshSubject.next(null);
return authSvc.refreshToken()!.pipe(
switchMap(res => {
refreshInProgress = false;
const newToken = res.accessToken;
authSvc.safeSetToken(newToken);
refreshSubject.next(newToken);
const retryReq = clonedRequest.clone({
headers: clonedRequest.headers.set('Authorization', `Bearer ${newToken}`),
withCredentials: true
});
return next(retryReq);
}),
catchError(refreshErr => {
refreshInProgress = false;
refreshSubject.next(null);
authSvc.logout();
return throwError(() => refreshErr);
})
);
})
);
}; };

View File

@ -1,11 +1,10 @@
@if(!loggedIn()){
<router-outlet></router-outlet>
} @else {
<main> <main>
@if(loggedIn()){ <app-contact></app-contact>
<app-contact></app-contact>
}
<div class="main-content"> <div class="main-content">
@if(loggedIn()){
<nav class="navbar"> <nav class="navbar">
<ul class="navbar-list"> <ul class="navbar-list">
<li class="navbar-item"> <li class="navbar-item">
@ -22,11 +21,12 @@
</li> </li>
</ul> </ul>
</nav> </nav>
}
<div class="page-container"> <div class="page-container">
<router-outlet></router-outlet> <router-outlet></router-outlet>
</div> </div>
</div> </div>
</main>
</main>
}

View File

@ -17,6 +17,5 @@ export class AdminLayout {
this.authSvc.accessTokenSub.subscribe(token => { this.authSvc.accessTokenSub.subscribe(token => {
this.loggedIn.set(token !== null); this.loggedIn.set(token !== null);
}); });
console.log("AdminLayout constructed");
} }
} }

View File

@ -321,7 +321,7 @@
transition: var(--transition-2); transition: var(--transition-2);
} }
.sidebar.active { max-height: 405px; } .sidebar.active { max-height: 505px; }
.sidebar-info { .sidebar-info {
position: relative; position: relative;