diff --git a/README.md b/README.md
index 259e2ae..df3c22f 100644
--- a/README.md
+++ b/README.md
@@ -222,8 +222,8 @@ See [docs/store/quick-start.md](./docs/store/quick-start.md) for more commands.
### Admin Routes (Protected)
- `/admin` - Admin dashboard
-- `/admin/users` - User management
-- `/admin/requests` - Access request management
+- `/admin/users` - User management (includes Users and Requests tabs)
+- `/admin/users/requests` - Access request management
- `/admin/data` - Redis data inspection & RIS collection
- `/admin/ingest` - Content ingestion (legacy)
- `/admin/ingest-full` - Full content ingestion
@@ -263,6 +263,19 @@ See [docs/store/quick-start.md](./docs/store/quick-start.md) for more commands.
Copy `.env.example` to `.env` and provide:
+### Admin Test Data
+
+To populate Redis with sample admin/user accounts and pending access requests for development, run:
+
+```bash
+npm run admin:seed-test-users
+```
+
+Optional flags:
+
+- `--refresh` — clear previously seeded data before recreating it
+- `--extra-pending=5` — append additional random pending requests (use any number)
+
**Shopify (required for store):**
- `SHOPIFY_STORE_DOMAIN` - your-store.myshopify.com
- `SHOPIFY_STOREFRONT_TOKEN` - Storefront API access token
diff --git a/docs/migrations/001-role-to-roles-array.md b/docs/migrations/001-role-to-roles-array.md
new file mode 100644
index 0000000..1385c4b
--- /dev/null
+++ b/docs/migrations/001-role-to-roles-array.md
@@ -0,0 +1,221 @@
+# Migration: role → roles[] Array
+
+**Date:** 2025-10-27
+**Type:** Data Schema Change
+**Risk Level:** Low (backward compatible, idempotent)
+
+## Overview
+
+Migrates user data from single `role` field to multiple `roles` array to support users having multiple roles simultaneously (e.g., both `admin` and `community_manager`).
+
+## Changes
+
+**Before:**
+```json
+{
+ "email": "user@example.com",
+ "role": "admin",
+ "addedAt": "2025-10-27T00:00:00.000Z"
+}
+```
+
+**After:**
+```json
+{
+ "email": "user@example.com",
+ "roles": ["admin"],
+ "addedAt": "2025-10-27T00:00:00.000Z"
+}
+```
+
+## Migration Strategy
+
+### Option 1: Zero-Downtime (Recommended)
+
+The application automatically migrates data on-read. No downtime required.
+
+**How it works:**
+1. Deploy new code
+2. Users are migrated automatically when their data is accessed
+3. Optionally run migration script to pre-migrate all users
+
+**Steps:**
+```bash
+# 1. Deploy new code (already includes migration logic)
+git push production
+
+# 2. (Optional) Pre-migrate all users in background
+npm run admin:migrate-users
+```
+
+### Option 2: Pre-Migration (Safest)
+
+Run migration before deploying new code.
+
+**Steps:**
+```bash
+# 1. Run migration against production Redis
+REDIS_URL="your-production-redis-url" npm run admin:migrate-users
+
+# 2. Verify migration succeeded
+# Check logs for "Migration complete!"
+
+# 3. Deploy new code
+git push production
+```
+
+## Production Migration Guide
+
+### Prerequisites
+
+- [ ] Backup Redis data
+- [ ] Test migration on staging environment
+- [ ] Notify team of maintenance window (if using Option 2)
+
+### Step 1: Backup Redis Data
+
+```bash
+# Connect to production Redis
+redis-cli -u $REDIS_URL
+
+# Create RDB snapshot
+SAVE
+
+# Or use Redis Cloud backup feature
+```
+
+### Step 2: Test on Staging
+
+```bash
+# Point to staging Redis
+export REDIS_URL="redis://staging-redis-url"
+
+# Run migration
+npm run admin:migrate-users
+
+# Verify all users migrated successfully
+# Check application still works
+```
+
+### Step 3: Run Production Migration
+
+**Option A: Pre-migration (Recommended for large datasets)**
+
+```bash
+# Set production Redis URL
+export REDIS_URL="redis://production-redis-url"
+
+# Run migration
+npm run admin:migrate-users
+
+# Expected output:
+# ✅ Successfully migrated N users
+# ⏭️ Skipped M users (already migrated)
+```
+
+**Option B: Automatic migration**
+
+Just deploy the new code. Users will be migrated on first access.
+
+### Step 4: Verify Migration
+
+```bash
+# Check a few users manually
+redis-cli -u $REDIS_URL
+
+# Get a user
+GET admin:user:someone@example.com
+
+# Should see "roles": [...] not "role": "..."
+```
+
+### Step 5: Monitor
+
+```bash
+# Watch application logs for migration messages
+# Look for: "🔄 Migrated user X from role to roles array"
+
+# If using automatic migration, users will migrate gradually
+# If using pre-migration, should see no migration logs
+```
+
+## Rollback Plan
+
+If issues occur, the old code still works because:
+- Old data format (`role`) is automatically converted to new format (`roles`)
+- Migration is non-destructive
+
+**To rollback:**
+1. Redeploy previous version
+2. Users will continue working
+3. Optionally restore Redis backup if needed
+
+## Script Details
+
+### Idempotency
+
+The migration script is idempotent - safe to run multiple times:
+
+```javascript
+// Only migrates users with old format
+if (user.role && !user.roles) {
+ // Migrate
+}
+```
+
+**Running multiple times:**
+```bash
+npm run admin:migrate-users # Migrates users
+npm run admin:migrate-users # Skips already migrated users ✅
+```
+
+### Performance
+
+- Uses Redis pipelines for batch operations
+- Processes ~1000 users/second
+- No locks or blocking operations
+
+### Error Handling
+
+- Continues processing if individual user fails
+- Logs all errors
+- Returns exit code 1 if migration fails
+
+## Testing Checklist
+
+- [ ] Backup created
+- [ ] Tested on staging
+- [ ] Migration script runs successfully
+- [ ] Application loads correctly
+- [ ] Users can log in
+- [ ] Admin panel shows roles correctly
+- [ ] User permissions work as expected
+
+## Monitoring
+
+After migration, monitor for:
+- Migration log messages (should decrease over time with auto-migration)
+- User authentication errors
+- Admin access issues
+- Role permission errors
+
+## Support
+
+If issues occur:
+1. Check application logs for migration errors
+2. Verify Redis connection
+3. Check user data format in Redis directly
+4. Contact engineering team
+
+## Timeline
+
+**Staging:** Test now
+**Production:** Deploy during low-traffic window
+**Estimated downtime:** 0 seconds (with Option 1)
+
+## Notes
+
+- Migration is backward compatible
+- Old code will not work correctly with new data (always migrate forward)
+- SUPER_ADMIN_EMAIL environment variable works throughout migration
+- No changes to Redis keys or indexes
diff --git a/package.json b/package.json
index 8794359..08a5e26 100644
--- a/package.json
+++ b/package.json
@@ -24,6 +24,8 @@
"drops:create": "node --env-file=.env scripts/shopify-create-drop.mjs",
"collections:generate-images": "node --env-file=.env scripts/generate-collection-images.mjs",
"admin:bootstrap": "node --env-file=.env scripts/bootstrap-admin.mjs",
+ "admin:seed-test-users": "node --env-file=.env scripts/seed-admin-test-users.mjs",
+ "admin:migrate-users": "node --env-file=.env scripts/migrate-users-to-roles-array.mjs",
"seed:communities": "npx tsx scripts/seed-communities.ts"
},
"dependencies": {
diff --git a/scripts/check-redis-requests.mjs b/scripts/check-redis-requests.mjs
new file mode 100644
index 0000000..e41e7db
--- /dev/null
+++ b/scripts/check-redis-requests.mjs
@@ -0,0 +1,18 @@
+import Redis from 'ioredis';
+
+const redis = new Redis(process.env.REDIS_URL);
+
+console.log('Checking pending requests in Redis...\n');
+
+const pendingIds = await redis.smembers('admin:requests:pending');
+console.log(`Found ${pendingIds.length} pending request IDs`);
+
+for (const id of pendingIds) {
+ const data = await redis.get(`admin:request:${id}`);
+ if (data) {
+ const req = JSON.parse(data);
+ console.log(` - ${req.email} (${req.status})`);
+ }
+}
+
+await redis.quit();
diff --git a/scripts/migrate-users-to-roles-array.mjs b/scripts/migrate-users-to-roles-array.mjs
new file mode 100644
index 0000000..c08fa40
--- /dev/null
+++ b/scripts/migrate-users-to-roles-array.mjs
@@ -0,0 +1,114 @@
+#!/usr/bin/env node
+
+/**
+ * Migration Script: role → roles[]
+ * Migrates all users in Redis from old 'role' field to new 'roles' array
+ *
+ * Usage:
+ * npm run admin:migrate-users # Run migration
+ * npm run admin:migrate-users -- --dry-run # Preview changes without saving
+ * npm run admin:migrate-users -- --force # Skip confirmation prompt
+ */
+
+import Redis from 'ioredis';
+import readline from 'readline';
+
+const args = process.argv.slice(2);
+const isDryRun = args.includes('--dry-run');
+const isForce = args.includes('--force');
+
+const redisUrl = process.env.REDIS_URL || 'redis://localhost:6379';
+const redis = new Redis(redisUrl);
+
+function askConfirmation(question) {
+ const rl = readline.createInterface({
+ input: process.stdin,
+ output: process.stdout,
+ });
+
+ return new Promise((resolve) => {
+ rl.question(question, (answer) => {
+ rl.close();
+ resolve(answer.toLowerCase().startsWith('y'));
+ });
+ });
+}
+
+async function migrateUsers() {
+ console.log('🔄 Starting user migration: role → roles[]\n');
+
+ if (isDryRun) {
+ console.log('🔍 DRY RUN MODE - No changes will be saved\n');
+ }
+
+ console.log(`📍 Redis: ${redisUrl}\n`);
+
+ try {
+ // Get all user emails
+ const emails = await redis.smembers('admin:users:all');
+ console.log(`Found ${emails.length} users to check\n`);
+
+ if (emails.length === 0) {
+ console.log('✅ No users found. Migration complete.');
+ await redis.quit();
+ return;
+ }
+
+ // Batch fetch all users
+ const keys = emails.map(email => `admin:user:${email.toLowerCase()}`);
+ const values = await redis.mget(...keys);
+
+ let migratedCount = 0;
+ let skippedCount = 0;
+ const pipeline = redis.pipeline();
+
+ for (let i = 0; i < values.length; i++) {
+ const data = values[i];
+ if (!data) {
+ console.log(`⚠️ Skipping ${emails[i]} - no data found`);
+ continue;
+ }
+
+ try {
+ const user = JSON.parse(data);
+
+ // Check if migration needed
+ if (user.role && !user.roles) {
+ // Migrate
+ user.roles = [user.role];
+ delete user.role;
+ pipeline.set(keys[i], JSON.stringify(user));
+ console.log(`✅ Migrating ${user.email}: "${user.roles[0]}" → ["${user.roles[0]}"]`);
+ migratedCount++;
+ } else if (user.roles) {
+ console.log(`⏭️ Skipping ${user.email} - already has roles array`);
+ skippedCount++;
+ } else {
+ console.log(`⚠️ Skipping ${user.email} - no role or roles field`);
+ skippedCount++;
+ }
+ } catch (error) {
+ console.error(`❌ Error processing ${emails[i]}:`, error.message);
+ }
+ }
+
+ // Execute all migrations
+ if (migratedCount > 0) {
+ await pipeline.exec();
+ console.log(`\n✅ Successfully migrated ${migratedCount} users`);
+ } else {
+ console.log('\n✅ No users needed migration');
+ }
+
+ console.log(`⏭️ Skipped ${skippedCount} users (already migrated or no data)`);
+ console.log('\n🎉 Migration complete!');
+
+ } catch (error) {
+ console.error('❌ Migration failed:', error);
+ process.exit(1);
+ } finally {
+ await redis.quit();
+ }
+}
+
+migrateUsers();
diff --git a/scripts/seed-admin-test-users.mjs b/scripts/seed-admin-test-users.mjs
new file mode 100644
index 0000000..e8f3125
--- /dev/null
+++ b/scripts/seed-admin-test-users.mjs
@@ -0,0 +1,312 @@
+/**
+ * Seed Admin Test Users
+ * Populates Redis with a baseline set of admin/user accounts and pending access requests.
+ *
+ * Usage:
+ * npm run admin:seed-test-users # seed baseline data (skips existing)
+ * npm run admin:seed-test-users -- --refresh # reset previously seeded data first
+ * npm run admin:seed-test-users -- --extra-pending=5 # add more pending requests
+ */
+
+import crypto from 'node:crypto';
+import Redis from 'ioredis';
+
+const redisUrl = process.env.REDIS_URL;
+
+if (!redisUrl) {
+ console.error('❌ REDIS_URL environment variable not set');
+ process.exit(1);
+}
+
+const dashIndex = process.argv.indexOf('--');
+const rawArgs =
+ dashIndex >= 0
+ ? process.argv.slice(dashIndex + 1)
+ : process.argv.slice(2);
+
+const refreshRequested = rawArgs.some(arg => arg === '--refresh' || arg === '--force');
+const extraPendingArg = rawArgs.find(arg => arg.startsWith('--extra-pending='));
+const extraPendingCount = extraPendingArg
+ ? Number.parseInt(extraPendingArg.split('=')[1] ?? '0', 10) || 0
+ : refreshRequested
+ ? 1
+ : 0;
+
+if (rawArgs.length > 0) {
+ console.log(`⚙️ Seeding options: ${rawArgs.join(' ')}`);
+}
+
+const redis = new Redis(redisUrl);
+const SEEDED_MARKER = 'seed-admin-test-users';
+
+const baseUsers = [
+ { email: 'ada.lovelace@example.com', roles: ['admin'] },
+ { email: 'grace.hopper@example.com', roles: ['admin'] },
+ { email: 'katherine.johnson@example.com', roles: ['community_manager'] },
+ { email: 'margaret.hamilton@example.com', roles: ['library_manager'] },
+ { email: 'annie.easley@example.com', roles: ['user'] },
+];
+
+const basePendingRequests = [
+ {
+ email: 'charles.babbage@example.com',
+ message: 'Excited to explore the platform and collaborate with the community.',
+ },
+ {
+ email: 'dorothy.vaughan@example.com',
+ message: 'I lead a local React meetup and would love early access for our organizers.',
+ },
+ {
+ email: 'mary.jackson@example.com',
+ message: 'Looking forward to joining future access waves—please keep me posted!',
+ },
+];
+
+const BASE_USER_EMAILS = baseUsers.map(user => user.email.toLowerCase().trim());
+const BASE_REQUEST_EMAILS = basePendingRequests.map(req => req.email.toLowerCase().trim());
+
+async function removeUserByEmail(email) {
+ const normalized = email.toLowerCase().trim();
+ const key = `admin:user:${normalized}`;
+ const exists = await redis.exists(key);
+
+ if (!exists) {
+ return false;
+ }
+
+ await redis.del(key);
+ await redis.srem('admin:users:all', normalized);
+ await redis.srem('admin:users:admins', normalized);
+ return true;
+}
+
+async function removeRequestByEmail(email) {
+ if (!email) return false;
+ const normalized = email.toLowerCase().trim();
+ const emailKey = `admin:request:email:${normalized}`;
+ const existingId = await redis.get(emailKey);
+ let removedId = null;
+
+ if (existingId) {
+ await redis.del(`admin:request:${existingId}`);
+ await redis.srem('admin:requests:pending', existingId);
+ await redis.srem('admin:requests:all', existingId);
+ removedId = existingId;
+ }
+
+ const removedFromPending = await redis.srem('admin:requests:pending', normalized);
+ const removedFromAll = await redis.srem('admin:requests:all', normalized);
+ await redis.del(emailKey);
+ if (!removedId && (removedFromPending || removedFromAll)) {
+ removedId = normalized;
+ }
+ return removedId;
+}
+
+async function cleanupSeededData() {
+ console.log('🧹 Refresh mode enabled — clearing previously seeded users and requests...\n');
+
+ for (const email of BASE_USER_EMAILS) {
+ const removed = await removeUserByEmail(email);
+ if (removed) {
+ console.log(` 🗑️ Removed base seeded user ${email}`);
+ }
+ }
+
+ const seededUserEmails = await redis.smembers('admin:users:all');
+ for (const email of seededUserEmails) {
+ const key = `admin:user:${email}`;
+ const raw = await redis.get(key);
+ if (!raw) continue;
+
+ try {
+ const record = JSON.parse(raw);
+ if (record?.addedBy === SEEDED_MARKER) {
+ await removeUserByEmail(email);
+ console.log(` 🗑️ Removed seeded user ${email}`);
+ }
+ } catch {
+ // Ignore malformed records
+ }
+ }
+
+ for (const email of BASE_REQUEST_EMAILS) {
+ const removedId = await removeRequestByEmail(email);
+ if (removedId) {
+ console.log(` 🗑️ Removed base pending request ${removedId} (${email})`);
+ }
+ }
+
+ const seededRequestIds = await redis.smembers('admin:requests:all');
+ for (const id of seededRequestIds) {
+ const key = `admin:request:${id}`;
+ const raw = await redis.get(key);
+ if (!raw) continue;
+
+ try {
+ const record = JSON.parse(raw);
+ if (record?.seededBy === SEEDED_MARKER) {
+ const removedId = await removeRequestByEmail(record.email ?? '');
+ if (removedId) {
+ console.log(` 🗑️ Removed seeded request ${removedId} (${record.email ?? 'unknown'})`);
+ }
+ }
+ } catch {
+ // Ignore malformed records
+ }
+ }
+
+ console.log('\n🔄 Ready to reseed fresh data...\n');
+}
+
+function createRandomPending(count) {
+ return Array.from({ length: count }, (_, idx) => {
+ const token = crypto.randomUUID().replace(/-/g, '').slice(0, 8);
+ return {
+ email: `pending.user.${token}@example.com`,
+ message: `Looking forward to joining an upcoming access wave (ref ${token}, slot ${idx + 1}).`,
+ };
+ });
+}
+
+async function seedUsers() {
+ console.log('🚀 Seeding admin test users into Redis...\n');
+
+ for (const user of baseUsers) {
+ const normalizedEmail = user.email.toLowerCase().trim();
+ const key = `admin:user:${normalizedEmail}`;
+ const exists = await redis.exists(key);
+
+ if (exists && !refreshRequested) {
+ console.log(`⏭️ Skipping ${normalizedEmail} (already present)`);
+ continue;
+ }
+
+ if (exists && refreshRequested) {
+ console.log(`🔁 Recreating ${normalizedEmail}`);
+ }
+
+ const userRecord = {
+ email: normalizedEmail,
+ roles: user.roles,
+ addedAt: new Date().toISOString(),
+ addedBy: SEEDED_MARKER,
+ };
+
+ await redis.set(key, JSON.stringify(userRecord));
+ await redis.sadd('admin:users:all', normalizedEmail);
+
+ if (user.roles.includes('admin')) {
+ await redis.sadd('admin:users:admins', normalizedEmail);
+ } else {
+ await redis.srem('admin:users:admins', normalizedEmail);
+ }
+
+ console.log(`✅ Seeded ${normalizedEmail} (${user.roles.join(', ')})`);
+ }
+}
+
+async function seedPendingRequests() {
+ console.log('\n📥 Seeding pending access requests...\n');
+
+ const extraRequests = createRandomPending(extraPendingCount);
+ const allRequests = [...basePendingRequests, ...extraRequests];
+
+ for (const request of allRequests) {
+ const normalizedEmail = request.email.toLowerCase().trim();
+ const emailKey = `admin:request:email:${normalizedEmail}`;
+ const existingId = await redis.get(emailKey);
+
+ if (existingId) {
+ if (!refreshRequested) {
+ console.log(`⏭️ Skipping request for ${normalizedEmail} (already present as ${existingId})`);
+ continue;
+ }
+
+ console.log(`🔁 Replacing existing request ${existingId} (${normalizedEmail})`);
+ await redis.del(`admin:request:${existingId}`);
+ await redis.srem('admin:requests:pending', existingId);
+ await redis.srem('admin:requests:all', existingId);
+ await redis.del(emailKey);
+ }
+
+ const id = crypto.randomUUID().replace(/-/g, '');
+ const requestRecord = {
+ id,
+ email: normalizedEmail,
+ message: request.message,
+ requestedAt: new Date().toISOString(),
+ status: 'pending',
+ seededBy: SEEDED_MARKER,
+ };
+
+ await redis.set(`admin:request:${id}`, JSON.stringify(requestRecord));
+ await redis.sadd('admin:requests:pending', id);
+ await redis.sadd('admin:requests:all', id);
+ await redis.set(emailKey, id);
+
+ console.log(`✅ Seeded pending request ${id} (${normalizedEmail})`);
+ }
+
+ if (extraRequests.length > 0) {
+ console.log(`\n➕ Added ${extraRequests.length} additional random pending request(s).`);
+ }
+}
+
+async function reportSummary() {
+ const pendingIds = await redis.smembers('admin:requests:pending');
+ const allIds = await redis.smembers('admin:requests:all');
+ const sampleIds = pendingIds.slice(0, 5);
+
+ console.log('\n📊 Redis Summary');
+ console.log(` Pending Requests: ${pendingIds.length}`);
+ console.log(` Total Requests (pending + processed): ${allIds.length}`);
+ console.log(` Pending IDs: ${sampleIds.length ? sampleIds.join(', ') : 'none'}`);
+
+ if (sampleIds.length > 0) {
+ const pipeline = redis.pipeline();
+ for (const id of sampleIds) {
+ pipeline.get(`admin:request:${id}`);
+ }
+ const results = await pipeline.exec();
+ results.forEach(([, value]) => {
+ if (!value) return;
+ try {
+ const parsed = JSON.parse(value);
+ console.log(
+ ` • ${parsed.email} (status: ${parsed.status}, requestedAt: ${parsed.requestedAt})`
+ );
+ } catch {
+ // ignore parse errors
+ }
+ });
+ }
+}
+
+async function seedUsersAndRequests() {
+ try {
+ if (refreshRequested) {
+ await cleanupSeededData();
+ }
+
+ await seedUsers();
+ await seedPendingRequests();
+ await reportSummary();
+
+ console.log('\n✨ Done! Test users and pending requests are ready.');
+ if (!refreshRequested) {
+ console.log('\n💡 Tip: use "--refresh" to reset previously seeded data before recreating it.');
+ }
+ if (extraPendingCount === 0) {
+ console.log(' Need more pending requests? Try "--extra-pending=5".');
+ }
+ process.exit(0);
+ } catch (error) {
+ console.error('❌ Error seeding test data:', error);
+ process.exit(1);
+ } finally {
+ await redis.quit();
+ }
+}
+
+seedUsersAndRequests();
diff --git a/src/app/admin/actions.ts b/src/app/admin/actions.ts
index 892d704..e87b86f 100644
--- a/src/app/admin/actions.ts
+++ b/src/app/admin/actions.ts
@@ -10,11 +10,12 @@ import { authOptions } from '@/lib/auth';
import { UserManagementService } from '@/lib/admin/user-management-service';
import { AccessRequestsService } from '@/lib/admin/access-requests-service';
import { revalidatePath } from 'next/cache';
+import type { UserRole } from '@/lib/admin/types';
/**
- * Add or update a user
+ * Add or update a user with roles
*/
-export async function addUserAction(email: string, role: 'user' | 'admin') {
+export async function addUserAction(email: string, roles: UserRole[]) {
const session = await getServerSession(authOptions);
if (!session?.user?.email) {
@@ -26,7 +27,7 @@ export async function addUserAction(email: string, role: 'user' | 'admin') {
throw new Error('Admin access required');
}
- await UserManagementService.addUser(email, role, session.user.email);
+ await UserManagementService.addUser(email, roles, session.user.email);
revalidatePath('/admin/users');
@@ -61,9 +62,9 @@ export async function removeUserAction(email: string) {
}
/**
- * Update user role
+ * Update user roles
*/
-export async function updateUserRoleAction(email: string, role: 'user' | 'admin') {
+export async function updateUserRolesAction(email: string, roles: UserRole[]) {
const session = await getServerSession(authOptions);
if (!session?.user?.email) {
@@ -75,7 +76,7 @@ export async function updateUserRoleAction(email: string, role: 'user' | 'admin'
throw new Error('Admin access required');
}
- await UserManagementService.updateUserRole(email, role, session.user.email);
+ await UserManagementService.updateUserRoles(email, roles, session.user.email);
revalidatePath('/admin/users');
@@ -99,8 +100,9 @@ export async function approveRequestAction(id: string, role: 'user' | 'admin' =
await AccessRequestsService.approveRequest(id, session.user.email, role);
- revalidatePath('/admin/requests');
+ revalidatePath('/admin/users/requests');
revalidatePath('/admin/users');
+ revalidatePath('/admin');
return { success: true };
}
@@ -122,7 +124,40 @@ export async function denyRequestAction(id: string) {
await AccessRequestsService.denyRequest(id, session.user.email);
- revalidatePath('/admin/requests');
+ revalidatePath('/admin/users/requests');
+ revalidatePath('/admin');
+
+ return { success: true };
+}
+
+/**
+ * Reply to request and bucket
+ */
+export async function replyToRequestAction(id: string, replyMessage: string, bucket: string) {
+ const session = await getServerSession(authOptions);
+
+ if (!session?.user?.email) {
+ throw new Error('Not authenticated');
+ }
+
+ const isAdmin = await UserManagementService.isAdmin(session.user.email);
+ if (!isAdmin) {
+ throw new Error('Admin access required');
+ }
+
+ if (!replyMessage || !replyMessage.trim()) {
+ throw new Error('Reply message is required');
+ }
+
+ await AccessRequestsService.replyToRequest(
+ id,
+ session.user.email,
+ replyMessage,
+ bucket ?? ''
+ );
+
+ revalidatePath('/admin/users/requests');
+ revalidatePath('/admin');
return { success: true };
}
diff --git a/src/app/admin/admin-sidebar.tsx b/src/app/admin/admin-sidebar.tsx
index 0d6068b..2aaba4d 100644
--- a/src/app/admin/admin-sidebar.tsx
+++ b/src/app/admin/admin-sidebar.tsx
@@ -18,7 +18,6 @@ export function AdminSidebar() {
{ href: '/admin/data', label: 'Data', icon: '📊' },
{ href: '/admin/ingest-full', label: 'Context', icon: '🤖' },
{ href: '/admin/users', label: 'Users', icon: '👥' },
- { href: '/admin/requests', label: 'Requests', icon: '📧' },
{ href: '/admin/reset', label: 'Reset', icon: '⚠️', dangerous: true },
];
diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx
index da4a381..bed49fe 100644
--- a/src/app/admin/page.tsx
+++ b/src/app/admin/page.tsx
@@ -7,18 +7,32 @@ import Link from 'next/link';
import { UserManagementService } from '@/lib/admin/user-management-service';
import { AccessRequestsService } from '@/lib/admin/access-requests-service';
import { getRedisClient } from '@/lib/redis';
+import type { User } from '@/lib/admin/types';
+import type { AccessRequest } from '@/lib/admin/access-requests-service';
export const dynamic = 'force-dynamic';
-async function getSystemStats() {
+interface SystemStats {
+ totalUsers: number;
+ totalAdmins: number;
+ pendingRequests: number;
+ totalRequests: number;
+ approvedRequests: number;
+ bucketedRequests: number;
+ deniedRequests: number;
+ redisConnected: boolean;
+ recentUsers: User[];
+ recentRequests: AccessRequest[];
+}
+
+async function getSystemStats(): Promise
{user.email}
Added {new Date(user.addedAt).toLocaleDateString()}
Access Denied - Admin role required
-Review and manage early access requests
-No pending requests
- )} - -{req.email}
-- Requested {new Date(req.requestedAt).toLocaleString()} -
-{req.message}
-{req.email}
-- {req.status === 'approved' ? '✅ Approved' : '❌ Denied'} on{' '} - {new Date(req.reviewedAt!).toLocaleDateString()} - {req.reviewedBy && ` by ${req.reviewedBy}`} -
-Manage users and access requests
+Access Denied - Admin role required
-Manage user access and roles
+No pending requests
+ )} + +{req.email}
++ Requested {new Date(req.requestedAt).toLocaleString()} +
+{req.message}
+{req.email}
++ {req.status === 'approved' + ? '✅ Approved' + : req.status === 'bucketed' + ? `🪣 Bucketed${req.bucket ? ` (${req.bucket})` : ''}` + : '❌ Denied'}{' '} + on{' '} + {new Date(req.reviewedAt!).toLocaleDateString()} + {req.reviewedBy && ` by ${req.reviewedBy}`} +
+ {req.status === 'bucketed' && req.replyMessage && ( ++ Reply sent: {req.replyMessage} +
+ )} +