feat(security): global password lock gate with 24h session auto logoff

This commit is contained in:
Gabriel Ramos 2026-06-10 17:36:49 -04:00
parent a264c08922
commit 8cd54908d0
3 changed files with 278 additions and 20 deletions

119
README.md
View file

@ -1,26 +1,107 @@
# AI Recruitment Platform (ATS)
# Semillero AI Recruitment Platform (ATS)
## Project Description
A modern ATS platform designed to parse PDF CVs using multimodal AI, rank candidates against job vacancies using semantic vector search, and orchestrate automated recruitment stages via an external n8n hub.
A modern Applicant Tracking System (ATS) built with Next.js App Router, Tailwind CSS, Supabase, pgvector, Google Gemini, and n8n automation. It parses PDF resumes, ranks candidates with semantic vector similarity search, and manages candidate pipelines with real-time comments, pinning, and n8n-powered next-step AI suggestions.
## Core User Stories
- **Recruiter - CV Upload:** As a recruiter, I want to upload CVs in PDF format so they can be evaluated automatically.
- **Recruiter - Vacancy Ranking:** As a recruiter, I want a ranking of candidates per job vacancy.
- **Recruiter - Seniority Detection:** As a recruiter, I want the system to detect seniority to adjust interviews.
- **Recruiter - Profile Summary:** As a recruiter, I want a concise AI summary of the profile for quick review.
- **Hiring Manager - Comparative Scoring:** As a hiring manager, I want a comparative score between candidates.
- **Recruiter - Stage Automation:** As a recruiter, I want candidates to move through recruitment stages automatically.
- **Candidate - Automated Emails:** As a candidate, I want to receive automated email confirmations for every stage change.
- **Talent Team - Vacancy Metrics:** As a talent team, we want metrics on the progress per vacancy.
---
## Setup & Prerequisites
## 🔒 Security & Access Control
The application is protected by a global secure access gate at startup.
* **Security Lock Screen**: Any access to the platform redirects to a bilingual lock screen requiring a password.
* **Encrypted Transmission**: The password is submitted encrypted over HTTPS to a secure server-side endpoint `/api/auth`.
* **Environment Configuration**: Store your password in the `.env` file under `APP_PASSWORD`.
* **Default Fallback**: If no `APP_PASSWORD` env variable is set, the system defaults to:
```
Semillero2026!
```
* **Auto Logoff**: Sessions are tracked via a secure timestamp. Users are automatically logged off and returned to the lock screen after **24 hours (1 day)** of inactivity.
---
## 🛠️ Setup & Environment Configuration
Create a `.env` file at the root of the project with the following variables:
### 1. Google AI Studio Account (Mandatory)
Vector embeddings matching and search operations require a direct call to the Google Gemini Embeddings API (`models/gemini-embedding-001`).
* **Prerequisite**: You must obtain a free-tier or paid-tier Gemini API key from [Google AI Studio](https://aistudio.google.com/).
* **Usage**: The embedding model is free for up to 1,500 requests per day (15 requests per minute), which covers standard development and testing requirements.
* **Configuration**: Add your key to the `.env` file at the root of the project:
```env
GEMINI_API_KEY=your_google_ai_studio_api_key_here
# Database Credentials
NEXT_PUBLIC_SUPABASE_URL=your_supabase_url
NEXT_PUBLIC_SUPABASE_ANON_KEY=your_supabase_anon_key
SUPABASE_SECRET_KEY=your_supabase_service_role_key
# Security Access Lock
APP_PASSWORD=Semillero2026!
# LLM Core API Key
GEMINI_API_KEY=your_gemini_api_key
# n8n Automation Engine Settings
N8N_HOST=https://n8n.yourdomain.com
N8N_API_KEY=your_n8n_api_key
NEXT_PUBLIC_N8N_WEBHOOK_URL=https://n8n.yourdomain.com/webhook/evaluate-candidate
```
---
## 🚀 Installation & Running
### 1. Install Dependencies
```bash
npm install
```
### 2. Run Development Server
```bash
npm run dev
```
### 3. Production Build & Verification
```bash
npm run build
npm run lint
```
### 4. Deploy n8n Workflow
To deploy or update the recruitment workflows inside your n8n workspace, run:
```bash
npx ts-node -O '{"module": "commonjs"}' scripts/deploy-n8n-v2.ts --fallback --fallback-provider=gemini
```
---
## 📋 Interactive Demo Script (Presentation Walkthrough)
Follow these steps to demonstrate the end-to-end capabilities during your presentation:
### Step 1: Secure Login & Startup
1. Open the application. You will be greeted by the **Secure Access Lock Screen**.
2. Select your preferred language (English or Spanish) and toggle between **Light/Dark Mode** using the top-right controls.
3. Enter the password `Semillero2026!` and click **Unlock**.
### Step 2: Define a Job Vacancy
1. Navigate to the **Vacancies** (Vacantes) tab.
2. Click **Create Vacancy**. Enter a Title (e.g. `Senior Frontend Engineer`) and requirements.
3. Observe the newly created card appear in the vacancy sidebar.
### Step 3: Ingest Candidate CVs (RAG parsing)
1. Select the newly created vacancy.
2. Drag and drop or upload a candidate CV in PDF format.
3. The system parses the PDF, generates a text vector embedding, inserts the candidate record, and automatically creates an initial **Screening** interview.
### Step 4: Run AI Suitability Match
1. Click **Run AI Evaluation** on the candidate.
2. Observe the suitabilty score (0-100), risk level (Low, Medium, High), classification, and a concise 3-sentence summary generated by Gemini.
3. Check the A-Z list of candidates in the **Candidates** (Candidatos) tab to see the details, linked vacancies, and duplicate file warnings if you re-upload the same PDF.
### Step 5: Pipeline Interview Management
1. Go to the **Interviews** (Entrevistas) tab.
2. Observe the **Open Positions** sidebar showing positions and candidate counts.
3. Click on a candidate. Watch the **Open Positions** sidebar slide out of view with a smooth CSS transition, and the Candidate List + Detail Pane expand to fill the open space.
4. Toggle the **Pin** button next to the candidate's name to move them to the top of the list.
### Step 6: Interactive Timestamps & AI next steps
1. Click **Get AI Suggestion** inside the candidate's interview detail pane.
2. An n8n webhook will query Gemini and append a concise markdown list of recommended next steps as a comment in the timeline.
3. Try clicking on any comment card. Observe it collapse/uncollapse with a chevron icon transition.
4. Modify the interview **Stage** dropdown. The stage updates instantly in the database with a timestamp log in the comment timeline.
5. Click **Back to All Interviews** in the top navigation. Watch the **Open Positions** sidebar animate back into view.

17
app/api/auth/route.ts Normal file
View file

@ -0,0 +1,17 @@
import { NextRequest, NextResponse } from "next/server";
export async function POST(request: NextRequest) {
try {
const { password } = await request.json();
const correctPassword = process.env.APP_PASSWORD || "Semillero2026!";
if (password === correctPassword) {
return NextResponse.json({ success: true });
}
return NextResponse.json({ success: false, error: "Incorrect password" }, { status: 401 });
} catch (error) {
console.error("Auth API Error:", error);
return NextResponse.json({ success: false, error: "Internal Server Error" }, { status: 500 });
}
}

View file

@ -14,15 +14,41 @@ interface AppContextType {
const AppContext = createContext<AppContextType | undefined>(undefined);
const loginTranslations = {
en: {
title: "Secure Access",
subtitle: "Please enter the password to access the Semillero AI Recruitment Platform.",
placeholder: "Enter password",
button: "Unlock",
error: "Incorrect password",
required: "Password is required",
unlocking: "Unlocking...",
},
es: {
title: "Acceso Seguro",
subtitle: "Por favor ingrese la contraseña para acceder a la Plataforma de Reclutamiento Semillero IA.",
placeholder: "Ingrese la contraseña",
button: "Desbloquear",
error: "Contraseña incorrecta",
required: "La contraseña es requerida",
unlocking: "Desbloqueando...",
}
};
export function AppProvider({ children }: { children: React.ReactNode }) {
const [lang, setLangState] = useState<Language>("en");
const [theme, setThemeState] = useState<Theme>("light");
const [mounted, setMounted] = useState(false);
const [isAuthenticated, setIsAuthenticated] = useState(false);
const [password, setPassword] = useState("");
const [errorMsg, setErrorMsg] = useState("");
const [loadingAuth, setLoadingAuth] = useState(false);
// Load from localStorage on mount
useEffect(() => {
const storedLang = localStorage.getItem("lang") as Language;
const storedTheme = localStorage.getItem("theme") as Theme;
const sessionTime = localStorage.getItem("auth_session_time");
setTimeout(() => {
if (storedLang === "en" || storedLang === "es") {
@ -38,6 +64,18 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
setThemeState("dark");
}
}
// Check auth session: auto log off after 24 hours (1 day)
if (sessionTime) {
const lastSession = parseInt(sessionTime, 10);
const oneDayMs = 24 * 60 * 60 * 1000;
if (Date.now() - lastSession < oneDayMs) {
setIsAuthenticated(true);
} else {
localStorage.removeItem("auth_session_time");
}
}
setMounted(true);
}, 0);
}, []);
@ -63,6 +101,128 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
localStorage.setItem("theme", newTheme);
};
const handleLogin = async (e: React.FormEvent) => {
e.preventDefault();
if (!password.trim()) {
setErrorMsg(lang === "es" ? loginTranslations.es.required : loginTranslations.en.required);
return;
}
setLoadingAuth(true);
setErrorMsg("");
try {
const response = await fetch("/api/auth", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ password }),
});
if (response.ok) {
localStorage.setItem("auth_session_time", Date.now().toString());
setIsAuthenticated(true);
} else {
await response.json();
setErrorMsg(lang === "es" ? loginTranslations.es.error : loginTranslations.en.error);
}
} catch (err) {
console.error("Authentication failed:", err);
setErrorMsg("Connection error. Please try again.");
} finally {
setLoadingAuth(false);
}
};
const t = lang === "es" ? loginTranslations.es : loginTranslations.en;
if (!mounted) {
return (
<div className="min-h-screen flex items-center justify-center bg-slate-50 dark:bg-slate-950 transition-colors duration-200">
<div className="w-8 h-8 border-2 border-blue-600 border-t-transparent rounded-full animate-spin"></div>
</div>
);
}
if (!isAuthenticated) {
return (
<AppContext.Provider value={{ lang, setLang, theme, setTheme }}>
<div className="min-h-screen flex items-center justify-center bg-slate-50 dark:bg-slate-950 p-4 transition-colors duration-200 relative font-sans">
{/* Top-right Language and Theme Controls */}
<div className="absolute top-4 right-4 flex items-center gap-2">
{/* Language Selector */}
<button
onClick={() => setLang(lang === "en" ? "es" : "en")}
className="px-2.5 py-1.5 text-xs font-semibold text-slate-700 dark:text-slate-200 bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-800 rounded-md shadow-xs hover:bg-slate-50 dark:hover:bg-slate-800 transition duration-150 cursor-pointer"
>
{lang === "en" ? "Español (ES)" : "English (EN)"}
</button>
{/* Theme Toggle */}
<button
onClick={() => setTheme(theme === "light" ? "dark" : "light")}
className="p-1.5 text-slate-500 hover:text-slate-700 dark:text-slate-400 dark:hover:text-slate-200 bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-800 rounded-md shadow-xs hover:bg-slate-50 dark:hover:bg-slate-800 transition duration-150 cursor-pointer"
>
{theme === "light" ? (
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="w-4.5 h-4.5">
<path strokeLinecap="round" strokeLinejoin="round" d="M21.752 15.002A9.72 9.72 0 0 1 18 15.75c-5.385 0-9.75-4.365-9.75-9.75 0-1.33.266-2.597.748-3.752A9.753 9.753 0 0 0 3 11.25C3 16.635 7.365 21 12.75 21a9.753 9.753 0 0 0 9.002-5.998Z" />
</svg>
) : (
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="w-4.5 h-4.5">
<path strokeLinecap="round" strokeLinejoin="round" d="M12 3v2.25m0 13.5V21M4.22 4.22l1.59 1.59m12.38 12.38l1.59 1.59M3 12h2.25m13.5 0H21M6.09 18.36l1.59-1.59m12.38-12.38l-1.59 1.59M12 7.5a4.5 4.5 0 1 1 0 9 4.5 4.5 0 0 1 0-9Z" />
</svg>
)}
</button>
</div>
{/* Login Lock Card */}
<div className="bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-800 rounded-lg shadow-sm p-8 max-w-sm w-full flex flex-col gap-5 transition-colors duration-200">
<div className="flex flex-col items-center text-center gap-2">
{/* Shield Lock Icon */}
<div className="w-12 h-12 bg-blue-50 dark:bg-blue-950/40 rounded-full flex items-center justify-center text-blue-600 dark:text-blue-400">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor" className="w-6 h-6">
<path strokeLinecap="round" strokeLinejoin="round" d="M16.5 10.5V6.75a4.5 4.5 0 1 0-9 0V10.5m-2.25 10.5h13.5c.621 0 1.125-.504 1.125-1.125V11.25c0-.621-.504-1.125-1.125-1.125H5.25c-.621 0-1.125.504-1.125 1.125v7.875c0 .621.504 1.125 1.125 1.125Z" />
</svg>
</div>
<h2 className="text-xl font-bold text-slate-900 dark:text-white mt-1">
{t.title}
</h2>
<p className="text-xs text-slate-500 dark:text-slate-400 leading-normal max-w-[280px]">
{t.subtitle}
</p>
</div>
<form onSubmit={handleLogin} className="flex flex-col gap-4">
<div className="flex flex-col gap-1.5">
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder={t.placeholder}
disabled={loadingAuth}
className="w-full px-3.5 py-2 border border-slate-200 dark:border-slate-800 rounded-md text-slate-900 dark:text-white bg-slate-50 dark:bg-slate-800/40 text-sm focus:outline-none focus:border-blue-500 dark:focus:border-blue-500/80 transition duration-150"
autoFocus
/>
{errorMsg && (
<p className="text-[11px] text-red-600 dark:text-red-400 font-semibold mt-0.5">
{errorMsg}
</p>
)}
</div>
<button
type="submit"
disabled={loadingAuth}
className="w-full py-2 bg-blue-600 hover:bg-blue-700 disabled:opacity-50 text-white font-semibold text-sm rounded-md transition duration-200 cursor-pointer flex items-center justify-center gap-1.5"
>
{loadingAuth ? t.unlocking : t.button}
</button>
</form>
</div>
</div>
</AppContext.Provider>
);
}
return (
<AppContext.Provider value={{ lang, setLang, theme, setTheme }}>
{children}