feat: implement registration flow, backend auth services, and multi-agent response synthesizer
Browse files- agents/synthesizer.py +18 -19
- backend/app/api/v1/documents.py +10 -0
- backend/app/main.py +25 -8
- backend/app/schemas/auth.py +1 -0
- backend/app/services/auth_service.py +1 -1
- frontend/src/components/layout/AppLayout.tsx +2 -2
- frontend/src/pages/AdminPage.tsx +97 -16
- frontend/src/pages/DocumentsPage.tsx +33 -5
- frontend/src/pages/RegisterPage.tsx +26 -8
agents/synthesizer.py
CHANGED
|
@@ -16,13 +16,15 @@ from agents.state import CopilotState
|
|
| 16 |
logger = structlog.get_logger(__name__)
|
| 17 |
|
| 18 |
SYNTHESIZER_SYSTEM_PROMPT = """You are an Enterprise AI Copilot Assistant.
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
1. **
|
| 23 |
-
2. **
|
| 24 |
-
3. **
|
| 25 |
-
4. **
|
|
|
|
|
|
|
| 26 |
"""
|
| 27 |
|
| 28 |
# ββ Built-in greeting responses for initial turn ββββββββββββ
|
|
@@ -38,7 +40,6 @@ def _extract_name_from_history(history: list) -> str | None:
|
|
| 38 |
for msg in history:
|
| 39 |
if msg.get("role") == "user":
|
| 40 |
content = msg.get("content", "")
|
| 41 |
-
# Match "my name is X", "i am X", "im X", "call me X"
|
| 42 |
match = re.search(r"\b(?:my name is|i am|i'm|call me)\s+([A-Za-z]+)", content, re.IGNORECASE)
|
| 43 |
if match:
|
| 44 |
name = match.group(1).capitalize()
|
|
@@ -52,17 +53,17 @@ def _build_chunk_summary(chunks: list) -> str:
|
|
| 52 |
if not chunks:
|
| 53 |
return ""
|
| 54 |
|
| 55 |
-
summary_parts = ["π **Here
|
| 56 |
for i, c in enumerate(chunks[:5], 1):
|
| 57 |
-
doc_name = c.get("document_name", "
|
| 58 |
page = c.get("page", "N/A")
|
| 59 |
text = c.get("text", "")
|
| 60 |
-
if len(text) >
|
| 61 |
-
text = text[:
|
| 62 |
-
summary_parts.append(f"**{i}
|
| 63 |
summary_parts.append(f"> {text}\n")
|
| 64 |
|
| 65 |
-
summary_parts.append("\n---\n*π‘ Tip: Set up your Groq API key in `.env` for
|
| 66 |
return "\n".join(summary_parts)
|
| 67 |
|
| 68 |
|
|
@@ -82,12 +83,10 @@ async def synthesizer_node(state: CopilotState) -> CopilotState:
|
|
| 82 |
# Format context for prompt
|
| 83 |
context_str = ""
|
| 84 |
if chunks:
|
| 85 |
-
context_str = "---
|
| 86 |
for i, c in enumerate(chunks, 1):
|
| 87 |
-
context_str += f"[{i}]
|
| 88 |
-
context_str += f"
|
| 89 |
-
elif routing != "direct":
|
| 90 |
-
context_str = "No specific internal document context was found for this query.\n"
|
| 91 |
|
| 92 |
# Format history
|
| 93 |
history_str = ""
|
|
|
|
| 16 |
logger = structlog.get_logger(__name__)
|
| 17 |
|
| 18 |
SYNTHESIZER_SYSTEM_PROMPT = """You are an Enterprise AI Copilot Assistant.
|
| 19 |
+
Your primary job is to analyze and synthesize accurate answers using the provided context, web search results, or general knowledge.
|
| 20 |
+
|
| 21 |
+
CRITICAL RULES FOR RESPONSES:
|
| 22 |
+
1. **FULL DOCUMENT ACCESS**: When RETRIEVED CONTEXT is present, you HAVE FULL ACCESS to the user's uploaded file excerpts. NEVER state or claim that the document is "not publicly available", "private", or "unavailable".
|
| 23 |
+
2. **NO NEGATIVE DISCLAIMERS**: NEVER say or output phrases like "There is no related content", "No specific internal document context was found", or "I could not find information" when answering general queries or web search questions. Simply answer the query directly and helpfully.
|
| 24 |
+
3. **SUMMARIZATION**: If the user asks "Tell me about the contents of [document_name]" or asks what is in a document, summarize the key concepts, sections, and topics present in the Excerpts.
|
| 25 |
+
4. **CONCISENESS**: Be direct. Answer the user's question in the very first paragraph. Use markdown bullet points and bold headers for clarity.
|
| 26 |
+
5. **CITATIONS**: Reference document names, page numbers, or web sources clearly.
|
| 27 |
+
6. **CONVERSATION MEMORY**: Use the CONVERSATION HISTORY to remember details the user shared (such as their name or previous topics).
|
| 28 |
"""
|
| 29 |
|
| 30 |
# ββ Built-in greeting responses for initial turn ββββββββββββ
|
|
|
|
| 40 |
for msg in history:
|
| 41 |
if msg.get("role") == "user":
|
| 42 |
content = msg.get("content", "")
|
|
|
|
| 43 |
match = re.search(r"\b(?:my name is|i am|i'm|call me)\s+([A-Za-z]+)", content, re.IGNORECASE)
|
| 44 |
if match:
|
| 45 |
name = match.group(1).capitalize()
|
|
|
|
| 53 |
if not chunks:
|
| 54 |
return ""
|
| 55 |
|
| 56 |
+
summary_parts = ["π **Here is what I extracted from your document:**\n"]
|
| 57 |
for i, c in enumerate(chunks[:5], 1):
|
| 58 |
+
doc_name = c.get("document_name", "Uploaded Document")
|
| 59 |
page = c.get("page", "N/A")
|
| 60 |
text = c.get("text", "")
|
| 61 |
+
if len(text) > 350:
|
| 62 |
+
text = text[:350] + "..."
|
| 63 |
+
summary_parts.append(f"**Section {i} β {doc_name}** (Page {page})")
|
| 64 |
summary_parts.append(f"> {text}\n")
|
| 65 |
|
| 66 |
+
summary_parts.append("\n---\n*π‘ Tip: Set up your free Groq API key in `.env` for AI-powered synthesis.*")
|
| 67 |
return "\n".join(summary_parts)
|
| 68 |
|
| 69 |
|
|
|
|
| 83 |
# Format context for prompt
|
| 84 |
context_str = ""
|
| 85 |
if chunks:
|
| 86 |
+
context_str = "--- CONTEXT & SEARCH RESULTS ---\n"
|
| 87 |
for i, c in enumerate(chunks, 1):
|
| 88 |
+
context_str += f"[{i}] Source: {c.get('document_name')} (Page {c.get('page', 'N/A')})\n"
|
| 89 |
+
context_str += f"Excerpt: {c.get('text')}\n\n"
|
|
|
|
|
|
|
| 90 |
|
| 91 |
# Format history
|
| 92 |
history_str = ""
|
backend/app/api/v1/documents.py
CHANGED
|
@@ -125,6 +125,16 @@ async def upload_document(
|
|
| 125 |
if doc_type not in valid_types:
|
| 126 |
doc_type = "general"
|
| 127 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 128 |
# Save file to tenant disk location
|
| 129 |
upload_dir = os.path.join(settings.UPLOAD_DIR, current_user.tenant_id)
|
| 130 |
os.makedirs(upload_dir, exist_ok=True)
|
|
|
|
| 125 |
if doc_type not in valid_types:
|
| 126 |
doc_type = "general"
|
| 127 |
|
| 128 |
+
# Enforce RBAC permissions on document category upload
|
| 129 |
+
if current_user.role == UserRole.EMPLOYEE and doc_type in {"hr", "finance", "legal"}:
|
| 130 |
+
raise HTTPException(
|
| 131 |
+
status_code=status.HTTP_403_FORBIDDEN,
|
| 132 |
+
detail={
|
| 133 |
+
"error": "permission_denied",
|
| 134 |
+
"message": f"Standard employees cannot upload restricted '{doc_type}' documents. Admin/Manager approval required.",
|
| 135 |
+
},
|
| 136 |
+
)
|
| 137 |
+
|
| 138 |
# Save file to tenant disk location
|
| 139 |
upload_dir = os.path.join(settings.UPLOAD_DIR, current_user.tenant_id)
|
| 140 |
os.makedirs(upload_dir, exist_ok=True)
|
backend/app/main.py
CHANGED
|
@@ -173,14 +173,31 @@ def create_app() -> FastAPI:
|
|
| 173 |
|
| 174 |
return diag_results
|
| 175 |
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 184 |
|
| 185 |
return app
|
| 186 |
|
|
|
|
| 173 |
|
| 174 |
return diag_results
|
| 175 |
|
| 176 |
+
# ββ HuggingFace Static SPA Serving ββββββββββββββββββββββββββββ
|
| 177 |
+
import os
|
| 178 |
+
from fastapi.staticfiles import StaticFiles
|
| 179 |
+
from fastapi.responses import FileResponse
|
| 180 |
+
|
| 181 |
+
static_dir = os.path.abspath("/app/static")
|
| 182 |
+
if not os.path.exists(static_dir):
|
| 183 |
+
static_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", "static"))
|
| 184 |
+
|
| 185 |
+
if os.path.exists(static_dir):
|
| 186 |
+
assets_dir = os.path.join(static_dir, "assets")
|
| 187 |
+
if os.path.exists(assets_dir):
|
| 188 |
+
app.mount("/assets", StaticFiles(directory=assets_dir), name="assets")
|
| 189 |
+
|
| 190 |
+
@app.get("/{full_path:path}", include_in_schema=False)
|
| 191 |
+
async def serve_spa(full_path: str):
|
| 192 |
+
if full_path.startswith("api/") or full_path in ["docs", "redoc", "openapi.json", "health"]:
|
| 193 |
+
return JSONResponse(status_code=404, content={"detail": "Not Found"})
|
| 194 |
+
file_path = os.path.join(static_dir, full_path)
|
| 195 |
+
if os.path.exists(file_path) and os.path.isfile(file_path):
|
| 196 |
+
return FileResponse(file_path)
|
| 197 |
+
index_path = os.path.join(static_dir, "index.html")
|
| 198 |
+
if os.path.exists(index_path):
|
| 199 |
+
return FileResponse(index_path)
|
| 200 |
+
return JSONResponse(status_code=404, content={"detail": "Not Found"})
|
| 201 |
|
| 202 |
return app
|
| 203 |
|
backend/app/schemas/auth.py
CHANGED
|
@@ -15,6 +15,7 @@ class RegisterRequest(BaseModel):
|
|
| 15 |
password: str
|
| 16 |
full_name: str
|
| 17 |
tenant_name: str # Creates a new tenant for this user
|
|
|
|
| 18 |
|
| 19 |
@field_validator("password")
|
| 20 |
@classmethod
|
|
|
|
| 15 |
password: str
|
| 16 |
full_name: str
|
| 17 |
tenant_name: str # Creates a new tenant for this user
|
| 18 |
+
role: Optional[UserRole] = None # Optional role override (admin, hr, finance, employee)
|
| 19 |
|
| 20 |
@field_validator("password")
|
| 21 |
@classmethod
|
backend/app/services/auth_service.py
CHANGED
|
@@ -64,7 +64,7 @@ async def register_user(
|
|
| 64 |
email=data.email,
|
| 65 |
hashed_password=hash_password(data.password),
|
| 66 |
full_name=data.full_name,
|
| 67 |
-
role=UserRole.ADMIN.value,
|
| 68 |
tenant_id=tenant.id,
|
| 69 |
is_active=True,
|
| 70 |
is_verified=True,
|
|
|
|
| 64 |
email=data.email,
|
| 65 |
hashed_password=hash_password(data.password),
|
| 66 |
full_name=data.full_name,
|
| 67 |
+
role=data.role.value if data.role else UserRole.ADMIN.value,
|
| 68 |
tenant_id=tenant.id,
|
| 69 |
is_active=True,
|
| 70 |
is_verified=True,
|
frontend/src/components/layout/AppLayout.tsx
CHANGED
|
@@ -9,9 +9,9 @@ export default function AppLayout() {
|
|
| 9 |
<Sidebar />
|
| 10 |
|
| 11 |
{/* Main content */}
|
| 12 |
-
<div className="flex flex-col flex-1 min-w-0">
|
| 13 |
<Header />
|
| 14 |
-
<main className="flex-1 overflow-
|
| 15 |
<Outlet />
|
| 16 |
</main>
|
| 17 |
</div>
|
|
|
|
| 9 |
<Sidebar />
|
| 10 |
|
| 11 |
{/* Main content */}
|
| 12 |
+
<div className="flex flex-col flex-1 min-w-0 h-full overflow-hidden">
|
| 13 |
<Header />
|
| 14 |
+
<main className="flex-1 overflow-y-auto min-h-0">
|
| 15 |
<Outlet />
|
| 16 |
</main>
|
| 17 |
</div>
|
frontend/src/pages/AdminPage.tsx
CHANGED
|
@@ -1,5 +1,5 @@
|
|
| 1 |
import { useState, useEffect } from 'react';
|
| 2 |
-
import { Shield, Users, FileText, UserPlus, RefreshCw, Lock, CheckCircle, XCircle } from 'lucide-react';
|
| 3 |
import { adminApi } from '@/services/api';
|
| 4 |
import { clsx } from 'clsx';
|
| 5 |
import toast from 'react-hot-toast';
|
|
@@ -15,7 +15,7 @@ interface UserRecord {
|
|
| 15 |
|
| 16 |
interface AuditRecord {
|
| 17 |
id: string;
|
| 18 |
-
user_id:
|
| 19 |
action: string;
|
| 20 |
agent_name?: string;
|
| 21 |
outcome: string;
|
|
@@ -33,10 +33,12 @@ export default function AdminPage() {
|
|
| 33 |
|
| 34 |
// Invite modal state
|
| 35 |
const [showInviteModal, setShowInviteModal] = useState(false);
|
|
|
|
| 36 |
const [inviteForm, setInviteForm] = useState({
|
| 37 |
email: '',
|
| 38 |
full_name: '',
|
| 39 |
role: 'employee',
|
|
|
|
| 40 |
});
|
| 41 |
|
| 42 |
const loadAdminData = async () => {
|
|
@@ -67,15 +69,29 @@ export default function AdminPage() {
|
|
| 67 |
e.preventDefault();
|
| 68 |
try {
|
| 69 |
await adminApi.inviteUser(inviteForm);
|
| 70 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 71 |
setShowInviteModal(false);
|
| 72 |
-
setInviteForm({ email: '', full_name: '', role: 'employee' });
|
| 73 |
loadAdminData();
|
| 74 |
-
} catch {
|
| 75 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 76 |
}
|
| 77 |
};
|
| 78 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 79 |
return (
|
| 80 |
<div className="p-6 lg:p-8 space-y-6 max-w-6xl mx-auto animate-fade-in">
|
| 81 |
{/* Header */}
|
|
@@ -128,7 +144,10 @@ export default function AdminPage() {
|
|
| 128 |
<div className="flex justify-between items-center">
|
| 129 |
<h3 className="text-white font-medium text-sm">Active Workspace Users ({users.length})</h3>
|
| 130 |
<button
|
| 131 |
-
onClick={() =>
|
|
|
|
|
|
|
|
|
|
| 132 |
className="flex items-center gap-2 px-3 py-2 rounded-lg bg-primary-600 hover:bg-primary-500 text-white text-xs font-semibold"
|
| 133 |
>
|
| 134 |
<UserPlus size={14} />
|
|
@@ -153,7 +172,13 @@ export default function AdminPage() {
|
|
| 153 |
<div className="text-xs text-dark-400">{u.email}</div>
|
| 154 |
</td>
|
| 155 |
<td className="p-3">
|
| 156 |
-
<span className=
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 157 |
{u.role}
|
| 158 |
</span>
|
| 159 |
</td>
|
|
@@ -237,7 +262,10 @@ export default function AdminPage() {
|
|
| 237 |
{showInviteModal && (
|
| 238 |
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm flex items-center justify-center p-4 z-50">
|
| 239 |
<form onSubmit={handleInvite} className="glass-card w-full max-w-md p-6 space-y-4">
|
| 240 |
-
<h3 className="text-white font-bold text-lg
|
|
|
|
|
|
|
|
|
|
| 241 |
<div>
|
| 242 |
<label className="text-xs text-dark-300">Full Name</label>
|
| 243 |
<input
|
|
@@ -245,6 +273,7 @@ export default function AdminPage() {
|
|
| 245 |
required
|
| 246 |
value={inviteForm.full_name}
|
| 247 |
onChange={(e) => setInviteForm({ ...inviteForm, full_name: e.target.value })}
|
|
|
|
| 248 |
className="w-full px-3 py-2 bg-dark-800 border border-dark-600 rounded text-sm text-white mt-1"
|
| 249 |
/>
|
| 250 |
</div>
|
|
@@ -255,27 +284,42 @@ export default function AdminPage() {
|
|
| 255 |
required
|
| 256 |
value={inviteForm.email}
|
| 257 |
onChange={(e) => setInviteForm({ ...inviteForm, email: e.target.value })}
|
|
|
|
| 258 |
className="w-full px-3 py-2 bg-dark-800 border border-dark-600 rounded text-sm text-white mt-1"
|
| 259 |
/>
|
| 260 |
</div>
|
| 261 |
<div>
|
| 262 |
-
<label className="text-xs text-dark-300">Role</label>
|
| 263 |
<select
|
| 264 |
value={inviteForm.role}
|
| 265 |
onChange={(e) => setInviteForm({ ...inviteForm, role: e.target.value })}
|
| 266 |
className="w-full px-3 py-2 bg-dark-800 border border-dark-600 rounded text-sm text-white mt-1"
|
| 267 |
>
|
| 268 |
-
<option value="employee">Employee</option>
|
| 269 |
-
<option value="hr">HR</option>
|
| 270 |
-
<option value="finance">Finance</option>
|
| 271 |
-
<option value="admin">Admin</option>
|
| 272 |
</select>
|
| 273 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 274 |
<div className="flex justify-end gap-3 mt-4">
|
| 275 |
<button
|
| 276 |
type="button"
|
| 277 |
onClick={() => setShowInviteModal(false)}
|
| 278 |
-
className="px-4 py-2 rounded bg-dark-700 text-dark-300 text-xs font-semibold"
|
| 279 |
>
|
| 280 |
Cancel
|
| 281 |
</button>
|
|
@@ -283,12 +327,49 @@ export default function AdminPage() {
|
|
| 283 |
type="submit"
|
| 284 |
className="px-4 py-2 rounded bg-primary-600 hover:bg-primary-500 text-white text-xs font-semibold"
|
| 285 |
>
|
| 286 |
-
|
| 287 |
</button>
|
| 288 |
</div>
|
| 289 |
</form>
|
| 290 |
</div>
|
| 291 |
)}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 292 |
</div>
|
| 293 |
);
|
| 294 |
}
|
|
|
|
| 1 |
import { useState, useEffect } from 'react';
|
| 2 |
+
import { Shield, Users, FileText, UserPlus, RefreshCw, Lock, CheckCircle, XCircle, Copy, Key } from 'lucide-react';
|
| 3 |
import { adminApi } from '@/services/api';
|
| 4 |
import { clsx } from 'clsx';
|
| 5 |
import toast from 'react-hot-toast';
|
|
|
|
| 15 |
|
| 16 |
interface AuditRecord {
|
| 17 |
id: string;
|
| 18 |
+
user_id: string;
|
| 19 |
action: string;
|
| 20 |
agent_name?: string;
|
| 21 |
outcome: string;
|
|
|
|
| 33 |
|
| 34 |
// Invite modal state
|
| 35 |
const [showInviteModal, setShowInviteModal] = useState(false);
|
| 36 |
+
const [inviteSuccess, setInviteSuccess] = useState<{ email: string; password: string; role: string } | null>(null);
|
| 37 |
const [inviteForm, setInviteForm] = useState({
|
| 38 |
email: '',
|
| 39 |
full_name: '',
|
| 40 |
role: 'employee',
|
| 41 |
+
password: 'Welcome@123!',
|
| 42 |
});
|
| 43 |
|
| 44 |
const loadAdminData = async () => {
|
|
|
|
| 69 |
e.preventDefault();
|
| 70 |
try {
|
| 71 |
await adminApi.inviteUser(inviteForm);
|
| 72 |
+
setInviteSuccess({
|
| 73 |
+
email: inviteForm.email,
|
| 74 |
+
password: inviteForm.password,
|
| 75 |
+
role: inviteForm.role,
|
| 76 |
+
});
|
| 77 |
setShowInviteModal(false);
|
|
|
|
| 78 |
loadAdminData();
|
| 79 |
+
} catch (err: any) {
|
| 80 |
+
let msg = 'Failed to invite user.';
|
| 81 |
+
if (err?.response?.data?.detail?.message) {
|
| 82 |
+
msg = err.response.data.detail.message;
|
| 83 |
+
}
|
| 84 |
+
toast.error(msg);
|
| 85 |
}
|
| 86 |
};
|
| 87 |
|
| 88 |
+
const copyCredentials = () => {
|
| 89 |
+
if (!inviteSuccess) return;
|
| 90 |
+
const text = `Login Credentials for Enterprise AI Copilot:\nURL: http://localhost:3000/login\nEmail: ${inviteSuccess.email}\nPassword: ${inviteSuccess.password}\nRole: ${inviteSuccess.role}`;
|
| 91 |
+
navigator.clipboard.writeText(text);
|
| 92 |
+
toast.success('Credentials copied to clipboard!');
|
| 93 |
+
};
|
| 94 |
+
|
| 95 |
return (
|
| 96 |
<div className="p-6 lg:p-8 space-y-6 max-w-6xl mx-auto animate-fade-in">
|
| 97 |
{/* Header */}
|
|
|
|
| 144 |
<div className="flex justify-between items-center">
|
| 145 |
<h3 className="text-white font-medium text-sm">Active Workspace Users ({users.length})</h3>
|
| 146 |
<button
|
| 147 |
+
onClick={() => {
|
| 148 |
+
setInviteForm({ email: '', full_name: '', role: 'employee', password: 'Welcome@123!' });
|
| 149 |
+
setShowInviteModal(true);
|
| 150 |
+
}}
|
| 151 |
className="flex items-center gap-2 px-3 py-2 rounded-lg bg-primary-600 hover:bg-primary-500 text-white text-xs font-semibold"
|
| 152 |
>
|
| 153 |
<UserPlus size={14} />
|
|
|
|
| 172 |
<div className="text-xs text-dark-400">{u.email}</div>
|
| 173 |
</td>
|
| 174 |
<td className="p-3">
|
| 175 |
+
<span className={clsx(
|
| 176 |
+
'text-xs px-2 py-0.5 rounded font-semibold uppercase',
|
| 177 |
+
u.role === 'admin' && 'bg-primary-500/20 text-primary-300 border border-primary-500/30',
|
| 178 |
+
u.role === 'hr' && 'bg-pink-500/20 text-pink-300 border border-pink-500/30',
|
| 179 |
+
u.role === 'finance' && 'bg-amber-500/20 text-amber-300 border border-amber-500/30',
|
| 180 |
+
u.role === 'employee' && 'bg-dark-700 text-dark-300'
|
| 181 |
+
)}>
|
| 182 |
{u.role}
|
| 183 |
</span>
|
| 184 |
</td>
|
|
|
|
| 262 |
{showInviteModal && (
|
| 263 |
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm flex items-center justify-center p-4 z-50">
|
| 264 |
<form onSubmit={handleInvite} className="glass-card w-full max-w-md p-6 space-y-4">
|
| 265 |
+
<h3 className="text-white font-bold text-lg flex items-center gap-2">
|
| 266 |
+
<UserPlus size={18} className="text-primary-400" />
|
| 267 |
+
Invite New Team Member
|
| 268 |
+
</h3>
|
| 269 |
<div>
|
| 270 |
<label className="text-xs text-dark-300">Full Name</label>
|
| 271 |
<input
|
|
|
|
| 273 |
required
|
| 274 |
value={inviteForm.full_name}
|
| 275 |
onChange={(e) => setInviteForm({ ...inviteForm, full_name: e.target.value })}
|
| 276 |
+
placeholder="Jane Doe"
|
| 277 |
className="w-full px-3 py-2 bg-dark-800 border border-dark-600 rounded text-sm text-white mt-1"
|
| 278 |
/>
|
| 279 |
</div>
|
|
|
|
| 284 |
required
|
| 285 |
value={inviteForm.email}
|
| 286 |
onChange={(e) => setInviteForm({ ...inviteForm, email: e.target.value })}
|
| 287 |
+
placeholder="jane@company.com"
|
| 288 |
className="w-full px-3 py-2 bg-dark-800 border border-dark-600 rounded text-sm text-white mt-1"
|
| 289 |
/>
|
| 290 |
</div>
|
| 291 |
<div>
|
| 292 |
+
<label className="text-xs text-dark-300">Assigned Role</label>
|
| 293 |
<select
|
| 294 |
value={inviteForm.role}
|
| 295 |
onChange={(e) => setInviteForm({ ...inviteForm, role: e.target.value })}
|
| 296 |
className="w-full px-3 py-2 bg-dark-800 border border-dark-600 rounded text-sm text-white mt-1"
|
| 297 |
>
|
| 298 |
+
<option value="employee">π€ Employee (General Knowledge)</option>
|
| 299 |
+
<option value="hr">π HR Specialist (Policies & Handbooks)</option>
|
| 300 |
+
<option value="finance">π° Finance Specialist (Financials & Sales)</option>
|
| 301 |
+
<option value="admin">π Admin (Full Workspace Control)</option>
|
| 302 |
</select>
|
| 303 |
</div>
|
| 304 |
+
<div>
|
| 305 |
+
<label className="text-xs text-dark-300 flex items-center justify-between">
|
| 306 |
+
<span>Temporary Password</span>
|
| 307 |
+
<span className="text-dark-500 font-normal text-xs">(Provide to employee to log in)</span>
|
| 308 |
+
</label>
|
| 309 |
+
<input
|
| 310 |
+
type="text"
|
| 311 |
+
required
|
| 312 |
+
minLength={8}
|
| 313 |
+
value={inviteForm.password}
|
| 314 |
+
onChange={(e) => setInviteForm({ ...inviteForm, password: e.target.value })}
|
| 315 |
+
className="w-full px-3 py-2 bg-dark-800 border border-dark-600 rounded text-sm text-white mt-1 font-mono"
|
| 316 |
+
/>
|
| 317 |
+
</div>
|
| 318 |
<div className="flex justify-end gap-3 mt-4">
|
| 319 |
<button
|
| 320 |
type="button"
|
| 321 |
onClick={() => setShowInviteModal(false)}
|
| 322 |
+
className="px-4 py-2 rounded bg-dark-700 text-dark-300 text-xs font-semibold hover:bg-dark-600"
|
| 323 |
>
|
| 324 |
Cancel
|
| 325 |
</button>
|
|
|
|
| 327 |
type="submit"
|
| 328 |
className="px-4 py-2 rounded bg-primary-600 hover:bg-primary-500 text-white text-xs font-semibold"
|
| 329 |
>
|
| 330 |
+
Create Account & Invite
|
| 331 |
</button>
|
| 332 |
</div>
|
| 333 |
</form>
|
| 334 |
</div>
|
| 335 |
)}
|
| 336 |
+
|
| 337 |
+
{/* Invite Success Confirmation Modal */}
|
| 338 |
+
{inviteSuccess && (
|
| 339 |
+
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm flex items-center justify-center p-4 z-50">
|
| 340 |
+
<div className="glass-card w-full max-w-md p-6 space-y-4 text-center">
|
| 341 |
+
<div className="w-12 h-12 rounded-full bg-green-500/20 border border-green-500/30 flex items-center justify-center mx-auto text-green-400">
|
| 342 |
+
<CheckCircle size={24} />
|
| 343 |
+
</div>
|
| 344 |
+
<h3 className="text-white font-bold text-lg">User Account Created!</h3>
|
| 345 |
+
<p className="text-dark-300 text-sm">
|
| 346 |
+
The employee account has been added to your workspace. Provide them with these credentials to log in:
|
| 347 |
+
</p>
|
| 348 |
+
|
| 349 |
+
<div className="bg-dark-800 p-4 rounded-lg border border-dark-600 text-left space-y-2 text-sm font-mono">
|
| 350 |
+
<div><span className="text-dark-400">Email:</span> <span className="text-white">{inviteSuccess.email}</span></div>
|
| 351 |
+
<div><span className="text-dark-400">Password:</span> <span className="text-primary-300">{inviteSuccess.password}</span></div>
|
| 352 |
+
<div><span className="text-dark-400">Role:</span> <span className="text-white capitalize">{inviteSuccess.role}</span></div>
|
| 353 |
+
<div><span className="text-dark-400">Login URL:</span> <span className="text-dark-300">http://localhost:3000/login</span></div>
|
| 354 |
+
</div>
|
| 355 |
+
|
| 356 |
+
<div className="flex gap-3 mt-4">
|
| 357 |
+
<button
|
| 358 |
+
onClick={copyCredentials}
|
| 359 |
+
className="flex-1 flex items-center justify-center gap-2 py-2 rounded bg-dark-700 hover:bg-dark-600 text-white text-xs font-semibold"
|
| 360 |
+
>
|
| 361 |
+
<Copy size={14} /> Copy Details
|
| 362 |
+
</button>
|
| 363 |
+
<button
|
| 364 |
+
onClick={() => setInviteSuccess(null)}
|
| 365 |
+
className="flex-1 py-2 rounded bg-primary-600 hover:bg-primary-500 text-white text-xs font-semibold"
|
| 366 |
+
>
|
| 367 |
+
Done
|
| 368 |
+
</button>
|
| 369 |
+
</div>
|
| 370 |
+
</div>
|
| 371 |
+
</div>
|
| 372 |
+
)}
|
| 373 |
</div>
|
| 374 |
);
|
| 375 |
}
|
frontend/src/pages/DocumentsPage.tsx
CHANGED
|
@@ -5,6 +5,7 @@ import {
|
|
| 5 |
Clock, RefreshCw, ChevronDown, AlertCircle, FileUp, MessageSquare,
|
| 6 |
} from 'lucide-react';
|
| 7 |
import { documentsApi } from '@/services/api';
|
|
|
|
| 8 |
import { clsx } from 'clsx';
|
| 9 |
import toast from 'react-hot-toast';
|
| 10 |
|
|
@@ -70,6 +71,30 @@ function UploadZone({
|
|
| 70 |
e.target.value = '';
|
| 71 |
};
|
| 72 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 73 |
return (
|
| 74 |
<div className="space-y-4">
|
| 75 |
{/* Doc type selector */}
|
|
@@ -84,14 +109,17 @@ function UploadZone({
|
|
| 84 |
}}
|
| 85 |
className="appearance-none pl-3 pr-8 py-1.5 rounded-lg bg-dark-800 border border-dark-600 text-sm text-white focus:outline-none focus:ring-2 focus:ring-primary-500/30 cursor-pointer"
|
| 86 |
>
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
<option value="legal">Legal</option>
|
| 91 |
-
<option value="technical">Technical</option>
|
| 92 |
</select>
|
| 93 |
<ChevronDown size={14} className="absolute right-2 top-1/2 -translate-y-1/2 text-dark-400 pointer-events-none" />
|
| 94 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 95 |
</div>
|
| 96 |
|
| 97 |
{/* Drop zone */}
|
|
|
|
| 5 |
Clock, RefreshCw, ChevronDown, AlertCircle, FileUp, MessageSquare,
|
| 6 |
} from 'lucide-react';
|
| 7 |
import { documentsApi } from '@/services/api';
|
| 8 |
+
import { useAuthStore } from '@/store/authStore';
|
| 9 |
import { clsx } from 'clsx';
|
| 10 |
import toast from 'react-hot-toast';
|
| 11 |
|
|
|
|
| 71 |
e.target.value = '';
|
| 72 |
};
|
| 73 |
|
| 74 |
+
const userRole = useAuthStore.getState().user?.role || 'employee';
|
| 75 |
+
|
| 76 |
+
const categoryOptions = (() => {
|
| 77 |
+
if (userRole === 'admin') return [
|
| 78 |
+
{ value: 'general', label: 'General' },
|
| 79 |
+
{ value: 'hr', label: 'HR / Policy' },
|
| 80 |
+
{ value: 'finance', label: 'Finance' },
|
| 81 |
+
{ value: 'legal', label: 'Legal' },
|
| 82 |
+
{ value: 'technical', label: 'Technical' },
|
| 83 |
+
];
|
| 84 |
+
if (userRole === 'hr') return [
|
| 85 |
+
{ value: 'general', label: 'General' },
|
| 86 |
+
{ value: 'hr', label: 'HR / Policy' },
|
| 87 |
+
];
|
| 88 |
+
if (userRole === 'finance') return [
|
| 89 |
+
{ value: 'general', label: 'General' },
|
| 90 |
+
{ value: 'finance', label: 'Finance' },
|
| 91 |
+
];
|
| 92 |
+
return [
|
| 93 |
+
{ value: 'general', label: 'General' },
|
| 94 |
+
{ value: 'technical', label: 'Technical' },
|
| 95 |
+
];
|
| 96 |
+
})();
|
| 97 |
+
|
| 98 |
return (
|
| 99 |
<div className="space-y-4">
|
| 100 |
{/* Doc type selector */}
|
|
|
|
| 109 |
}}
|
| 110 |
className="appearance-none pl-3 pr-8 py-1.5 rounded-lg bg-dark-800 border border-dark-600 text-sm text-white focus:outline-none focus:ring-2 focus:ring-primary-500/30 cursor-pointer"
|
| 111 |
>
|
| 112 |
+
{categoryOptions.map((opt) => (
|
| 113 |
+
<option key={opt.value} value={opt.value}>{opt.label}</option>
|
| 114 |
+
))}
|
|
|
|
|
|
|
| 115 |
</select>
|
| 116 |
<ChevronDown size={14} className="absolute right-2 top-1/2 -translate-y-1/2 text-dark-400 pointer-events-none" />
|
| 117 |
</div>
|
| 118 |
+
{userRole === 'employee' && (
|
| 119 |
+
<span className="text-xs text-amber-400 bg-amber-400/10 border border-amber-400/20 px-2 py-0.5 rounded">
|
| 120 |
+
π Employee: Restricted to General/Tech docs (Admin approval required for HR/Finance)
|
| 121 |
+
</span>
|
| 122 |
+
)}
|
| 123 |
</div>
|
| 124 |
|
| 125 |
{/* Drop zone */}
|
frontend/src/pages/RegisterPage.tsx
CHANGED
|
@@ -1,6 +1,6 @@
|
|
| 1 |
import { useState, FormEvent } from 'react';
|
| 2 |
import { Link } from 'react-router-dom';
|
| 3 |
-
import { Bot, Eye, EyeOff } from 'lucide-react';
|
| 4 |
import { useAuthStore } from '@/store/authStore';
|
| 5 |
import toast from 'react-hot-toast';
|
| 6 |
|
|
@@ -12,9 +12,10 @@ export default function RegisterPage() {
|
|
| 12 |
email: '',
|
| 13 |
password: '',
|
| 14 |
tenant_name: '',
|
|
|
|
| 15 |
});
|
| 16 |
|
| 17 |
-
const update = (key: keyof typeof form) => (e: React.ChangeEvent<HTMLInputElement>) => {
|
| 18 |
clearError();
|
| 19 |
setForm((prev) => ({ ...prev, [key]: e.target.value }));
|
| 20 |
};
|
|
@@ -22,7 +23,7 @@ export default function RegisterPage() {
|
|
| 22 |
const handleSubmit = async (e: FormEvent) => {
|
| 23 |
e.preventDefault();
|
| 24 |
try {
|
| 25 |
-
await register(form);
|
| 26 |
toast.success('Account created! Welcome aboard π');
|
| 27 |
} catch {
|
| 28 |
// error shown in form
|
|
@@ -99,6 +100,27 @@ export default function RegisterPage() {
|
|
| 99 |
/>
|
| 100 |
</div>
|
| 101 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 102 |
{/* Password */}
|
| 103 |
<div className="space-y-1.5">
|
| 104 |
<label className="text-sm font-medium text-dark-200" htmlFor="reg-password">Password</label>
|
|
@@ -137,13 +159,9 @@ export default function RegisterPage() {
|
|
| 137 |
Creating account...
|
| 138 |
</span>
|
| 139 |
) : (
|
| 140 |
-
'Create
|
| 141 |
)}
|
| 142 |
</button>
|
| 143 |
-
|
| 144 |
-
<p className="text-xs text-dark-500 text-center">
|
| 145 |
-
You'll be the Admin of your company workspace.
|
| 146 |
-
</p>
|
| 147 |
</form>
|
| 148 |
</div>
|
| 149 |
</div>
|
|
|
|
| 1 |
import { useState, FormEvent } from 'react';
|
| 2 |
import { Link } from 'react-router-dom';
|
| 3 |
+
import { Bot, Eye, EyeOff, ChevronDown } from 'lucide-react';
|
| 4 |
import { useAuthStore } from '@/store/authStore';
|
| 5 |
import toast from 'react-hot-toast';
|
| 6 |
|
|
|
|
| 12 |
email: '',
|
| 13 |
password: '',
|
| 14 |
tenant_name: '',
|
| 15 |
+
role: 'admin',
|
| 16 |
});
|
| 17 |
|
| 18 |
+
const update = (key: keyof typeof form) => (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
|
| 19 |
clearError();
|
| 20 |
setForm((prev) => ({ ...prev, [key]: e.target.value }));
|
| 21 |
};
|
|
|
|
| 23 |
const handleSubmit = async (e: FormEvent) => {
|
| 24 |
e.preventDefault();
|
| 25 |
try {
|
| 26 |
+
await register(form as any);
|
| 27 |
toast.success('Account created! Welcome aboard π');
|
| 28 |
} catch {
|
| 29 |
// error shown in form
|
|
|
|
| 100 |
/>
|
| 101 |
</div>
|
| 102 |
|
| 103 |
+
{/* User Role Selection */}
|
| 104 |
+
<div className="space-y-1.5">
|
| 105 |
+
<label className="text-sm font-medium text-dark-200" htmlFor="role">
|
| 106 |
+
Account Role
|
| 107 |
+
</label>
|
| 108 |
+
<div className="relative">
|
| 109 |
+
<select
|
| 110 |
+
id="role"
|
| 111 |
+
value={form.role}
|
| 112 |
+
onChange={update('role')}
|
| 113 |
+
className="input-glow w-full px-4 py-2.5 rounded-lg bg-dark-800 border border-dark-600 text-white text-sm appearance-none cursor-pointer"
|
| 114 |
+
>
|
| 115 |
+
<option value="admin">π Admin (Full Access)</option>
|
| 116 |
+
<option value="hr">π HR Specialist (Policies & Handbooks)</option>
|
| 117 |
+
<option value="finance">π° Finance Specialist (Financials & Sales)</option>
|
| 118 |
+
<option value="employee">π€ Standard Employee (General Knowledge)</option>
|
| 119 |
+
</select>
|
| 120 |
+
<ChevronDown size={16} className="absolute right-3 top-1/2 -translate-y-1/2 text-dark-400 pointer-events-none" />
|
| 121 |
+
</div>
|
| 122 |
+
</div>
|
| 123 |
+
|
| 124 |
{/* Password */}
|
| 125 |
<div className="space-y-1.5">
|
| 126 |
<label className="text-sm font-medium text-dark-200" htmlFor="reg-password">Password</label>
|
|
|
|
| 159 |
Creating account...
|
| 160 |
</span>
|
| 161 |
) : (
|
| 162 |
+
'Create account'
|
| 163 |
)}
|
| 164 |
</button>
|
|
|
|
|
|
|
|
|
|
|
|
|
| 165 |
</form>
|
| 166 |
</div>
|
| 167 |
</div>
|