Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions frontend/src/app/api/auth/token/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { cookies } from "next/headers";
import { NextResponse } from "next/server";

export async function GET() {
const cookieStore = await cookies();
const token = cookieStore.get("access_token")?.value;
return NextResponse.json({ token: token ?? null });
}
22 changes: 16 additions & 6 deletions frontend/src/components/auth/auth-guard.tsx
Original file line number Diff line number Diff line change
@@ -1,19 +1,29 @@
"use client";

import { useEffect } from "react";
import { useEffect, useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { isAuthenticated } from "@/lib/auth";
import { isAuthenticated, isInitialized, initializeAuth } from "@/lib/auth";
import { useAuthStore } from "@/stores/auth-store";
import { Skeleton } from "@/components/ui/skeleton";

export function AuthGuard({ children }: { children: React.ReactNode }) {
const router = useRouter();
const searchParams = useSearchParams();
const user = useAuthStore((s) => s.user);
const authenticated = isAuthenticated() || !!user;
const [ready, setReady] = useState(isInitialized());

useEffect(() => {
if (!authenticated) {
if (!isInitialized()) {
initializeAuth().then(() => setReady(true));
} else {
setReady(true);
}
}, []);

const authenticated = ready && (isAuthenticated() || !!user);

useEffect(() => {
if (ready && !authenticated) {
const redirect = searchParams?.get("redirect");
if (redirect) {
router.replace("/login?redirect=" + encodeURIComponent(redirect));
Expand All @@ -26,9 +36,9 @@ export function AuthGuard({ children }: { children: React.ReactNode }) {
}
}
}
}, [authenticated, router, searchParams]);
}, [authenticated, ready, router, searchParams]);

if (!authenticated) {
if (!ready || !authenticated) {
return (
<div className="flex h-screen items-center justify-center gap-4 p-8">
<Skeleton className="h-12 w-12 rounded-full" />
Expand Down
34 changes: 29 additions & 5 deletions frontend/src/lib/auth.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,44 @@
const TOKEN_KEY = "atlas_access_token";
const USER_KEY = "atlas_user";

let inMemoryToken: string | null = null;
let _initialized = false;

export function getAccessToken(): string | null {
if (typeof window === "undefined") return null;
return localStorage.getItem(TOKEN_KEY);
return inMemoryToken;
}

export function setTokens(access: string): void {
localStorage.setItem(TOKEN_KEY, access);
inMemoryToken = access;
}

export function clearAuth(): void {
localStorage.removeItem(TOKEN_KEY);
inMemoryToken = null;
_initialized = false;
localStorage.removeItem(USER_KEY);
}

export async function initializeAuth(): Promise<boolean> {
if (typeof window === "undefined") return false;
if (_initialized) return true;
try {
const res = await fetch("/api/auth/token");
const data = await res.json();
if (data.token) {
inMemoryToken = data.token;
_initialized = true;
return true;
}
} catch {
// network error — proceed as unauthenticated
}
_initialized = true;
return false;
}

export function isInitialized(): boolean {
return _initialized;
}

export function setStoredUser(user: object): void {
localStorage.setItem(USER_KEY, JSON.stringify(user));
}
Expand Down
10 changes: 9 additions & 1 deletion frontend/src/stores/auth-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { persist } from "zustand/middleware";
import type { User } from "@/types";
import {
clearAuth,
initializeAuth,
setStoredUser,
setTokens,
} from "@/lib/auth";
Expand All @@ -21,6 +22,7 @@ interface AuthState {
}) => Promise<void>;
logout: () => void;
setUser: (user: User | null) => void;
initialize: () => Promise<void>;
}

export const useAuthStore = create<AuthState>()(
Expand All @@ -29,6 +31,9 @@ export const useAuthStore = create<AuthState>()(
user: null,
isLoading: false,
setUser: (user) => set({ user }),
initialize: async () => {
await initializeAuth();
},
login: async (email, password) => {
set({ isLoading: true });
try {
Expand Down Expand Up @@ -76,6 +81,9 @@ export const useAuthStore = create<AuthState>()(
if (typeof window !== "undefined") window.location.href = "/login";
},
}),
{ name: "atlas-auth", partialize: (s) => ({ user: s.user }) }
{
name: "atlas-auth",
partialize: (s) => ({ user: s.user }),
}
)
);
4 changes: 4 additions & 0 deletions services/leave-service/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,10 @@
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-amqp</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
</dependency>
<dependency>
<groupId>org.flywaydb</groupId>
<artifactId>flyway-core</artifactId>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
package com.atlas.leave;

import com.atlas.leave.security.RequiresRole;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;

import java.time.LocalDate;
import java.util.List;
Expand All @@ -18,19 +22,22 @@ public LeaveController(LeaveService service) {
this.service = service;
}

@RequiresRole({"admin", "hr", "manager", "employee"})
@GetMapping
public ResponseEntity<List<LeaveRecord>> getAllLeaveRequests(
@RequestHeader(value = "X-Tenant-Id", defaultValue = "default") String tenantId) {
return ResponseEntity.ok(service.getAllLeaveRequests(tenantId));
}

@RequiresRole({"admin", "hr", "manager", "employee"})
@GetMapping("/employee/{employeeId}")
public ResponseEntity<List<LeaveRecord>> getLeaveByEmployee(
@RequestHeader(value = "X-Tenant-Id", defaultValue = "default") String tenantId,
@PathVariable String employeeId) {
return ResponseEntity.ok(service.getLeaveByEmployeeId(tenantId, employeeId));
}

@RequiresRole({"admin", "hr", "manager", "employee"})
@PostMapping("/request")
public ResponseEntity<?> requestLeave(
@RequestBody Map<String, String> request,
Expand All @@ -51,21 +58,17 @@ public ResponseEntity<?> requestLeave(
}
}

@RequiresRole({"admin", "hr", "manager"})
@PutMapping("/{id}/status")
public ResponseEntity<?> updateLeaveStatus(
@PathVariable Long id,
@RequestBody Map<String, String> request,
@RequestHeader(value = "X-User-Role", required = false) String userRole,
@RequestHeader(value = "X-Tenant-Id", defaultValue = "default") String tenantId) {

// Ensure a role is provided; the service handles role-based authorization
if (userRole == null || userRole.isBlank()) {
return ResponseEntity.status(403)
.body(Map.of("message", "Access denied: X-User-Role header is required"));
}

try {
String status = request.get("status");
ServletRequestAttributes attrs = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
String userRole = attrs != null ? (String) attrs.getRequest().getAttribute("x-user-role") : "employee";
LeaveRecord record = service.updateLeaveStatus(tenantId, id, status, userRole);
return ResponseEntity.ok(record);
} catch (IllegalArgumentException e) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package com.atlas.leave.security;

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface RequiresRole {
String[] value() default {};
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
package com.atlas.leave.security;

import jakarta.servlet.http.HttpServletRequest;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Component;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;

import java.util.Map;

@Aspect
@Component
public class RoleAspect {

@Around("@annotation(requiresRole)")
public Object checkRole(ProceedingJoinPoint joinPoint, RequiresRole requiresRole) throws Throwable {
ServletRequestAttributes attrs = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
if (attrs == null) {
return ResponseEntity.status(500).body(Map.of("error", "No request context"));
}

HttpServletRequest request = attrs.getRequest();
String userRole = (String) request.getAttribute("x-user-role");

if (userRole == null || userRole.isBlank()) {
return ResponseEntity.status(403).body(Map.of("error", "Access denied: no role assigned"));
}

String[] allowedRoles = requiresRole.value();
if (allowedRoles.length == 0) {
return joinPoint.proceed();
}

for (String role : allowedRoles) {
if (userRole.equalsIgnoreCase(role)) {
return joinPoint.proceed();
}
}

return ResponseEntity.status(403).body(Map.of("error", "Access denied: requires one of roles: " + String.join(", ", allowedRoles)));
}
}
4 changes: 4 additions & 0 deletions services/payroll-java-service/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,10 @@
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
</dependency>
<dependency>
<groupId>org.flywaydb</groupId>
<artifactId>flyway-core</artifactId>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package com.ems.payroll;

import com.ems.payroll.security.RequiresRole;
import org.springframework.http.ResponseEntity;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
Expand All @@ -19,27 +20,25 @@ public PayrollController(PayrollService service) {
this.service = service;
}

@RequiresRole({"admin", "hr", "manager", "employee"})
@GetMapping
public ResponseEntity<List<PayrollRecord>> getAllPayrolls(@RequestHeader(value = "X-Tenant-Id", defaultValue = "default") String tenantId) {
return ResponseEntity.ok(service.getAllPayrolls(tenantId));
}

@RequiresRole({"admin", "hr", "manager", "employee"})
@GetMapping("/employee/{employeeId}")
public ResponseEntity<List<PayrollRecord>> getPayrollsByEmployee(
@RequestHeader(value = "X-Tenant-Id", defaultValue = "default") String tenantId,
@PathVariable String employeeId) {
return ResponseEntity.ok(service.getPayrollsByEmployeeId(tenantId, employeeId));
}

@RequiresRole({"admin"})
@PostMapping("/run")
public ResponseEntity<?> runPayroll(
@RequestBody Map<String, Object> request,
@RequestHeader(value = "X-User-Role", required = false) String userRole,
@RequestHeader(value = "X-Tenant-Id", defaultValue = "default") String tenantId) {

if (userRole == null || !userRole.equalsIgnoreCase("admin")) {
return ResponseEntity.status(403).body(Map.of("message", "Access denied: Requires administrator privileges"));
}

try {
String employeeId = (String) request.get("employeeId");
Expand Down
Loading
Loading