diff --git a/CHANGELOG.md b/CHANGELOG.md index 5bd31e9..341b671 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changes +## February 20, 2025 +- Added user management interface in Settings page for administrators +- Added admin secret key field in Settings for accessing user management +- Added ability to create, edit, and delete users with research descriptions +- Added user list view with basic information display +- Added confirmation dialog for user deletion to prevent accidental removal +- Added API key viewing capability for administrators with copy to clipboard functionality + ## February 19, 2025 - Added advanced search mode for DANDI Archive Browser with neurodata type filtering - Added matching files count indicator for advanced search results in DANDI Archive Browser diff --git a/job-manager/.env.example b/job-manager/.env.example index 835e9f8..30d3edd 100644 --- a/job-manager/.env.example +++ b/job-manager/.env.example @@ -1,8 +1,8 @@ # MongoDB connection string MONGODB_URI=mongodb://localhost:27017/neurosift_jobs -# API Keys for authentication -API_SUBMIT_KEY=your-submit-key-here +# Admin configuration +ADMIN_SECRET_KEY=your-secure-admin-secret-key-here # Memobin API Key for file storage -MEMOBIN_API_KEY=your-memobin-key-here +MEMOBIN_API_KEY=the-memobin-key-here diff --git a/job-manager/app/api/jobs/[id]/route.ts b/job-manager/app/api/jobs/[id]/route.ts index 9d84b02..e9088dd 100644 --- a/job-manager/app/api/jobs/[id]/route.ts +++ b/job-manager/app/api/jobs/[id]/route.ts @@ -72,8 +72,6 @@ export async function GET( * - Success: Updated job object * - Error: 404 if job not found, 500 for server errors * - * Required API key: 'fulfill' - * * Only specified fields will be updated, maintaining data integrity */ export async function PATCH( diff --git a/job-manager/app/api/jobs/route.ts b/job-manager/app/api/jobs/route.ts index 0da2832..0e43750 100644 --- a/job-manager/app/api/jobs/route.ts +++ b/job-manager/app/api/jobs/route.ts @@ -47,9 +47,9 @@ export async function OPTIONS() { * 5. Return the job ID (either new or existing) */ export async function POST(request: NextRequest) { - // Job creation still requires API key for security - const authError = validateApiKey(request); - if (authError) return authError; + // Validate API key and get user info + const auth = await validateApiKey(request); + if (auth instanceof NextResponse) return auth; try { const body = await request.json(); @@ -64,10 +64,11 @@ export async function POST(request: NextRequest) { // Convert input to string if it's an object const inputString = typeof input === 'object' ? JSON.stringify(input) : input.toString(); - // Check for existing job with same type and input + // Check for existing job with same type, input, and user const existingJob = await Job.findOne({ type, input: inputString, + userId: auth.userId, status: { $in: ['pending', 'running', 'completed'] } }); @@ -79,7 +80,8 @@ export async function POST(request: NextRequest) { type, input: inputString, status: 'pending', - progress: 0 + progress: 0, + userId: auth.userId }; const job = await Job.create(newJob); diff --git a/job-manager/app/api/jobs/search/route.ts b/job-manager/app/api/jobs/search/route.ts index 075586a..7dcaacd 100644 --- a/job-manager/app/api/jobs/search/route.ts +++ b/job-manager/app/api/jobs/search/route.ts @@ -39,16 +39,15 @@ export async function OPTIONS() { * - Success: Array of matching jobs (limited to 100, sorted by creation date) * - Error: 500 for server errors * - * Accepted API keys: 'submit' or 'fulfill' - * * The endpoint will: * 1. Build a query based on provided filters * 2. Return the most recent 100 matching jobs */ export async function POST(request: NextRequest) { - // Accept either submit or fulfill API keys - const submitKeyError = validateApiKey(request); - if (submitKeyError) return submitKeyError; + const authResult = await validateApiKey(request); + if (authResult instanceof NextResponse) { + return authResult; + } try { const body = await request.json(); diff --git a/job-manager/app/api/users/[userId]/key/route.ts b/job-manager/app/api/users/[userId]/key/route.ts new file mode 100644 index 0000000..3d11e08 --- /dev/null +++ b/job-manager/app/api/users/[userId]/key/route.ts @@ -0,0 +1,43 @@ +import { NextRequest, NextResponse } from 'next/server'; +import connectDB, { User } from '../../../../../lib/db'; +import { validateApiKey } from '../../../../../middleware/auth'; + +export async function OPTIONS() { + return new NextResponse(null, { + status: 200, + headers: { + 'Access-Control-Allow-Origin': 'http://localhost:5173', + 'Access-Control-Allow-Methods': 'GET, OPTIONS', + 'Access-Control-Allow-Headers': 'Content-Type, Authorization' + } + }); +} + +export async function GET( + request: NextRequest, + { params }: { params: { userId: string } } +) { + // Only admin can retrieve API keys + const adminKey = process.env.ADMIN_SECRET_KEY; + const authHeader = request.headers.get('Authorization'); + const isAdmin = adminKey && authHeader?.split(' ')[1] === adminKey; + + if (!isAdmin) { + return new NextResponse('Unauthorized - Admin access required', { status: 401 }); + } + + try { + await connectDB(); + + const user = await User.findOne({ userId: params.userId }); + + if (!user) { + return new NextResponse('User not found', { status: 404 }); + } + + return NextResponse.json({ apiKey: user.apiKey }); + } catch (error) { + console.error('Error retrieving API key:', error); + return new NextResponse('Internal Server Error', { status: 500 }); + } +} diff --git a/job-manager/app/api/users/[userId]/route.ts b/job-manager/app/api/users/[userId]/route.ts new file mode 100644 index 0000000..0d39ba4 --- /dev/null +++ b/job-manager/app/api/users/[userId]/route.ts @@ -0,0 +1,150 @@ +import { NextRequest, NextResponse } from 'next/server'; +import connectDB, { User } from '../../../../lib/db'; +import { validateApiKey } from '../../../../middleware/auth'; + +/** + * Handle CORS preflight requests + */ +export async function OPTIONS() { + return new NextResponse(null, { + status: 200, + headers: { + 'Access-Control-Allow-Origin': 'http://localhost:5173', + 'Access-Control-Allow-Methods': 'GET, PUT, DELETE, OPTIONS, POST', + 'Access-Control-Allow-Headers': 'Content-Type, Authorization' + } + }); +} + +/** + * Get user details + * Requires either: + * - Admin secret key + * - User's own API key + */ +export async function GET( + request: NextRequest, + { params }: { params: { userId: string } } +) { + // Admin key allows access to any user + const adminKey = process.env.ADMIN_SECRET_KEY; + const authHeader = request.headers.get('Authorization'); + const isAdmin = adminKey && authHeader?.split(' ')[1] === adminKey; + + if (!isAdmin) { + const auth = await validateApiKey(request); + if (auth instanceof NextResponse) return auth; + if (auth.userId !== params.userId) { + return new NextResponse('Unauthorized', { status: 401 }); + } + } + + try { + await connectDB(); + + const user = await User.findOne( + { userId: params.userId }, + { apiKey: 0 } // Exclude API key from response + ); + + if (!user) { + return new NextResponse('User not found', { status: 404 }); + } + + return NextResponse.json({ user }); + } catch (error) { + console.error('Error getting user:', error); + return new NextResponse('Internal Server Error', { status: 500 }); + } +} + +/** + * Update user details + * Requires either: + * - Admin secret key + * - User's own API key + */ +export async function DELETE( + request: NextRequest, + { params }: { params: { userId: string } } +) { + // Only admin can delete users + const adminKey = process.env.ADMIN_SECRET_KEY; + const authHeader = request.headers.get('Authorization'); + const isAdmin = adminKey && authHeader?.split(' ')[1] === adminKey; + + if (!isAdmin) { + return new NextResponse('Unauthorized - Admin access required', { status: 401 }); + } + + try { + await connectDB(); + + const result = await User.deleteOne({ userId: params.userId }); + + if (result.deletedCount === 0) { + return new NextResponse('User not found', { status: 404 }); + } + + return new NextResponse('User deleted successfully', { status: 200 }); + } catch (error) { + console.error('Error deleting user:', error); + return new NextResponse('Internal Server Error', { status: 500 }); + } +} + +/** + * Update user details + * Requires either: + * - Admin secret key + * - User's own API key + */ +export async function PUT( + request: NextRequest, + { params }: { params: { userId: string } } +) { + // Admin key allows access to any user + const adminKey = process.env.ADMIN_SECRET_KEY; + const authHeader = request.headers.get('Authorization'); + const isAdmin = adminKey && authHeader?.split(' ')[1] === adminKey; + + // Users can only update their own details unless they're admin + if (!isAdmin && auth.userId !== params.userId) { + return new NextResponse('Unauthorized', { status: 401 }); + } + + try { + const body = await request.json(); + const { name, email, researchDescription } = body; + + await connectDB(); + + const user = await User.findOne({ userId: params.userId }); + + if (!user) { + return new NextResponse('User not found', { status: 404 }); + } + + // Update allowed fields + if (name) user.name = name; + if (email) user.email = email; + if (researchDescription) user.researchDescription = researchDescription; + + await user.save(); + + // Return updated user without API key + const userResponse = { + userId: user.userId, + name: user.name, + email: user.email, + researchDescription: user.researchDescription, + createdAt: user.createdAt, + updatedAt: user.updatedAt + }; + + return NextResponse.json({ user: userResponse }); + } catch (error) { + console.error('Error updating user:', error); + return new NextResponse('Internal Server Error', { status: 500 }); + } +} diff --git a/job-manager/app/api/users/route.ts b/job-manager/app/api/users/route.ts new file mode 100644 index 0000000..d331779 --- /dev/null +++ b/job-manager/app/api/users/route.ts @@ -0,0 +1,102 @@ +import { NextRequest, NextResponse } from 'next/server'; +import crypto from 'crypto'; +import connectDB, { User } from '../../../lib/db'; + +/** + * Handle CORS preflight requests + */ +export async function OPTIONS() { + return new NextResponse(null, { + status: 200, + headers: { + 'Access-Control-Allow-Origin': 'http://localhost:5173', + 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS', + 'Access-Control-Allow-Headers': 'Content-Type, Authorization' + } + }); +} + +/** + * Create a new user with a unique API key + * Requires admin secret key for authorization + */ +export async function POST(request: NextRequest) { + // Only admin can create users + const adminKey = process.env.ADMIN_SECRET_KEY; + if (!adminKey) { + return new NextResponse('Admin secret key not configured', { status: 500 }); + } + + const authHeader = request.headers.get('Authorization'); + if (!authHeader || !authHeader.startsWith('Bearer ') || authHeader.split(' ')[1] !== adminKey) { + return new NextResponse('Unauthorized', { status: 401 }); + } + + try { + const body = await request.json(); + const { name, email, researchDescription } = body; + + if (!name || !email || !researchDescription) { + return new NextResponse('Missing required fields', { status: 400 }); + } + + await connectDB(); + + // Generate unique user ID and API key + const userId = crypto.randomBytes(16).toString('hex'); + const apiKey = crypto.randomBytes(32).toString('hex'); + + const newUser = await User.create({ + userId, + apiKey, + name, + email, + researchDescription + }); + + // Only return the API key during user creation + return NextResponse.json({ + user: { + userId: newUser.userId, + name: newUser.name, + email: newUser.email, + researchDescription: newUser.researchDescription, + createdAt: newUser.createdAt, + apiKey // Include API key only in creation response + } + }); + } catch (error) { + console.error('Error creating user:', error); + return new NextResponse('Internal Server Error', { status: 500 }); + } +} + +/** + * List all users (without API keys) + * Requires admin secret key for authorization + */ +export async function GET(request: NextRequest) { + // Only admin can list users + const adminKey = process.env.ADMIN_SECRET_KEY; + if (!adminKey) { + return new NextResponse('Admin secret key not configured', { status: 500 }); + } + + const authHeader = request.headers.get('Authorization'); + if (!authHeader || !authHeader.startsWith('Bearer ') || authHeader.split(' ')[1] !== adminKey) { + return new NextResponse('Unauthorized', { status: 401 }); + } + + try { + await connectDB(); + + const users = await User.find({}, { + apiKey: 0 // Exclude API keys from the response + }).sort({ createdAt: -1 }); + + return NextResponse.json({ users }); + } catch (error) { + console.error('Error listing users:', error); + return new NextResponse('Internal Server Error', { status: 500 }); + } +} diff --git a/job-manager/lib/db.ts b/job-manager/lib/db.ts index 3be84e7..9a1f7d9 100644 --- a/job-manager/lib/db.ts +++ b/job-manager/lib/db.ts @@ -36,6 +36,62 @@ async function connectDB() { return cached.conn; } +// User interface +export interface IUser { + userId: string; + name: string; + email: string; + researchDescription: string; + apiKey: string; + createdAt: Date; + updatedAt: Date; +} + +export interface IUserDocument extends IUser, Document {} + +// User schema +const userSchema = new mongoose.Schema({ + userId: { + type: String, + required: true, + unique: true + }, + name: { + type: String, + required: true + }, + email: { + type: String, + required: true + }, + researchDescription: { + type: String, + required: true + }, + apiKey: { + type: String, + required: true, + unique: true + }, + createdAt: { + type: Date, + default: Date.now + }, + updatedAt: { + type: Date, + default: Date.now + } +}); + +// Update the updatedAt field on save +userSchema.pre('save', function(next) { + this.updatedAt = new Date(); + next(); +}); + +// Create indexes for fast lookups +userSchema.index({ apiKey: 1 }, { unique: true }); + // Job interface export interface IJob { status: 'pending' | 'running' | 'completed' | 'failed'; @@ -44,6 +100,7 @@ export interface IJob { progress: number; output?: string; error?: string; + userId: string; // Reference to user who created the job createdAt: Date; updatedAt: Date; } @@ -73,6 +130,10 @@ const jobSchema = new mongoose.Schema({ }, output: String, error: String, + userId: { + type: String, + required: true + }, createdAt: { type: Date, default: Date.now @@ -83,7 +144,7 @@ const jobSchema = new mongoose.Schema({ } }); -// Create compound index on type and input +// Create compound indexes jobSchema.index({ type: 1, input: 1 }); // Update the updatedAt field on save @@ -93,5 +154,6 @@ jobSchema.pre('save', function(next) { }); export const Job: Model = mongoose.models.Job || mongoose.model('Job', jobSchema); +export const User: Model = mongoose.models.User || mongoose.model('User', userSchema); export default connectDB; diff --git a/job-manager/middleware/auth.ts b/job-manager/middleware/auth.ts index 5d096e4..8ef6302 100644 --- a/job-manager/middleware/auth.ts +++ b/job-manager/middleware/auth.ts @@ -1,19 +1,35 @@ import { NextRequest, NextResponse } from 'next/server'; +import connectDB, { User } from '../lib/db'; -export function validateApiKey(request: NextRequest) { +export type AuthResult = NextResponse | { + userId: string; + authorized: true; +}; + +export async function validateApiKey(request: NextRequest): Promise { const authHeader = request.headers.get('Authorization'); if (!authHeader || !authHeader.startsWith('Bearer ')) { return new NextResponse('Unauthorized', { status: 401 }); } const providedKey = authHeader.split(' ')[1]; - const expectedKey = process.env.API_SUBMIT_KEY; - if (!expectedKey || providedKey !== expectedKey) { - return new NextResponse('Unauthorized', { status: 401 }); - } + try { + await connectDB(); - return null; + // Find user by API key + const user = await User.findOne({ apiKey: providedKey }); + + if (!user) { + return new NextResponse('Unauthorized', { status: 401 }); + } + + // Return null for success, but attach user info to request + return { userId: user.userId, authorized: true }; + } catch (error) { + console.error('Error validating API key:', error); + return new NextResponse('Internal Server Error', { status: 500 }); + } } import { IJobDocument } from '../lib/db'; diff --git a/job-manager/package-lock.json b/job-manager/package-lock.json index c5c52ba..34708e5 100644 --- a/job-manager/package-lock.json +++ b/job-manager/package-lock.json @@ -13,14 +13,49 @@ "@types/react": "^18.2.61", "@types/react-dom": "^18.2.19", "axios": "^1.7.9", + "dotenv": "^16.4.5", "mongodb": "^6.3.0", "mongoose": "^8.2.0", "next": "^14.1.0", "react": "^18.2.0", "react-dom": "^18.2.0", + "ts-node": "^10.9.2", "typescript": "^5.3.3" } }, + "node_modules/@cspotcode/source-map-support": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", + "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.9" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz", + "integrity": "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", + "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", + "dependencies": { + "@jridgewell/resolve-uri": "^3.0.3", + "@jridgewell/sourcemap-codec": "^1.4.10" + } + }, "node_modules/@mongodb-js/saslprep": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/@mongodb-js/saslprep/-/saslprep-1.2.0.tgz", @@ -183,6 +218,26 @@ "tslib": "^2.4.0" } }, + "node_modules/@tsconfig/node10": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.11.tgz", + "integrity": "sha512-DcRjDCujK/kCk/cUe8Xz8ZSpm8mS3mNNpta+jGCA6USEDfktlNvm1+IuZ9eTcDbNk41BHwpHHeW+N1lKCz4zOw==" + }, + "node_modules/@tsconfig/node12": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz", + "integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==" + }, + "node_modules/@tsconfig/node14": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz", + "integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==" + }, + "node_modules/@tsconfig/node16": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz", + "integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==" + }, "node_modules/@types/mongodb": { "version": "4.0.7", "resolved": "https://registry.npmjs.org/@types/mongodb/-/mongodb-4.0.7.tgz", @@ -235,6 +290,33 @@ "@types/webidl-conversions": "*" } }, + "node_modules/acorn": { + "version": "8.14.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.0.tgz", + "integrity": "sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA==", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-walk": { + "version": "8.3.4", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.4.tgz", + "integrity": "sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==", + "dependencies": { + "acorn": "^8.11.0" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/arg": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", + "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==" + }, "node_modules/asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", @@ -316,6 +398,11 @@ "node": ">= 0.8" } }, + "node_modules/create-require": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", + "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==" + }, "node_modules/csstype": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", @@ -345,6 +432,25 @@ "node": ">=0.4.0" } }, + "node_modules/diff": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz", + "integrity": "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==", + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/dotenv": { + "version": "16.4.7", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.7.tgz", + "integrity": "sha512-47qPchRCykZC03FhkYAhrvwU4xDBFIj1QPqaarj6mdM/hgUzfPHcpkHJOn3mJAufFeeAxAzeGsr5X0M4k6fLZQ==", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -551,6 +657,11 @@ "loose-envify": "cli.js" } }, + "node_modules/make-error": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", + "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==" + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -886,6 +997,48 @@ "node": ">=18" } }, + "node_modules/ts-node": { + "version": "10.9.2", + "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz", + "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", + "dependencies": { + "@cspotcode/source-map-support": "^0.8.0", + "@tsconfig/node10": "^1.0.7", + "@tsconfig/node12": "^1.0.7", + "@tsconfig/node14": "^1.0.0", + "@tsconfig/node16": "^1.0.2", + "acorn": "^8.4.1", + "acorn-walk": "^8.1.1", + "arg": "^4.1.0", + "create-require": "^1.1.0", + "diff": "^4.0.1", + "make-error": "^1.1.1", + "v8-compile-cache-lib": "^3.0.1", + "yn": "3.1.1" + }, + "bin": { + "ts-node": "dist/bin.js", + "ts-node-cwd": "dist/bin-cwd.js", + "ts-node-esm": "dist/bin-esm.js", + "ts-node-script": "dist/bin-script.js", + "ts-node-transpile-only": "dist/bin-transpile.js", + "ts-script": "dist/bin-script-deprecated.js" + }, + "peerDependencies": { + "@swc/core": ">=1.2.50", + "@swc/wasm": ">=1.2.50", + "@types/node": "*", + "typescript": ">=2.7" + }, + "peerDependenciesMeta": { + "@swc/core": { + "optional": true + }, + "@swc/wasm": { + "optional": true + } + } + }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", @@ -908,6 +1061,11 @@ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz", "integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==" }, + "node_modules/v8-compile-cache-lib": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", + "integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==" + }, "node_modules/webidl-conversions": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", @@ -927,6 +1085,14 @@ "engines": { "node": ">=18" } + }, + "node_modules/yn": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", + "integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==", + "engines": { + "node": ">=6" + } } } } diff --git a/job-manager/package.json b/job-manager/package.json index 51d6f70..7b6652d 100644 --- a/job-manager/package.json +++ b/job-manager/package.json @@ -3,6 +3,7 @@ "version": "1.0.0", "description": "Job manager service for Neurosift", "private": true, + "type": "module", "scripts": { "dev": "next dev -p 3000", "build": "next build", @@ -20,6 +21,8 @@ "next": "^14.1.0", "react": "^18.2.0", "react-dom": "^18.2.0", - "typescript": "^5.3.3" + "typescript": "^5.3.3", + "dotenv": "^16.4.5", + "ts-node": "^10.9.2" } } diff --git a/job-manager/run-job.py b/job-manager/run-job.py index 498ed81..da265d0 100755 --- a/job-manager/run-job.py +++ b/job-manager/run-job.py @@ -14,8 +14,8 @@ ) # Configuration -# API_BASE_URL = os.getenv('API_BASE_URL', 'http://localhost:3000/api') -API_BASE_URL = os.getenv("API_BASE_URL", "https://neurosift-job-manager.vercel.app/api") +API_BASE_URL = os.getenv("API_BASE_URL", "http://localhost:3000/api") +# API_BASE_URL = os.getenv("API_BASE_URL", "https://neurosift-job-manager.vercel.app/api") def get_upload_urls(job_id: str, file_name: str, size: int) -> tuple[str, str]: diff --git a/neurosift_job_runner/src/neurosift_job_runner/job_utils.py b/neurosift_job_runner/src/neurosift_job_runner/job_utils.py index 784a847..9884f31 100644 --- a/neurosift_job_runner/src/neurosift_job_runner/job_utils.py +++ b/neurosift_job_runner/src/neurosift_job_runner/job_utils.py @@ -11,7 +11,9 @@ ) DEFAULT_API_BASE_URL = os.getenv( - "NEUROSIFT_API_BASE_URL", "https://neurosift-job-manager.vercel.app/api" + # "NEUROSIFT_API_BASE_URL", "https://neurosift-job-manager.vercel.app/api" + "NEUROSIFT_API_BASE_URL", + "http://localhost:3000/api", ) diff --git a/src/jobManager/components/UserManagementSection.tsx b/src/jobManager/components/UserManagementSection.tsx new file mode 100644 index 0000000..9cff7a8 --- /dev/null +++ b/src/jobManager/components/UserManagementSection.tsx @@ -0,0 +1,345 @@ +import React, { useState, useEffect } from "react"; +import { + Box, + Button, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + Paper, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + TextField, + Typography, + Alert, + CircularProgress, +} from "@mui/material"; +import { useUserManagement, NewUser, User } from "../useUserManagement"; + +interface UserManagementSectionProps { + adminApiKey: string; +} + +const UserManagementSection: React.FC = ({ + adminApiKey, +}) => { + const { + users, + loading, + loadingApiKey, + error, + fetchUsers, + createUser, + updateUser, + deleteUser, + getUserApiKey, + } = useUserManagement(adminApiKey); + const [openDialog, setOpenDialog] = useState(false); + const [deleteConfirmDialog, setDeleteConfirmDialog] = useState(false); + const [userToDelete, setUserToDelete] = useState(null); + const [editUser, setEditUser] = useState(null); + const [formData, setFormData] = useState({ + name: "", + email: "", + researchDescription: "", + }); + const [successMessage, setSuccessMessage] = useState(null); + const [newUserApiKey, setNewUserApiKey] = useState(null); + const [showApiKeyDialog, setShowApiKeyDialog] = useState(false); + const [selectedUserApiKey, setSelectedUserApiKey] = useState( + null, + ); + const [selectedUserName, setSelectedUserName] = useState(""); + + useEffect(() => { + if (adminApiKey) { + fetchUsers(); + } + }, [adminApiKey, fetchUsers]); + + const handleOpenDialog = (user?: User) => { + if (user) { + setEditUser(user); + setFormData({ + name: user.name, + email: user.email, + researchDescription: user.researchDescription, + }); + } else { + setEditUser(null); + setFormData({ + name: "", + email: "", + researchDescription: "", + }); + } + setOpenDialog(true); + setNewUserApiKey(null); + }; + + const handleCloseDialog = () => { + setOpenDialog(false); + setEditUser(null); + setFormData({ + name: "", + email: "", + researchDescription: "", + }); + setNewUserApiKey(null); + }; + + const handleSubmit = async () => { + if (editUser) { + await updateUser(editUser.userId, formData); + setSuccessMessage("User updated successfully"); + } else { + const newUser = await createUser(formData); + if (newUser?.apiKey) { + setNewUserApiKey(newUser.apiKey); + setSuccessMessage("User created successfully"); + } + } + handleCloseDialog(); + setTimeout(() => setSuccessMessage(null), 5000); + }; + + if (!adminApiKey) { + return null; + } + + return ( + + + User Management + + + + {error && ( + + {error} + + )} + + {successMessage && ( + + {successMessage} + + )} + + {loading ? ( + + + + ) : ( + + + + + Name + Email + Research Description + Created At + Action + + + + {users.map((user) => ( + + {user.name} + {user.email} + {user.researchDescription} + + {new Date(user.createdAt).toLocaleDateString()} + + + + + + + + + + ))} + +
+
+ )} + + {/* Delete Confirmation Dialog */} + setDeleteConfirmDialog(false)} + > + Confirm Delete + + + Are you sure you want to delete user "{userToDelete?.name}"? This + action cannot be undone. + + + + + + + + + + {editUser ? "Edit User" : "Create User"} + + {newUserApiKey && ( + + API Key: {newUserApiKey} +
+ Please save this key as it won't be shown again. +
+ )} + setFormData({ ...formData, name: e.target.value })} + /> + + setFormData({ ...formData, email: e.target.value }) + } + /> + + setFormData({ ...formData, researchDescription: e.target.value }) + } + /> +
+ + + + +
+ + {/* API Key Dialog */} + setShowApiKeyDialog(false)} + > + API Key for {selectedUserName} + + {loadingApiKey ? ( + + + + ) : ( + + + + )} + + + + + + +
+ ); +}; + +export default UserManagementSection; diff --git a/src/jobManager/useNeurosiftJob.ts b/src/jobManager/useNeurosiftJob.ts index 2eb3ab9..8418238 100644 --- a/src/jobManager/useNeurosiftJob.ts +++ b/src/jobManager/useNeurosiftJob.ts @@ -1,8 +1,7 @@ import { useState, useCallback, useEffect } from "react"; -// Hard-coded for now - would come from environment in production -const NSJM_API_BASE_URL = "https://neurosift-job-manager.vercel.app/api"; -const NSJM_API_SUBMIT_KEY = "d38b9460ae73a5e4690dd03b13c4a1dc"; +const NSJM_API_BASE_URL = "http://localhost:3000/api"; +// const NSJM_API_BASE_URL = "https://neurosift-job-manager.vercel.app/api"; export interface Job { _id: string; @@ -29,7 +28,11 @@ export const useNeurosiftJob = ( const response = await fetch(`${NSJM_API_BASE_URL}/jobs/search`, { method: "POST", headers: { - Authorization: `Bearer ${NSJM_API_SUBMIT_KEY}`, + ...(localStorage.getItem("neurosiftApiKey") + ? { + Authorization: `Bearer ${localStorage.getItem("neurosiftApiKey")}`, + } + : {}), "Content-Type": "application/json", }, body: JSON.stringify({ @@ -64,7 +67,11 @@ export const useNeurosiftJob = ( const response = await fetch(`${NSJM_API_BASE_URL}/jobs`, { method: "POST", headers: { - Authorization: `Bearer ${NSJM_API_SUBMIT_KEY}`, + ...(localStorage.getItem("neurosiftApiKey") + ? { + Authorization: `Bearer ${localStorage.getItem("neurosiftApiKey")}`, + } + : {}), "Content-Type": "application/json", }, body: JSON.stringify({ @@ -93,7 +100,11 @@ export const useNeurosiftJob = ( try { const response = await fetch(`${NSJM_API_BASE_URL}/jobs/${jobId}`, { headers: { - Authorization: `Bearer ${NSJM_API_SUBMIT_KEY}`, + ...(localStorage.getItem("neurosiftApiKey") + ? { + Authorization: `Bearer ${localStorage.getItem("neurosiftApiKey")}`, + } + : {}), }, }); diff --git a/src/jobManager/useUserManagement.ts b/src/jobManager/useUserManagement.ts new file mode 100644 index 0000000..7614f26 --- /dev/null +++ b/src/jobManager/useUserManagement.ts @@ -0,0 +1,187 @@ +import { useState, useCallback } from "react"; + +// Using same base URL as jobs +const NSJM_API_BASE_URL = "http://localhost:3000/api"; + +export interface User { + userId: string; + name: string; + email: string; + researchDescription: string; + createdAt: string; + updatedAt: string; +} + +export interface NewUser { + name: string; + email: string; + researchDescription: string; +} + +export const useUserManagement = (adminApiKey: string) => { + const [users, setUsers] = useState([]); + const [loading, setLoading] = useState(false); + const [loadingApiKey, setLoadingApiKey] = useState(false); + const [error, setError] = useState(null); + + const fetchUsers = useCallback(async () => { + if (!adminApiKey) return; + setLoading(true); + setError(null); + + try { + const response = await fetch(`${NSJM_API_BASE_URL}/users`, { + headers: { + Authorization: `Bearer ${adminApiKey}`, + }, + }); + + if (!response.ok) { + throw new Error(`Error fetching users: ${response.statusText}`); + } + + const data = await response.json(); + setUsers(data.users); + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : "Unknown error"; + setError(`Failed to fetch users: ${errorMessage}`); + } finally { + setLoading(false); + } + }, [adminApiKey]); + + const createUser = useCallback( + async (user: NewUser) => { + if (!adminApiKey) return null; + setError(null); + + try { + const response = await fetch(`${NSJM_API_BASE_URL}/users`, { + method: "POST", + headers: { + Authorization: `Bearer ${adminApiKey}`, + "Content-Type": "application/json", + }, + body: JSON.stringify(user), + }); + + if (!response.ok) { + throw new Error(`Error creating user: ${response.statusText}`); + } + + const data = await response.json(); + await fetchUsers(); // Refresh user list + return data.user; // Returns user with apiKey + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : "Unknown error"; + setError(`Failed to create user: ${errorMessage}`); + return null; + } + }, + [adminApiKey, fetchUsers], + ); + + const updateUser = useCallback( + async (userId: string, updates: Partial) => { + if (!adminApiKey) return; + setError(null); + + try { + const response = await fetch(`${NSJM_API_BASE_URL}/users/${userId}`, { + method: "PUT", + headers: { + Authorization: `Bearer ${adminApiKey}`, + "Content-Type": "application/json", + }, + body: JSON.stringify(updates), + }); + + if (!response.ok) { + throw new Error(`Error updating user: ${response.statusText}`); + } + + await fetchUsers(); // Refresh user list + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : "Unknown error"; + setError(`Failed to update user: ${errorMessage}`); + } + }, + [adminApiKey, fetchUsers], + ); + + const deleteUser = useCallback( + async (userId: string) => { + if (!adminApiKey) return; + setError(null); + + try { + const response = await fetch(`${NSJM_API_BASE_URL}/users/${userId}`, { + method: "DELETE", + headers: { + Authorization: `Bearer ${adminApiKey}`, + }, + }); + + if (!response.ok) { + throw new Error(`Error deleting user: ${response.statusText}`); + } + + await fetchUsers(); // Refresh user list + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : "Unknown error"; + setError(`Failed to delete user: ${errorMessage}`); + } + }, + [adminApiKey, fetchUsers], + ); + + const getUserApiKey = useCallback( + async (userId: string) => { + if (!adminApiKey) return null; + setLoadingApiKey(true); + setError(null); + + try { + const response = await fetch( + `${NSJM_API_BASE_URL}/users/${userId}/key`, + { + headers: { + Authorization: `Bearer ${adminApiKey}`, + }, + }, + ); + + if (!response.ok) { + throw new Error(`Error fetching API key: ${response.statusText}`); + } + + const data = await response.json(); + return data.apiKey; + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : "Unknown error"; + setError(`Failed to fetch API key: ${errorMessage}`); + return null; + } finally { + setLoadingApiKey(false); + } + }, + [adminApiKey], + ); + + return { + users, + loading, + loadingApiKey, + error, + fetchUsers, + createUser, + updateUser, + deleteUser, + getUserApiKey, + }; +}; diff --git a/src/pages/SettingsPage/SettingsPage.tsx b/src/pages/SettingsPage/SettingsPage.tsx index 6f98cb5..90944fd 100644 --- a/src/pages/SettingsPage/SettingsPage.tsx +++ b/src/pages/SettingsPage/SettingsPage.tsx @@ -1,5 +1,6 @@ import { Box, Typography, TextField, Paper, Alert } from "@mui/material"; import { FunctionComponent, useState, useEffect } from "react"; +import UserManagementSection from "../../jobManager/components/UserManagementSection"; type SettingsPageProps = { width: number; @@ -10,19 +11,34 @@ const SettingsPage: FunctionComponent = ({ width, height, }) => { + const [neurosiftApiKey, setNeurosiftApiKey] = useState(""); const [dandiApiKey, setDandiApiKey] = useState(""); const [dandiStagingApiKey, setDandiStagingApiKey] = useState(""); + const [adminApiKey, setAdminApiKey] = useState(""); const [showSaveNotification, setShowSaveNotification] = useState(false); useEffect(() => { // Load initial values from localStorage + const savedNeurosiftApiKey = localStorage.getItem("neurosiftApiKey") || ""; const savedDandiApiKey = localStorage.getItem("dandiApiKey") || ""; const savedDandiStagingApiKey = localStorage.getItem("dandiStagingApiKey") || ""; + const savedAdminApiKey = localStorage.getItem("adminApiKey") || ""; + setNeurosiftApiKey(savedNeurosiftApiKey); setDandiApiKey(savedDandiApiKey); setDandiStagingApiKey(savedDandiStagingApiKey); + setAdminApiKey(savedAdminApiKey); }, []); + const handleNeurosiftApiKeyChange = ( + event: React.ChangeEvent, + ) => { + const newKey = event.target.value; + setNeurosiftApiKey(newKey); + localStorage.setItem("neurosiftApiKey", newKey); + setShowSaveNotification(true); + }; + const handleDandiApiKeyChange = ( event: React.ChangeEvent, ) => { @@ -48,6 +64,34 @@ const SettingsPage: FunctionComponent = ({ Settings + + Neurosift API Key + + + Enter your Neurosift API key for accessing job management + features. You can obtain a key by filling out{" "} + + this form + + + } + /> + + + DANDI API Keys @@ -78,6 +122,29 @@ const SettingsPage: FunctionComponent = ({ )} + + + + Admin Settings + + { + const newKey = e.target.value; + setAdminApiKey(newKey); + localStorage.setItem("adminApiKey", newKey); + setShowSaveNotification(true); + }} + margin="normal" + type="password" + helperText="Enter the admin secret key to manage users" + /> + + + );