Yvonne Priscilla commited on
Commit ·
e394370
1
Parent(s): 8f32a26
update login and redirect
Browse files- src/app/api/agentic/create_weight/route.ts +4 -2
- src/app/api/auth/login/route.ts +46 -60
- src/app/api/auth/logout/route.ts +9 -16
- src/app/api/auth/me/route.ts +17 -32
- src/app/api/file/score_card/route.ts +41 -0
- src/app/api/me/route.ts +0 -22
- src/app/login/page.tsx +6 -25
- src/app/providers.tsx +2 -3
- src/app/recruitment/layout.tsx +58 -5
- src/components/LoginForm.tsx +84 -125
- src/components/button.tsx +1 -1
- src/components/dashboard/candidates-table.tsx +2 -2
- src/components/dashboard/header.tsx +14 -15
- src/components/dashboard/metrics-row.tsx +1 -3
- src/composables/useAuth.ts +61 -0
- src/composables/useUser.ts +0 -30
- src/lib/api.ts +18 -4
- src/lib/auth-context.tsx +0 -150
- src/lib/auth.ts +0 -96
- src/lib/rbac.ts +0 -72
- src/middleware.ts +48 -53
src/app/api/agentic/create_weight/route.ts
CHANGED
|
@@ -1,7 +1,9 @@
|
|
| 1 |
-
import {
|
|
|
|
| 2 |
|
| 3 |
export async function POST(request: NextRequest) {
|
| 4 |
-
const
|
|
|
|
| 5 |
const { searchParams } = new URL(request.url)
|
| 6 |
const criteria_id = searchParams.get("criteria_id")
|
| 7 |
|
|
|
|
| 1 |
+
import { cookies } from "next/headers";
|
| 2 |
+
import { NextRequest, NextResponse } from "next/server";
|
| 3 |
|
| 4 |
export async function POST(request: NextRequest) {
|
| 5 |
+
const cookieStore = await cookies();
|
| 6 |
+
const token = cookieStore.get('auth_token')?.value;
|
| 7 |
const { searchParams } = new URL(request.url)
|
| 8 |
const criteria_id = searchParams.get("criteria_id")
|
| 9 |
|
src/app/api/auth/login/route.ts
CHANGED
|
@@ -1,97 +1,83 @@
|
|
| 1 |
-
/
|
| 2 |
-
|
| 3 |
-
|
| 4 |
-
*
|
| 5 |
-
* Accepts credentials, exchanges for token with backend,
|
| 6 |
-
* stores token in HTTP-only cookie, returns user data
|
| 7 |
-
*/
|
| 8 |
-
|
| 9 |
-
import { NextRequest, NextResponse } from "next/server";
|
| 10 |
-
import { cookies } from "next/headers";
|
| 11 |
-
|
| 12 |
-
const BACKEND_URL = process.env.NEXT_PUBLIC_API_URL || "https://byteriot-candidateexplorer.hf.space/CandidateExplorer";
|
| 13 |
|
| 14 |
export async function POST(request: NextRequest) {
|
| 15 |
try {
|
| 16 |
const body = await request.json();
|
| 17 |
const { username, password } = body;
|
| 18 |
|
|
|
|
|
|
|
| 19 |
if (!username || !password) {
|
| 20 |
return NextResponse.json(
|
| 21 |
-
{ message:
|
| 22 |
{ status: 400 }
|
| 23 |
);
|
| 24 |
}
|
| 25 |
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
|
|
|
| 37 |
|
| 38 |
if (!loginResponse.ok) {
|
| 39 |
-
|
| 40 |
-
console.error("[Login] Backend login failed:", error);
|
| 41 |
return NextResponse.json(
|
| 42 |
-
{ message:
|
| 43 |
{ status: 401 }
|
| 44 |
);
|
| 45 |
}
|
| 46 |
|
| 47 |
-
const
|
| 48 |
-
|
| 49 |
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
{
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
// 2. Fetch user data from backend /admin/me
|
| 59 |
-
const meResponse = await fetch(`${BACKEND_URL}/admin/me`, {
|
| 60 |
-
method: "GET",
|
| 61 |
-
headers: {
|
| 62 |
-
Authorization: `Bearer ${accessToken}`,
|
| 63 |
-
},
|
| 64 |
-
});
|
| 65 |
|
| 66 |
-
if (!
|
| 67 |
-
console.
|
| 68 |
return NextResponse.json(
|
| 69 |
-
{ message:
|
| 70 |
{ status: 500 }
|
| 71 |
);
|
| 72 |
}
|
| 73 |
|
| 74 |
-
const userData = await
|
|
|
|
| 75 |
|
| 76 |
-
//
|
| 77 |
const cookieStore = await cookies();
|
| 78 |
-
cookieStore.set(
|
| 79 |
httpOnly: true,
|
| 80 |
-
secure: process.env.NODE_ENV ===
|
| 81 |
-
sameSite:
|
| 82 |
-
path:
|
| 83 |
maxAge: 7 * 24 * 60 * 60, // 7 days
|
| 84 |
});
|
| 85 |
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
} catch (error) {
|
| 91 |
-
console.error(
|
| 92 |
return NextResponse.json(
|
| 93 |
-
{ message:
|
| 94 |
{ status: 500 }
|
| 95 |
);
|
| 96 |
}
|
| 97 |
-
}
|
|
|
|
| 1 |
+
// app/api/auth/login/route.ts
|
| 2 |
+
import { cookies } from 'next/headers';
|
| 3 |
+
import { NextRequest, NextResponse } from 'next/server';
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4 |
|
| 5 |
export async function POST(request: NextRequest) {
|
| 6 |
try {
|
| 7 |
const body = await request.json();
|
| 8 |
const { username, password } = body;
|
| 9 |
|
| 10 |
+
console.log('🟢 [API] Login attempt for:', username);
|
| 11 |
+
|
| 12 |
if (!username || !password) {
|
| 13 |
return NextResponse.json(
|
| 14 |
+
{ message: 'Username and password are required' },
|
| 15 |
{ status: 400 }
|
| 16 |
);
|
| 17 |
}
|
| 18 |
|
| 19 |
+
const formdata = new FormData()
|
| 20 |
+
formdata.append("username", username)
|
| 21 |
+
formdata.append("password", password)
|
| 22 |
+
|
| 23 |
+
// Call backend
|
| 24 |
+
const loginResponse = await fetch(
|
| 25 |
+
'https://byteriot-candidateexplorer.hf.space/CandidateExplorer/admin/login',
|
| 26 |
+
{
|
| 27 |
+
method: 'POST',
|
| 28 |
+
body: formdata,
|
| 29 |
+
}
|
| 30 |
+
);
|
| 31 |
|
| 32 |
if (!loginResponse.ok) {
|
| 33 |
+
console.log('🟢 [API] Backend rejected login');
|
|
|
|
| 34 |
return NextResponse.json(
|
| 35 |
+
{ message: 'Invalid credentials' },
|
| 36 |
{ status: 401 }
|
| 37 |
);
|
| 38 |
}
|
| 39 |
|
| 40 |
+
const { access_token } = await loginResponse.json();
|
| 41 |
+
console.log('🟢 [API] Got token from backend');
|
| 42 |
|
| 43 |
+
// Get user data
|
| 44 |
+
const userResponse = await fetch(
|
| 45 |
+
'https://byteriot-candidateexplorer.hf.space/CandidateExplorer/admin/me',
|
| 46 |
+
{
|
| 47 |
+
headers: { Authorization: `Bearer ${access_token}` },
|
| 48 |
+
}
|
| 49 |
+
);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 50 |
|
| 51 |
+
if (!userResponse.ok) {
|
| 52 |
+
console.log('🟢 [API] Failed to get user data');
|
| 53 |
return NextResponse.json(
|
| 54 |
+
{ message: 'Failed to get user data' },
|
| 55 |
{ status: 500 }
|
| 56 |
);
|
| 57 |
}
|
| 58 |
|
| 59 |
+
const userData = await userResponse.json();
|
| 60 |
+
console.log('🟢 [API] Got user data:', userData);
|
| 61 |
|
| 62 |
+
// Set cookie using Next.js cookies API (no 'cookie' package needed!)
|
| 63 |
const cookieStore = await cookies();
|
| 64 |
+
cookieStore.set('auth_token', access_token, {
|
| 65 |
httpOnly: true,
|
| 66 |
+
secure: process.env.NODE_ENV === 'production',
|
| 67 |
+
sameSite: 'lax',
|
| 68 |
+
path: '/',
|
| 69 |
maxAge: 7 * 24 * 60 * 60, // 7 days
|
| 70 |
});
|
| 71 |
|
| 72 |
+
console.log('🟢 [API] Cookie set successfully');
|
| 73 |
+
|
| 74 |
+
return NextResponse.json(userData, { status: 200 });
|
| 75 |
+
|
| 76 |
} catch (error) {
|
| 77 |
+
console.error('🟢 [API] Login error:', error);
|
| 78 |
return NextResponse.json(
|
| 79 |
+
{ message: 'Login failed' },
|
| 80 |
{ status: 500 }
|
| 81 |
);
|
| 82 |
}
|
| 83 |
+
}
|
src/app/api/auth/logout/route.ts
CHANGED
|
@@ -1,28 +1,21 @@
|
|
| 1 |
-
/
|
| 2 |
-
|
| 3 |
-
|
| 4 |
-
*
|
| 5 |
-
* Clears the HTTP-only auth token cookie
|
| 6 |
-
*/
|
| 7 |
|
| 8 |
-
|
| 9 |
-
import { cookies } from "next/headers";
|
| 10 |
-
|
| 11 |
-
export async function POST(request: NextRequest) {
|
| 12 |
try {
|
| 13 |
const cookieStore = await cookies();
|
| 14 |
-
cookieStore.delete(
|
| 15 |
-
localStorage.removeItem("token")
|
| 16 |
|
| 17 |
return NextResponse.json(
|
| 18 |
-
{ message:
|
| 19 |
{ status: 200 }
|
| 20 |
);
|
| 21 |
} catch (error) {
|
| 22 |
-
console.error(
|
| 23 |
return NextResponse.json(
|
| 24 |
-
{ message:
|
| 25 |
{ status: 500 }
|
| 26 |
);
|
| 27 |
}
|
| 28 |
-
}
|
|
|
|
| 1 |
+
// app/api/auth/logout/route.ts
|
| 2 |
+
import { cookies } from 'next/headers';
|
| 3 |
+
import { NextResponse } from 'next/server';
|
|
|
|
|
|
|
|
|
|
| 4 |
|
| 5 |
+
export async function POST() {
|
|
|
|
|
|
|
|
|
|
| 6 |
try {
|
| 7 |
const cookieStore = await cookies();
|
| 8 |
+
cookieStore.delete('auth_token');
|
|
|
|
| 9 |
|
| 10 |
return NextResponse.json(
|
| 11 |
+
{ message: 'Logged out successfully' },
|
| 12 |
{ status: 200 }
|
| 13 |
);
|
| 14 |
} catch (error) {
|
| 15 |
+
console.error('Logout error:', error);
|
| 16 |
return NextResponse.json(
|
| 17 |
+
{ message: 'Logout failed' },
|
| 18 |
{ status: 500 }
|
| 19 |
);
|
| 20 |
}
|
| 21 |
+
}
|
src/app/api/auth/me/route.ts
CHANGED
|
@@ -1,55 +1,40 @@
|
|
| 1 |
-
/
|
| 2 |
-
|
| 3 |
-
|
| 4 |
-
*
|
| 5 |
-
* Fetches user data from backend /admin/me endpoint
|
| 6 |
-
* Uses token from HTTP-only cookie
|
| 7 |
-
*/
|
| 8 |
|
| 9 |
-
|
| 10 |
-
import { cookies } from "next/headers";
|
| 11 |
-
|
| 12 |
-
const BACKEND_URL = process.env.NEXT_PUBLIC_API_URL || "https://byteriot-candidateexplorer.hf.space/CandidateExplorer";
|
| 13 |
-
|
| 14 |
-
export async function GET(request: NextRequest) {
|
| 15 |
try {
|
| 16 |
const cookieStore = await cookies();
|
| 17 |
-
const token = cookieStore.get(
|
| 18 |
|
| 19 |
if (!token) {
|
| 20 |
return NextResponse.json(
|
| 21 |
-
{ message:
|
| 22 |
{ status: 401 }
|
| 23 |
);
|
| 24 |
}
|
| 25 |
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
});
|
| 33 |
|
| 34 |
if (!response.ok) {
|
| 35 |
-
if (response.status === 401) {
|
| 36 |
-
return NextResponse.json(
|
| 37 |
-
{ message: "Unauthorized" },
|
| 38 |
-
{ status: 401 }
|
| 39 |
-
);
|
| 40 |
-
}
|
| 41 |
return NextResponse.json(
|
| 42 |
-
{ message:
|
| 43 |
-
{ status:
|
| 44 |
);
|
| 45 |
}
|
| 46 |
|
| 47 |
const userData = await response.json();
|
| 48 |
return NextResponse.json(userData, { status: 200 });
|
|
|
|
| 49 |
} catch (error) {
|
| 50 |
-
console.error(
|
| 51 |
return NextResponse.json(
|
| 52 |
-
{ message:
|
| 53 |
{ status: 500 }
|
| 54 |
);
|
| 55 |
}
|
|
|
|
| 1 |
+
// app/api/auth/me/route.ts
|
| 2 |
+
import { cookies } from 'next/headers';
|
| 3 |
+
import { NextResponse } from 'next/server';
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4 |
|
| 5 |
+
export async function GET() {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6 |
try {
|
| 7 |
const cookieStore = await cookies();
|
| 8 |
+
const token = cookieStore.get('auth_token')?.value;
|
| 9 |
|
| 10 |
if (!token) {
|
| 11 |
return NextResponse.json(
|
| 12 |
+
{ message: 'Not authenticated' },
|
| 13 |
{ status: 401 }
|
| 14 |
);
|
| 15 |
}
|
| 16 |
|
| 17 |
+
const response = await fetch(
|
| 18 |
+
'https://byteriot-candidateexplorer.hf.space/CandidateExplorer/admin/me',
|
| 19 |
+
{
|
| 20 |
+
headers: { Authorization: `Bearer ${token}` },
|
| 21 |
+
}
|
| 22 |
+
);
|
|
|
|
| 23 |
|
| 24 |
if (!response.ok) {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 25 |
return NextResponse.json(
|
| 26 |
+
{ message: 'Invalid token' },
|
| 27 |
+
{ status: 401 }
|
| 28 |
);
|
| 29 |
}
|
| 30 |
|
| 31 |
const userData = await response.json();
|
| 32 |
return NextResponse.json(userData, { status: 200 });
|
| 33 |
+
|
| 34 |
} catch (error) {
|
| 35 |
+
console.error('Get user error:', error);
|
| 36 |
return NextResponse.json(
|
| 37 |
+
{ message: 'Failed to get user' },
|
| 38 |
{ status: 500 }
|
| 39 |
);
|
| 40 |
}
|
src/app/api/file/score_card/route.ts
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// app/api/auth/me/route.ts
|
| 2 |
+
import { cookies } from 'next/headers';
|
| 3 |
+
import { NextResponse } from 'next/server';
|
| 4 |
+
|
| 5 |
+
export async function GET() {
|
| 6 |
+
try {
|
| 7 |
+
const cookieStore = await cookies();
|
| 8 |
+
const token = cookieStore.get('auth_token')?.value;
|
| 9 |
+
|
| 10 |
+
if (!token) {
|
| 11 |
+
return NextResponse.json(
|
| 12 |
+
{ message: 'Not authenticated' },
|
| 13 |
+
{ status: 401 }
|
| 14 |
+
);
|
| 15 |
+
}
|
| 16 |
+
|
| 17 |
+
const response = await fetch(
|
| 18 |
+
'https://byteriot-candidateexplorer.hf.space/CandidateExplorer/file/score_card',
|
| 19 |
+
{
|
| 20 |
+
headers: { Authorization: `Bearer ${token}` },
|
| 21 |
+
}
|
| 22 |
+
);
|
| 23 |
+
|
| 24 |
+
if (!response.ok) {
|
| 25 |
+
return NextResponse.json(
|
| 26 |
+
{ message: 'Invalid token' },
|
| 27 |
+
{ status: 401 }
|
| 28 |
+
);
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
const scoreCardData = await response.json();
|
| 32 |
+
return NextResponse.json(scoreCardData, { status: 200 });
|
| 33 |
+
|
| 34 |
+
} catch (error) {
|
| 35 |
+
console.error('Get score card error:', error);
|
| 36 |
+
return NextResponse.json(
|
| 37 |
+
{ message: 'Failed to get score card' },
|
| 38 |
+
{ status: 500 }
|
| 39 |
+
);
|
| 40 |
+
}
|
| 41 |
+
}
|
src/app/api/me/route.ts
DELETED
|
@@ -1,22 +0,0 @@
|
|
| 1 |
-
import { NextRequest, NextResponse } from "next/server"
|
| 2 |
-
|
| 3 |
-
export async function GET(request: NextRequest) {
|
| 4 |
-
const token = request.headers.get("Authorization")
|
| 5 |
-
|
| 6 |
-
if (!token) {
|
| 7 |
-
return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
| 8 |
-
}
|
| 9 |
-
|
| 10 |
-
const res = await fetch("https://byteriot-candidateexplorer.hf.space/CandidateExplorer/admin/me", {
|
| 11 |
-
headers: {
|
| 12 |
-
Authorization: token,
|
| 13 |
-
},
|
| 14 |
-
})
|
| 15 |
-
|
| 16 |
-
if (!res.ok) {
|
| 17 |
-
return NextResponse.json({ error: "Failed to fetch user" }, { status: res.status })
|
| 18 |
-
}
|
| 19 |
-
|
| 20 |
-
const data = await res.json()
|
| 21 |
-
return NextResponse.json(data)
|
| 22 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
src/app/login/page.tsx
CHANGED
|
@@ -1,40 +1,21 @@
|
|
| 1 |
-
/
|
| 2 |
-
|
| 3 |
-
* Route: /login
|
| 4 |
-
* Publicly accessible page for user authentication
|
| 5 |
-
*/
|
| 6 |
-
|
| 7 |
-
import { LoginForm } from "@/components/LoginForm";
|
| 8 |
-
|
| 9 |
-
export const metadata = {
|
| 10 |
-
title: "Login - Candidate Explorer",
|
| 11 |
-
description: "Sign in to your account",
|
| 12 |
-
};
|
| 13 |
|
| 14 |
export default function LoginPage() {
|
| 15 |
return (
|
| 16 |
-
<div className="min-h-screen flex items-center justify-center bg-gray-50
|
| 17 |
<div className="w-full max-w-md space-y-8">
|
| 18 |
-
{/* Header */}
|
| 19 |
<div className="text-center">
|
| 20 |
-
<h2 className="text-3xl font-
|
| 21 |
-
Candidate Explorer
|
| 22 |
-
</h2>
|
| 23 |
<p className="mt-2 text-sm text-gray-600">
|
| 24 |
-
Sign in to
|
| 25 |
</p>
|
| 26 |
</div>
|
| 27 |
|
| 28 |
-
{/* Login Form */}
|
| 29 |
<div className="bg-white py-8 px-6 shadow rounded-lg">
|
| 30 |
<LoginForm />
|
| 31 |
</div>
|
| 32 |
-
|
| 33 |
-
{/* Footer */}
|
| 34 |
-
<p className="text-center text-xs text-gray-500">
|
| 35 |
-
byteriot - Candidate Explorer © {new Date().getFullYear()} |{" "}
|
| 36 |
-
</p>
|
| 37 |
</div>
|
| 38 |
</div>
|
| 39 |
);
|
| 40 |
-
}
|
|
|
|
| 1 |
+
// pages/login.tsx
|
| 2 |
+
import { LoginForm } from '@/components/LoginForm';
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3 |
|
| 4 |
export default function LoginPage() {
|
| 5 |
return (
|
| 6 |
+
<div className="min-h-screen flex items-center justify-center bg-gray-50">
|
| 7 |
<div className="w-full max-w-md space-y-8">
|
|
|
|
| 8 |
<div className="text-center">
|
| 9 |
+
<h2 className="text-3xl font-bold">Candidate Explorer</h2>
|
|
|
|
|
|
|
| 10 |
<p className="mt-2 text-sm text-gray-600">
|
| 11 |
+
Sign in to your account
|
| 12 |
</p>
|
| 13 |
</div>
|
| 14 |
|
|
|
|
| 15 |
<div className="bg-white py-8 px-6 shadow rounded-lg">
|
| 16 |
<LoginForm />
|
| 17 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 18 |
</div>
|
| 19 |
</div>
|
| 20 |
);
|
| 21 |
+
}
|
src/app/providers.tsx
CHANGED
|
@@ -5,9 +5,8 @@
|
|
| 5 |
* Wraps the entire app with necessary providers
|
| 6 |
*/
|
| 7 |
|
|
|
|
| 8 |
import React from "react";
|
| 9 |
-
import { QueryClientProvider, QueryClient } from "@tanstack/react-query";
|
| 10 |
-
import { AuthProvider } from "@/lib/auth-context";
|
| 11 |
|
| 12 |
const queryClient = new QueryClient({
|
| 13 |
defaultOptions: {
|
|
@@ -21,7 +20,7 @@ const queryClient = new QueryClient({
|
|
| 21 |
export function Providers({ children }: { children: React.ReactNode }) {
|
| 22 |
return (
|
| 23 |
<QueryClientProvider client={queryClient}>
|
| 24 |
-
|
| 25 |
</QueryClientProvider>
|
| 26 |
);
|
| 27 |
}
|
|
|
|
| 5 |
* Wraps the entire app with necessary providers
|
| 6 |
*/
|
| 7 |
|
| 8 |
+
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
| 9 |
import React from "react";
|
|
|
|
|
|
|
| 10 |
|
| 11 |
const queryClient = new QueryClient({
|
| 12 |
defaultOptions: {
|
|
|
|
| 20 |
export function Providers({ children }: { children: React.ReactNode }) {
|
| 21 |
return (
|
| 22 |
<QueryClientProvider client={queryClient}>
|
| 23 |
+
{children}
|
| 24 |
</QueryClientProvider>
|
| 25 |
);
|
| 26 |
}
|
src/app/recruitment/layout.tsx
CHANGED
|
@@ -1,16 +1,69 @@
|
|
| 1 |
-
|
| 2 |
-
|
| 3 |
import { Header } from '@/components/dashboard/header';
|
| 4 |
import { HeaderMenu } from '@/components/dashboard/header-menu';
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5 |
|
| 6 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 7 |
children,
|
| 8 |
}: {
|
| 9 |
children: React.ReactNode;
|
| 10 |
}) {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 11 |
return (
|
| 12 |
<div className="flex h-screen bg-background">
|
| 13 |
-
{/* <Sidebar /> */}
|
| 14 |
<div className="flex-1 flex flex-col overflow-hidden">
|
| 15 |
<Header />
|
| 16 |
<HeaderMenu />
|
|
@@ -22,4 +75,4 @@ export default function RootLayout({
|
|
| 22 |
</div>
|
| 23 |
</div>
|
| 24 |
);
|
| 25 |
-
}
|
|
|
|
| 1 |
+
// app/recruitment/layout.tsx
|
|
|
|
| 2 |
import { Header } from '@/components/dashboard/header';
|
| 3 |
import { HeaderMenu } from '@/components/dashboard/header-menu';
|
| 4 |
+
import { cookies } from 'next/headers';
|
| 5 |
+
import { redirect } from 'next/navigation';
|
| 6 |
+
|
| 7 |
+
async function getUser() {
|
| 8 |
+
console.log('🟡 [Layout] Checking authentication...');
|
| 9 |
+
|
| 10 |
+
const cookieStore = await cookies();
|
| 11 |
+
const token = cookieStore.get('auth_token')?.value;
|
| 12 |
+
|
| 13 |
+
console.log('🟡 [Layout] Token exists:', token ? 'YES' : 'NO');
|
| 14 |
+
console.log('🟡 [Layout] Token value:', token ? token.substring(0, 20) + '...' : 'none');
|
| 15 |
+
|
| 16 |
+
if (!token) {
|
| 17 |
+
console.log('🟡 [Layout] No token found, returning null');
|
| 18 |
+
return null;
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
try {
|
| 22 |
+
console.log('🟡 [Layout] Verifying token with backend...');
|
| 23 |
+
|
| 24 |
+
const response = await fetch(
|
| 25 |
+
'https://byteriot-candidateexplorer.hf.space/CandidateExplorer/admin/me',
|
| 26 |
+
{
|
| 27 |
+
headers: { Authorization: `Bearer ${token}` },
|
| 28 |
+
cache: 'no-store',
|
| 29 |
+
}
|
| 30 |
+
);
|
| 31 |
+
|
| 32 |
+
console.log('🟡 [Layout] Backend response status:', response.status);
|
| 33 |
+
|
| 34 |
+
if (!response.ok) {
|
| 35 |
+
console.log('🟡 [Layout] Token invalid, backend returned:', response.status);
|
| 36 |
+
return null;
|
| 37 |
+
}
|
| 38 |
|
| 39 |
+
const userData = await response.json();
|
| 40 |
+
console.log('🟡 [Layout] User authenticated:', userData);
|
| 41 |
+
return userData;
|
| 42 |
+
|
| 43 |
+
} catch (error) {
|
| 44 |
+
console.error('🟡 [Layout] Error fetching user:', error);
|
| 45 |
+
return null;
|
| 46 |
+
}
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
export default async function RecruitmentLayout({
|
| 50 |
children,
|
| 51 |
}: {
|
| 52 |
children: React.ReactNode;
|
| 53 |
}) {
|
| 54 |
+
console.log('🟡 [Layout] Layout rendering...');
|
| 55 |
+
|
| 56 |
+
const user = await getUser();
|
| 57 |
+
|
| 58 |
+
if (!user) {
|
| 59 |
+
console.log('🟡 [Layout] No user, redirecting to login');
|
| 60 |
+
redirect('/login');
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
console.log('🟡 [Layout] User found, rendering layout');
|
| 64 |
+
|
| 65 |
return (
|
| 66 |
<div className="flex h-screen bg-background">
|
|
|
|
| 67 |
<div className="flex-1 flex flex-col overflow-hidden">
|
| 68 |
<Header />
|
| 69 |
<HeaderMenu />
|
|
|
|
| 75 |
</div>
|
| 76 |
</div>
|
| 77 |
);
|
| 78 |
+
}
|
src/components/LoginForm.tsx
CHANGED
|
@@ -1,145 +1,104 @@
|
|
| 1 |
-
|
|
|
|
| 2 |
|
| 3 |
-
/
|
| 4 |
-
|
| 5 |
-
* Handles user authentication with username/password
|
| 6 |
-
* Uses React Hook Form + Zod validation + Radix UI
|
| 7 |
-
*/
|
| 8 |
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
Form,
|
| 15 |
-
FormField,
|
| 16 |
-
FormItem,
|
| 17 |
-
FormLabel,
|
| 18 |
-
FormControl,
|
| 19 |
-
FormMessage,
|
| 20 |
-
} from "@/components/ui/form";
|
| 21 |
-
import { Input } from "@/components/ui/input";
|
| 22 |
-
import { Button } from "@/components/ui/button";
|
| 23 |
-
import { Alert, AlertDescription } from "@/components/ui/alert";
|
| 24 |
-
import { Spinner } from "@/components/ui/spinner";
|
| 25 |
-
import { useAuth } from "@/lib/auth-context";
|
| 26 |
-
import { AuthError } from "@/types/auth";
|
| 27 |
-
|
| 28 |
-
// Validation schema
|
| 29 |
-
const loginSchema = z.object({
|
| 30 |
-
username: z
|
| 31 |
-
.string()
|
| 32 |
-
.min(1, "Username is required")
|
| 33 |
-
.min(3, "Username must be at least 3 characters"),
|
| 34 |
-
password: z
|
| 35 |
-
.string()
|
| 36 |
-
.min(1, "Password is required")
|
| 37 |
-
.min(6, "Password must be at least 6 characters"),
|
| 38 |
-
});
|
| 39 |
|
| 40 |
-
|
| 41 |
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
|
|
|
| 45 |
|
| 46 |
-
|
| 47 |
-
resolver: zodResolver(loginSchema),
|
| 48 |
-
defaultValues: {
|
| 49 |
-
username: "",
|
| 50 |
-
password: "",
|
| 51 |
-
},
|
| 52 |
-
});
|
| 53 |
|
| 54 |
-
const onSubmit = async (data: LoginFormData) => {
|
| 55 |
-
setApiError(null);
|
| 56 |
try {
|
| 57 |
-
await
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 62 |
}
|
| 63 |
};
|
| 64 |
|
| 65 |
return (
|
| 66 |
-
<
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
<
|
| 71 |
-
<AlertDescription>{apiError}</AlertDescription>
|
| 72 |
-
</Alert>
|
| 73 |
)}
|
| 74 |
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
/>
|
| 90 |
-
</FormControl>
|
| 91 |
-
{fieldState.error && (
|
| 92 |
-
<FormMessage>{fieldState.error.message}</FormMessage>
|
| 93 |
-
)}
|
| 94 |
-
</FormItem>
|
| 95 |
-
)}
|
| 96 |
-
/>
|
| 97 |
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
/>
|
| 113 |
-
</FormControl>
|
| 114 |
-
{fieldState.error && (
|
| 115 |
-
<FormMessage>{fieldState.error.message}</FormMessage>
|
| 116 |
-
)}
|
| 117 |
-
</FormItem>
|
| 118 |
-
)}
|
| 119 |
-
/>
|
| 120 |
|
| 121 |
-
|
| 122 |
-
<Button
|
| 123 |
type="submit"
|
| 124 |
disabled={isLoading}
|
| 125 |
-
className="w-full"
|
| 126 |
-
size="lg"
|
| 127 |
>
|
| 128 |
-
{isLoading ?
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
<span>Signing in...</span>
|
| 132 |
-
</div>
|
| 133 |
-
) : (
|
| 134 |
-
"Sign In"
|
| 135 |
-
)}
|
| 136 |
-
</Button>
|
| 137 |
-
|
| 138 |
-
{/* Info Text */}
|
| 139 |
-
<p className="text-center text-sm text-gray-500">
|
| 140 |
-
Use your admin credentials to login
|
| 141 |
-
</p>
|
| 142 |
-
</form>
|
| 143 |
-
</Form>
|
| 144 |
);
|
| 145 |
}
|
|
|
|
| 1 |
+
// components/LoginForm.tsx
|
| 2 |
+
'use client';
|
| 3 |
|
| 4 |
+
import { useRouter } from 'next/navigation';
|
| 5 |
+
import { useState } from 'react';
|
|
|
|
|
|
|
|
|
|
| 6 |
|
| 7 |
+
export function LoginForm() {
|
| 8 |
+
const [username, setUsername] = useState('');
|
| 9 |
+
const [password, setPassword] = useState('');
|
| 10 |
+
const [error, setError] = useState('');
|
| 11 |
+
const [isLoading, setIsLoading] = useState(false);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 12 |
|
| 13 |
+
const router = useRouter()
|
| 14 |
|
| 15 |
+
const handleSubmit = async (e: React.FormEvent) => {
|
| 16 |
+
e.preventDefault();
|
| 17 |
+
setError('');
|
| 18 |
+
setIsLoading(true);
|
| 19 |
|
| 20 |
+
console.log('🔵 [Form] Submitting login...');
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 21 |
|
|
|
|
|
|
|
| 22 |
try {
|
| 23 |
+
const response = await fetch('/api/auth/login', {
|
| 24 |
+
method: 'POST',
|
| 25 |
+
headers: {
|
| 26 |
+
'Content-Type': 'application/json',
|
| 27 |
+
},
|
| 28 |
+
body: JSON.stringify({ username, password }),
|
| 29 |
+
});
|
| 30 |
+
|
| 31 |
+
console.log('🔵 [Form] Response status:', response.status);
|
| 32 |
+
|
| 33 |
+
const data = await response.json();
|
| 34 |
+
|
| 35 |
+
if (!response.ok) {
|
| 36 |
+
console.log('🔵 [Form] Login failed:', data.message);
|
| 37 |
+
setError(data.message || 'Login failed');
|
| 38 |
+
setIsLoading(false);
|
| 39 |
+
return;
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
console.log('🔵 [Form] Login successful!');
|
| 43 |
+
console.log('🔵 [Form] User data:', data);
|
| 44 |
+
|
| 45 |
+
// Use window.location for a full page reload to ensure cookie is loaded
|
| 46 |
+
console.log('🔵 [Form] Redirecting to /recruitment...');
|
| 47 |
+
// window.location.href = '/recruitment';
|
| 48 |
+
router.push("/recruitment")
|
| 49 |
+
|
| 50 |
+
} catch (err) {
|
| 51 |
+
console.error('🔵 [Form] Error:', err);
|
| 52 |
+
setError('Something went wrong. Please try again.');
|
| 53 |
+
setIsLoading(false);
|
| 54 |
}
|
| 55 |
};
|
| 56 |
|
| 57 |
return (
|
| 58 |
+
<form onSubmit={handleSubmit} className="space-y-6">
|
| 59 |
+
{error && (
|
| 60 |
+
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-md text-sm">
|
| 61 |
+
{error}
|
| 62 |
+
</div>
|
|
|
|
|
|
|
| 63 |
)}
|
| 64 |
|
| 65 |
+
<div>
|
| 66 |
+
<label htmlFor="username" className="block text-sm font-medium text-gray-700">
|
| 67 |
+
Username
|
| 68 |
+
</label>
|
| 69 |
+
<input
|
| 70 |
+
id="username"
|
| 71 |
+
type="text"
|
| 72 |
+
value={username}
|
| 73 |
+
onChange={(e) => setUsername(e.target.value)}
|
| 74 |
+
required
|
| 75 |
+
disabled={isLoading}
|
| 76 |
+
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500"
|
| 77 |
+
/>
|
| 78 |
+
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 79 |
|
| 80 |
+
<div>
|
| 81 |
+
<label htmlFor="password" className="block text-sm font-medium text-gray-700">
|
| 82 |
+
Password
|
| 83 |
+
</label>
|
| 84 |
+
<input
|
| 85 |
+
id="password"
|
| 86 |
+
type="password"
|
| 87 |
+
value={password}
|
| 88 |
+
onChange={(e) => setPassword(e.target.value)}
|
| 89 |
+
required
|
| 90 |
+
disabled={isLoading}
|
| 91 |
+
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500"
|
| 92 |
+
/>
|
| 93 |
+
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 94 |
|
| 95 |
+
<button
|
|
|
|
| 96 |
type="submit"
|
| 97 |
disabled={isLoading}
|
| 98 |
+
className="w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed"
|
|
|
|
| 99 |
>
|
| 100 |
+
{isLoading ? 'Signing in...' : 'Sign in'}
|
| 101 |
+
</button>
|
| 102 |
+
</form>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 103 |
);
|
| 104 |
}
|
src/components/button.tsx
CHANGED
|
@@ -24,7 +24,7 @@ export default function Button({
|
|
| 24 |
<button
|
| 25 |
onClick={onClick}
|
| 26 |
type={type}
|
| 27 |
-
className={`rounded-[8px] px-4 py-2 ${buttonStyles}
|
| 28 |
disabled ? "opacity-50 cursor-not-allowed" : ""
|
| 29 |
}`}
|
| 30 |
>
|
|
|
|
| 24 |
<button
|
| 25 |
onClick={onClick}
|
| 26 |
type={type}
|
| 27 |
+
className={`rounded-[8px] px-4 py-2 ${buttonStyles} hover:cursor-pointer ${className} ${
|
| 28 |
disabled ? "opacity-50 cursor-not-allowed" : ""
|
| 29 |
}`}
|
| 30 |
>
|
src/components/dashboard/candidates-table.tsx
CHANGED
|
@@ -6,7 +6,7 @@ import { Checkbox } from "@/components/ui/checkbox"
|
|
| 6 |
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"
|
| 7 |
import { Form, FormControl, FormField, FormItem, FormLabel } from "@/components/ui/form"
|
| 8 |
import { Input } from "@/components/ui/input"
|
| 9 |
-
import {
|
| 10 |
import { authFetch } from "@/lib/api"
|
| 11 |
import { exportCandidatesToCSV } from "@/lib/export-service"
|
| 12 |
import { createAndCalculateScore } from "@/lib/scoring-service"
|
|
@@ -894,7 +894,7 @@ export default function CandidateTable() {
|
|
| 894 |
const [debouncedSearch, setDebouncedSearch] = useState("")
|
| 895 |
const [currentPage, setCurrentPage] = useState(1)
|
| 896 |
const [sortConfig, setSortConfig] = useState<{ key: string | null; direction: "asc" | "desc" }>({ key: null, direction: "asc" })
|
| 897 |
-
const { user } =
|
| 898 |
|
| 899 |
const defaultColumns = allColumns.reduce(
|
| 900 |
(acc, col) => ({ ...acc, [col.id]: col.visible }),
|
|
|
|
| 6 |
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"
|
| 7 |
import { Form, FormControl, FormField, FormItem, FormLabel } from "@/components/ui/form"
|
| 8 |
import { Input } from "@/components/ui/input"
|
| 9 |
+
import { useAuth } from "@/composables/useAuth"
|
| 10 |
import { authFetch } from "@/lib/api"
|
| 11 |
import { exportCandidatesToCSV } from "@/lib/export-service"
|
| 12 |
import { createAndCalculateScore } from "@/lib/scoring-service"
|
|
|
|
| 894 |
const [debouncedSearch, setDebouncedSearch] = useState("")
|
| 895 |
const [currentPage, setCurrentPage] = useState(1)
|
| 896 |
const [sortConfig, setSortConfig] = useState<{ key: string | null; direction: "asc" | "desc" }>({ key: null, direction: "asc" })
|
| 897 |
+
const { user } = useAuth()
|
| 898 |
|
| 899 |
const defaultColumns = allColumns.reduce(
|
| 900 |
(acc, col) => ({ ...acc, [col.id]: col.visible }),
|
src/components/dashboard/header.tsx
CHANGED
|
@@ -1,8 +1,7 @@
|
|
| 1 |
"use client";
|
| 2 |
|
| 3 |
-
import {
|
| 4 |
-
import {
|
| 5 |
-
import * as DropdownMenu from "@radix-ui/react-dropdown-menu";
|
| 6 |
import { LogOut } from "lucide-react";
|
| 7 |
|
| 8 |
function getInitials(name: string) {
|
|
@@ -15,7 +14,7 @@ function getInitials(name: string) {
|
|
| 15 |
}
|
| 16 |
|
| 17 |
export function Header() {
|
| 18 |
-
const { user, logout,
|
| 19 |
|
| 20 |
const initials = user ? getInitials(user.full_name || user.username) : "?";
|
| 21 |
const displayName = user?.full_name || user?.username || "Loading...";
|
|
@@ -32,9 +31,9 @@ export function Header() {
|
|
| 32 |
return (
|
| 33 |
<header className="h-16 bg-white border-b border-gray-200 px-8 flex items-center justify-between">
|
| 34 |
<div></div>
|
| 35 |
-
<DropdownMenu
|
| 36 |
-
<
|
| 37 |
-
<
|
| 38 |
<div className="w-8 h-8 bg-gradient-to-br from-pink-300 to-orange-300 rounded-full flex items-center justify-center text-white text-sm font-semibold">
|
| 39 |
{initials}
|
| 40 |
</div>
|
|
@@ -42,19 +41,19 @@ export function Header() {
|
|
| 42 |
<span className="text-sm">{displayName}</span>
|
| 43 |
<span className="text-xs text-gray-500">{displayRole}</span>
|
| 44 |
</div>
|
| 45 |
-
|
| 46 |
-
</
|
| 47 |
-
<
|
| 48 |
-
<
|
| 49 |
-
disabled={
|
| 50 |
onClick={handleLogout}
|
| 51 |
className="px-4 py-2 text-sm text-red-600 hover:bg-red-50 flex items-center gap-2 cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"
|
| 52 |
>
|
| 53 |
<LogOut className="w-4 h-4" />
|
| 54 |
Logout
|
| 55 |
-
</
|
| 56 |
-
</
|
| 57 |
-
</DropdownMenu
|
| 58 |
</header>
|
| 59 |
);
|
| 60 |
}
|
|
|
|
| 1 |
"use client";
|
| 2 |
|
| 3 |
+
import { useAuth } from "@/composables/useAuth";
|
| 4 |
+
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@radix-ui/react-dropdown-menu";
|
|
|
|
| 5 |
import { LogOut } from "lucide-react";
|
| 6 |
|
| 7 |
function getInitials(name: string) {
|
|
|
|
| 14 |
}
|
| 15 |
|
| 16 |
export function Header() {
|
| 17 |
+
const { user, logout, isLoadingUser } = useAuth();
|
| 18 |
|
| 19 |
const initials = user ? getInitials(user.full_name || user.username) : "?";
|
| 20 |
const displayName = user?.full_name || user?.username || "Loading...";
|
|
|
|
| 31 |
return (
|
| 32 |
<header className="h-16 bg-white border-b border-gray-200 px-8 flex items-center justify-between">
|
| 33 |
<div></div>
|
| 34 |
+
<DropdownMenu>
|
| 35 |
+
<DropdownMenuTrigger asChild>
|
| 36 |
+
<div className="flex items-center gap-2 mt-0 cursor-pointer">
|
| 37 |
<div className="w-8 h-8 bg-gradient-to-br from-pink-300 to-orange-300 rounded-full flex items-center justify-center text-white text-sm font-semibold">
|
| 38 |
{initials}
|
| 39 |
</div>
|
|
|
|
| 41 |
<span className="text-sm">{displayName}</span>
|
| 42 |
<span className="text-xs text-gray-500">{displayRole}</span>
|
| 43 |
</div>
|
| 44 |
+
</div>
|
| 45 |
+
</DropdownMenuTrigger>
|
| 46 |
+
<DropdownMenuContent align="end" className="bg-white border border-gray-200 rounded-md shadow-lg">
|
| 47 |
+
<DropdownMenuItem
|
| 48 |
+
disabled={isLoadingUser}
|
| 49 |
onClick={handleLogout}
|
| 50 |
className="px-4 py-2 text-sm text-red-600 hover:bg-red-50 flex items-center gap-2 cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"
|
| 51 |
>
|
| 52 |
<LogOut className="w-4 h-4" />
|
| 53 |
Logout
|
| 54 |
+
</DropdownMenuItem>
|
| 55 |
+
</DropdownMenuContent>
|
| 56 |
+
</DropdownMenu>
|
| 57 |
</header>
|
| 58 |
);
|
| 59 |
}
|
src/components/dashboard/metrics-row.tsx
CHANGED
|
@@ -4,8 +4,6 @@ import { MetricsCard } from "@/components/ui/metrics-card";
|
|
| 4 |
import { authFetch } from "@/lib/api";
|
| 5 |
import { useQuery } from "@tanstack/react-query";
|
| 6 |
|
| 7 |
-
const BASE_URL = "https://byteriot-candidateexplorer.hf.space/CandidateExplorer"
|
| 8 |
-
|
| 9 |
type ScoreData = {
|
| 10 |
data: {
|
| 11 |
total_file: number
|
|
@@ -25,7 +23,7 @@ const fallbackScore: ScoreData = {
|
|
| 25 |
export function MetricsRow() {
|
| 26 |
|
| 27 |
const fetchScore = async (): Promise<ScoreData> => {
|
| 28 |
-
const res = await authFetch(`
|
| 29 |
if (!res.ok) throw new Error("Failed to fetch score")
|
| 30 |
return res.json()
|
| 31 |
}
|
|
|
|
| 4 |
import { authFetch } from "@/lib/api";
|
| 5 |
import { useQuery } from "@tanstack/react-query";
|
| 6 |
|
|
|
|
|
|
|
| 7 |
type ScoreData = {
|
| 8 |
data: {
|
| 9 |
total_file: number
|
|
|
|
| 23 |
export function MetricsRow() {
|
| 24 |
|
| 25 |
const fetchScore = async (): Promise<ScoreData> => {
|
| 26 |
+
const res = await authFetch(`/api/file/score_card`)
|
| 27 |
if (!res.ok) throw new Error("Failed to fetch score")
|
| 28 |
return res.json()
|
| 29 |
}
|
src/composables/useAuth.ts
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// hooks/useAuth.ts
|
| 2 |
+
import { authFetch } from "@/lib/api";
|
| 3 |
+
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
| 4 |
+
import { useRouter } from "next/navigation";
|
| 5 |
+
|
| 6 |
+
interface MeResponse {
|
| 7 |
+
user_id: string;
|
| 8 |
+
username: string;
|
| 9 |
+
email: string;
|
| 10 |
+
full_name: string;
|
| 11 |
+
role: string;
|
| 12 |
+
is_active: boolean;
|
| 13 |
+
tenant_id: string;
|
| 14 |
+
created_at: string;
|
| 15 |
+
}
|
| 16 |
+
|
| 17 |
+
export const useAuth = () => {
|
| 18 |
+
const queryClient = useQueryClient();
|
| 19 |
+
const router = useRouter();
|
| 20 |
+
|
| 21 |
+
// Get current user
|
| 22 |
+
const {
|
| 23 |
+
data: user,
|
| 24 |
+
isLoading: isLoadingUser,
|
| 25 |
+
error: userError,
|
| 26 |
+
} = useQuery<MeResponse>({
|
| 27 |
+
queryKey: ["me"],
|
| 28 |
+
queryFn: async () => {
|
| 29 |
+
const res = await authFetch("/api/auth/me");
|
| 30 |
+
if (!res.ok) throw new Error("Failed to fetch user");
|
| 31 |
+
return res.json();
|
| 32 |
+
},
|
| 33 |
+
staleTime: 1000 * 60 * 5,
|
| 34 |
+
retry: false,
|
| 35 |
+
});
|
| 36 |
+
|
| 37 |
+
// Logout mutation
|
| 38 |
+
const logoutMutation = useMutation({
|
| 39 |
+
mutationFn: async () => {
|
| 40 |
+
const res = await fetch("/api/auth/logout", {
|
| 41 |
+
method: "POST",
|
| 42 |
+
});
|
| 43 |
+
if (!res.ok) throw new Error("Logout failed");
|
| 44 |
+
return res.json();
|
| 45 |
+
},
|
| 46 |
+
onSuccess: () => {
|
| 47 |
+
queryClient.clear();
|
| 48 |
+
router.push("/login");
|
| 49 |
+
router.refresh();
|
| 50 |
+
},
|
| 51 |
+
});
|
| 52 |
+
|
| 53 |
+
return {
|
| 54 |
+
user,
|
| 55 |
+
isLoadingUser,
|
| 56 |
+
userError,
|
| 57 |
+
logout: logoutMutation.mutate,
|
| 58 |
+
isLoggingOut: logoutMutation.isPending,
|
| 59 |
+
isAuthenticated: !!user,
|
| 60 |
+
};
|
| 61 |
+
};
|
src/composables/useUser.ts
DELETED
|
@@ -1,30 +0,0 @@
|
|
| 1 |
-
import { authFetch } from "@/lib/api";
|
| 2 |
-
import { useQuery } from "@tanstack/react-query";
|
| 3 |
-
|
| 4 |
-
interface MeResponse {
|
| 5 |
-
user_id: string;
|
| 6 |
-
username: string;
|
| 7 |
-
email: string;
|
| 8 |
-
full_name: string;
|
| 9 |
-
role: string;
|
| 10 |
-
is_active: boolean;
|
| 11 |
-
tenant_id: string;
|
| 12 |
-
created_at: string;
|
| 13 |
-
}
|
| 14 |
-
|
| 15 |
-
export const useUser = () => {
|
| 16 |
-
const { data: user } = useQuery<MeResponse>({
|
| 17 |
-
queryKey: ["me"],
|
| 18 |
-
queryFn: async () => {
|
| 19 |
-
const res = await authFetch("/api/me");
|
| 20 |
-
if (!res.ok) throw new Error("Failed to fetch user");
|
| 21 |
-
const json = await res.json();
|
| 22 |
-
return json;
|
| 23 |
-
},
|
| 24 |
-
staleTime: 1000 * 60 * 5, // cache 5 minutes
|
| 25 |
-
});
|
| 26 |
-
|
| 27 |
-
return {
|
| 28 |
-
user,
|
| 29 |
-
};
|
| 30 |
-
};
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
src/lib/api.ts
CHANGED
|
@@ -1,12 +1,26 @@
|
|
| 1 |
-
|
| 2 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3 |
|
| 4 |
return fetch(url, {
|
| 5 |
...options,
|
|
|
|
| 6 |
headers: {
|
| 7 |
"Content-Type": "application/json",
|
| 8 |
-
Authorization: `Bearer ${token}`,
|
| 9 |
...options?.headers,
|
| 10 |
},
|
| 11 |
-
})
|
| 12 |
}
|
|
|
|
| 1 |
+
function getCookie(name: string) {
|
| 2 |
+
if (typeof document === "undefined") return null;
|
| 3 |
+
|
| 4 |
+
const match = document.cookie
|
| 5 |
+
.split("; ")
|
| 6 |
+
.find((row) => row.startsWith(name + "="));
|
| 7 |
+
|
| 8 |
+
return match?.split("=")[1] ?? null;
|
| 9 |
+
}
|
| 10 |
+
|
| 11 |
+
export async function authFetch(
|
| 12 |
+
url: string,
|
| 13 |
+
options?: RequestInit
|
| 14 |
+
) {
|
| 15 |
+
const token = getCookie("auth_token");
|
| 16 |
|
| 17 |
return fetch(url, {
|
| 18 |
...options,
|
| 19 |
+
credentials: "include",
|
| 20 |
headers: {
|
| 21 |
"Content-Type": "application/json",
|
| 22 |
+
...(token && { Authorization: `Bearer ${token}` }),
|
| 23 |
...options?.headers,
|
| 24 |
},
|
| 25 |
+
});
|
| 26 |
}
|
src/lib/auth-context.tsx
DELETED
|
@@ -1,150 +0,0 @@
|
|
| 1 |
-
"use client";
|
| 2 |
-
|
| 3 |
-
/**
|
| 4 |
-
* Auth Context & Provider
|
| 5 |
-
* Provides user state and auth methods throughout the app
|
| 6 |
-
* Wraps the entire app at root layout
|
| 7 |
-
*/
|
| 8 |
-
|
| 9 |
-
import React, { createContext, useContext, useEffect, useState, useCallback } from "react";
|
| 10 |
-
import { User, AuthContextType, AuthError } from "@/types/auth";
|
| 11 |
-
import { getUser, logout as logoutAuth } from "@/lib/auth";
|
| 12 |
-
import { useQueryClient } from "@tanstack/react-query";
|
| 13 |
-
|
| 14 |
-
const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
| 15 |
-
|
| 16 |
-
export function AuthProvider({ children }: { children: React.ReactNode }) {
|
| 17 |
-
const [user, setUser] = useState<User | null>(null);
|
| 18 |
-
const [isLoading, setIsLoading] = useState(true);
|
| 19 |
-
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
| 20 |
-
const queryClient = useQueryClient();
|
| 21 |
-
|
| 22 |
-
// Fetch user data on mount
|
| 23 |
-
useEffect(() => {
|
| 24 |
-
const initAuth = async () => {
|
| 25 |
-
try {
|
| 26 |
-
setIsLoading(true);
|
| 27 |
-
const userData = await getUser();
|
| 28 |
-
setUser(userData);
|
| 29 |
-
setIsAuthenticated(true);
|
| 30 |
-
} catch (error) {
|
| 31 |
-
// User not authenticated
|
| 32 |
-
setUser(null);
|
| 33 |
-
setIsAuthenticated(false);
|
| 34 |
-
} finally {
|
| 35 |
-
setIsLoading(false);
|
| 36 |
-
}
|
| 37 |
-
};
|
| 38 |
-
|
| 39 |
-
initAuth();
|
| 40 |
-
}, []);
|
| 41 |
-
|
| 42 |
-
// Login function
|
| 43 |
-
const login = useCallback(
|
| 44 |
-
async (username: string, password: string) => {
|
| 45 |
-
setIsLoading(true);
|
| 46 |
-
|
| 47 |
-
const formData = new FormData();
|
| 48 |
-
formData.append('username', username);
|
| 49 |
-
formData.append('password', password);
|
| 50 |
-
|
| 51 |
-
try {
|
| 52 |
-
const response = await fetch("https://byteriot-candidateexplorer.hf.space/CandidateExplorer/admin/login", {
|
| 53 |
-
method: "POST",
|
| 54 |
-
credentials: "include",
|
| 55 |
-
body: formData,
|
| 56 |
-
});
|
| 57 |
-
|
| 58 |
-
if (!response.ok) {
|
| 59 |
-
const error = await response.json().catch(() => ({}));
|
| 60 |
-
throw new AuthError(error.message || "Login failed", "LOGIN_ERROR", response.status);
|
| 61 |
-
}
|
| 62 |
-
|
| 63 |
-
const userData = await response.json();
|
| 64 |
-
setUser(userData);
|
| 65 |
-
// preserve this line as requested (access_token is returned by the API route)
|
| 66 |
-
try {
|
| 67 |
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
| 68 |
-
(localStorage as any).setItem("token", (userData as any).access_token);
|
| 69 |
-
} catch (e) {
|
| 70 |
-
// ignore storage errors
|
| 71 |
-
}
|
| 72 |
-
setIsAuthenticated(true);
|
| 73 |
-
|
| 74 |
-
// Redirect to recruitment page
|
| 75 |
-
if (typeof window !== "undefined") {
|
| 76 |
-
window.location.href = "/recruitment";
|
| 77 |
-
}
|
| 78 |
-
} catch (error) {
|
| 79 |
-
setUser(null);
|
| 80 |
-
setIsAuthenticated(false);
|
| 81 |
-
throw error instanceof AuthError ? error : new AuthError("Login failed");
|
| 82 |
-
} finally {
|
| 83 |
-
setIsLoading(false);
|
| 84 |
-
}
|
| 85 |
-
},
|
| 86 |
-
[]
|
| 87 |
-
);
|
| 88 |
-
|
| 89 |
-
// Logout function
|
| 90 |
-
const logout = useCallback(async () => {
|
| 91 |
-
setIsLoading(true);
|
| 92 |
-
try {
|
| 93 |
-
await logoutAuth();
|
| 94 |
-
setUser(null);
|
| 95 |
-
setIsAuthenticated(false);
|
| 96 |
-
// Clear and invalidate queries to prevent stale data
|
| 97 |
-
try {
|
| 98 |
-
queryClient.clear();
|
| 99 |
-
} catch (e) {
|
| 100 |
-
// ignore if not available
|
| 101 |
-
}
|
| 102 |
-
await queryClient.invalidateQueries();
|
| 103 |
-
// Redirect to login
|
| 104 |
-
if (typeof window !== "undefined") {
|
| 105 |
-
window.location.href = "/login";
|
| 106 |
-
}
|
| 107 |
-
} catch (error) {
|
| 108 |
-
console.error("Logout error:", error);
|
| 109 |
-
// Still clear local state even if API call fails
|
| 110 |
-
setUser(null);
|
| 111 |
-
setIsAuthenticated(false);
|
| 112 |
-
if (typeof window !== "undefined") {
|
| 113 |
-
window.location.href = "/login";
|
| 114 |
-
}
|
| 115 |
-
} finally {
|
| 116 |
-
setIsLoading(false);
|
| 117 |
-
}
|
| 118 |
-
}, [queryClient]);
|
| 119 |
-
|
| 120 |
-
// Refresh user data
|
| 121 |
-
const refreshUser = useCallback(async () => {
|
| 122 |
-
try {
|
| 123 |
-
const userData = await getUser();
|
| 124 |
-
setUser(userData);
|
| 125 |
-
setIsAuthenticated(true);
|
| 126 |
-
} catch (error) {
|
| 127 |
-
setUser(null);
|
| 128 |
-
setIsAuthenticated(false);
|
| 129 |
-
}
|
| 130 |
-
}, []);
|
| 131 |
-
|
| 132 |
-
const value: AuthContextType = {
|
| 133 |
-
user,
|
| 134 |
-
isLoading,
|
| 135 |
-
isAuthenticated,
|
| 136 |
-
login,
|
| 137 |
-
logout,
|
| 138 |
-
refreshUser,
|
| 139 |
-
};
|
| 140 |
-
|
| 141 |
-
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
| 142 |
-
}
|
| 143 |
-
|
| 144 |
-
export function useAuth(): AuthContextType {
|
| 145 |
-
const context = useContext(AuthContext);
|
| 146 |
-
if (context === undefined) {
|
| 147 |
-
throw new Error("useAuth must be used within AuthProvider");
|
| 148 |
-
}
|
| 149 |
-
return context;
|
| 150 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
src/lib/auth.ts
DELETED
|
@@ -1,96 +0,0 @@
|
|
| 1 |
-
/**
|
| 2 |
-
* Core Authentication Utilities
|
| 3 |
-
* Handles token management via HTTP-only cookies (server-side)
|
| 4 |
-
* Never exposes token to client-side JavaScript
|
| 5 |
-
*/
|
| 6 |
-
|
| 7 |
-
import { User, AuthError } from "@/types/auth";
|
| 8 |
-
|
| 9 |
-
const API_URL = process.env.NEXT_PUBLIC_API_URL || "https://byteriot-candidateexplorer.hf.space/CandidateExplorer";
|
| 10 |
-
|
| 11 |
-
/**
|
| 12 |
-
* Call backend /admin/me endpoint to fetch user data
|
| 13 |
-
* Uses cookies for token storage (set by API route)
|
| 14 |
-
*/
|
| 15 |
-
export async function getUser(): Promise<User> {
|
| 16 |
-
try {
|
| 17 |
-
const response = await fetch("https://byteriot-candidateexplorer.hf.space/CandidateExplorer/admin/me", {
|
| 18 |
-
method: "GET",
|
| 19 |
-
credentials: "include", // Include cookies
|
| 20 |
-
headers: {
|
| 21 |
-
"Content-Type": "application/json",
|
| 22 |
-
},
|
| 23 |
-
});
|
| 24 |
-
|
| 25 |
-
if (!response.ok) {
|
| 26 |
-
if (response.status === 401) {
|
| 27 |
-
throw new AuthError("Unauthorized", "INVALID_TOKEN", 401);
|
| 28 |
-
}
|
| 29 |
-
const error = await response.json();
|
| 30 |
-
throw new AuthError(error.message || "Failed to fetch user", "FETCH_USER_ERROR", response.status);
|
| 31 |
-
}
|
| 32 |
-
|
| 33 |
-
return await response.json();
|
| 34 |
-
} catch (error) {
|
| 35 |
-
if (error instanceof AuthError) throw error;
|
| 36 |
-
throw new AuthError("Failed to fetch user data", "FETCH_USER_ERROR");
|
| 37 |
-
}
|
| 38 |
-
}
|
| 39 |
-
|
| 40 |
-
/**
|
| 41 |
-
* Logout: Clear auth cookie on server via API route
|
| 42 |
-
*/
|
| 43 |
-
export async function logout(): Promise<void> {
|
| 44 |
-
try {
|
| 45 |
-
const response = await fetch(`${new URL(window?.location?.href || "").origin}/api/auth/logout`, {
|
| 46 |
-
method: "POST",
|
| 47 |
-
credentials: "include",
|
| 48 |
-
headers: {
|
| 49 |
-
"Content-Type": "application/json",
|
| 50 |
-
},
|
| 51 |
-
});
|
| 52 |
-
|
| 53 |
-
if (!response.ok) {
|
| 54 |
-
throw new AuthError("Failed to logout", "LOGOUT_ERROR", response.status);
|
| 55 |
-
}
|
| 56 |
-
} catch (error) {
|
| 57 |
-
if (error instanceof AuthError) throw error;
|
| 58 |
-
throw new AuthError("Logout failed", "LOGOUT_ERROR");
|
| 59 |
-
}
|
| 60 |
-
}
|
| 61 |
-
|
| 62 |
-
/**
|
| 63 |
-
* Check if token exists in HTTP-only cookie (server-side only)
|
| 64 |
-
* Note: Cannot directly check cookies from client-side due to httpOnly flag
|
| 65 |
-
* Use AuthContext.isAuthenticated instead
|
| 66 |
-
*/
|
| 67 |
-
export function isAuthenticated(token?: string): boolean {
|
| 68 |
-
return !!token;
|
| 69 |
-
}
|
| 70 |
-
|
| 71 |
-
/**
|
| 72 |
-
* Generic fetch wrapper for API calls (not auth endpoints)
|
| 73 |
-
* Automatically includes Bearer token from cookie (set by login route)
|
| 74 |
-
*/
|
| 75 |
-
export async function authFetch(
|
| 76 |
-
url: string,
|
| 77 |
-
options: RequestInit = {}
|
| 78 |
-
): Promise<Response> {
|
| 79 |
-
const response = await fetch(`${API_URL}${url}`, {
|
| 80 |
-
...options,
|
| 81 |
-
credentials: "include",
|
| 82 |
-
headers: {
|
| 83 |
-
"Content-Type": "application/json",
|
| 84 |
-
...options.headers,
|
| 85 |
-
},
|
| 86 |
-
});
|
| 87 |
-
|
| 88 |
-
if (!response.ok) {
|
| 89 |
-
if (response.status === 401) {
|
| 90 |
-
throw new AuthError("Unauthorized", "INVALID_TOKEN", 401);
|
| 91 |
-
}
|
| 92 |
-
throw new AuthError(`API request failed: ${response.statusText}`, "API_ERROR", response.status);
|
| 93 |
-
}
|
| 94 |
-
|
| 95 |
-
return response;
|
| 96 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
src/lib/rbac.ts
DELETED
|
@@ -1,72 +0,0 @@
|
|
| 1 |
-
/**
|
| 2 |
-
* Role-Based Access Control (RBAC)
|
| 3 |
-
* Check user permissions based on role from authentication context
|
| 4 |
-
*/
|
| 5 |
-
|
| 6 |
-
import { User } from "@/types/auth";
|
| 7 |
-
|
| 8 |
-
export const ROLES = {
|
| 9 |
-
ADMIN: "admin",
|
| 10 |
-
RECRUITER: "recruiter",
|
| 11 |
-
VIEWER: "viewer",
|
| 12 |
-
} as const;
|
| 13 |
-
|
| 14 |
-
export type Role = (typeof ROLES)[keyof typeof ROLES];
|
| 15 |
-
|
| 16 |
-
/**
|
| 17 |
-
* Check if user has a specific role
|
| 18 |
-
* @param user - User object from auth context
|
| 19 |
-
* @param role - Role to check
|
| 20 |
-
* @returns true if user has the role
|
| 21 |
-
*/
|
| 22 |
-
export function hasRole(user: User | null, role: Role): boolean {
|
| 23 |
-
if (!user) return false;
|
| 24 |
-
return user.role.toLowerCase() === role.toLowerCase();
|
| 25 |
-
}
|
| 26 |
-
|
| 27 |
-
/**
|
| 28 |
-
* Check if user has any of the provided roles
|
| 29 |
-
* @param user - User object from auth context
|
| 30 |
-
* @param roles - Array of roles to check
|
| 31 |
-
* @returns true if user has any of the roles
|
| 32 |
-
*/
|
| 33 |
-
export function hasAnyRole(user: User | null, roles: Role[]): boolean {
|
| 34 |
-
if (!user) return false;
|
| 35 |
-
return roles.some((role) => hasRole(user, role));
|
| 36 |
-
}
|
| 37 |
-
|
| 38 |
-
/**
|
| 39 |
-
* Check if user has all of the provided roles
|
| 40 |
-
* @param user - User object from auth context
|
| 41 |
-
* @param roles - Array of roles to check
|
| 42 |
-
* @returns true if user has all the roles
|
| 43 |
-
*/
|
| 44 |
-
export function hasAllRoles(user: User | null, roles: Role[]): boolean {
|
| 45 |
-
if (!user) return false;
|
| 46 |
-
return roles.every((role) => hasRole(user, role));
|
| 47 |
-
}
|
| 48 |
-
|
| 49 |
-
/**
|
| 50 |
-
* Require a specific role - throw error if user doesn't have it
|
| 51 |
-
* Suitable for server-side authorization checks
|
| 52 |
-
* @param user - User object
|
| 53 |
-
* @param role - Required role
|
| 54 |
-
* @throws Error if user doesn't have the role
|
| 55 |
-
*/
|
| 56 |
-
export function requireRole(user: User | null, role: Role): void {
|
| 57 |
-
if (!hasRole(user, role)) {
|
| 58 |
-
throw new Error(`User does not have required role: ${role}`);
|
| 59 |
-
}
|
| 60 |
-
}
|
| 61 |
-
|
| 62 |
-
/**
|
| 63 |
-
* Require any of the provided roles
|
| 64 |
-
* @param user - User object
|
| 65 |
-
* @param roles - Array of acceptable roles
|
| 66 |
-
* @throws Error if user doesn't have any of the roles
|
| 67 |
-
*/
|
| 68 |
-
export function requireAnyRole(user: User | null, roles: Role[]): void {
|
| 69 |
-
if (!hasAnyRole(user, roles)) {
|
| 70 |
-
throw new Error(`User does not have any of the required roles: ${roles.join(", ")}`);
|
| 71 |
-
}
|
| 72 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
src/middleware.ts
CHANGED
|
@@ -1,68 +1,68 @@
|
|
| 1 |
import { NextRequest, NextResponse } from "next/server"
|
| 2 |
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
"/recruitment",
|
| 6 |
-
"/dashboard",
|
| 7 |
-
]
|
| 8 |
-
|
| 9 |
-
// API routes that require authentication
|
| 10 |
-
const protectedApiRoutes = [
|
| 11 |
-
"/api/cv-profile",
|
| 12 |
-
"/api/cv-profile/options",
|
| 13 |
]
|
| 14 |
|
| 15 |
-
|
| 16 |
-
const publicRoutes = [
|
| 17 |
"/login",
|
| 18 |
-
"/api/auth/login",
|
| 19 |
-
"/api/auth/logout",
|
| 20 |
]
|
| 21 |
|
| 22 |
export function middleware(request: NextRequest) {
|
| 23 |
-
const pathname = request.nextUrl
|
| 24 |
const token = request.cookies.get("auth_token")?.value
|
| 25 |
|
| 26 |
-
|
| 27 |
-
const
|
| 28 |
-
|
| 29 |
-
|
| 30 |
|
| 31 |
-
//
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
|
|
|
|
|
|
|
|
|
| 36 |
}
|
| 37 |
-
return NextResponse.
|
|
|
|
|
|
|
| 38 |
}
|
| 39 |
|
| 40 |
-
//
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 45 |
}
|
|
|
|
| 46 |
return NextResponse.next()
|
| 47 |
}
|
| 48 |
|
| 49 |
-
//
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
|
|
|
|
|
|
|
|
|
| 55 |
}
|
| 56 |
|
| 57 |
-
//
|
| 58 |
-
if (
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
} else {
|
| 63 |
-
// If not logged in, redirect to login
|
| 64 |
-
return NextResponse.redirect(new URL("/login", request.url))
|
| 65 |
-
}
|
| 66 |
}
|
| 67 |
|
| 68 |
return NextResponse.next()
|
|
@@ -70,14 +70,9 @@ export function middleware(request: NextRequest) {
|
|
| 70 |
|
| 71 |
export const config = {
|
| 72 |
matcher: [
|
| 73 |
-
// API routes
|
| 74 |
-
"/api/:path*",
|
| 75 |
-
// Protected pages
|
| 76 |
-
"/recruitment/:path*",
|
| 77 |
-
"/dashboard/:path*",
|
| 78 |
-
// Auth pages
|
| 79 |
-
"/login",
|
| 80 |
-
// Root
|
| 81 |
"/",
|
|
|
|
|
|
|
|
|
|
| 82 |
],
|
| 83 |
}
|
|
|
|
| 1 |
import { NextRequest, NextResponse } from "next/server"
|
| 2 |
|
| 3 |
+
const publicApiRoutes = [
|
| 4 |
+
"/api/auth/login",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5 |
]
|
| 6 |
|
| 7 |
+
const publicPages = [
|
|
|
|
| 8 |
"/login",
|
|
|
|
|
|
|
| 9 |
]
|
| 10 |
|
| 11 |
export function middleware(request: NextRequest) {
|
| 12 |
+
const { pathname } = request.nextUrl
|
| 13 |
const token = request.cookies.get("auth_token")?.value
|
| 14 |
|
| 15 |
+
const isApiRoute = pathname.startsWith("/api")
|
| 16 |
+
const isPublicApi = publicApiRoutes.some(route =>
|
| 17 |
+
pathname.startsWith(route)
|
| 18 |
+
)
|
| 19 |
|
| 20 |
+
// -----------------------
|
| 21 |
+
// 🔹 ROOT HANDLING
|
| 22 |
+
// -----------------------
|
| 23 |
+
if (pathname === "/") {
|
| 24 |
+
if (token) {
|
| 25 |
+
return NextResponse.redirect(
|
| 26 |
+
new URL("/recruitment", request.url)
|
| 27 |
+
)
|
| 28 |
}
|
| 29 |
+
return NextResponse.redirect(
|
| 30 |
+
new URL("/login", request.url)
|
| 31 |
+
)
|
| 32 |
}
|
| 33 |
|
| 34 |
+
// -----------------------
|
| 35 |
+
// 🔹 API PROTECTION
|
| 36 |
+
// -----------------------
|
| 37 |
+
if (isApiRoute) {
|
| 38 |
+
if (isPublicApi) return NextResponse.next()
|
| 39 |
+
|
| 40 |
+
if (!token) {
|
| 41 |
+
return NextResponse.json(
|
| 42 |
+
{ error: "Unauthorized" },
|
| 43 |
+
{ status: 401 }
|
| 44 |
+
)
|
| 45 |
}
|
| 46 |
+
|
| 47 |
return NextResponse.next()
|
| 48 |
}
|
| 49 |
|
| 50 |
+
// -----------------------
|
| 51 |
+
// 🔹 PAGE PROTECTION
|
| 52 |
+
// -----------------------
|
| 53 |
+
|
| 54 |
+
// Logged in & accessing login
|
| 55 |
+
if (token && pathname === "/login") {
|
| 56 |
+
return NextResponse.redirect(
|
| 57 |
+
new URL("/recruitment", request.url)
|
| 58 |
+
)
|
| 59 |
}
|
| 60 |
|
| 61 |
+
// Not logged in & accessing protected pages
|
| 62 |
+
if (!token && !publicPages.includes(pathname)) {
|
| 63 |
+
return NextResponse.redirect(
|
| 64 |
+
new URL("/login", request.url)
|
| 65 |
+
)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 66 |
}
|
| 67 |
|
| 68 |
return NextResponse.next()
|
|
|
|
| 70 |
|
| 71 |
export const config = {
|
| 72 |
matcher: [
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 73 |
"/",
|
| 74 |
+
"/login",
|
| 75 |
+
"/recruitment/:path*",
|
| 76 |
+
"/api/:path*",
|
| 77 |
],
|
| 78 |
}
|