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">
@if (model.socialLinks?.linkedin) {
<li class="social-item">
<a href="{{model.socialLinks?.linkedin}}" target="_blank" class="social-link">
<i class="fa-brands fa-linkedin"></i>
</a>
</li>
<li class="social-item">
<a href="{{model.socialLinks?.linkedin}}" target="_blank" class="social-link">
<i class="fa-brands fa-linkedin"></i>
</a>
</li>
}
@if (model.socialLinks?.gitHub) {
<li class="social-item">
<a href="{{model.socialLinks?.gitHub}}" target="_blank" class="social-link">
<i class="fa-brands fa-github"></i>
</a>
</li>
<li class="social-item">
<a href="{{model.socialLinks?.gitHub}}" target="_blank" class="social-link">
<i class="fa-brands fa-github"></i>
</a>
</li>
}
@if (model.socialLinks?.blogUrl) {
<li class="social-item">
<a href="{{model.socialLinks?.blogUrl}}" target="_blank" class="social-link">
<i class="fa-duotone fa-blog"></i>
</a>
</li>
<li class="social-item">
<a href="{{model.socialLinks?.blogUrl}}" target="_blank" class="social-link">
<i class="fa-duotone fa-blog"></i>
</a>
</li>
}
</ul>
</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>

View File

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

View File

@ -56,9 +56,9 @@
<a>
<figure class="project-img">
<!-- <div class="project-item-icon-box">
<ion-icon name="eye-outline"></ion-icon>
</div> -->
<div class="project-item-icon-box">
<ion-icon name="eye-outline"></ion-icon>
</div>
<img src="{{imagesOrigin + project.imagePath}}" alt="{{project.name}}" loading="lazy">
</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 { BaseComponent } from '../base.component';
import { AdminService } from '../services/admin.service';
@ -10,7 +10,8 @@ import { CommonModule } from '@angular/common';
selector: 'app-projects',
templateUrl: './projects.html',
styleUrl: './projects.scss',
imports: [CommonModule]
imports: [CommonModule],
schemas: [CUSTOM_ELEMENTS_SCHEMA]
})
export class Projects extends BaseComponent<IProjects> implements OnInit {
filter = 'All';

View File

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

View File

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

View File

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

View File

@ -1,7 +1,13 @@
<div class="verify-wrapper">
<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 -->
@if (!isOtpSent()) {
@ -35,17 +41,5 @@
</button>
</div>
}
<!-- Step 3: Success -->
<!-- @if (isVerified()) {
<div>
<p class="success-msg">{{ message() }}✅</p>
</div>
}
@if (isError()) {
<p class="error-message">{{ message() }}</p>
} -->
</div>
</div>

View File

@ -9,93 +9,94 @@ import {
import { BehaviorSubject, catchError, filter, Observable, switchMap, take, throwError } from 'rxjs';
import { AuthService } from '../auth/auth.service';
let refreshInProgress = false;
const refreshSubject = new BehaviorSubject<string | null>(null);
export const AuthInterceptor: HttpInterceptorFn = (
req: HttpRequest<unknown>,
next: HttpHandlerFn
): Observable<HttpEvent<unknown>> => {
let refreshInProgress = false;
const refreshSubject = new BehaviorSubject<string | null>(null);
const authSvc: AuthService = inject(AuthService);
let headers = req.headers;
// add API key
const apiKey = authSvc.getApiKey();
if (apiKey) {
headers = headers.set('XApiKey', apiKey);
}
// add API key
const apiKey = authSvc.getApiKey();
if (apiKey) {
headers = headers.set('XApiKey', apiKey);
}
// add content type if needed
if (!(req.body instanceof FormData) &&
(req.method === 'POST' || req.method === 'PUT') &&
!headers.has('Content-Type')) {
// add content type if needed
if (!(req.body instanceof FormData) &&
(req.method === 'POST' || req.method === 'PUT') &&
!headers.has('Content-Type')) {
headers = headers.set('Content-Type', 'application/json');
}
headers = headers.set('Content-Type', 'application/json');
}
// add access token
const token = authSvc.currentToken;
if (token) {
headers = headers.set('Authorization', `Bearer ${token}`);
}
// add access token
const token = authSvc.currentToken;
if (token) {
headers = headers.set('Authorization', `Bearer ${token}`);
}
// final cloned request
const clonedRequest = req.clone({
headers,
withCredentials: true
});
// final cloned request
const clonedRequest = req.clone({
headers,
withCredentials: true
});
return next(clonedRequest).pipe(
catchError((err: HttpErrorResponse) => {
return next(clonedRequest).pipe(
catchError((err: HttpErrorResponse) => {
if (err.status !== 401) {
return throwError(() => err);
}
if (err.status !== 401) {
return throwError(() => err);
}
// if refresh is already in progress
if (refreshInProgress) {
return refreshSubject.pipe(
filter(t => t !== null),
take(1),
switchMap(newToken => {
// if refresh is already in progress
if (refreshInProgress) {
return refreshSubject.pipe(
filter(t => t !== null),
take(1),
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({
headers: clonedRequest.headers.set('Authorization', `Bearer ${newToken}`),
withCredentials: true
});
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>
@if(loggedIn()){
<app-contact></app-contact>
}
<app-contact></app-contact>
<div class="main-content">
@if(loggedIn()){
<nav class="navbar">
<ul class="navbar-list">
<li class="navbar-item">
@ -22,11 +21,12 @@
</li>
</ul>
</nav>
}
<div class="page-container">
<router-outlet></router-outlet>
</div>
</div>
</main>
</main>
}

View File

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

View File

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