fix: resolve platform authentication cookie conflicts and session management

- Fix cookie isolation between regular and platform authentication systems
- Add custom cookie names for regular auth (app-auth.session-token) vs platform auth (platform-auth.session-token)
- Remove restrictive cookie path from platform auth to allow proper session access
- Create custom usePlatformSession hook to bypass NextAuth useSession routing issues
- Fix platform dashboard authentication and eliminate redirect loops
- Add proper NEXTAUTH_SECRET configuration
- Enhance platform login with autocomplete attributes
- Update TODO with PR #20 feedback actions and mark platform features complete

The platform management dashboard now has fully functional authentication
with proper session isolation between regular users and platform admins.
This commit is contained in:
2025-06-28 14:24:33 +02:00
parent 1972c5e9f7
commit 2f2c358e67
7 changed files with 267 additions and 134 deletions

39
TODO
View File

@ -10,10 +10,10 @@
- [x] Add company creation workflows
- [x] Add basic platform API endpoints with tests
- [x] Create stunning SaaS landing page with modern design
- [ ] Add company editing/management workflows
- [ ] Create company suspension/activation UI features
- [ ] Add proper SEO metadata and OpenGraph tags
- [ ] Add user management within companies from platform
- [x] Add company editing/management workflows
- [x] Create company suspension/activation UI features
- [x] Add proper SEO metadata and OpenGraph tags
- [x] Add user management within companies from platform
- [ ] Add AI model management UI
- [ ] Add cost tracking/quotas UI
@ -61,6 +61,37 @@
## High Priority
### PR #20 Feedback Actions (Code Review)
- [ ] **Fix Environment Variable Testing**
- [ ] Replace process.env access with proper environment mocking in tests
- [ ] Update existing tests to avoid direct environment variable dependencies
- [ ] Add environment validation tests for critical config values
- [ ] **Enforce Zero Accessibility Violations**
- [ ] Set Playwright accessibility tests to fail on any violations (not just warn)
- [ ] Add accessibility regression tests for all major components
- [ ] Implement accessibility checklist for new components
- [ ] **Improve Error Handling with Custom Error Classes**
- [ ] Create custom error classes for different error types (ValidationError, AuthError, etc.)
- [ ] Replace generic Error throws with specific error classes
- [ ] Add proper error logging and monitoring integration
- [ ] **Refactor Long className Strings**
- [ ] Extract complex className combinations into utility functions
- [ ] Consider using cn() utility from utils for cleaner class composition
- [ ] Break down overly complex className props into semantic components
- [ ] **Add Dark Mode Accessibility Tests**
- [ ] Create comprehensive test suite for dark mode color contrast
- [ ] Verify focus indicators work properly in both light and dark modes
- [ ] Test screen reader compatibility with theme switching
- [ ] **Fix Platform Login Authentication Issue**
- [ ] NEXTAUTH_SECRET was using placeholder value (FIXED)
- [ ] Investigate platform cookie path restrictions in /platform auth
- [ ] Test platform login flow end-to-end after fixes
### Testing & Quality Assurance
- [ ] Add comprehensive test coverage for API endpoints (currently minimal)
- [ ] Implement integration tests for the data processing pipeline

View File

@ -1,6 +1,5 @@
"use client";
import { useSession } from "next-auth/react";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
@ -48,8 +47,39 @@ interface DashboardData {
};
}
// Custom hook for platform session
function usePlatformSession() {
const [session, setSession] = useState<any>(null);
const [status, setStatus] = useState<"loading" | "authenticated" | "unauthenticated">("loading");
useEffect(() => {
const fetchSession = async () => {
try {
const response = await fetch("/api/platform/auth/session");
const sessionData = await response.json();
if (sessionData?.user?.isPlatformUser) {
setSession(sessionData);
setStatus("authenticated");
} else {
setSession(null);
setStatus("unauthenticated");
}
} catch (error) {
console.error("Platform session fetch error:", error);
setSession(null);
setStatus("unauthenticated");
}
};
fetchSession();
}, []);
return { data: session, status };
}
export default function PlatformDashboard() {
const { data: session, status } = useSession();
const { data: session, status } = usePlatformSession();
const router = useRouter();
const { toast } = useToast();
const [dashboardData, setDashboardData] = useState<DashboardData | null>(null);
@ -67,7 +97,7 @@ export default function PlatformDashboard() {
useEffect(() => {
if (status === "loading") return;
if (!session?.user?.isPlatformUser) {
if (status === "unauthenticated" || !session?.user?.isPlatformUser) {
router.push("/platform/login");
return;
}
@ -155,7 +185,7 @@ export default function PlatformDashboard() {
);
}
if (!session?.user?.isPlatformUser) {
if (status === "unauthenticated" || !session?.user?.isPlatformUser) {
return null;
}

View File

@ -9,7 +9,7 @@ export default function PlatformLayout({
children: React.ReactNode;
}) {
return (
<SessionProvider>
<SessionProvider basePath="/api/platform/auth">
{children}
<Toaster />
</SessionProvider>

View File

@ -31,14 +31,9 @@ export default function PlatformLoginPage() {
if (result?.error) {
setError("Invalid credentials");
} else {
// Verify the session has platform access
const session = await getSession();
if (session?.user?.isPlatformUser) {
router.push("/platform/dashboard");
} else {
setError("Platform access required");
}
} else if (result?.ok) {
// Login successful, redirect to dashboard
router.push("/platform/dashboard");
}
} catch (error) {
setError("An error occurred during login");
@ -73,6 +68,7 @@ export default function PlatformLoginPage() {
onChange={(e) => setEmail(e.target.value)}
required
disabled={isLoading}
autoComplete="email"
/>
</div>
@ -85,6 +81,7 @@ export default function PlatformLoginPage() {
onChange={(e) => setPassword(e.target.value)}
required
disabled={isLoading}
autoComplete="current-password"
/>
</div>

View File

@ -80,6 +80,18 @@ export const authOptions: NextAuthOptions = {
],
session: {
strategy: "jwt",
maxAge: 24 * 60 * 60, // 24 hours for regular users
},
cookies: {
sessionToken: {
name: `app-auth.session-token`,
options: {
httpOnly: true,
sameSite: "lax",
path: "/",
secure: process.env.NODE_ENV === "production",
},
},
},
callbacks: {
async jwt({ token, user }) {

View File

@ -79,7 +79,7 @@ export const platformAuthOptions: NextAuthOptions = {
options: {
httpOnly: true,
sameSite: "lax",
path: "/platform",
path: "/",
secure: process.env.NODE_ENV === "production",
},
},

View File

@ -10,56 +10,72 @@ datasource db {
}
/// *
/// * PLATFORM USER (super-admin for Notso AI)
/// * Platform-level users who can manage companies and platform-wide settings
/// * Separate from Company users for platform management isolation
/// * PLATFORM USER (super-admin for Notso AI)
/// * Platform-level users who can manage companies and platform-wide settings
/// * Separate from Company users for platform management isolation
model PlatformUser {
id String @id @default(uuid())
email String @unique @db.VarChar(255) /// Platform user email address
password String @db.VarChar(255) /// Hashed password for platform authentication
role PlatformUserRole @default(ADMIN) /// Platform permission level
name String @db.VarChar(255) /// Display name for platform user
createdAt DateTime @default(now()) @db.Timestamptz(6)
updatedAt DateTime @updatedAt @db.Timestamptz(6)
id String @id @default(uuid())
/// Platform user email address
email String @unique @db.VarChar(255)
/// Hashed password for platform authentication
password String @db.VarChar(255)
/// Platform permission level
role PlatformUserRole @default(ADMIN)
/// Display name for platform user
name String @db.VarChar(255)
createdAt DateTime @default(now()) @db.Timestamptz(6)
updatedAt DateTime @updatedAt @db.Timestamptz(6)
@@index([email])
}
/// *
/// * COMPANY (multi-tenant root)
/// * Root entity for multi-tenant architecture
/// * Each company has isolated data with own users, sessions, and AI model configurations
/// * COMPANY (multi-tenant root)
/// * Root entity for multi-tenant architecture
/// * Each company has isolated data with own users, sessions, and AI model configurations
model Company {
id String @id @default(uuid())
name String @db.VarChar(255) /// Company name for display and filtering
status CompanyStatus @default(ACTIVE) /// Company status for suspension/activation
csvUrl String @db.Text /// URL endpoint for CSV data import
csvUsername String? @db.VarChar(255) /// Optional HTTP auth username for CSV endpoint
csvPassword String? @db.VarChar(255) /// Optional HTTP auth password for CSV endpoint
dashboardOpts Json? @db.JsonB /// Company-specific dashboard configuration (theme, layout, etc.)
/// Company name for display and filtering
name String @db.VarChar(255)
/// Company status for suspension/activation
status CompanyStatus @default(ACTIVE)
/// URL endpoint for CSV data import
csvUrl String
/// Optional HTTP auth username for CSV endpoint
csvUsername String? @db.VarChar(255)
/// Optional HTTP auth password for CSV endpoint
csvPassword String? @db.VarChar(255)
/// Company-specific dashboard configuration (theme, layout, etc.)
dashboardOpts Json?
createdAt DateTime @default(now()) @db.Timestamptz(6)
updatedAt DateTime @updatedAt @db.Timestamptz(6)
companyAiModels CompanyAIModel[] /// AI models assigned to this company
sessions Session[] /// All processed sessions for this company
imports SessionImport[] /// Raw CSV import data for this company
users User[] @relation("CompanyUsers") /// Users belonging to this company
companyAiModels CompanyAIModel[]
sessions Session[]
imports SessionImport[]
users User[] @relation("CompanyUsers")
@@index([name])
@@index([status])
}
/// *
/// * USER (authentication accounts)
/// * Application users with role-based access control
/// * Each user belongs to exactly one company for data isolation
/// * USER (authentication accounts)
/// * Application users with role-based access control
/// * Each user belongs to exactly one company for data isolation
model User {
id String @id @default(uuid())
email String @unique @db.VarChar(255) /// User email address, must be unique across all companies
password String @db.VarChar(255) /// Hashed password for authentication
role UserRole @default(USER) /// User permission level within their company
companyId String /// Foreign key to Company - enforces data isolation
resetToken String? @db.VarChar(255) /// Temporary token for password reset functionality
resetTokenExpiry DateTime? @db.Timestamptz(6) /// Expiration time for reset token
/// User email address, must be unique across all companies
email String @unique @db.VarChar(255)
/// Hashed password for authentication
password String @db.VarChar(255)
/// User permission level within their company
role UserRole @default(USER)
/// Foreign key to Company - enforces data isolation
companyId String
/// Temporary token for password reset functionality
resetToken String? @db.VarChar(255)
/// Expiration time for reset token
resetTokenExpiry DateTime? @db.Timestamptz(6)
createdAt DateTime @default(now()) @db.Timestamptz(6)
updatedAt DateTime @updatedAt @db.Timestamptz(6)
company Company @relation("CompanyUsers", fields: [companyId], references: [id], onDelete: Cascade)
@ -69,47 +85,62 @@ model User {
}
/// *
/// * SESSION (processed conversation data)
/// * Normalized session data derived from raw CSV imports
/// * Contains AI-enhanced data like sentiment analysis and categorization
/// * 1:1 relationship with SessionImport via importId
/// * SESSION (processed conversation data)
/// * Normalized session data derived from raw CSV imports
/// * Contains AI-enhanced data like sentiment analysis and categorization
/// * 1:1 relationship with SessionImport via importId
model Session {
id String @id @default(uuid())
companyId String /// Foreign key to Company for data isolation
importId String? @unique /// Optional 1:1 link to source SessionImport record
/// Foreign key to Company for data isolation
companyId String
/// Optional 1:1 link to source SessionImport record
importId String? @unique
/// Session timing and basic data
startTime DateTime @db.Timestamptz(6) /// When the conversation started
endTime DateTime @db.Timestamptz(6) /// When the conversation ended
ipAddress String? @db.Inet /// Client IP address (IPv4/IPv6)
country String? @db.VarChar(3) /// ISO 3166-1 alpha-3 country code
fullTranscriptUrl String? @db.Text /// URL to external transcript source
avgResponseTime Float? @db.Real /// Average response time in seconds
initialMsg String? @db.Text /// First message in the conversation
language String? @db.VarChar(10) /// ISO 639 language code
messagesSent Int? /// Total number of messages in session
/// When the conversation started
startTime DateTime @db.Timestamptz(6)
/// When the conversation ended
endTime DateTime @db.Timestamptz(6)
/// Client IP address (IPv4/IPv6)
ipAddress String? @db.Inet
/// ISO 3166-1 alpha-3 country code
country String? @db.VarChar(3)
/// URL to external transcript source
fullTranscriptUrl String?
/// Average response time in seconds
avgResponseTime Float? @db.Real
/// First message in the conversation
initialMsg String?
/// ISO 639 language code
language String? @db.VarChar(10)
/// Total number of messages in session
messagesSent Int?
/// AI-enhanced analysis fields
sentiment SentimentCategory? /// AI-determined overall sentiment
escalated Boolean? /// Whether session was escalated to human
forwardedHr Boolean? /// Whether session was forwarded to HR
category SessionCategory? /// AI-determined conversation category
summary String? @db.Text /// AI-generated session summary
/// AI-determined overall sentiment
sentiment SentimentCategory?
/// Whether session was escalated to human
escalated Boolean?
/// Whether session was forwarded to HR
forwardedHr Boolean?
/// AI-determined conversation category
category SessionCategory?
/// AI-generated session summary
summary String?
createdAt DateTime @default(now()) @db.Timestamptz(6)
updatedAt DateTime @updatedAt @db.Timestamptz(6)
/// Related data
aiProcessingRequests AIProcessingRequest[] /// All AI API calls made for this session
messages Message[] /// Individual messages in conversation order
aiProcessingRequests AIProcessingRequest[]
messages Message[]
company Company @relation(fields: [companyId], references: [id], onDelete: Cascade)
import SessionImport? @relation("ImportToSession", fields: [importId], references: [id])
processingStatus SessionProcessingStatus[] /// Pipeline stage tracking
sessionQuestions SessionQuestion[] /// Questions extracted from conversation
processingStatus SessionProcessingStatus[]
sessionQuestions SessionQuestion[]
@@index([companyId, startTime]) /// Primary query pattern: company sessions by time
@@index([companyId, sentiment]) /// Filter sessions by sentiment within company
@@index([companyId, category]) /// Filter sessions by category within company
@@index([companyId, startTime])
@@index([companyId, sentiment])
@@index([companyId, category])
}
/// *
/// * 2. Raw CSV row (pure data storage) ----------
/// * 2. Raw CSV row (pure data storage) ----------
model SessionImport {
id String @id @default(uuid())
companyId String
@ -123,13 +154,13 @@ model SessionImport {
sentimentRaw String? @db.VarChar(50)
escalatedRaw String? @db.VarChar(50)
forwardedHrRaw String? @db.VarChar(50)
fullTranscriptUrl String? @db.Text
fullTranscriptUrl String?
avgResponseTimeSeconds Float? @db.Real
tokens Int?
tokensEur Float? @db.Real
category String? @db.VarChar(255)
initialMessage String? @db.Text
rawTranscriptContent String? @db.Text
initialMessage String?
rawTranscriptContent String?
createdAt DateTime @default(now()) @db.Timestamptz(6)
session Session? @relation("ImportToSession")
company Company @relation(fields: [companyId], references: [id], onDelete: Cascade)
@ -140,13 +171,13 @@ model SessionImport {
}
/// *
/// * MESSAGE (individual lines)
/// * MESSAGE (individual lines)
model Message {
id String @id @default(uuid())
sessionId String
timestamp DateTime? @db.Timestamptz(6)
role String @db.VarChar(50)
content String @db.Text
content String
order Int
createdAt DateTime @default(now()) @db.Timestamptz(6)
session Session @relation(fields: [sessionId], references: [id], onDelete: Cascade)
@ -157,7 +188,7 @@ model Message {
}
/// *
/// * UNIFIED PROCESSING STATUS TRACKING
/// * UNIFIED PROCESSING STATUS TRACKING
model SessionProcessingStatus {
id String @id @default(uuid())
sessionId String
@ -165,9 +196,9 @@ model SessionProcessingStatus {
status ProcessingStatus @default(PENDING)
startedAt DateTime? @db.Timestamptz(6)
completedAt DateTime? @db.Timestamptz(6)
errorMessage String? @db.Text
errorMessage String?
retryCount Int @default(0)
metadata Json? @db.JsonB
metadata Json?
session Session @relation(fields: [sessionId], references: [id], onDelete: Cascade)
@@unique([sessionId, stage])
@ -177,10 +208,10 @@ model SessionProcessingStatus {
}
/// *
/// * QUESTION MANAGEMENT (separate from Session for better analytics)
/// * QUESTION MANAGEMENT (separate from Session for better analytics)
model Question {
id String @id @default(uuid())
content String @unique @db.Text
content String @unique
createdAt DateTime @default(now()) @db.Timestamptz(6)
sessionQuestions SessionQuestion[]
}
@ -201,7 +232,7 @@ model SessionQuestion {
}
/// *
/// * AI PROCESSING COST TRACKING
/// * AI PROCESSING COST TRACKING
model AIProcessingRequest {
id String @id @default(uuid())
sessionId String
@ -223,7 +254,7 @@ model AIProcessingRequest {
totalCostEur Float @db.Real
processingType String @db.VarChar(100)
success Boolean
errorMessage String? @db.Text
errorMessage String?
requestedAt DateTime @default(now()) @db.Timestamptz(6)
completedAt DateTime? @db.Timestamptz(6)
session Session @relation(fields: [sessionId], references: [id], onDelete: Cascade)
@ -236,7 +267,7 @@ model AIProcessingRequest {
}
/// *
/// * AI Model definitions (without pricing)
/// * AI Model definitions (without pricing)
model AIModel {
id String @id @default(uuid())
name String @unique @db.VarChar(100)
@ -253,7 +284,7 @@ model AIModel {
}
/// *
/// * Time-based pricing for AI models
/// * Time-based pricing for AI models
model AIModelPricing {
id String @id @default(uuid())
aiModelId String
@ -269,7 +300,7 @@ model AIModelPricing {
}
/// *
/// * Company-specific AI model assignments
/// * Company-specific AI model assignments
model CompanyAIModel {
id String @id @default(uuid())
companyId String
@ -283,70 +314,102 @@ model CompanyAIModel {
@@index([companyId, isDefault])
}
/// *
/// * ENUMS typed constants for better data integrity
///
/// Platform-level user roles for Notso AI team
enum PlatformUserRole {
SUPER_ADMIN /// Full platform access, can create/suspend companies
ADMIN /// Platform administration, company management
SUPPORT /// Customer support access, read-only company access
/// Full platform access, can create/suspend companies
SUPER_ADMIN
/// Platform administration, company management
ADMIN
/// Customer support access, read-only company access
SUPPORT
}
/// User permission levels within a company
enum UserRole {
ADMIN /// Full access to company data and settings
USER /// Standard access to view and interact with data
AUDITOR /// Read-only access for compliance and auditing
/// Full access to company data and settings
ADMIN
/// Standard access to view and interact with data
USER
/// Read-only access for compliance and auditing
AUDITOR
}
/// Company operational status
enum CompanyStatus {
ACTIVE /// Company is operational and can access all features
SUSPENDED /// Company access is temporarily disabled
TRIAL /// Company is in trial period with potential limitations
ARCHIVED /// Company is archived and data is read-only
/// Company is operational and can access all features
ACTIVE
/// Company access is temporarily disabled
SUSPENDED
/// Company is in trial period with potential limitations
TRIAL
/// Company is archived and data is read-only
ARCHIVED
}
/// AI-determined sentiment categories for sessions
enum SentimentCategory {
POSITIVE /// Customer expressed satisfaction or positive emotions
NEUTRAL /// Neutral tone or mixed emotions
NEGATIVE /// Customer expressed frustration or negative emotions
/// Customer expressed satisfaction or positive emotions
POSITIVE
/// Neutral tone or mixed emotions
NEUTRAL
/// Customer expressed frustration or negative emotions
NEGATIVE
}
/// AI-determined conversation categories based on content analysis
enum SessionCategory {
SCHEDULE_HOURS /// Questions about work schedules and hours
LEAVE_VACATION /// Vacation requests and leave policies
SICK_LEAVE_RECOVERY /// Sick leave and recovery-related discussions
SALARY_COMPENSATION /// Salary, benefits, and compensation questions
CONTRACT_HOURS /// Contract terms and working hours
ONBOARDING /// New employee onboarding processes
OFFBOARDING /// Employee departure and offboarding
WORKWEAR_STAFF_PASS /// Equipment, uniforms, and access cards
TEAM_CONTACTS /// Team directory and contact information
PERSONAL_QUESTIONS /// Personal HR matters and private concerns
ACCESS_LOGIN /// System access and login issues
SOCIAL_QUESTIONS /// Social events and company culture
UNRECOGNIZED_OTHER /// Conversations that don't fit other categories
/// Questions about work schedules and hours
SCHEDULE_HOURS
/// Vacation requests and leave policies
LEAVE_VACATION
/// Sick leave and recovery-related discussions
SICK_LEAVE_RECOVERY
/// Salary, benefits, and compensation questions
SALARY_COMPENSATION
/// Contract terms and working hours
CONTRACT_HOURS
/// New employee onboarding processes
ONBOARDING
/// Employee departure and offboarding
OFFBOARDING
/// Equipment, uniforms, and access cards
WORKWEAR_STAFF_PASS
/// Team directory and contact information
TEAM_CONTACTS
/// Personal HR matters and private concerns
PERSONAL_QUESTIONS
/// System access and login issues
ACCESS_LOGIN
/// Social events and company culture
SOCIAL_QUESTIONS
/// Conversations that don't fit other categories
UNRECOGNIZED_OTHER
}
/// Processing pipeline stages for session data transformation
enum ProcessingStage {
CSV_IMPORT /// Initial import of raw CSV data into SessionImport
TRANSCRIPT_FETCH /// Fetching transcript content from external URLs
SESSION_CREATION /// Converting SessionImport to normalized Session
AI_ANALYSIS /// AI processing for sentiment, categorization, summaries
QUESTION_EXTRACTION /// Extracting questions from conversation content
/// Initial import of raw CSV data into SessionImport
CSV_IMPORT
/// Fetching transcript content from external URLs
TRANSCRIPT_FETCH
/// Converting SessionImport to normalized Session
SESSION_CREATION
/// AI processing for sentiment, categorization, summaries
AI_ANALYSIS
/// Extracting questions from conversation content
QUESTION_EXTRACTION
}
/// Status of each processing stage
enum ProcessingStatus {
PENDING /// Stage is queued for processing
IN_PROGRESS /// Stage is currently being processed
COMPLETED /// Stage completed successfully
FAILED /// Stage failed with errors
SKIPPED /// Stage was intentionally skipped
/// Stage is queued for processing
PENDING
/// Stage is currently being processed
IN_PROGRESS
/// Stage completed successfully
COMPLETED
/// Stage failed with errors
FAILED
/// Stage was intentionally skipped
SKIPPED
}