tblaisaacliao commited on
Commit
071f340
·
1 Parent(s): 1b10d43

support admin login page

Browse files
.env.example CHANGED
@@ -19,16 +19,20 @@ CZ_OPENAI_API_KEY=your-openai-api-key
19
  MODEL_NAME=gpt-5
20
 
21
  # ------------------------------------------------------------------------------
22
- # Authentication (OPTIONAL)
23
  # ------------------------------------------------------------------------------
24
  # Shared password for all users (used for Basic Auth)
25
- # Default: cz-2025
26
- BASIC_AUTH_PASSWORD=cz-2025
 
 
 
 
 
27
 
28
  # Default password shown in the UI registration form
29
  # Should match BASIC_AUTH_PASSWORD for convenience
30
- # Default: cz-2025
31
- NEXT_PUBLIC_DEFAULT_PASSWORD=cz-2025
32
 
33
  # ------------------------------------------------------------------------------
34
  # Database Configuration
 
19
  MODEL_NAME=gpt-5
20
 
21
  # ------------------------------------------------------------------------------
22
+ # Authentication (REQUIRED)
23
  # ------------------------------------------------------------------------------
24
  # Shared password for all users (used for Basic Auth)
25
+ # REQUIRED - no default value
26
+ BASIC_AUTH_PASSWORD=
27
+
28
+ # Admin password for admin panel access
29
+ # Users who login with this password get admin-level access
30
+ # REQUIRED for admin access - no default value
31
+ ADMIN_PASSWORD=
32
 
33
  # Default password shown in the UI registration form
34
  # Should match BASIC_AUTH_PASSWORD for convenience
35
+ NEXT_PUBLIC_DEFAULT_PASSWORD=
 
36
 
37
  # ------------------------------------------------------------------------------
38
  # Database Configuration
src/app/admin/conversations/[conversationId]/page.tsx CHANGED
@@ -3,6 +3,7 @@
3
  import { useState, useEffect } from 'react';
4
  import Link from 'next/link';
5
  import { useParams } from 'next/navigation';
 
6
 
7
  interface Conversation {
8
  id: string;
@@ -47,7 +48,17 @@ export default function AdminConversationDetailPage() {
47
  const fetchConversationDetail = async () => {
48
  try {
49
  setLoading(true);
50
- const response = await fetch(`/api/admin/conversations/${conversationId}`);
 
 
 
 
 
 
 
 
 
 
51
 
52
  if (!response.ok) {
53
  if (response.status === 404) {
 
3
  import { useState, useEffect } from 'react';
4
  import Link from 'next/link';
5
  import { useParams } from 'next/navigation';
6
+ import { getStoredCredentials, createBasicAuthHeader } from '@/lib/auth/session';
7
 
8
  interface Conversation {
9
  id: string;
 
48
  const fetchConversationDetail = async () => {
49
  try {
50
  setLoading(true);
51
+
52
+ const credentials = getStoredCredentials();
53
+ if (!credentials) {
54
+ throw new Error('Not authenticated');
55
+ }
56
+
57
+ const headers = {
58
+ Authorization: createBasicAuthHeader(credentials.username, credentials.password),
59
+ };
60
+
61
+ const response = await fetch(`/api/admin/conversations/${conversationId}`, { headers });
62
 
63
  if (!response.ok) {
64
  if (response.status === 404) {
src/app/admin/conversations/page.tsx CHANGED
@@ -2,6 +2,7 @@
2
 
3
  import { useState, useEffect } from 'react';
4
  import Link from 'next/link';
 
5
 
6
  interface Conversation {
7
  id: string;
@@ -43,6 +44,16 @@ export default function AdminConversationsPage() {
43
  const fetchConversations = async () => {
44
  try {
45
  setLoading(true);
 
 
 
 
 
 
 
 
 
 
46
  const params = new URLSearchParams({
47
  page: page.toString(),
48
  limit: '50',
@@ -53,7 +64,7 @@ export default function AdminConversationsPage() {
53
  ...(coachPromptFilter && { coachPromptId: coachPromptFilter }),
54
  });
55
 
56
- const response = await fetch(`/api/admin/conversations?${params}`);
57
  if (!response.ok) {
58
  throw new Error('Failed to fetch conversations');
59
  }
 
2
 
3
  import { useState, useEffect } from 'react';
4
  import Link from 'next/link';
5
+ import { getStoredCredentials, createBasicAuthHeader } from '@/lib/auth/session';
6
 
7
  interface Conversation {
8
  id: string;
 
44
  const fetchConversations = async () => {
45
  try {
46
  setLoading(true);
47
+
48
+ const credentials = getStoredCredentials();
49
+ if (!credentials) {
50
+ throw new Error('Not authenticated');
51
+ }
52
+
53
+ const headers = {
54
+ Authorization: createBasicAuthHeader(credentials.username, credentials.password),
55
+ };
56
+
57
  const params = new URLSearchParams({
58
  page: page.toString(),
59
  limit: '50',
 
64
  ...(coachPromptFilter && { coachPromptId: coachPromptFilter }),
65
  });
66
 
67
+ const response = await fetch(`/api/admin/conversations?${params}`, { headers });
68
  if (!response.ok) {
69
  throw new Error('Failed to fetch conversations');
70
  }
src/app/admin/layout.tsx CHANGED
@@ -1,12 +1,54 @@
1
  'use client';
2
 
3
- import { useState } from 'react';
4
  import Link from 'next/link';
5
- import { usePathname } from 'next/navigation';
 
6
 
7
  export default function AdminLayout({ children }: { children: React.ReactNode }) {
8
  const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
 
 
9
  const pathname = usePathname();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
10
 
11
  const navigation = [
12
  { name: '📊 Dashboard', href: '/admin', icon: '📊' },
@@ -22,6 +64,23 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
22
  return pathname?.startsWith(href);
23
  };
24
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
25
  return (
26
  <div className="min-h-screen bg-gray-50">
27
  {/* Mobile Header */}
 
1
  'use client';
2
 
3
+ import { useState, useEffect } from 'react';
4
  import Link from 'next/link';
5
+ import { usePathname, useRouter } from 'next/navigation';
6
+ import { getStoredCredentials, createBasicAuthHeader } from '@/lib/auth/session';
7
 
8
  export default function AdminLayout({ children }: { children: React.ReactNode }) {
9
  const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
10
+ const [isLoading, setIsLoading] = useState(true);
11
+ const [isAuthorized, setIsAuthorized] = useState(false);
12
  const pathname = usePathname();
13
+ const router = useRouter();
14
+
15
+ useEffect(() => {
16
+ async function checkAdminAccess() {
17
+ const credentials = getStoredCredentials();
18
+ if (!credentials) {
19
+ router.replace('/');
20
+ return;
21
+ }
22
+
23
+ try {
24
+ const response = await fetch('/api/auth/me', {
25
+ headers: {
26
+ Authorization: createBasicAuthHeader(credentials.username, credentials.password),
27
+ },
28
+ });
29
+
30
+ if (!response.ok) {
31
+ router.replace('/dashboard');
32
+ return;
33
+ }
34
+
35
+ const data = await response.json();
36
+ if (data.sessionRole !== 'admin') {
37
+ router.replace('/dashboard');
38
+ return;
39
+ }
40
+
41
+ setIsAuthorized(true);
42
+ } catch (error) {
43
+ console.error('[Admin] Auth check failed:', error);
44
+ router.replace('/dashboard');
45
+ } finally {
46
+ setIsLoading(false);
47
+ }
48
+ }
49
+
50
+ checkAdminAccess();
51
+ }, [router]);
52
 
53
  const navigation = [
54
  { name: '📊 Dashboard', href: '/admin', icon: '📊' },
 
64
  return pathname?.startsWith(href);
65
  };
66
 
67
+ // Show loading state while checking auth
68
+ if (isLoading) {
69
+ return (
70
+ <div className="min-h-screen bg-gray-50 flex items-center justify-center">
71
+ <div className="text-center">
72
+ <div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto"></div>
73
+ <p className="mt-4 text-gray-600">Checking access...</p>
74
+ </div>
75
+ </div>
76
+ );
77
+ }
78
+
79
+ // Don't render content if not authorized (will redirect)
80
+ if (!isAuthorized) {
81
+ return null;
82
+ }
83
+
84
  return (
85
  <div className="min-h-screen bg-gray-50">
86
  {/* Mobile Header */}
src/app/admin/page.tsx CHANGED
@@ -2,6 +2,7 @@
2
 
3
  import { useState, useEffect } from 'react';
4
  import Link from 'next/link';
 
5
 
6
  interface Stats {
7
  users: {
@@ -44,10 +45,19 @@ export default function AdminDashboardPage() {
44
  try {
45
  setLoading(true);
46
 
 
 
 
 
 
 
 
 
 
47
  // Fetch stats and health in parallel
48
  const [statsRes, healthRes] = await Promise.all([
49
- fetch('/api/admin/stats'),
50
- fetch('/api/admin/health'),
51
  ]);
52
 
53
  if (!statsRes.ok || !healthRes.ok) {
 
2
 
3
  import { useState, useEffect } from 'react';
4
  import Link from 'next/link';
5
+ import { getStoredCredentials, createBasicAuthHeader } from '@/lib/auth/session';
6
 
7
  interface Stats {
8
  users: {
 
45
  try {
46
  setLoading(true);
47
 
48
+ const credentials = getStoredCredentials();
49
+ if (!credentials) {
50
+ throw new Error('Not authenticated');
51
+ }
52
+
53
+ const headers = {
54
+ Authorization: createBasicAuthHeader(credentials.username, credentials.password),
55
+ };
56
+
57
  // Fetch stats and health in parallel
58
  const [statsRes, healthRes] = await Promise.all([
59
+ fetch('/api/admin/stats', { headers }),
60
+ fetch('/api/admin/health', { headers }),
61
  ]);
62
 
63
  if (!statsRes.ok || !healthRes.ok) {
src/app/admin/users/[userId]/page.tsx CHANGED
@@ -3,6 +3,7 @@
3
  import { useState, useEffect } from 'react';
4
  import Link from 'next/link';
5
  import { useParams } from 'next/navigation';
 
6
 
7
  interface User {
8
  id: string;
@@ -45,7 +46,17 @@ export default function AdminUserDetailPage() {
45
  const fetchUserDetail = async () => {
46
  try {
47
  setLoading(true);
48
- const response = await fetch(`/api/admin/users/${userId}`);
 
 
 
 
 
 
 
 
 
 
49
 
50
  if (!response.ok) {
51
  if (response.status === 404) {
 
3
  import { useState, useEffect } from 'react';
4
  import Link from 'next/link';
5
  import { useParams } from 'next/navigation';
6
+ import { getStoredCredentials, createBasicAuthHeader } from '@/lib/auth/session';
7
 
8
  interface User {
9
  id: string;
 
46
  const fetchUserDetail = async () => {
47
  try {
48
  setLoading(true);
49
+
50
+ const credentials = getStoredCredentials();
51
+ if (!credentials) {
52
+ throw new Error('Not authenticated');
53
+ }
54
+
55
+ const headers = {
56
+ Authorization: createBasicAuthHeader(credentials.username, credentials.password),
57
+ };
58
+
59
+ const response = await fetch(`/api/admin/users/${userId}`, { headers });
60
 
61
  if (!response.ok) {
62
  if (response.status === 404) {
src/app/admin/users/page.tsx CHANGED
@@ -2,6 +2,7 @@
2
 
3
  import { useState, useEffect } from 'react';
4
  import Link from 'next/link';
 
5
 
6
  interface User {
7
  id: string;
@@ -30,6 +31,16 @@ export default function AdminUsersPage() {
30
  const fetchUsers = async () => {
31
  try {
32
  setLoading(true);
 
 
 
 
 
 
 
 
 
 
33
  const params = new URLSearchParams({
34
  page: page.toString(),
35
  limit: '50',
@@ -38,7 +49,7 @@ export default function AdminUsersPage() {
38
  ...(search && { search }),
39
  });
40
 
41
- const response = await fetch(`/api/admin/users?${params}`);
42
  if (!response.ok) {
43
  throw new Error('Failed to fetch users');
44
  }
 
2
 
3
  import { useState, useEffect } from 'react';
4
  import Link from 'next/link';
5
+ import { getStoredCredentials, createBasicAuthHeader } from '@/lib/auth/session';
6
 
7
  interface User {
8
  id: string;
 
31
  const fetchUsers = async () => {
32
  try {
33
  setLoading(true);
34
+
35
+ const credentials = getStoredCredentials();
36
+ if (!credentials) {
37
+ throw new Error('Not authenticated');
38
+ }
39
+
40
+ const headers = {
41
+ Authorization: createBasicAuthHeader(credentials.username, credentials.password),
42
+ };
43
+
44
  const params = new URLSearchParams({
45
  page: page.toString(),
46
  limit: '50',
 
49
  ...(search && { search }),
50
  });
51
 
52
+ const response = await fetch(`/api/admin/users?${params}`, { headers });
53
  if (!response.ok) {
54
  throw new Error('Failed to fetch users');
55
  }
src/app/api/admin/conversations/[conversationId]/export/tsv/route.ts CHANGED
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from 'next/server';
2
  import { ConversationRepository } from '@/lib/repositories/conversation-repository';
3
  import { MessageRepository } from '@/lib/repositories/message-repository';
4
  import { getDatabase } from '@/lib/db';
 
5
 
6
  /**
7
  * Sanitizes a filename by removing or replacing invalid characters
@@ -83,12 +84,14 @@ function formatMessageWithSpeaker(
83
  * GET /api/admin/conversations/:conversationId/export/tsv
84
  *
85
  * Export a single conversation's messages as a TSV file
 
86
  */
87
  export async function GET(
88
  request: NextRequest,
89
  { params }: { params: Promise<{ conversationId: string }> }
90
  ) {
91
  try {
 
92
  const { conversationId } = await params;
93
 
94
  // Fetch conversation and messages - use view to get names
@@ -181,6 +184,14 @@ export async function GET(
181
  },
182
  });
183
  } catch (error) {
 
 
 
 
 
 
 
 
184
  console.error('Error exporting conversation to TSV:', error);
185
  return NextResponse.json(
186
  { error: 'Failed to export conversation' },
 
2
  import { ConversationRepository } from '@/lib/repositories/conversation-repository';
3
  import { MessageRepository } from '@/lib/repositories/message-repository';
4
  import { getDatabase } from '@/lib/db';
5
+ import { requireAdminAuth, createUnauthorizedResponse, createForbiddenResponse } from '@/lib/auth';
6
 
7
  /**
8
  * Sanitizes a filename by removing or replacing invalid characters
 
84
  * GET /api/admin/conversations/:conversationId/export/tsv
85
  *
86
  * Export a single conversation's messages as a TSV file
87
+ * Requires admin authentication
88
  */
89
  export async function GET(
90
  request: NextRequest,
91
  { params }: { params: Promise<{ conversationId: string }> }
92
  ) {
93
  try {
94
+ await requireAdminAuth(request);
95
  const { conversationId } = await params;
96
 
97
  // Fetch conversation and messages - use view to get names
 
184
  },
185
  });
186
  } catch (error) {
187
+ if (error instanceof Error) {
188
+ if (error.message === 'Unauthorized') {
189
+ return createUnauthorizedResponse();
190
+ }
191
+ if (error.message === 'Forbidden') {
192
+ return createForbiddenResponse();
193
+ }
194
+ }
195
  console.error('Error exporting conversation to TSV:', error);
196
  return NextResponse.json(
197
  { error: 'Failed to export conversation' },
src/app/api/admin/conversations/[conversationId]/route.ts CHANGED
@@ -1,16 +1,19 @@
1
- import { NextResponse } from 'next/server';
2
  import { ConversationRepository } from '@/lib/repositories/conversation-repository';
3
  import { getPromptService } from '@/lib/services/prompt-service';
 
4
 
5
  /**
6
  * GET /api/admin/conversations/:conversationId
7
  * Get full conversation with all messages
 
8
  */
9
  export async function GET(
10
- request: Request,
11
  { params }: { params: Promise<{ conversationId: string }> }
12
  ) {
13
  try {
 
14
  const { conversationId } = await params;
15
 
16
  const repo = new ConversationRepository();
@@ -49,6 +52,14 @@ export async function GET(
49
  });
50
  }
51
  } catch (error) {
 
 
 
 
 
 
 
 
52
  console.error('[GET /api/admin/conversations/:conversationId] Error:', error);
53
  return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
54
  }
 
1
+ import { NextRequest, NextResponse } from 'next/server';
2
  import { ConversationRepository } from '@/lib/repositories/conversation-repository';
3
  import { getPromptService } from '@/lib/services/prompt-service';
4
+ import { requireAdminAuth, createUnauthorizedResponse, createForbiddenResponse } from '@/lib/auth';
5
 
6
  /**
7
  * GET /api/admin/conversations/:conversationId
8
  * Get full conversation with all messages
9
+ * Requires admin authentication
10
  */
11
  export async function GET(
12
+ request: NextRequest,
13
  { params }: { params: Promise<{ conversationId: string }> }
14
  ) {
15
  try {
16
+ await requireAdminAuth(request);
17
  const { conversationId } = await params;
18
 
19
  const repo = new ConversationRepository();
 
52
  });
53
  }
54
  } catch (error) {
55
+ if (error instanceof Error) {
56
+ if (error.message === 'Unauthorized') {
57
+ return createUnauthorizedResponse();
58
+ }
59
+ if (error.message === 'Forbidden') {
60
+ return createForbiddenResponse();
61
+ }
62
+ }
63
  console.error('[GET /api/admin/conversations/:conversationId] Error:', error);
64
  return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
65
  }
src/app/api/admin/conversations/route.ts CHANGED
@@ -1,9 +1,11 @@
1
  import { NextRequest, NextResponse } from 'next/server';
2
  import { ConversationRepository } from '@/lib/repositories/conversation-repository';
 
3
 
4
  /**
5
  * GET /api/admin/conversations
6
  * List all conversations with filtering, pagination, and sorting
 
7
  *
8
  * Query parameters:
9
  * - userId: Partial search on user ID and username (e.g., "te" matches "test-user", "teresa", "user-test-123")
@@ -16,6 +18,7 @@ import { ConversationRepository } from '@/lib/repositories/conversation-reposito
16
  */
17
  export async function GET(request: NextRequest) {
18
  try {
 
19
  const searchParams = request.nextUrl.searchParams;
20
  const userId = searchParams.get('userId') || undefined;
21
  const studentPromptId = searchParams.get('studentPromptId') || undefined;
@@ -74,6 +77,14 @@ export async function GET(request: NextRequest) {
74
  },
75
  });
76
  } catch (error) {
 
 
 
 
 
 
 
 
77
  console.error('[GET /api/admin/conversations] Error:', error);
78
  return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
79
  }
 
1
  import { NextRequest, NextResponse } from 'next/server';
2
  import { ConversationRepository } from '@/lib/repositories/conversation-repository';
3
+ import { requireAdminAuth, createUnauthorizedResponse, createForbiddenResponse } from '@/lib/auth';
4
 
5
  /**
6
  * GET /api/admin/conversations
7
  * List all conversations with filtering, pagination, and sorting
8
+ * Requires admin authentication
9
  *
10
  * Query parameters:
11
  * - userId: Partial search on user ID and username (e.g., "te" matches "test-user", "teresa", "user-test-123")
 
18
  */
19
  export async function GET(request: NextRequest) {
20
  try {
21
+ await requireAdminAuth(request);
22
  const searchParams = request.nextUrl.searchParams;
23
  const userId = searchParams.get('userId') || undefined;
24
  const studentPromptId = searchParams.get('studentPromptId') || undefined;
 
77
  },
78
  });
79
  } catch (error) {
80
+ if (error instanceof Error) {
81
+ if (error.message === 'Unauthorized') {
82
+ return createUnauthorizedResponse();
83
+ }
84
+ if (error.message === 'Forbidden') {
85
+ return createForbiddenResponse();
86
+ }
87
+ }
88
  console.error('[GET /api/admin/conversations] Error:', error);
89
  return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
90
  }
src/app/api/admin/health/route.ts CHANGED
@@ -1,5 +1,6 @@
1
- import { NextResponse } from 'next/server';
2
  import { getDatabase } from '@/lib/db';
 
3
 
4
  /**
5
  * Cache for health metrics
@@ -16,9 +17,11 @@ const CACHE_TTL_MS = 30 * 1000; // 30 seconds
16
  /**
17
  * GET /api/admin/health
18
  * Get database health metrics with caching
 
19
  */
20
- export async function GET() {
21
  try {
 
22
  // Check cache first
23
  if (healthCache && Date.now() < healthCache.expiresAt) {
24
  return NextResponse.json({
@@ -84,6 +87,14 @@ export async function GET() {
84
 
85
  return NextResponse.json(responseData);
86
  } catch (error) {
 
 
 
 
 
 
 
 
87
  console.error('[GET /api/admin/health] Error:', error);
88
  return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
89
  }
 
1
+ import { NextRequest, NextResponse } from 'next/server';
2
  import { getDatabase } from '@/lib/db';
3
+ import { requireAdminAuth, createUnauthorizedResponse, createForbiddenResponse } from '@/lib/auth';
4
 
5
  /**
6
  * Cache for health metrics
 
17
  /**
18
  * GET /api/admin/health
19
  * Get database health metrics with caching
20
+ * Requires admin authentication
21
  */
22
+ export async function GET(request: NextRequest) {
23
  try {
24
+ await requireAdminAuth(request);
25
  // Check cache first
26
  if (healthCache && Date.now() < healthCache.expiresAt) {
27
  return NextResponse.json({
 
87
 
88
  return NextResponse.json(responseData);
89
  } catch (error) {
90
+ if (error instanceof Error) {
91
+ if (error.message === 'Unauthorized') {
92
+ return createUnauthorizedResponse();
93
+ }
94
+ if (error.message === 'Forbidden') {
95
+ return createForbiddenResponse();
96
+ }
97
+ }
98
  console.error('[GET /api/admin/health] Error:', error);
99
  return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
100
  }
src/app/api/admin/indexes/route.ts CHANGED
@@ -1,12 +1,15 @@
1
- import { NextResponse } from 'next/server';
2
  import { getDatabase } from '@/lib/db';
 
3
 
4
  /**
5
  * GET /api/admin/indexes
6
  * List all custom indexes in the database
 
7
  */
8
- export async function GET() {
9
  try {
 
10
  const db = getDatabase();
11
 
12
  // Query PostgreSQL system catalog for indexes
@@ -32,6 +35,14 @@ export async function GET() {
32
  })),
33
  });
34
  } catch (error) {
 
 
 
 
 
 
 
 
35
  console.error('[GET /api/admin/indexes] Error:', error);
36
  return NextResponse.json(
37
  { error: 'Internal server error', details: error instanceof Error ? error.message : 'Unknown' },
 
1
+ import { NextRequest, NextResponse } from 'next/server';
2
  import { getDatabase } from '@/lib/db';
3
+ import { requireAdminAuth, createUnauthorizedResponse, createForbiddenResponse } from '@/lib/auth';
4
 
5
  /**
6
  * GET /api/admin/indexes
7
  * List all custom indexes in the database
8
+ * Requires admin authentication
9
  */
10
+ export async function GET(request: NextRequest) {
11
  try {
12
+ await requireAdminAuth(request);
13
  const db = getDatabase();
14
 
15
  // Query PostgreSQL system catalog for indexes
 
35
  })),
36
  });
37
  } catch (error) {
38
+ if (error instanceof Error) {
39
+ if (error.message === 'Unauthorized') {
40
+ return createUnauthorizedResponse();
41
+ }
42
+ if (error.message === 'Forbidden') {
43
+ return createForbiddenResponse();
44
+ }
45
+ }
46
  console.error('[GET /api/admin/indexes] Error:', error);
47
  return NextResponse.json(
48
  { error: 'Internal server error', details: error instanceof Error ? error.message : 'Unknown' },
src/app/api/admin/stats/route.ts CHANGED
@@ -1,12 +1,15 @@
1
- import { NextResponse } from 'next/server';
2
  import { getDatabase } from '@/lib/db';
 
3
 
4
  /**
5
  * GET /api/admin/stats
6
  * Get overall system statistics
 
7
  */
8
- export async function GET() {
9
  try {
 
10
  const db = getDatabase();
11
 
12
  // Calculate time ranges
@@ -90,6 +93,14 @@ export async function GET() {
90
  },
91
  });
92
  } catch (error) {
 
 
 
 
 
 
 
 
93
  console.error('[GET /api/admin/stats] Error:', error);
94
  return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
95
  }
 
1
+ import { NextRequest, NextResponse } from 'next/server';
2
  import { getDatabase } from '@/lib/db';
3
+ import { requireAdminAuth, createUnauthorizedResponse, createForbiddenResponse } from '@/lib/auth';
4
 
5
  /**
6
  * GET /api/admin/stats
7
  * Get overall system statistics
8
+ * Requires admin authentication
9
  */
10
+ export async function GET(request: NextRequest) {
11
  try {
12
+ await requireAdminAuth(request);
13
  const db = getDatabase();
14
 
15
  // Calculate time ranges
 
93
  },
94
  });
95
  } catch (error) {
96
+ if (error instanceof Error) {
97
+ if (error.message === 'Unauthorized') {
98
+ return createUnauthorizedResponse();
99
+ }
100
+ if (error.message === 'Forbidden') {
101
+ return createForbiddenResponse();
102
+ }
103
+ }
104
  console.error('[GET /api/admin/stats] Error:', error);
105
  return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
106
  }
src/app/api/admin/users/[userId]/route.ts CHANGED
@@ -1,15 +1,18 @@
1
- import { NextResponse } from 'next/server';
2
  import { UserRepository } from '@/lib/repositories/user-repository';
 
3
 
4
  /**
5
  * GET /api/admin/users/:userId
6
  * Get detailed user information with recent conversations
 
7
  */
8
  export async function GET(
9
- request: Request,
10
  { params }: { params: Promise<{ userId: string }> }
11
  ) {
12
  try {
 
13
  const { userId } = await params;
14
 
15
  const repo = new UserRepository();
@@ -26,6 +29,14 @@ export async function GET(
26
  recentConversations: result.recentConversations,
27
  });
28
  } catch (error) {
 
 
 
 
 
 
 
 
29
  console.error('[GET /api/admin/users/:userId] Error:', error);
30
  return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
31
  }
 
1
+ import { NextRequest, NextResponse } from 'next/server';
2
  import { UserRepository } from '@/lib/repositories/user-repository';
3
+ import { requireAdminAuth, createUnauthorizedResponse, createForbiddenResponse } from '@/lib/auth';
4
 
5
  /**
6
  * GET /api/admin/users/:userId
7
  * Get detailed user information with recent conversations
8
+ * Requires admin authentication
9
  */
10
  export async function GET(
11
+ request: NextRequest,
12
  { params }: { params: Promise<{ userId: string }> }
13
  ) {
14
  try {
15
+ await requireAdminAuth(request);
16
  const { userId } = await params;
17
 
18
  const repo = new UserRepository();
 
29
  recentConversations: result.recentConversations,
30
  });
31
  } catch (error) {
32
+ if (error instanceof Error) {
33
+ if (error.message === 'Unauthorized') {
34
+ return createUnauthorizedResponse();
35
+ }
36
+ if (error.message === 'Forbidden') {
37
+ return createForbiddenResponse();
38
+ }
39
+ }
40
  console.error('[GET /api/admin/users/:userId] Error:', error);
41
  return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
42
  }
src/app/api/admin/users/route.ts CHANGED
@@ -1,12 +1,15 @@
1
  import { NextRequest, NextResponse } from 'next/server';
2
  import { UserRepository } from '@/lib/repositories/user-repository';
 
3
 
4
  /**
5
  * GET /api/admin/users
6
  * List all users with pagination, search, and sorting
 
7
  */
8
  export async function GET(request: NextRequest) {
9
  try {
 
10
  const searchParams = request.nextUrl.searchParams;
11
  const search = searchParams.get('search') || undefined;
12
  const page = parseInt(searchParams.get('page') || '1');
@@ -41,6 +44,14 @@ export async function GET(request: NextRequest) {
41
  },
42
  });
43
  } catch (error) {
 
 
 
 
 
 
 
 
44
  console.error('[GET /api/admin/users] Error:', error);
45
  return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
46
  }
 
1
  import { NextRequest, NextResponse } from 'next/server';
2
  import { UserRepository } from '@/lib/repositories/user-repository';
3
+ import { requireAdminAuth, createUnauthorizedResponse, createForbiddenResponse } from '@/lib/auth';
4
 
5
  /**
6
  * GET /api/admin/users
7
  * List all users with pagination, search, and sorting
8
+ * Requires admin authentication
9
  */
10
  export async function GET(request: NextRequest) {
11
  try {
12
+ await requireAdminAuth(request);
13
  const searchParams = request.nextUrl.searchParams;
14
  const search = searchParams.get('search') || undefined;
15
  const page = parseInt(searchParams.get('page') || '1');
 
44
  },
45
  });
46
  } catch (error) {
47
+ if (error instanceof Error) {
48
+ if (error.message === 'Unauthorized') {
49
+ return createUnauthorizedResponse();
50
+ }
51
+ if (error.message === 'Forbidden') {
52
+ return createForbiddenResponse();
53
+ }
54
+ }
55
  console.error('[GET /api/admin/users] Error:', error);
56
  return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
57
  }
src/app/api/auth/me/route.ts ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { NextRequest, NextResponse } from 'next/server';
2
+ import { requireBasicAuth, createUnauthorizedResponse } from '@/lib/auth';
3
+
4
+ /**
5
+ * GET /api/auth/me
6
+ * Returns current user info including session role
7
+ * Used by admin UI to check access permissions
8
+ */
9
+ export async function GET(request: NextRequest) {
10
+ try {
11
+ const auth = await requireBasicAuth(request);
12
+
13
+ return NextResponse.json({
14
+ userId: auth.userId,
15
+ username: auth.username,
16
+ sessionRole: auth.sessionRole,
17
+ });
18
+ } catch (error) {
19
+ if (error instanceof Error && error.message === 'Unauthorized') {
20
+ return createUnauthorizedResponse();
21
+ }
22
+ console.error('[GET /api/auth/me] Error:', error);
23
+ return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
24
+ }
25
+ }
src/lib/auth/index.ts CHANGED
@@ -10,14 +10,32 @@ import { parseBasicAuthHeader, getDefaultPassword } from './session';
10
  // Re-export session utilities
11
  export * from './session';
12
 
 
 
 
 
 
 
 
 
 
 
 
 
 
13
  /**
14
  * Basic HTTP Authentication middleware for API routes
15
  * Checks for Basic Auth credentials against registered users
 
16
  *
17
  * @throws Error with message 'Unauthorized' if authentication fails
18
- * @returns User info {userId, username} if authenticated
19
  */
20
- export async function requireBasicAuth(request: NextRequest): Promise<{ userId: string; username: string }> {
 
 
 
 
21
  const authHeader = request.headers.get('authorization');
22
 
23
  if (!authHeader || !authHeader.startsWith('Basic ')) {
@@ -32,13 +50,6 @@ export async function requireBasicAuth(request: NextRequest): Promise<{ userId:
32
 
33
  const { username, password } = credentials;
34
 
35
- // Validate password against environment variable (same for all users)
36
- const validPassword = getDefaultPassword();
37
-
38
- if (password !== validPassword) {
39
- throw new Error('Unauthorized');
40
- }
41
-
42
  // Check if user exists in database
43
  const userRepo = new UserRepository();
44
  const user = await userRepo.findByUsername(username);
@@ -47,11 +58,47 @@ export async function requireBasicAuth(request: NextRequest): Promise<{ userId:
47
  throw new Error('Unauthorized');
48
  }
49
 
50
- // Authentication successful - return user info
51
- return {
52
- userId: user.id,
53
- username: user.username,
54
- };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
55
  }
56
 
57
  /**
@@ -65,3 +112,15 @@ export function createUnauthorizedResponse(message = 'Unauthorized'): Response {
65
  },
66
  });
67
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
10
  // Re-export session utilities
11
  export * from './session';
12
 
13
+ /**
14
+ * Session role type - determines access level
15
+ */
16
+ export type SessionRole = 'user' | 'admin';
17
+
18
+ /**
19
+ * Get admin password from environment variable
20
+ * Returns undefined if not set (admin access disabled)
21
+ */
22
+ export function getAdminPassword(): string | undefined {
23
+ return process.env.ADMIN_PASSWORD;
24
+ }
25
+
26
  /**
27
  * Basic HTTP Authentication middleware for API routes
28
  * Checks for Basic Auth credentials against registered users
29
+ * Returns session role based on which password was used
30
  *
31
  * @throws Error with message 'Unauthorized' if authentication fails
32
+ * @returns User info {userId, username, sessionRole} if authenticated
33
  */
34
+ export async function requireBasicAuth(request: NextRequest): Promise<{
35
+ userId: string;
36
+ username: string;
37
+ sessionRole: SessionRole;
38
+ }> {
39
  const authHeader = request.headers.get('authorization');
40
 
41
  if (!authHeader || !authHeader.startsWith('Basic ')) {
 
50
 
51
  const { username, password } = credentials;
52
 
 
 
 
 
 
 
 
53
  // Check if user exists in database
54
  const userRepo = new UserRepository();
55
  const user = await userRepo.findByUsername(username);
 
58
  throw new Error('Unauthorized');
59
  }
60
 
61
+ // Get passwords from environment
62
+ const adminPassword = getAdminPassword();
63
+ const userPassword = getDefaultPassword();
64
+
65
+ // Check admin password first (if configured)
66
+ if (adminPassword && password === adminPassword) {
67
+ return {
68
+ userId: user.id,
69
+ username: user.username,
70
+ sessionRole: 'admin',
71
+ };
72
+ } else if (userPassword && password === userPassword) {
73
+ return {
74
+ userId: user.id,
75
+ username: user.username,
76
+ sessionRole: 'user',
77
+ };
78
+ } else {
79
+ throw new Error('Unauthorized');
80
+ }
81
+ }
82
+
83
+ /**
84
+ * Admin authentication middleware for API routes
85
+ * Requires admin session role (must login with ADMIN_PASSWORD)
86
+ *
87
+ * @throws Error with message 'Forbidden' if not admin
88
+ * @returns User info with admin session role
89
+ */
90
+ export async function requireAdminAuth(request: NextRequest): Promise<{
91
+ userId: string;
92
+ username: string;
93
+ sessionRole: 'admin';
94
+ }> {
95
+ const auth = await requireBasicAuth(request);
96
+
97
+ if (auth.sessionRole !== 'admin') {
98
+ throw new Error('Forbidden');
99
+ }
100
+
101
+ return auth as { userId: string; username: string; sessionRole: 'admin' };
102
  }
103
 
104
  /**
 
112
  },
113
  });
114
  }
115
+
116
+ /**
117
+ * Helper to create 403 Forbidden response
118
+ */
119
+ export function createForbiddenResponse(message = 'Forbidden'): Response {
120
+ return new Response(JSON.stringify({ error: message }), {
121
+ status: 403,
122
+ headers: {
123
+ 'Content-Type': 'application/json',
124
+ },
125
+ });
126
+ }
src/lib/auth/session.ts CHANGED
@@ -81,8 +81,9 @@ export function parseBasicAuthHeader(authHeader: string): { username: string; pa
81
  }
82
 
83
  /**
84
- * Get the default/expected password from environment
 
85
  */
86
- export function getDefaultPassword(): string {
87
- return process.env.BASIC_AUTH_PASSWORD || process.env.NEXT_PUBLIC_DEFAULT_PASSWORD || 'cz-2025';
88
  }
 
81
  }
82
 
83
  /**
84
+ * Get the user password from environment
85
+ * Returns undefined if not set
86
  */
87
+ export function getDefaultPassword(): string | undefined {
88
+ return process.env.BASIC_AUTH_PASSWORD || process.env.NEXT_PUBLIC_DEFAULT_PASSWORD;
89
  }
tests/e2e/admin-conversation-export-concurrent.api.spec.ts CHANGED
@@ -1,12 +1,14 @@
1
  import { test, expect } from '@playwright/test';
2
 
3
  const BASE_URL = 'http://localhost:3000';
 
4
 
5
  test.describe('Admin Conversation TSV Export - Concurrent Requests', () => {
6
  let testUserId: string;
7
  let testUsername: string;
8
  let conversation1Id: string;
9
  let conversation2Id: string;
 
10
  const testPassword = process.env.BASIC_AUTH_PASSWORD || 'cz-2025';
11
 
12
  test.beforeAll(async ({ request }) => {
@@ -24,6 +26,9 @@ test.describe('Admin Conversation TSV Export - Concurrent Requests', () => {
24
  const userData = await registerResponse.json();
25
  testUserId = userData.user.id;
26
 
 
 
 
27
  // Create Basic Auth header
28
  const authHeader = `Basic ${Buffer.from(`${testUsername}:${testPassword}`).toString('base64')}`;
29
 
@@ -117,14 +122,16 @@ test.describe('Admin Conversation TSV Export - Concurrent Requests', () => {
117
  test('Sequential exports should not mix conversations', async ({ request }) => {
118
  // Export first conversation
119
  const response1 = await request.get(
120
- `${BASE_URL}/api/admin/conversations/${conversation1Id}/export/tsv`
 
121
  );
122
  expect(response1.ok()).toBeTruthy();
123
  const tsv1 = await response1.text();
124
 
125
  // Export second conversation
126
  const response2 = await request.get(
127
- `${BASE_URL}/api/admin/conversations/${conversation2Id}/export/tsv`
 
128
  );
129
  expect(response2.ok()).toBeTruthy();
130
  const tsv2 = await response2.text();
@@ -145,8 +152,8 @@ test.describe('Admin Conversation TSV Export - Concurrent Requests', () => {
145
  test('Concurrent exports should not mix conversations', async ({ request }) => {
146
  // Trigger both exports simultaneously
147
  const [response1, response2] = await Promise.all([
148
- request.get(`${BASE_URL}/api/admin/conversations/${conversation1Id}/export/tsv`),
149
- request.get(`${BASE_URL}/api/admin/conversations/${conversation2Id}/export/tsv`),
150
  ]);
151
 
152
  expect(response1.ok()).toBeTruthy();
@@ -173,10 +180,10 @@ test.describe('Admin Conversation TSV Export - Concurrent Requests', () => {
173
  const exports = [];
174
  for (let i = 0; i < 5; i++) {
175
  exports.push(
176
- request.get(`${BASE_URL}/api/admin/conversations/${conversation1Id}/export/tsv`)
177
  );
178
  exports.push(
179
- request.get(`${BASE_URL}/api/admin/conversations/${conversation2Id}/export/tsv`)
180
  );
181
  }
182
 
@@ -212,7 +219,8 @@ test.describe('Admin Conversation TSV Export - Concurrent Requests', () => {
212
 
213
  test('Exported TSV should include conversation ID in every row', async ({ request }) => {
214
  const response = await request.get(
215
- `${BASE_URL}/api/admin/conversations/${conversation1Id}/export/tsv`
 
216
  );
217
  expect(response.ok()).toBeTruthy();
218
  const tsv = await response.text();
 
1
  import { test, expect } from '@playwright/test';
2
 
3
  const BASE_URL = 'http://localhost:3000';
4
+ const adminPassword = process.env.ADMIN_PASSWORD || 'cz-2025-admin';
5
 
6
  test.describe('Admin Conversation TSV Export - Concurrent Requests', () => {
7
  let testUserId: string;
8
  let testUsername: string;
9
  let conversation1Id: string;
10
  let conversation2Id: string;
11
+ let adminAuthHeader: string;
12
  const testPassword = process.env.BASIC_AUTH_PASSWORD || 'cz-2025';
13
 
14
  test.beforeAll(async ({ request }) => {
 
26
  const userData = await registerResponse.json();
27
  testUserId = userData.user.id;
28
 
29
+ // Create Admin Auth header (required for admin endpoints)
30
+ adminAuthHeader = `Basic ${Buffer.from(`${testUsername}:${adminPassword}`).toString('base64')}`;
31
+
32
  // Create Basic Auth header
33
  const authHeader = `Basic ${Buffer.from(`${testUsername}:${testPassword}`).toString('base64')}`;
34
 
 
122
  test('Sequential exports should not mix conversations', async ({ request }) => {
123
  // Export first conversation
124
  const response1 = await request.get(
125
+ `${BASE_URL}/api/admin/conversations/${conversation1Id}/export/tsv`,
126
+ { headers: { Authorization: adminAuthHeader } }
127
  );
128
  expect(response1.ok()).toBeTruthy();
129
  const tsv1 = await response1.text();
130
 
131
  // Export second conversation
132
  const response2 = await request.get(
133
+ `${BASE_URL}/api/admin/conversations/${conversation2Id}/export/tsv`,
134
+ { headers: { Authorization: adminAuthHeader } }
135
  );
136
  expect(response2.ok()).toBeTruthy();
137
  const tsv2 = await response2.text();
 
152
  test('Concurrent exports should not mix conversations', async ({ request }) => {
153
  // Trigger both exports simultaneously
154
  const [response1, response2] = await Promise.all([
155
+ request.get(`${BASE_URL}/api/admin/conversations/${conversation1Id}/export/tsv`, { headers: { Authorization: adminAuthHeader } }),
156
+ request.get(`${BASE_URL}/api/admin/conversations/${conversation2Id}/export/tsv`, { headers: { Authorization: adminAuthHeader } }),
157
  ]);
158
 
159
  expect(response1.ok()).toBeTruthy();
 
180
  const exports = [];
181
  for (let i = 0; i < 5; i++) {
182
  exports.push(
183
+ request.get(`${BASE_URL}/api/admin/conversations/${conversation1Id}/export/tsv`, { headers: { Authorization: adminAuthHeader } })
184
  );
185
  exports.push(
186
+ request.get(`${BASE_URL}/api/admin/conversations/${conversation2Id}/export/tsv`, { headers: { Authorization: adminAuthHeader } })
187
  );
188
  }
189
 
 
219
 
220
  test('Exported TSV should include conversation ID in every row', async ({ request }) => {
221
  const response = await request.get(
222
+ `${BASE_URL}/api/admin/conversations/${conversation1Id}/export/tsv`,
223
+ { headers: { Authorization: adminAuthHeader } }
224
  );
225
  expect(response.ok()).toBeTruthy();
226
  const tsv = await response.text();
tests/e2e/admin-conversation-export.api.spec.ts CHANGED
@@ -1,11 +1,13 @@
1
  import { test, expect } from '@playwright/test';
2
 
3
  const BASE_URL = 'http://localhost:3000';
 
4
 
5
  test.describe('Admin Conversation TSV Export API', () => {
6
  let testUserId: string;
7
  let testConversationId: string;
8
  let testUsername: string;
 
9
  const testPassword = process.env.BASIC_AUTH_PASSWORD || 'cz-2025';
10
 
11
  test.beforeAll(async ({ request }) => {
@@ -23,6 +25,9 @@ test.describe('Admin Conversation TSV Export API', () => {
23
  const userData = await registerResponse.json();
24
  testUserId = userData.user.id;
25
 
 
 
 
26
  // Create Basic Auth header
27
  const authHeader = `Basic ${Buffer.from(`${testUsername}:${testPassword}`).toString('base64')}`;
28
 
@@ -100,7 +105,8 @@ test.describe('Admin Conversation TSV Export API', () => {
100
  request,
101
  }) => {
102
  const response = await request.get(
103
- `${BASE_URL}/api/admin/conversations/${testConversationId}/export/tsv`
 
104
  );
105
 
106
  expect(response.ok()).toBeTruthy();
@@ -121,7 +127,8 @@ test.describe('Admin Conversation TSV Export API', () => {
121
  request,
122
  }) => {
123
  const response = await request.get(
124
- `${BASE_URL}/api/admin/conversations/${testConversationId}/export/tsv`
 
125
  );
126
 
127
  const tsvContent = await response.text();
@@ -148,7 +155,8 @@ test.describe('Admin Conversation TSV Export API', () => {
148
  request,
149
  }) => {
150
  const response = await request.get(
151
- `${BASE_URL}/api/admin/conversations/${testConversationId}/export/tsv`
 
152
  );
153
 
154
  const tsvContent = await response.text();
@@ -161,7 +169,8 @@ test.describe('Admin Conversation TSV Export API', () => {
161
  request,
162
  }) => {
163
  const response = await request.get(
164
- `${BASE_URL}/api/admin/conversations/${testConversationId}/export/tsv`
 
165
  );
166
 
167
  const tsvContent = await response.text();
@@ -174,7 +183,8 @@ test.describe('Admin Conversation TSV Export API', () => {
174
  request,
175
  }) => {
176
  const response = await request.get(
177
- `${BASE_URL}/api/admin/conversations/${testConversationId}/export/tsv`
 
178
  );
179
 
180
  const tsvContent = await response.text();
@@ -188,14 +198,16 @@ test.describe('Admin Conversation TSV Export API', () => {
188
  }) => {
189
  // First, get the conversation to see how many messages it has
190
  const detailResponse = await request.get(
191
- `${BASE_URL}/api/admin/conversations/${testConversationId}`
 
192
  );
193
  const detailData = await detailResponse.json();
194
  const messageCount = detailData.messages.length;
195
 
196
  // Now get the TSV
197
  const response = await request.get(
198
- `${BASE_URL}/api/admin/conversations/${testConversationId}/export/tsv`
 
199
  );
200
 
201
  const tsvContent = await response.text();
@@ -210,7 +222,8 @@ test.describe('Admin Conversation TSV Export API', () => {
210
  request,
211
  }) => {
212
  const response = await request.get(
213
- `${BASE_URL}/api/admin/conversations/${testConversationId}/export/tsv`
 
214
  );
215
 
216
  const tsvContent = await response.text();
@@ -264,7 +277,8 @@ test.describe('Admin Conversation TSV Export API', () => {
264
 
265
  // Export the FIRST conversation
266
  const response = await request.get(
267
- `${BASE_URL}/api/admin/conversations/${testConversationId}/export/tsv`
 
268
  );
269
 
270
  const tsvContent = await response.text();
@@ -284,7 +298,8 @@ test.describe('Admin Conversation TSV Export API', () => {
284
  }) => {
285
  const fakeConvId = '00000000-0000-0000-0000-000000000000';
286
  const response = await request.get(
287
- `${BASE_URL}/api/admin/conversations/${fakeConvId}/export/tsv`
 
288
  );
289
 
290
  expect(response.status()).toBe(404);
@@ -298,7 +313,8 @@ test.describe('Admin Conversation TSV Export API', () => {
298
  request,
299
  }) => {
300
  const response = await request.get(
301
- `${BASE_URL}/api/admin/conversations/${testConversationId}/export/tsv`
 
302
  );
303
 
304
  const contentDisposition = response.headers()['content-disposition'];
@@ -360,7 +376,8 @@ test.describe('Admin Conversation TSV Export API', () => {
360
 
361
  // Export should work
362
  const response = await request.get(
363
- `${BASE_URL}/api/admin/conversations/${specialConvId}/export/tsv`
 
364
  );
365
 
366
  expect(response.ok()).toBeTruthy();
@@ -387,7 +404,8 @@ test.describe('Admin Conversation TSV Export API', () => {
387
  request,
388
  }) => {
389
  const response = await request.get(
390
- `${BASE_URL}/api/admin/conversations/${testConversationId}/export/tsv`
 
391
  );
392
 
393
  const tsvContent = await response.text();
@@ -439,7 +457,10 @@ test.describe('Admin Conversation TSV Export API', () => {
439
  });
440
 
441
  // Export TSV
442
- const response = await request.get(`${BASE_URL}/api/admin/conversations/${speakerTestConvId}/export/tsv`);
 
 
 
443
 
444
  // Should return 200 (verifies UTF-8 encoding works, not ByteString error)
445
  expect(response.ok()).toBeTruthy();
 
1
  import { test, expect } from '@playwright/test';
2
 
3
  const BASE_URL = 'http://localhost:3000';
4
+ const adminPassword = process.env.ADMIN_PASSWORD || 'cz-2025-admin';
5
 
6
  test.describe('Admin Conversation TSV Export API', () => {
7
  let testUserId: string;
8
  let testConversationId: string;
9
  let testUsername: string;
10
+ let adminAuthHeader: string;
11
  const testPassword = process.env.BASIC_AUTH_PASSWORD || 'cz-2025';
12
 
13
  test.beforeAll(async ({ request }) => {
 
25
  const userData = await registerResponse.json();
26
  testUserId = userData.user.id;
27
 
28
+ // Create Admin Auth header (required for admin endpoints)
29
+ adminAuthHeader = `Basic ${Buffer.from(`${testUsername}:${adminPassword}`).toString('base64')}`;
30
+
31
  // Create Basic Auth header
32
  const authHeader = `Basic ${Buffer.from(`${testUsername}:${testPassword}`).toString('base64')}`;
33
 
 
105
  request,
106
  }) => {
107
  const response = await request.get(
108
+ `${BASE_URL}/api/admin/conversations/${testConversationId}/export/tsv`,
109
+ { headers: { Authorization: adminAuthHeader } }
110
  );
111
 
112
  expect(response.ok()).toBeTruthy();
 
127
  request,
128
  }) => {
129
  const response = await request.get(
130
+ `${BASE_URL}/api/admin/conversations/${testConversationId}/export/tsv`,
131
+ { headers: { Authorization: adminAuthHeader } }
132
  );
133
 
134
  const tsvContent = await response.text();
 
155
  request,
156
  }) => {
157
  const response = await request.get(
158
+ `${BASE_URL}/api/admin/conversations/${testConversationId}/export/tsv`,
159
+ { headers: { Authorization: adminAuthHeader } }
160
  );
161
 
162
  const tsvContent = await response.text();
 
169
  request,
170
  }) => {
171
  const response = await request.get(
172
+ `${BASE_URL}/api/admin/conversations/${testConversationId}/export/tsv`,
173
+ { headers: { Authorization: adminAuthHeader } }
174
  );
175
 
176
  const tsvContent = await response.text();
 
183
  request,
184
  }) => {
185
  const response = await request.get(
186
+ `${BASE_URL}/api/admin/conversations/${testConversationId}/export/tsv`,
187
+ { headers: { Authorization: adminAuthHeader } }
188
  );
189
 
190
  const tsvContent = await response.text();
 
198
  }) => {
199
  // First, get the conversation to see how many messages it has
200
  const detailResponse = await request.get(
201
+ `${BASE_URL}/api/admin/conversations/${testConversationId}`,
202
+ { headers: { Authorization: adminAuthHeader } }
203
  );
204
  const detailData = await detailResponse.json();
205
  const messageCount = detailData.messages.length;
206
 
207
  // Now get the TSV
208
  const response = await request.get(
209
+ `${BASE_URL}/api/admin/conversations/${testConversationId}/export/tsv`,
210
+ { headers: { Authorization: adminAuthHeader } }
211
  );
212
 
213
  const tsvContent = await response.text();
 
222
  request,
223
  }) => {
224
  const response = await request.get(
225
+ `${BASE_URL}/api/admin/conversations/${testConversationId}/export/tsv`,
226
+ { headers: { Authorization: adminAuthHeader } }
227
  );
228
 
229
  const tsvContent = await response.text();
 
277
 
278
  // Export the FIRST conversation
279
  const response = await request.get(
280
+ `${BASE_URL}/api/admin/conversations/${testConversationId}/export/tsv`,
281
+ { headers: { Authorization: adminAuthHeader } }
282
  );
283
 
284
  const tsvContent = await response.text();
 
298
  }) => {
299
  const fakeConvId = '00000000-0000-0000-0000-000000000000';
300
  const response = await request.get(
301
+ `${BASE_URL}/api/admin/conversations/${fakeConvId}/export/tsv`,
302
+ { headers: { Authorization: adminAuthHeader } }
303
  );
304
 
305
  expect(response.status()).toBe(404);
 
313
  request,
314
  }) => {
315
  const response = await request.get(
316
+ `${BASE_URL}/api/admin/conversations/${testConversationId}/export/tsv`,
317
+ { headers: { Authorization: adminAuthHeader } }
318
  );
319
 
320
  const contentDisposition = response.headers()['content-disposition'];
 
376
 
377
  // Export should work
378
  const response = await request.get(
379
+ `${BASE_URL}/api/admin/conversations/${specialConvId}/export/tsv`,
380
+ { headers: { Authorization: adminAuthHeader } }
381
  );
382
 
383
  expect(response.ok()).toBeTruthy();
 
404
  request,
405
  }) => {
406
  const response = await request.get(
407
+ `${BASE_URL}/api/admin/conversations/${testConversationId}/export/tsv`,
408
+ { headers: { Authorization: adminAuthHeader } }
409
  );
410
 
411
  const tsvContent = await response.text();
 
457
  });
458
 
459
  // Export TSV
460
+ const response = await request.get(
461
+ `${BASE_URL}/api/admin/conversations/${speakerTestConvId}/export/tsv`,
462
+ { headers: { Authorization: adminAuthHeader } }
463
+ );
464
 
465
  // Should return 200 (verifies UTF-8 encoding works, not ByteString error)
466
  expect(response.ok()).toBeTruthy();
tests/e2e/admin-conversations.api.spec.ts CHANGED
@@ -1,11 +1,13 @@
1
  import { test, expect } from '@playwright/test';
2
 
3
  const BASE_URL = 'http://localhost:3000';
 
4
 
5
  test.describe('Admin Conversations APIs', () => {
6
  let testUserId: string;
7
  let testConversationId: string;
8
  let testUsername: string;
 
9
  const testPassword = process.env.BASIC_AUTH_PASSWORD || 'cz-2025';
10
 
11
  test.beforeAll(async ({ request }) => {
@@ -23,6 +25,9 @@ test.describe('Admin Conversations APIs', () => {
23
  const userData = await registerResponse.json();
24
  testUserId = userData.user.id;
25
 
 
 
 
26
  // Create Basic Auth header
27
  const authHeader = `Basic ${Buffer.from(`${testUsername}:${testPassword}`).toString('base64')}`;
28
 
@@ -56,7 +61,9 @@ test.describe('Admin Conversations APIs', () => {
56
  test('GET /api/admin/conversations - should return conversations list with default params', async ({
57
  request,
58
  }) => {
59
- const response = await request.get(`${BASE_URL}/api/admin/conversations`);
 
 
60
  expect(response.ok()).toBeTruthy();
61
  expect(response.status()).toBe(200);
62
 
@@ -81,7 +88,9 @@ test.describe('Admin Conversations APIs', () => {
81
  test('GET /api/admin/conversations - should validate conversation object structure', async ({
82
  request,
83
  }) => {
84
- const response = await request.get(`${BASE_URL}/api/admin/conversations?limit=1`);
 
 
85
  const data = await response.json();
86
 
87
  if (data.conversations.length > 0) {
@@ -120,7 +129,8 @@ test.describe('Admin Conversations APIs', () => {
120
 
121
  test('GET /api/admin/conversations - should filter by userId', async ({ request }) => {
122
  const response = await request.get(
123
- `${BASE_URL}/api/admin/conversations?userId=${testUserId}`
 
124
  );
125
  expect(response.ok()).toBeTruthy();
126
 
@@ -140,7 +150,8 @@ test.describe('Admin Conversations APIs', () => {
140
  request,
141
  }) => {
142
  const response = await request.get(
143
- `${BASE_URL}/api/admin/conversations?studentPromptId=ruirui`
 
144
  );
145
  expect(response.ok()).toBeTruthy();
146
 
@@ -154,7 +165,8 @@ test.describe('Admin Conversations APIs', () => {
154
 
155
  test('GET /api/admin/conversations - should filter by coachPromptId', async ({ request }) => {
156
  const response = await request.get(
157
- `${BASE_URL}/api/admin/conversations?coachPromptId=empathetic`
 
158
  );
159
  expect(response.ok()).toBeTruthy();
160
 
@@ -168,7 +180,8 @@ test.describe('Admin Conversations APIs', () => {
168
 
169
  test('GET /api/admin/conversations - should support multiple filters', async ({ request }) => {
170
  const response = await request.get(
171
- `${BASE_URL}/api/admin/conversations?userId=${testUserId}&studentPromptId=ruirui&coachPromptId=empathetic`
 
172
  );
173
  expect(response.ok()).toBeTruthy();
174
 
@@ -184,12 +197,14 @@ test.describe('Admin Conversations APIs', () => {
184
 
185
  test('GET /api/admin/conversations - should sort by createdAt', async ({ request }) => {
186
  const ascResponse = await request.get(
187
- `${BASE_URL}/api/admin/conversations?sortBy=createdAt&sortOrder=asc&limit=10`
 
188
  );
189
  expect(ascResponse.ok()).toBeTruthy();
190
 
191
  const descResponse = await request.get(
192
- `${BASE_URL}/api/admin/conversations?sortBy=createdAt&sortOrder=desc&limit=10`
 
193
  );
194
  expect(descResponse.ok()).toBeTruthy();
195
 
@@ -213,7 +228,8 @@ test.describe('Admin Conversations APIs', () => {
213
 
214
  test('GET /api/admin/conversations - should sort by lastActiveAt', async ({ request }) => {
215
  const response = await request.get(
216
- `${BASE_URL}/api/admin/conversations?sortBy=lastActiveAt&sortOrder=desc&limit=10`
 
217
  );
218
  expect(response.ok()).toBeTruthy();
219
 
@@ -229,7 +245,8 @@ test.describe('Admin Conversations APIs', () => {
229
 
230
  test('GET /api/admin/conversations - should sort by messageCount', async ({ request }) => {
231
  const response = await request.get(
232
- `${BASE_URL}/api/admin/conversations?sortBy=messageCount&sortOrder=desc&limit=10`
 
233
  );
234
  expect(response.ok()).toBeTruthy();
235
 
@@ -247,7 +264,8 @@ test.describe('Admin Conversations APIs', () => {
247
  request,
248
  }) => {
249
  const response = await request.get(
250
- `${BASE_URL}/api/admin/conversations?sortBy=invalidField`
 
251
  );
252
 
253
  expect(response.status()).toBe(400);
@@ -258,13 +276,16 @@ test.describe('Admin Conversations APIs', () => {
258
  });
259
 
260
  test('GET /api/admin/conversations - should support pagination', async ({ request }) => {
261
- const page1Response = await request.get(`${BASE_URL}/api/admin/conversations?page=1&limit=5`);
 
 
262
  expect(page1Response.ok()).toBeTruthy();
263
  const page1Data = await page1Response.json();
264
 
265
  if (page1Data.pagination.totalPages > 1) {
266
  const page2Response = await request.get(
267
- `${BASE_URL}/api/admin/conversations?page=2&limit=5`
 
268
  );
269
  expect(page2Response.ok()).toBeTruthy();
270
  const page2Data = await page2Response.json();
@@ -280,7 +301,9 @@ test.describe('Admin Conversations APIs', () => {
280
  });
281
 
282
  test('GET /api/admin/conversations - should enforce max limit of 100', async ({ request }) => {
283
- const response = await request.get(`${BASE_URL}/api/admin/conversations?limit=200`);
 
 
284
  const data = await response.json();
285
 
286
  // Should cap at 100
@@ -291,7 +314,8 @@ test.describe('Admin Conversations APIs', () => {
291
  request,
292
  }) => {
293
  const response = await request.get(
294
- `${BASE_URL}/api/admin/conversations/${testConversationId}`
 
295
  );
296
  expect(response.ok()).toBeTruthy();
297
  expect(response.status()).toBe(200);
@@ -323,7 +347,8 @@ test.describe('Admin Conversations APIs', () => {
323
  request,
324
  }) => {
325
  const response = await request.get(
326
- `${BASE_URL}/api/admin/conversations/${testConversationId}`
 
327
  );
328
  const data = await response.json();
329
 
@@ -363,7 +388,8 @@ test.describe('Admin Conversations APIs', () => {
363
  request,
364
  }) => {
365
  const response = await request.get(
366
- `${BASE_URL}/api/admin/conversations/${testConversationId}`
 
367
  );
368
  const data = await response.json();
369
 
@@ -376,7 +402,9 @@ test.describe('Admin Conversations APIs', () => {
376
  request,
377
  }) => {
378
  const fakeConvId = '00000000-0000-0000-0000-000000000000';
379
- const response = await request.get(`${BASE_URL}/api/admin/conversations/${fakeConvId}`);
 
 
380
 
381
  expect(response.status()).toBe(404);
382
 
@@ -390,7 +418,8 @@ test.describe('Admin Conversations APIs', () => {
390
  }) => {
391
  const fakeUserId = '00000000-0000-0000-0000-000000000000';
392
  const response = await request.get(
393
- `${BASE_URL}/api/admin/conversations?userId=${fakeUserId}`
 
394
  );
395
  expect(response.ok()).toBeTruthy();
396
 
@@ -403,7 +432,8 @@ test.describe('Admin Conversations APIs', () => {
403
  request,
404
  }) => {
405
  const response = await request.get(
406
- `${BASE_URL}/api/admin/conversations/${testConversationId}`
 
407
  );
408
  const data = await response.json();
409
 
 
1
  import { test, expect } from '@playwright/test';
2
 
3
  const BASE_URL = 'http://localhost:3000';
4
+ const adminPassword = process.env.ADMIN_PASSWORD || 'cz-2025-admin';
5
 
6
  test.describe('Admin Conversations APIs', () => {
7
  let testUserId: string;
8
  let testConversationId: string;
9
  let testUsername: string;
10
+ let adminAuthHeader: string;
11
  const testPassword = process.env.BASIC_AUTH_PASSWORD || 'cz-2025';
12
 
13
  test.beforeAll(async ({ request }) => {
 
25
  const userData = await registerResponse.json();
26
  testUserId = userData.user.id;
27
 
28
+ // Create Admin Auth header (required for admin endpoints)
29
+ adminAuthHeader = `Basic ${Buffer.from(`${testUsername}:${adminPassword}`).toString('base64')}`;
30
+
31
  // Create Basic Auth header
32
  const authHeader = `Basic ${Buffer.from(`${testUsername}:${testPassword}`).toString('base64')}`;
33
 
 
61
  test('GET /api/admin/conversations - should return conversations list with default params', async ({
62
  request,
63
  }) => {
64
+ const response = await request.get(`${BASE_URL}/api/admin/conversations`, {
65
+ headers: { Authorization: adminAuthHeader },
66
+ });
67
  expect(response.ok()).toBeTruthy();
68
  expect(response.status()).toBe(200);
69
 
 
88
  test('GET /api/admin/conversations - should validate conversation object structure', async ({
89
  request,
90
  }) => {
91
+ const response = await request.get(`${BASE_URL}/api/admin/conversations?limit=1`, {
92
+ headers: { Authorization: adminAuthHeader },
93
+ });
94
  const data = await response.json();
95
 
96
  if (data.conversations.length > 0) {
 
129
 
130
  test('GET /api/admin/conversations - should filter by userId', async ({ request }) => {
131
  const response = await request.get(
132
+ `${BASE_URL}/api/admin/conversations?userId=${testUserId}`,
133
+ { headers: { Authorization: adminAuthHeader } }
134
  );
135
  expect(response.ok()).toBeTruthy();
136
 
 
150
  request,
151
  }) => {
152
  const response = await request.get(
153
+ `${BASE_URL}/api/admin/conversations?studentPromptId=ruirui`,
154
+ { headers: { Authorization: adminAuthHeader } }
155
  );
156
  expect(response.ok()).toBeTruthy();
157
 
 
165
 
166
  test('GET /api/admin/conversations - should filter by coachPromptId', async ({ request }) => {
167
  const response = await request.get(
168
+ `${BASE_URL}/api/admin/conversations?coachPromptId=empathetic`,
169
+ { headers: { Authorization: adminAuthHeader } }
170
  );
171
  expect(response.ok()).toBeTruthy();
172
 
 
180
 
181
  test('GET /api/admin/conversations - should support multiple filters', async ({ request }) => {
182
  const response = await request.get(
183
+ `${BASE_URL}/api/admin/conversations?userId=${testUserId}&studentPromptId=ruirui&coachPromptId=empathetic`,
184
+ { headers: { Authorization: adminAuthHeader } }
185
  );
186
  expect(response.ok()).toBeTruthy();
187
 
 
197
 
198
  test('GET /api/admin/conversations - should sort by createdAt', async ({ request }) => {
199
  const ascResponse = await request.get(
200
+ `${BASE_URL}/api/admin/conversations?sortBy=createdAt&sortOrder=asc&limit=10`,
201
+ { headers: { Authorization: adminAuthHeader } }
202
  );
203
  expect(ascResponse.ok()).toBeTruthy();
204
 
205
  const descResponse = await request.get(
206
+ `${BASE_URL}/api/admin/conversations?sortBy=createdAt&sortOrder=desc&limit=10`,
207
+ { headers: { Authorization: adminAuthHeader } }
208
  );
209
  expect(descResponse.ok()).toBeTruthy();
210
 
 
228
 
229
  test('GET /api/admin/conversations - should sort by lastActiveAt', async ({ request }) => {
230
  const response = await request.get(
231
+ `${BASE_URL}/api/admin/conversations?sortBy=lastActiveAt&sortOrder=desc&limit=10`,
232
+ { headers: { Authorization: adminAuthHeader } }
233
  );
234
  expect(response.ok()).toBeTruthy();
235
 
 
245
 
246
  test('GET /api/admin/conversations - should sort by messageCount', async ({ request }) => {
247
  const response = await request.get(
248
+ `${BASE_URL}/api/admin/conversations?sortBy=messageCount&sortOrder=desc&limit=10`,
249
+ { headers: { Authorization: adminAuthHeader } }
250
  );
251
  expect(response.ok()).toBeTruthy();
252
 
 
264
  request,
265
  }) => {
266
  const response = await request.get(
267
+ `${BASE_URL}/api/admin/conversations?sortBy=invalidField`,
268
+ { headers: { Authorization: adminAuthHeader } }
269
  );
270
 
271
  expect(response.status()).toBe(400);
 
276
  });
277
 
278
  test('GET /api/admin/conversations - should support pagination', async ({ request }) => {
279
+ const page1Response = await request.get(`${BASE_URL}/api/admin/conversations?page=1&limit=5`, {
280
+ headers: { Authorization: adminAuthHeader },
281
+ });
282
  expect(page1Response.ok()).toBeTruthy();
283
  const page1Data = await page1Response.json();
284
 
285
  if (page1Data.pagination.totalPages > 1) {
286
  const page2Response = await request.get(
287
+ `${BASE_URL}/api/admin/conversations?page=2&limit=5`,
288
+ { headers: { Authorization: adminAuthHeader } }
289
  );
290
  expect(page2Response.ok()).toBeTruthy();
291
  const page2Data = await page2Response.json();
 
301
  });
302
 
303
  test('GET /api/admin/conversations - should enforce max limit of 100', async ({ request }) => {
304
+ const response = await request.get(`${BASE_URL}/api/admin/conversations?limit=200`, {
305
+ headers: { Authorization: adminAuthHeader },
306
+ });
307
  const data = await response.json();
308
 
309
  // Should cap at 100
 
314
  request,
315
  }) => {
316
  const response = await request.get(
317
+ `${BASE_URL}/api/admin/conversations/${testConversationId}`,
318
+ { headers: { Authorization: adminAuthHeader } }
319
  );
320
  expect(response.ok()).toBeTruthy();
321
  expect(response.status()).toBe(200);
 
347
  request,
348
  }) => {
349
  const response = await request.get(
350
+ `${BASE_URL}/api/admin/conversations/${testConversationId}`,
351
+ { headers: { Authorization: adminAuthHeader } }
352
  );
353
  const data = await response.json();
354
 
 
388
  request,
389
  }) => {
390
  const response = await request.get(
391
+ `${BASE_URL}/api/admin/conversations/${testConversationId}`,
392
+ { headers: { Authorization: adminAuthHeader } }
393
  );
394
  const data = await response.json();
395
 
 
402
  request,
403
  }) => {
404
  const fakeConvId = '00000000-0000-0000-0000-000000000000';
405
+ const response = await request.get(`${BASE_URL}/api/admin/conversations/${fakeConvId}`, {
406
+ headers: { Authorization: adminAuthHeader },
407
+ });
408
 
409
  expect(response.status()).toBe(404);
410
 
 
418
  }) => {
419
  const fakeUserId = '00000000-0000-0000-0000-000000000000';
420
  const response = await request.get(
421
+ `${BASE_URL}/api/admin/conversations?userId=${fakeUserId}`,
422
+ { headers: { Authorization: adminAuthHeader } }
423
  );
424
  expect(response.ok()).toBeTruthy();
425
 
 
432
  request,
433
  }) => {
434
  const response = await request.get(
435
+ `${BASE_URL}/api/admin/conversations/${testConversationId}`,
436
+ { headers: { Authorization: adminAuthHeader } }
437
  );
438
  const data = await response.json();
439
 
tests/e2e/admin-stats-health.api.spec.ts CHANGED
@@ -1,10 +1,30 @@
1
  import { test, expect } from '@playwright/test';
2
 
3
  const BASE_URL = 'http://localhost:3000';
 
4
 
5
  test.describe('Admin Stats and Health APIs', () => {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6
  test('GET /api/admin/stats - should return system statistics', async ({ request }) => {
7
- const response = await request.get(`${BASE_URL}/api/admin/stats`);
 
 
8
  expect(response.ok()).toBeTruthy();
9
  expect(response.status()).toBe(200);
10
 
@@ -50,7 +70,9 @@ test.describe('Admin Stats and Health APIs', () => {
50
  });
51
 
52
  test('GET /api/admin/health - should return database health metrics', async ({ request }) => {
53
- const response = await request.get(`${BASE_URL}/api/admin/health`);
 
 
54
  expect(response.ok()).toBeTruthy();
55
  expect(response.status()).toBe(200);
56
 
@@ -86,7 +108,9 @@ test.describe('Admin Stats and Health APIs', () => {
86
  });
87
 
88
  test('GET /api/admin/stats - should have consistent data', async ({ request }) => {
89
- const response = await request.get(`${BASE_URL}/api/admin/stats`);
 
 
90
  const stats = await response.json();
91
 
92
  // New users in last 7 days should not exceed total users
@@ -110,7 +134,9 @@ test.describe('Admin Stats and Health APIs', () => {
110
  test('GET /api/admin/health - should show prompt_templates table with data', async ({
111
  request,
112
  }) => {
113
- const response = await request.get(`${BASE_URL}/api/admin/health`);
 
 
114
  const health = await response.json();
115
 
116
  const promptTemplatesTable = health.tables.find((t: any) => t.name === 'prompt_templates');
@@ -123,8 +149,8 @@ test.describe('Admin Stats and Health APIs', () => {
123
  test('Stats API totals should match Health API row counts', async ({ request }) => {
124
  // Fetch both endpoints
125
  const [statsRes, healthRes] = await Promise.all([
126
- request.get(`${BASE_URL}/api/admin/stats`),
127
- request.get(`${BASE_URL}/api/admin/health`),
128
  ]);
129
 
130
  expect(statsRes.ok()).toBeTruthy();
 
1
  import { test, expect } from '@playwright/test';
2
 
3
  const BASE_URL = 'http://localhost:3000';
4
+ const adminPassword = process.env.ADMIN_PASSWORD || 'cz-2025-admin';
5
 
6
  test.describe('Admin Stats and Health APIs', () => {
7
+ let adminAuthHeader: string;
8
+ let testUsername: string;
9
+
10
+ test.beforeAll(async ({ request }) => {
11
+ // Create a test user for admin auth
12
+ const timestamp = Date.now();
13
+ testUsername = `admin_stats_test_${timestamp}`;
14
+
15
+ const registerResponse = await request.post(`${BASE_URL}/api/auth/register`, {
16
+ data: { username: testUsername },
17
+ });
18
+ expect(registerResponse.ok()).toBeTruthy();
19
+
20
+ // Create Admin Auth header
21
+ adminAuthHeader = `Basic ${Buffer.from(`${testUsername}:${adminPassword}`).toString('base64')}`;
22
+ });
23
+
24
  test('GET /api/admin/stats - should return system statistics', async ({ request }) => {
25
+ const response = await request.get(`${BASE_URL}/api/admin/stats`, {
26
+ headers: { Authorization: adminAuthHeader },
27
+ });
28
  expect(response.ok()).toBeTruthy();
29
  expect(response.status()).toBe(200);
30
 
 
70
  });
71
 
72
  test('GET /api/admin/health - should return database health metrics', async ({ request }) => {
73
+ const response = await request.get(`${BASE_URL}/api/admin/health`, {
74
+ headers: { Authorization: adminAuthHeader },
75
+ });
76
  expect(response.ok()).toBeTruthy();
77
  expect(response.status()).toBe(200);
78
 
 
108
  });
109
 
110
  test('GET /api/admin/stats - should have consistent data', async ({ request }) => {
111
+ const response = await request.get(`${BASE_URL}/api/admin/stats`, {
112
+ headers: { Authorization: adminAuthHeader },
113
+ });
114
  const stats = await response.json();
115
 
116
  // New users in last 7 days should not exceed total users
 
134
  test('GET /api/admin/health - should show prompt_templates table with data', async ({
135
  request,
136
  }) => {
137
+ const response = await request.get(`${BASE_URL}/api/admin/health`, {
138
+ headers: { Authorization: adminAuthHeader },
139
+ });
140
  const health = await response.json();
141
 
142
  const promptTemplatesTable = health.tables.find((t: any) => t.name === 'prompt_templates');
 
149
  test('Stats API totals should match Health API row counts', async ({ request }) => {
150
  // Fetch both endpoints
151
  const [statsRes, healthRes] = await Promise.all([
152
+ request.get(`${BASE_URL}/api/admin/stats`, { headers: { Authorization: adminAuthHeader } }),
153
+ request.get(`${BASE_URL}/api/admin/health`, { headers: { Authorization: adminAuthHeader } }),
154
  ]);
155
 
156
  expect(statsRes.ok()).toBeTruthy();
tests/e2e/admin-users.api.spec.ts CHANGED
@@ -1,10 +1,12 @@
1
  import { test, expect } from '@playwright/test';
2
 
3
  const BASE_URL = 'http://localhost:3000';
 
4
 
5
  test.describe('Admin Users APIs', () => {
6
  let testUserId: string;
7
  let testUsername: string;
 
8
 
9
  test.beforeAll(async ({ request }) => {
10
  // Create a test user
@@ -20,12 +22,17 @@ test.describe('Admin Users APIs', () => {
20
  expect(response.ok()).toBeTruthy();
21
  const data = await response.json();
22
  testUserId = data.user.id;
 
 
 
23
  });
24
 
25
  test('GET /api/admin/users - should return users list with default params', async ({
26
  request,
27
  }) => {
28
- const response = await request.get(`${BASE_URL}/api/admin/users`);
 
 
29
  expect(response.ok()).toBeTruthy();
30
  expect(response.status()).toBe(200);
31
 
@@ -53,7 +60,9 @@ test.describe('Admin Users APIs', () => {
53
  });
54
 
55
  test('GET /api/admin/users - should validate user object structure', async ({ request }) => {
56
- const response = await request.get(`${BASE_URL}/api/admin/users?limit=1`);
 
 
57
  const data = await response.json();
58
 
59
  if (data.users.length > 0) {
@@ -86,7 +95,9 @@ test.describe('Admin Users APIs', () => {
86
  });
87
 
88
  test('GET /api/admin/users - should support search by username', async ({ request }) => {
89
- const response = await request.get(`${BASE_URL}/api/admin/users?search=${testUsername}`);
 
 
90
  expect(response.ok()).toBeTruthy();
91
 
92
  const data = await response.json();
@@ -103,7 +114,8 @@ test.describe('Admin Users APIs', () => {
103
 
104
  test('GET /api/admin/users - should support sorting by username', async ({ request }) => {
105
  const response = await request.get(
106
- `${BASE_URL}/api/admin/users?sortBy=username&sortOrder=asc&limit=10`
 
107
  );
108
  expect(response.ok()).toBeTruthy();
109
 
@@ -118,12 +130,14 @@ test.describe('Admin Users APIs', () => {
118
 
119
  test('GET /api/admin/users - should support sorting by createdAt', async ({ request }) => {
120
  const ascResponse = await request.get(
121
- `${BASE_URL}/api/admin/users?sortBy=createdAt&sortOrder=asc&limit=10`
 
122
  );
123
  expect(ascResponse.ok()).toBeTruthy();
124
 
125
  const descResponse = await request.get(
126
- `${BASE_URL}/api/admin/users?sortBy=createdAt&sortOrder=desc&limit=10`
 
127
  );
128
  expect(descResponse.ok()).toBeTruthy();
129
 
@@ -148,12 +162,16 @@ test.describe('Admin Users APIs', () => {
148
  });
149
 
150
  test('GET /api/admin/users - should support pagination', async ({ request }) => {
151
- const page1Response = await request.get(`${BASE_URL}/api/admin/users?page=1&limit=5`);
 
 
152
  expect(page1Response.ok()).toBeTruthy();
153
  const page1Data = await page1Response.json();
154
 
155
  if (page1Data.pagination.totalPages > 1) {
156
- const page2Response = await request.get(`${BASE_URL}/api/admin/users?page=2&limit=5`);
 
 
157
  expect(page2Response.ok()).toBeTruthy();
158
  const page2Data = await page2Response.json();
159
 
@@ -169,14 +187,18 @@ test.describe('Admin Users APIs', () => {
169
 
170
  test('GET /api/admin/users - should respect limit parameter', async ({ request }) => {
171
  const limit = 3;
172
- const response = await request.get(`${BASE_URL}/api/admin/users?limit=${limit}`);
 
 
173
  const data = await response.json();
174
 
175
  expect(data.users.length).toBeLessThanOrEqual(limit);
176
  });
177
 
178
  test('GET /api/admin/users - should enforce max limit of 100', async ({ request }) => {
179
- const response = await request.get(`${BASE_URL}/api/admin/users?limit=200`);
 
 
180
  const data = await response.json();
181
 
182
  // Should cap at 100
@@ -184,7 +206,9 @@ test.describe('Admin Users APIs', () => {
184
  });
185
 
186
  test('GET /api/admin/users/[userId] - should return user detail', async ({ request }) => {
187
- const response = await request.get(`${BASE_URL}/api/admin/users/${testUserId}`);
 
 
188
  expect(response.ok()).toBeTruthy();
189
  expect(response.status()).toBe(200);
190
 
@@ -236,7 +260,8 @@ test.describe('Admin Users APIs', () => {
236
 
237
  // Now fetch user detail
238
  const userDetailResponse = await request.get(
239
- `${BASE_URL}/api/admin/users/${userData.user.id}`
 
240
  );
241
  const data = await userDetailResponse.json();
242
 
@@ -271,7 +296,9 @@ test.describe('Admin Users APIs', () => {
271
  request,
272
  }) => {
273
  const fakeUserId = '00000000-0000-0000-0000-000000000000';
274
- const response = await request.get(`${BASE_URL}/api/admin/users/${fakeUserId}`);
 
 
275
 
276
  expect(response.status()).toBe(404);
277
 
@@ -282,7 +309,8 @@ test.describe('Admin Users APIs', () => {
282
 
283
  test('GET /api/admin/users - should handle empty search results', async ({ request }) => {
284
  const response = await request.get(
285
- `${BASE_URL}/api/admin/users?search=nonexistentuser12345xyz`
 
286
  );
287
  expect(response.ok()).toBeTruthy();
288
 
 
1
  import { test, expect } from '@playwright/test';
2
 
3
  const BASE_URL = 'http://localhost:3000';
4
+ const adminPassword = process.env.ADMIN_PASSWORD || 'cz-2025-admin';
5
 
6
  test.describe('Admin Users APIs', () => {
7
  let testUserId: string;
8
  let testUsername: string;
9
+ let adminAuthHeader: string;
10
 
11
  test.beforeAll(async ({ request }) => {
12
  // Create a test user
 
22
  expect(response.ok()).toBeTruthy();
23
  const data = await response.json();
24
  testUserId = data.user.id;
25
+
26
+ // Create Admin Auth header (required for admin endpoints)
27
+ adminAuthHeader = `Basic ${Buffer.from(`${testUsername}:${adminPassword}`).toString('base64')}`;
28
  });
29
 
30
  test('GET /api/admin/users - should return users list with default params', async ({
31
  request,
32
  }) => {
33
+ const response = await request.get(`${BASE_URL}/api/admin/users`, {
34
+ headers: { Authorization: adminAuthHeader },
35
+ });
36
  expect(response.ok()).toBeTruthy();
37
  expect(response.status()).toBe(200);
38
 
 
60
  });
61
 
62
  test('GET /api/admin/users - should validate user object structure', async ({ request }) => {
63
+ const response = await request.get(`${BASE_URL}/api/admin/users?limit=1`, {
64
+ headers: { Authorization: adminAuthHeader },
65
+ });
66
  const data = await response.json();
67
 
68
  if (data.users.length > 0) {
 
95
  });
96
 
97
  test('GET /api/admin/users - should support search by username', async ({ request }) => {
98
+ const response = await request.get(`${BASE_URL}/api/admin/users?search=${testUsername}`, {
99
+ headers: { Authorization: adminAuthHeader },
100
+ });
101
  expect(response.ok()).toBeTruthy();
102
 
103
  const data = await response.json();
 
114
 
115
  test('GET /api/admin/users - should support sorting by username', async ({ request }) => {
116
  const response = await request.get(
117
+ `${BASE_URL}/api/admin/users?sortBy=username&sortOrder=asc&limit=10`,
118
+ { headers: { Authorization: adminAuthHeader } }
119
  );
120
  expect(response.ok()).toBeTruthy();
121
 
 
130
 
131
  test('GET /api/admin/users - should support sorting by createdAt', async ({ request }) => {
132
  const ascResponse = await request.get(
133
+ `${BASE_URL}/api/admin/users?sortBy=createdAt&sortOrder=asc&limit=10`,
134
+ { headers: { Authorization: adminAuthHeader } }
135
  );
136
  expect(ascResponse.ok()).toBeTruthy();
137
 
138
  const descResponse = await request.get(
139
+ `${BASE_URL}/api/admin/users?sortBy=createdAt&sortOrder=desc&limit=10`,
140
+ { headers: { Authorization: adminAuthHeader } }
141
  );
142
  expect(descResponse.ok()).toBeTruthy();
143
 
 
162
  });
163
 
164
  test('GET /api/admin/users - should support pagination', async ({ request }) => {
165
+ const page1Response = await request.get(`${BASE_URL}/api/admin/users?page=1&limit=5`, {
166
+ headers: { Authorization: adminAuthHeader },
167
+ });
168
  expect(page1Response.ok()).toBeTruthy();
169
  const page1Data = await page1Response.json();
170
 
171
  if (page1Data.pagination.totalPages > 1) {
172
+ const page2Response = await request.get(`${BASE_URL}/api/admin/users?page=2&limit=5`, {
173
+ headers: { Authorization: adminAuthHeader },
174
+ });
175
  expect(page2Response.ok()).toBeTruthy();
176
  const page2Data = await page2Response.json();
177
 
 
187
 
188
  test('GET /api/admin/users - should respect limit parameter', async ({ request }) => {
189
  const limit = 3;
190
+ const response = await request.get(`${BASE_URL}/api/admin/users?limit=${limit}`, {
191
+ headers: { Authorization: adminAuthHeader },
192
+ });
193
  const data = await response.json();
194
 
195
  expect(data.users.length).toBeLessThanOrEqual(limit);
196
  });
197
 
198
  test('GET /api/admin/users - should enforce max limit of 100', async ({ request }) => {
199
+ const response = await request.get(`${BASE_URL}/api/admin/users?limit=200`, {
200
+ headers: { Authorization: adminAuthHeader },
201
+ });
202
  const data = await response.json();
203
 
204
  // Should cap at 100
 
206
  });
207
 
208
  test('GET /api/admin/users/[userId] - should return user detail', async ({ request }) => {
209
+ const response = await request.get(`${BASE_URL}/api/admin/users/${testUserId}`, {
210
+ headers: { Authorization: adminAuthHeader },
211
+ });
212
  expect(response.ok()).toBeTruthy();
213
  expect(response.status()).toBe(200);
214
 
 
260
 
261
  // Now fetch user detail
262
  const userDetailResponse = await request.get(
263
+ `${BASE_URL}/api/admin/users/${userData.user.id}`,
264
+ { headers: { Authorization: adminAuthHeader } }
265
  );
266
  const data = await userDetailResponse.json();
267
 
 
296
  request,
297
  }) => {
298
  const fakeUserId = '00000000-0000-0000-0000-000000000000';
299
+ const response = await request.get(`${BASE_URL}/api/admin/users/${fakeUserId}`, {
300
+ headers: { Authorization: adminAuthHeader },
301
+ });
302
 
303
  expect(response.status()).toBe(404);
304
 
 
309
 
310
  test('GET /api/admin/users - should handle empty search results', async ({ request }) => {
311
  const response = await request.get(
312
+ `${BASE_URL}/api/admin/users?search=nonexistentuser12345xyz`,
313
+ { headers: { Authorization: adminAuthHeader } }
314
  );
315
  expect(response.ok()).toBeTruthy();
316