@jhits/plugin-users 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +53 -0
- package/src/api/auth.ts +83 -0
- package/src/api/index.ts +11 -0
- package/src/api/router.ts +100 -0
- package/src/api/users.ts +208 -0
- package/src/api-server.ts +11 -0
- package/src/index.server.ts +12 -0
- package/src/index.tsx +21 -0
- package/src/locales/en.json +25 -0
- package/src/locales/nl.json +25 -0
- package/src/locales/sv.json +25 -0
- package/src/views/UserManagement.tsx +326 -0
package/package.json
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@jhits/plugin-users",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"description": "User management and authentication plugin for the JHITS ecosystem",
|
|
5
|
+
"publishConfig": {
|
|
6
|
+
"access": "public"
|
|
7
|
+
},
|
|
8
|
+
"main": "./src/index.tsx",
|
|
9
|
+
"types": "./src/index.tsx",
|
|
10
|
+
"exports": {
|
|
11
|
+
".": {
|
|
12
|
+
"types": "./src/index.tsx",
|
|
13
|
+
"default": "./src/index.tsx"
|
|
14
|
+
},
|
|
15
|
+
"./server": {
|
|
16
|
+
"types": "./src/index.server.ts",
|
|
17
|
+
"default": "./src/index.server.ts"
|
|
18
|
+
}
|
|
19
|
+
},
|
|
20
|
+
"dependencies": {
|
|
21
|
+
"@jhits/plugin-core": "^0.0.1",
|
|
22
|
+
"bcrypt": "^6.0.0",
|
|
23
|
+
"bcryptjs": "^2.4.3",
|
|
24
|
+
"framer-motion": "^12.23.26",
|
|
25
|
+
"lucide-react": "^0.562.0",
|
|
26
|
+
"mongodb": "^7.0.0",
|
|
27
|
+
"next-auth": "^4.24.13"
|
|
28
|
+
},
|
|
29
|
+
"peerDependencies": {
|
|
30
|
+
"next": ">=15.0.0",
|
|
31
|
+
"react": ">=18.0.0",
|
|
32
|
+
"react-dom": ">=18.0.0"
|
|
33
|
+
},
|
|
34
|
+
"devDependencies": {
|
|
35
|
+
"@tailwindcss/postcss": "^4",
|
|
36
|
+
"@types/bcrypt": "^6.0.0",
|
|
37
|
+
"@types/bcryptjs": "^2.4.6",
|
|
38
|
+
"@types/node": "^20.19.27",
|
|
39
|
+
"@types/react": "^19",
|
|
40
|
+
"@types/react-dom": "^19",
|
|
41
|
+
"eslint": "^9",
|
|
42
|
+
"eslint-config-next": "16.1.1",
|
|
43
|
+
"next": "16.1.1",
|
|
44
|
+
"react": "19.2.3",
|
|
45
|
+
"react-dom": "19.2.3",
|
|
46
|
+
"tailwindcss": "^4",
|
|
47
|
+
"typescript": "^5"
|
|
48
|
+
},
|
|
49
|
+
"files": [
|
|
50
|
+
"src",
|
|
51
|
+
"package.json"
|
|
52
|
+
]
|
|
53
|
+
}
|
package/src/api/auth.ts
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Plugin Users - Auth API Handler
|
|
3
|
+
* NextAuth API route handler for App Router
|
|
4
|
+
*
|
|
5
|
+
* This is a core plugin that provides authentication functionality.
|
|
6
|
+
* authOptions should be passed from the client app (typically from @jhits/dashboard/lib/auth)
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import NextAuth from 'next-auth';
|
|
10
|
+
import type { NextAuthOptions } from 'next-auth';
|
|
11
|
+
|
|
12
|
+
// Store authOptions - will be initialized by the client app
|
|
13
|
+
let authOptionsInstance: NextAuthOptions | null = null;
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Initialize the auth handler with authOptions
|
|
17
|
+
* This should be called by the client app before handling auth requests
|
|
18
|
+
*/
|
|
19
|
+
export function initAuthHandler(authOptions: NextAuthOptions) {
|
|
20
|
+
authOptionsInstance = authOptions;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Get the NextAuth handler
|
|
25
|
+
* Throws an error if authOptions haven't been initialized
|
|
26
|
+
*/
|
|
27
|
+
function getHandler() {
|
|
28
|
+
if (!authOptionsInstance) {
|
|
29
|
+
throw new Error(
|
|
30
|
+
'Auth handler not initialized. Call initAuthHandler() with authOptions from @jhits/dashboard/lib/auth'
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
return NextAuth(authOptionsInstance);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Export handlers - NextAuth handler works for both GET and POST
|
|
37
|
+
// NextAuth expects the route to be /api/auth/[...nextauth] with path segments in context.params.nextauth
|
|
38
|
+
export async function GET(req: any, context?: { params?: Promise<{ nextauth?: string[] }> }) {
|
|
39
|
+
const handler = getHandler();
|
|
40
|
+
|
|
41
|
+
// Use context if provided, otherwise extract from URL
|
|
42
|
+
let nextauthPath: string[] = [];
|
|
43
|
+
if (context?.params) {
|
|
44
|
+
const resolvedParams = await context.params;
|
|
45
|
+
nextauthPath = resolvedParams.nextauth || [];
|
|
46
|
+
} else {
|
|
47
|
+
// Fallback: extract from URL
|
|
48
|
+
const url = new URL(req.url);
|
|
49
|
+
const pathSegments = url.pathname.split('/').filter(Boolean);
|
|
50
|
+
// Path: /api/auth/session -> segments: ['api', 'auth', 'session'] -> nextauth: ['session']
|
|
51
|
+
nextauthPath = pathSegments.length > 2 ? pathSegments.slice(2) : [];
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Create context with nextauth parameter (NextAuth expects this)
|
|
55
|
+
const nextauthContext = {
|
|
56
|
+
params: Promise.resolve({ nextauth: nextauthPath })
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
return handler(req, nextauthContext);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export async function POST(req: any, context?: { params?: Promise<{ nextauth?: string[] }> }) {
|
|
63
|
+
const handler = getHandler();
|
|
64
|
+
|
|
65
|
+
// Use context if provided, otherwise extract from URL
|
|
66
|
+
let nextauthPath: string[] = [];
|
|
67
|
+
if (context?.params) {
|
|
68
|
+
const resolvedParams = await context.params;
|
|
69
|
+
nextauthPath = resolvedParams.nextauth || [];
|
|
70
|
+
} else {
|
|
71
|
+
// Fallback: extract from URL
|
|
72
|
+
const url = new URL(req.url);
|
|
73
|
+
const pathSegments = url.pathname.split('/').filter(Boolean);
|
|
74
|
+
nextauthPath = pathSegments.length > 2 ? pathSegments.slice(2) : [];
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const nextauthContext = {
|
|
78
|
+
params: Promise.resolve({ nextauth: nextauthPath })
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
return handler(req, nextauthContext);
|
|
82
|
+
}
|
|
83
|
+
|
package/src/api/index.ts
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
'use server';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Plugin Users API Router
|
|
5
|
+
* Centralized API handler for all users plugin routes
|
|
6
|
+
*
|
|
7
|
+
* This router handles requests to:
|
|
8
|
+
* - /api/auth/[...nextauth] (NextAuth routes)
|
|
9
|
+
* - /api/users (user management routes)
|
|
10
|
+
* - /api/plugin-users/* (plugin-specific routes)
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { NextRequest, NextResponse } from 'next/server';
|
|
14
|
+
import { UsersApiConfig } from './users';
|
|
15
|
+
import { GET_USERS, POST_USERS, PATCH_USER, DELETE_USER } from './users';
|
|
16
|
+
import { GET as AuthGET, POST as AuthPOST, initAuthHandler } from './auth';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Handle users API requests
|
|
20
|
+
* Routes requests to appropriate handlers based on path
|
|
21
|
+
* Handles both NextAuth routes (/api/auth) and user management routes (/api/users)
|
|
22
|
+
*/
|
|
23
|
+
export async function handleUsersApi(
|
|
24
|
+
req: NextRequest,
|
|
25
|
+
path: string[],
|
|
26
|
+
config: UsersApiConfig
|
|
27
|
+
): Promise<NextResponse> {
|
|
28
|
+
const method = req.method;
|
|
29
|
+
const route = path && path.length > 0 ? path[0] : '';
|
|
30
|
+
|
|
31
|
+
try {
|
|
32
|
+
// Initialize auth handler if authOptions are provided
|
|
33
|
+
if (config.authOptions) {
|
|
34
|
+
initAuthHandler(config.authOptions);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Route: /api/auth/[...nextauth] - NextAuth routes
|
|
38
|
+
// When routed from /api/auth, the path contains the nextauth segments
|
|
39
|
+
// Path structure: /api/auth/session -> path: ['session']
|
|
40
|
+
// Path structure: /api/auth/signin -> path: ['signin']
|
|
41
|
+
// Path structure: /api/auth -> path: [] (empty)
|
|
42
|
+
// We handle this by checking if route is empty (root /api/auth) or if it's a known NextAuth route
|
|
43
|
+
const isNextAuthRoute = route === '' ||
|
|
44
|
+
['session', 'signin', 'signout', 'callback', 'csrf', 'providers', 'error'].includes(route);
|
|
45
|
+
|
|
46
|
+
if (isNextAuthRoute) {
|
|
47
|
+
// This is a NextAuth route - use the path as nextauth segments
|
|
48
|
+
// For /api/auth, path is [] -> nextauthPath is []
|
|
49
|
+
// For /api/auth/session, path is ['session'] -> nextauthPath is ['session']
|
|
50
|
+
const nextauthPath = path;
|
|
51
|
+
|
|
52
|
+
// Create NextAuth context
|
|
53
|
+
const nextauthContext = {
|
|
54
|
+
params: Promise.resolve({ nextauth: nextauthPath })
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
if (method === 'GET') {
|
|
58
|
+
return await AuthGET(req, nextauthContext);
|
|
59
|
+
}
|
|
60
|
+
if (method === 'POST') {
|
|
61
|
+
return await AuthPOST(req, nextauthContext);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Route: /api/users or /api/plugin-users/users (user management)
|
|
66
|
+
if (route === 'users') {
|
|
67
|
+
if (path.length === 1) {
|
|
68
|
+
// /api/users or /api/plugin-users/users
|
|
69
|
+
if (method === 'GET') {
|
|
70
|
+
return await GET_USERS(req, config);
|
|
71
|
+
}
|
|
72
|
+
if (method === 'POST') {
|
|
73
|
+
return await POST_USERS(req, config);
|
|
74
|
+
}
|
|
75
|
+
} else if (path.length === 2) {
|
|
76
|
+
// /api/users/[id] or /api/plugin-users/users/[id]
|
|
77
|
+
const userId = path[1];
|
|
78
|
+
if (method === 'PATCH') {
|
|
79
|
+
return await PATCH_USER(req, userId, config);
|
|
80
|
+
}
|
|
81
|
+
if (method === 'DELETE') {
|
|
82
|
+
return await DELETE_USER(req, userId, config);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Method not allowed
|
|
88
|
+
return NextResponse.json(
|
|
89
|
+
{ error: `Method ${method} not allowed for route: ${route || '/'}` },
|
|
90
|
+
{ status: 405 }
|
|
91
|
+
);
|
|
92
|
+
} catch (error: any) {
|
|
93
|
+
console.error('[UsersApiRouter] Error:', error);
|
|
94
|
+
return NextResponse.json(
|
|
95
|
+
{ error: error.message || 'Internal server error' },
|
|
96
|
+
{ status: 500 }
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
package/src/api/users.ts
ADDED
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Plugin Users - Users API Handler
|
|
3
|
+
* Handles user management API routes
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { NextRequest, NextResponse } from 'next/server';
|
|
7
|
+
import { ObjectId } from 'mongodb';
|
|
8
|
+
import bcrypt from 'bcryptjs';
|
|
9
|
+
|
|
10
|
+
export interface UsersApiConfig {
|
|
11
|
+
/** MongoDB client promise */
|
|
12
|
+
getDb: () => Promise<{ db: () => any }>;
|
|
13
|
+
/** Collection name (default: 'users') */
|
|
14
|
+
collectionName?: string;
|
|
15
|
+
/** NextAuth options for authentication routes */
|
|
16
|
+
authOptions?: any;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* GET /api/plugin-users/users - List all users
|
|
21
|
+
*/
|
|
22
|
+
export async function GET_USERS(req: NextRequest, config: UsersApiConfig): Promise<NextResponse> {
|
|
23
|
+
try {
|
|
24
|
+
const dbConnection = await config.getDb();
|
|
25
|
+
const db = dbConnection.db();
|
|
26
|
+
const users = db.collection(config.collectionName || 'users');
|
|
27
|
+
|
|
28
|
+
// Exclude passwords for security
|
|
29
|
+
const userList = await users
|
|
30
|
+
.find({}, { projection: { password: 0 } })
|
|
31
|
+
.toArray();
|
|
32
|
+
|
|
33
|
+
return NextResponse.json(userList);
|
|
34
|
+
} catch (err: any) {
|
|
35
|
+
console.error('[UsersAPI] GET error:', err);
|
|
36
|
+
return NextResponse.json(
|
|
37
|
+
{ error: 'Failed to fetch users', detail: err.message },
|
|
38
|
+
{ status: 500 }
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* POST /api/plugin-users/users - Create new user
|
|
45
|
+
*/
|
|
46
|
+
export async function POST_USERS(req: NextRequest, config: UsersApiConfig): Promise<NextResponse> {
|
|
47
|
+
try {
|
|
48
|
+
const { email, name, role, password } = await req.json();
|
|
49
|
+
const dbConnection = await config.getDb();
|
|
50
|
+
const db = dbConnection.db();
|
|
51
|
+
const users = db.collection(config.collectionName || 'users');
|
|
52
|
+
|
|
53
|
+
// Validation
|
|
54
|
+
if (!password || password.length < 6) {
|
|
55
|
+
return NextResponse.json(
|
|
56
|
+
{ error: 'Password too short (minimum 6 characters)' },
|
|
57
|
+
{ status: 400 }
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const exists = await users.findOne({ email });
|
|
62
|
+
if (exists) {
|
|
63
|
+
return NextResponse.json(
|
|
64
|
+
{ error: 'User already exists' },
|
|
65
|
+
{ status: 400 }
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Hash the password
|
|
70
|
+
const hashedPassword = await bcrypt.hash(password, 12);
|
|
71
|
+
|
|
72
|
+
// Save to DB
|
|
73
|
+
const result = await users.insertOne({
|
|
74
|
+
email,
|
|
75
|
+
name,
|
|
76
|
+
role: role || 'editor',
|
|
77
|
+
password: hashedPassword,
|
|
78
|
+
createdAt: new Date(),
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
return NextResponse.json({
|
|
82
|
+
_id: result.insertedId,
|
|
83
|
+
email,
|
|
84
|
+
name,
|
|
85
|
+
role: role || 'editor',
|
|
86
|
+
createdAt: new Date()
|
|
87
|
+
}, { status: 201 });
|
|
88
|
+
} catch (err: any) {
|
|
89
|
+
console.error('[UsersAPI] POST error:', err);
|
|
90
|
+
return NextResponse.json(
|
|
91
|
+
{ error: 'Failed to create user', detail: err.message },
|
|
92
|
+
{ status: 500 }
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* PATCH /api/plugin-users/users/[id] - Update user
|
|
99
|
+
*/
|
|
100
|
+
export async function PATCH_USER(
|
|
101
|
+
req: NextRequest,
|
|
102
|
+
userId: string,
|
|
103
|
+
config: UsersApiConfig
|
|
104
|
+
): Promise<NextResponse> {
|
|
105
|
+
try {
|
|
106
|
+
const { role, name, password, currentPassword } = await req.json();
|
|
107
|
+
const dbConnection = await config.getDb();
|
|
108
|
+
const db = dbConnection.db();
|
|
109
|
+
const users = db.collection(config.collectionName || 'users');
|
|
110
|
+
|
|
111
|
+
// Prepare the update object
|
|
112
|
+
const updateData: any = { updatedAt: new Date() };
|
|
113
|
+
if (role) updateData.role = role;
|
|
114
|
+
if (name) updateData.name = name;
|
|
115
|
+
|
|
116
|
+
// Handle Password Update Logic
|
|
117
|
+
if (password) {
|
|
118
|
+
// Find the user first to verify identity
|
|
119
|
+
const user = await users.findOne({ _id: new ObjectId(userId) });
|
|
120
|
+
|
|
121
|
+
if (!user) {
|
|
122
|
+
return NextResponse.json({ error: 'User not found' }, { status: 404 });
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Safety Check: Verify current password before allowing change
|
|
126
|
+
if (!currentPassword) {
|
|
127
|
+
return NextResponse.json(
|
|
128
|
+
{ error: 'Current password is required' },
|
|
129
|
+
{ status: 400 }
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const isCorrect = await bcrypt.compare(currentPassword, user.password);
|
|
134
|
+
if (!isCorrect) {
|
|
135
|
+
return NextResponse.json(
|
|
136
|
+
{ error: 'Current password is incorrect' },
|
|
137
|
+
{ status: 401 }
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Hash the NEW password
|
|
142
|
+
if (password.length < 6) {
|
|
143
|
+
return NextResponse.json(
|
|
144
|
+
{ error: 'New password too short (minimum 6 characters)' },
|
|
145
|
+
{ status: 400 }
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
updateData.password = await bcrypt.hash(password, 12);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Execute the Update
|
|
152
|
+
const result = await users.updateOne(
|
|
153
|
+
{ _id: new ObjectId(userId) },
|
|
154
|
+
{ $set: updateData }
|
|
155
|
+
);
|
|
156
|
+
|
|
157
|
+
if (result.matchedCount === 0) {
|
|
158
|
+
return NextResponse.json({ error: 'User not found' }, { status: 404 });
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
return NextResponse.json({ message: 'Update successful' });
|
|
162
|
+
} catch (err: any) {
|
|
163
|
+
console.error('[UsersAPI] PATCH error:', err);
|
|
164
|
+
return NextResponse.json(
|
|
165
|
+
{ error: 'Update failed', detail: err.message },
|
|
166
|
+
{ status: 500 }
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* DELETE /api/plugin-users/users/[id] - Delete user
|
|
173
|
+
*/
|
|
174
|
+
export async function DELETE_USER(
|
|
175
|
+
req: NextRequest,
|
|
176
|
+
userId: string,
|
|
177
|
+
config: UsersApiConfig
|
|
178
|
+
): Promise<NextResponse> {
|
|
179
|
+
try {
|
|
180
|
+
const dbConnection = await config.getDb();
|
|
181
|
+
const db = dbConnection.db();
|
|
182
|
+
const users = db.collection(config.collectionName || 'users');
|
|
183
|
+
|
|
184
|
+
// Safety check: Prevent deleting developer account
|
|
185
|
+
const user = await users.findOne({ _id: new ObjectId(userId) });
|
|
186
|
+
if (user?.role === 'dev') {
|
|
187
|
+
return NextResponse.json(
|
|
188
|
+
{ error: 'Cannot delete developer account' },
|
|
189
|
+
{ status: 403 }
|
|
190
|
+
);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const result = await users.deleteOne({ _id: new ObjectId(userId) });
|
|
194
|
+
|
|
195
|
+
if (result.deletedCount === 0) {
|
|
196
|
+
return NextResponse.json({ error: 'User not found' }, { status: 404 });
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
return NextResponse.json({ message: 'User deleted' });
|
|
200
|
+
} catch (err: any) {
|
|
201
|
+
console.error('[UsersAPI] DELETE error:', err);
|
|
202
|
+
return NextResponse.json(
|
|
203
|
+
{ error: 'Delete failed', detail: err.message },
|
|
204
|
+
{ status: 500 }
|
|
205
|
+
);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Plugin Users - Server-Only API Exports
|
|
3
|
+
* This file is only imported in server-side code (API routes)
|
|
4
|
+
*
|
|
5
|
+
* IMPORTANT: This file uses Node.js modules and should NEVER
|
|
6
|
+
* be imported in client-side code. Only use in server-side API routes.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
// Re-export everything from the API index
|
|
10
|
+
export * from './api';
|
|
11
|
+
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Plugin Users - Server-Only Entry Point
|
|
3
|
+
* This file exports only server-side API handlers
|
|
4
|
+
* Used by the dynamic plugin router via @jhits/plugin-users/server
|
|
5
|
+
*
|
|
6
|
+
* Note: This file is server-only (no 'use server' needed - that's only for Server Actions)
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export { handleUsersApi as handleApi } from './api/router';
|
|
10
|
+
export { handleUsersApi } from './api/router'; // Keep original export for backward compatibility
|
|
11
|
+
export type { UsersApiConfig } from './api/users';
|
|
12
|
+
|
package/src/index.tsx
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import React from 'react';
|
|
4
|
+
import UserManagement from './views/UserManagement';
|
|
5
|
+
|
|
6
|
+
// User Management Plugin - Always enabled by default
|
|
7
|
+
export const Index: React.FC<any> = ({ subPath = [], siteId, locale: dashboardLocale = 'en' }) => {
|
|
8
|
+
return (
|
|
9
|
+
<div className="w-full h-full flex flex-col overflow-hidden">
|
|
10
|
+
<div className="flex-1 overflow-y-auto">
|
|
11
|
+
<UserManagement locale={dashboardLocale} />
|
|
12
|
+
</div>
|
|
13
|
+
</div>
|
|
14
|
+
);
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export default Index;
|
|
18
|
+
|
|
19
|
+
// Note: API handlers are server-only and exported from ./index.ts (server entry point)
|
|
20
|
+
// They are NOT exported here to prevent client/server context mixing
|
|
21
|
+
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
{
|
|
2
|
+
"title": "User Management",
|
|
3
|
+
"description": "Manage who has access to the dashboard.",
|
|
4
|
+
"newUser": "New User",
|
|
5
|
+
"searchUsers": "Search users...",
|
|
6
|
+
"user": "User",
|
|
7
|
+
"role": "Role",
|
|
8
|
+
"since": "Since",
|
|
9
|
+
"actions": "Actions",
|
|
10
|
+
"noUsersFound": "No users found matching your search.",
|
|
11
|
+
"addUser": "Add User",
|
|
12
|
+
"name": "Name",
|
|
13
|
+
"email": "Email Address",
|
|
14
|
+
"temporaryPassword": "Temporary Password",
|
|
15
|
+
"generateNewPassword": "Generate new password",
|
|
16
|
+
"editor": "Editor (Content only)",
|
|
17
|
+
"dev": "Developer",
|
|
18
|
+
"admin": "Admin (All settings)",
|
|
19
|
+
"cannotDeleteDev": "Cannot delete developer account.",
|
|
20
|
+
"confirmDelete": "Are you sure you want to delete this user?",
|
|
21
|
+
"errorDeleting": "Error deleting user.",
|
|
22
|
+
"failedToCreate": "Failed to create user",
|
|
23
|
+
"networkError": "Network error while creating user"
|
|
24
|
+
}
|
|
25
|
+
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
{
|
|
2
|
+
"title": "Gebruikersbeheer",
|
|
3
|
+
"description": "Beheer wie toegang heeft tot het dashboard.",
|
|
4
|
+
"newUser": "Nieuwe Gebruiker",
|
|
5
|
+
"searchUsers": "Zoek gebruikers...",
|
|
6
|
+
"user": "Gebruiker",
|
|
7
|
+
"role": "Rol",
|
|
8
|
+
"since": "Sinds",
|
|
9
|
+
"actions": "Acties",
|
|
10
|
+
"noUsersFound": "Geen gebruikers gevonden die voldoen aan je zoekopdracht.",
|
|
11
|
+
"addUser": "Gebruiker Toevoegen",
|
|
12
|
+
"name": "Naam",
|
|
13
|
+
"email": "E-mailadres",
|
|
14
|
+
"temporaryPassword": "Tijdelijk Wachtwoord",
|
|
15
|
+
"generateNewPassword": "Genereer nieuw wachtwoord",
|
|
16
|
+
"editor": "Editor (Alleen content)",
|
|
17
|
+
"dev": "Developer",
|
|
18
|
+
"admin": "Admin (Alle instellingen)",
|
|
19
|
+
"cannotDeleteDev": "Developer kan niet verwijderd worden.",
|
|
20
|
+
"confirmDelete": "Weet je zeker dat je deze gebruiker wilt verwijderen?",
|
|
21
|
+
"errorDeleting": "Fout bij verwijderen.",
|
|
22
|
+
"failedToCreate": "Aanmaken mislukt",
|
|
23
|
+
"networkError": "Netwerkfout bij aanmaken gebruiker"
|
|
24
|
+
}
|
|
25
|
+
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
{
|
|
2
|
+
"title": "Användarhantering",
|
|
3
|
+
"description": "Hantera vem som har tillgång till instrumentpanelen.",
|
|
4
|
+
"newUser": "Ny Användare",
|
|
5
|
+
"searchUsers": "Sök användare...",
|
|
6
|
+
"user": "Användare",
|
|
7
|
+
"role": "Roll",
|
|
8
|
+
"since": "Sedan",
|
|
9
|
+
"actions": "Åtgärder",
|
|
10
|
+
"noUsersFound": "Inga användare hittades som matchar din sökning.",
|
|
11
|
+
"addUser": "Lägg till Användare",
|
|
12
|
+
"name": "Namn",
|
|
13
|
+
"email": "E-postadress",
|
|
14
|
+
"temporaryPassword": "Tillfälligt Lösenord",
|
|
15
|
+
"generateNewPassword": "Generera nytt lösenord",
|
|
16
|
+
"editor": "Redaktör (Endast innehåll)",
|
|
17
|
+
"dev": "Utvecklare",
|
|
18
|
+
"admin": "Administratör (Alla inställningar)",
|
|
19
|
+
"cannotDeleteDev": "Kan inte ta bort utvecklarkonto.",
|
|
20
|
+
"confirmDelete": "Är du säker på att du vill ta bort denna användare?",
|
|
21
|
+
"errorDeleting": "Fel vid borttagning av användare.",
|
|
22
|
+
"failedToCreate": "Misslyckades med att skapa användare",
|
|
23
|
+
"networkError": "Nätverksfel vid skapande av användare"
|
|
24
|
+
}
|
|
25
|
+
|
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useState, useEffect } from "react";
|
|
4
|
+
import {
|
|
5
|
+
Users, UserPlus, Shield,
|
|
6
|
+
Trash2, Key, Search, Loader2, X, Eye, EyeOff, Copy, Check, Calendar
|
|
7
|
+
} from "lucide-react";
|
|
8
|
+
|
|
9
|
+
interface User {
|
|
10
|
+
_id: string;
|
|
11
|
+
name: string;
|
|
12
|
+
email: string;
|
|
13
|
+
role: 'dev' | 'admin' | 'editor';
|
|
14
|
+
createdAt: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export default function UserManagement({ locale = 'en' }: { locale?: string }) {
|
|
18
|
+
const [users, setUsers] = useState<User[]>([]);
|
|
19
|
+
const [loading, setLoading] = useState(true);
|
|
20
|
+
const [searchTerm, setSearchTerm] = useState("");
|
|
21
|
+
|
|
22
|
+
// Modal State
|
|
23
|
+
const [isModalOpen, setIsModalOpen] = useState(false);
|
|
24
|
+
const [isCreating, setIsCreating] = useState(false);
|
|
25
|
+
const [showPassword, setShowPassword] = useState(false);
|
|
26
|
+
const [copied, setCopied] = useState(false);
|
|
27
|
+
|
|
28
|
+
// Form State
|
|
29
|
+
const [formData, setFormData] = useState({
|
|
30
|
+
name: "",
|
|
31
|
+
email: "",
|
|
32
|
+
password: "",
|
|
33
|
+
role: "editor" as const
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
const fetchUsers = async () => {
|
|
37
|
+
try {
|
|
38
|
+
setLoading(true);
|
|
39
|
+
const res = await fetch('/api/users');
|
|
40
|
+
const data = await res.json();
|
|
41
|
+
if (Array.isArray(data)) setUsers(data);
|
|
42
|
+
} catch (err) {
|
|
43
|
+
console.error("Failed to load users", err);
|
|
44
|
+
} finally {
|
|
45
|
+
setLoading(false);
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
useEffect(() => { fetchUsers(); }, []);
|
|
50
|
+
|
|
51
|
+
const generateTempPassword = () => {
|
|
52
|
+
const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%";
|
|
53
|
+
let retVal = "";
|
|
54
|
+
for (let i = 0, n = charset.length; i < 10; ++i) {
|
|
55
|
+
retVal += charset.charAt(Math.floor(Math.random() * n));
|
|
56
|
+
}
|
|
57
|
+
setFormData({ ...formData, password: retVal });
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
const handleCreateUser = async (e: React.FormEvent) => {
|
|
61
|
+
e.preventDefault();
|
|
62
|
+
setIsCreating(true);
|
|
63
|
+
try {
|
|
64
|
+
const res = await fetch('/api/users', {
|
|
65
|
+
method: 'POST',
|
|
66
|
+
headers: { 'Content-Type': 'application/json' },
|
|
67
|
+
body: JSON.stringify(formData)
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
if (res.ok) {
|
|
71
|
+
const newUser = await res.json();
|
|
72
|
+
setUsers([...users, newUser]);
|
|
73
|
+
setIsModalOpen(false);
|
|
74
|
+
setFormData({ name: "", email: "", password: "", role: "editor" });
|
|
75
|
+
} else {
|
|
76
|
+
const err = await res.json();
|
|
77
|
+
alert(err.error || "Failed to create user");
|
|
78
|
+
}
|
|
79
|
+
} catch (err) {
|
|
80
|
+
alert("Network error while creating user");
|
|
81
|
+
} finally {
|
|
82
|
+
setIsCreating(false);
|
|
83
|
+
}
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
const copyToClipboard = () => {
|
|
87
|
+
navigator.clipboard.writeText(formData.password);
|
|
88
|
+
setCopied(true);
|
|
89
|
+
setTimeout(() => setCopied(false), 2000);
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
const handleDelete = async (userId: string, userRole: string) => {
|
|
93
|
+
if (userRole === 'dev') return alert("Cannot delete developer account.");
|
|
94
|
+
if (!confirm("Are you sure you want to delete this user?")) return;
|
|
95
|
+
|
|
96
|
+
try {
|
|
97
|
+
const res = await fetch(`/api/users/${userId}`, { method: 'DELETE' });
|
|
98
|
+
if (res.ok) setUsers(users.filter(u => u._id !== userId));
|
|
99
|
+
} catch (err) { alert("Error deleting user."); }
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
const filteredUsers = users.filter(user =>
|
|
103
|
+
user.name?.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
|
104
|
+
user.email?.toLowerCase().includes(searchTerm.toLowerCase())
|
|
105
|
+
);
|
|
106
|
+
|
|
107
|
+
return (
|
|
108
|
+
<div className="p-4 rounded-[2.5rem] sm:p-8 space-y-6 sm:space-y-8 animate-in fade-in duration-500 max-w-[1400px] mx-auto">
|
|
109
|
+
{/* Header */}
|
|
110
|
+
<div className="flex flex-col sm:flex-row sm:justify-between sm:items-end gap-4">
|
|
111
|
+
<div>
|
|
112
|
+
<h1 className="text-2xl sm:text-3xl font-pp uppercase font-bold text-neutral-900 dark:text-neutral-100">User Management</h1>
|
|
113
|
+
<p className="text-neutral-500 dark:text-neutral-400 text-xs sm:text-sm font-medium">Manage who has access to the dashboard.</p>
|
|
114
|
+
</div>
|
|
115
|
+
<button
|
|
116
|
+
onClick={() => { setIsModalOpen(true); generateTempPassword(); }}
|
|
117
|
+
className="flex items-center justify-center gap-2 px-5 py-3 bg-primary text-white rounded-xl font-bold hover:bg-primary/90 transition-all shadow-lg text-sm sm:text-base"
|
|
118
|
+
>
|
|
119
|
+
<UserPlus size={18} /> New User
|
|
120
|
+
</button>
|
|
121
|
+
</div>
|
|
122
|
+
|
|
123
|
+
{/* Toolbar */}
|
|
124
|
+
<div className="bg-dashboard-card rounded-2xl sm:rounded-[2rem] border border-dashboard-border shadow-sm overflow-hidden">
|
|
125
|
+
<div className="p-4 sm:p-6 border-b border-dashboard-border flex items-center justify-between bg-dashboard-bg">
|
|
126
|
+
<div className="relative w-full sm:w-72">
|
|
127
|
+
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-neutral-400" size={16} />
|
|
128
|
+
<input
|
|
129
|
+
type="text"
|
|
130
|
+
placeholder="Search users..."
|
|
131
|
+
value={searchTerm}
|
|
132
|
+
onChange={(e) => setSearchTerm(e.target.value)}
|
|
133
|
+
className="w-full pl-10 pr-4 py-2 bg-dashboard-card border border-dashboard-border rounded-lg text-sm outline-none focus:ring-2 focus:ring-primary/20 transition-all text-dashboard-text"
|
|
134
|
+
/>
|
|
135
|
+
</div>
|
|
136
|
+
{loading && <Loader2 className="animate-spin text-primary ml-4" size={20} />}
|
|
137
|
+
</div>
|
|
138
|
+
|
|
139
|
+
{/* Desktop Table */}
|
|
140
|
+
<div className="hidden md:block overflow-x-auto">
|
|
141
|
+
<table className="w-full text-left border-collapse">
|
|
142
|
+
<thead>
|
|
143
|
+
<tr className="text-[11px] font-bold text-neutral-400 uppercase tracking-widest border-b border-dashboard-border">
|
|
144
|
+
<th className="px-8 py-4">User</th>
|
|
145
|
+
<th className="px-8 py-4">Role</th>
|
|
146
|
+
<th className="px-8 py-4">Since</th>
|
|
147
|
+
<th className="px-8 py-4 text-right">Actions</th>
|
|
148
|
+
</tr>
|
|
149
|
+
</thead>
|
|
150
|
+
<tbody className="divide-y divide-neutral-100 dark:divide-neutral-800">
|
|
151
|
+
{filteredUsers.map((user) => (
|
|
152
|
+
<tr key={user._id} className="group hover:bg-dashboard-bg transition-colors">
|
|
153
|
+
<td className="px-8 py-5">
|
|
154
|
+
<div className="flex items-center gap-3">
|
|
155
|
+
<div className="w-10 h-10 rounded-full bg-primary/10 text-primary flex items-center justify-center font-bold border border-primary/20">
|
|
156
|
+
{user.name?.[0] || 'U'}
|
|
157
|
+
</div>
|
|
158
|
+
<div>
|
|
159
|
+
<p className="font-bold text-dashboard-text text-sm">{user.name}</p>
|
|
160
|
+
<p className="text-xs text-neutral-400">{user.email}</p>
|
|
161
|
+
</div>
|
|
162
|
+
</div>
|
|
163
|
+
</td>
|
|
164
|
+
<td className="px-8 py-5">
|
|
165
|
+
<span className={`inline-flex items-center gap-1.5 px-3 py-1 rounded-full text-[10px] font-bold uppercase tracking-wider ${user.role === 'dev' ? 'bg-primary text-white' : 'bg-dashboard-bg text-dashboard-text'
|
|
166
|
+
}`}>
|
|
167
|
+
<Shield size={10} /> {user.role}
|
|
168
|
+
</span>
|
|
169
|
+
</td>
|
|
170
|
+
<td className="px-8 py-5 text-sm text-neutral-500 dark:text-neutral-400">
|
|
171
|
+
{user.createdAt ? new Date(user.createdAt).toLocaleDateString() : '-'}
|
|
172
|
+
</td>
|
|
173
|
+
<td className="px-8 py-5 text-right">
|
|
174
|
+
<button
|
|
175
|
+
onClick={() => handleDelete(user._id, user.role)}
|
|
176
|
+
className="p-2 text-neutral-300 dark:text-neutral-600 hover:text-red-500 transition-colors disabled:opacity-0"
|
|
177
|
+
disabled={user.role === 'dev'}
|
|
178
|
+
>
|
|
179
|
+
<Trash2 size={16} />
|
|
180
|
+
</button>
|
|
181
|
+
</td>
|
|
182
|
+
</tr>
|
|
183
|
+
))}
|
|
184
|
+
</tbody>
|
|
185
|
+
</table>
|
|
186
|
+
</div>
|
|
187
|
+
|
|
188
|
+
{/* Mobile Card List */}
|
|
189
|
+
<div className="md:hidden divide-y divide-neutral-100 dark:divide-neutral-800">
|
|
190
|
+
{filteredUsers.map((user) => (
|
|
191
|
+
<div key={user._id} className="p-5 space-y-4">
|
|
192
|
+
<div className="flex justify-between items-start">
|
|
193
|
+
<div className="flex items-center gap-3">
|
|
194
|
+
<div className="w-10 h-10 rounded-full bg-primary/10 text-primary flex items-center justify-center font-bold border border-primary/20">
|
|
195
|
+
{user.name?.[0] || 'U'}
|
|
196
|
+
</div>
|
|
197
|
+
<div className="min-w-0">
|
|
198
|
+
<p className="font-bold text-neutral-900 dark:text-neutral-100 text-sm truncate">{user.name}</p>
|
|
199
|
+
<p className="text-[11px] text-neutral-400 truncate">{user.email}</p>
|
|
200
|
+
</div>
|
|
201
|
+
</div>
|
|
202
|
+
<button
|
|
203
|
+
onClick={() => handleDelete(user._id, user.role)}
|
|
204
|
+
className={`p-2 rounded-lg bg-red-50 dark:bg-red-950/20 text-red-400 ${user.role === 'dev' ? 'hidden' : 'block'}`}
|
|
205
|
+
>
|
|
206
|
+
<Trash2 size={16} />
|
|
207
|
+
</button>
|
|
208
|
+
</div>
|
|
209
|
+
|
|
210
|
+
<div className="flex items-center justify-between pt-2 border-t border-neutral-100 dark:border-neutral-800">
|
|
211
|
+
<span className={`inline-flex items-center gap-1.5 px-3 py-1 rounded-full text-[9px] font-black uppercase tracking-wider ${user.role === 'dev' ? 'bg-primary text-white' : 'bg-neutral-100 dark:bg-neutral-800 text-neutral-700 dark:text-neutral-300'
|
|
212
|
+
}`}>
|
|
213
|
+
<Shield size={10} /> {user.role}
|
|
214
|
+
</span>
|
|
215
|
+
<div className="flex items-center gap-1.5 text-[10px] text-neutral-400 font-medium">
|
|
216
|
+
<Calendar size={12} />
|
|
217
|
+
{user.createdAt ? new Date(user.createdAt).toLocaleDateString() : '-'}
|
|
218
|
+
</div>
|
|
219
|
+
</div>
|
|
220
|
+
</div>
|
|
221
|
+
))}
|
|
222
|
+
</div>
|
|
223
|
+
|
|
224
|
+
{filteredUsers.length === 0 && !loading && (
|
|
225
|
+
<div className="p-12 text-center text-neutral-400 text-sm">
|
|
226
|
+
No users found matching your search.
|
|
227
|
+
</div>
|
|
228
|
+
)}
|
|
229
|
+
</div>
|
|
230
|
+
|
|
231
|
+
{/* Create User Modal */}
|
|
232
|
+
{isModalOpen && (
|
|
233
|
+
<div className="fixed inset-0 z-50 flex items-end sm:items-center justify-center p-0 sm:p-4 bg-black/20 backdrop-blur-sm animate-in fade-in duration-200">
|
|
234
|
+
<div className="bg-white dark:bg-neutral-900 w-full max-w-md rounded-t-[2.5rem] sm:rounded-[2.5rem] shadow-2xl overflow-hidden animate-in slide-in-from-bottom sm:zoom-in-95 duration-300 max-h-[90vh] overflow-y-auto">
|
|
235
|
+
<div className="p-6 sm:p-8 bg-primary text-white flex justify-between items-center sticky top-0 z-10">
|
|
236
|
+
<h2 className="text-xl sm:text-2xl font-pp uppercase font-bold">New User</h2>
|
|
237
|
+
<button onClick={() => setIsModalOpen(false)} className="hover:rotate-90 transition-transform p-2">
|
|
238
|
+
<X size={24} />
|
|
239
|
+
</button>
|
|
240
|
+
</div>
|
|
241
|
+
|
|
242
|
+
<form onSubmit={handleCreateUser} className="p-6 sm:p-8 space-y-5">
|
|
243
|
+
<div className="space-y-2">
|
|
244
|
+
<label className="text-xs font-bold text-neutral-500 dark:text-neutral-400 uppercase tracking-widest ml-1">Name</label>
|
|
245
|
+
<input
|
|
246
|
+
required
|
|
247
|
+
className="w-full px-5 py-3 bg-dashboard-bg border-none rounded-2xl focus:ring-2 focus:ring-primary outline-none text-sm transition-all text-dashboard-text"
|
|
248
|
+
value={formData.name}
|
|
249
|
+
onChange={e => setFormData({ ...formData, name: e.target.value })}
|
|
250
|
+
placeholder="e.g. John Doe"
|
|
251
|
+
/>
|
|
252
|
+
</div>
|
|
253
|
+
|
|
254
|
+
<div className="space-y-2">
|
|
255
|
+
<label className="text-xs font-bold text-neutral-500 dark:text-neutral-400 uppercase tracking-widest ml-1">Email Address</label>
|
|
256
|
+
<input
|
|
257
|
+
required
|
|
258
|
+
type="email"
|
|
259
|
+
className="w-full px-5 py-3 bg-dashboard-bg border-none rounded-2xl focus:ring-2 focus:ring-primary outline-none text-sm transition-all text-dashboard-text"
|
|
260
|
+
value={formData.email}
|
|
261
|
+
onChange={e => setFormData({ ...formData, email: e.target.value })}
|
|
262
|
+
placeholder="email@example.com"
|
|
263
|
+
/>
|
|
264
|
+
</div>
|
|
265
|
+
|
|
266
|
+
<div className="space-y-2">
|
|
267
|
+
<label className="text-xs font-bold text-neutral-500 dark:text-neutral-400 uppercase tracking-widest ml-1">Temporary Password</label>
|
|
268
|
+
<div className="relative">
|
|
269
|
+
<input
|
|
270
|
+
required
|
|
271
|
+
type={showPassword ? "text" : "password"}
|
|
272
|
+
className="w-full px-5 py-3 bg-neutral-100 dark:bg-neutral-800 border-none rounded-2xl focus:ring-2 focus:ring-primary outline-none text-sm transition-all font-mono text-neutral-900 dark:text-neutral-100"
|
|
273
|
+
value={formData.password}
|
|
274
|
+
onChange={e => setFormData({ ...formData, password: e.target.value })}
|
|
275
|
+
/>
|
|
276
|
+
<div className="absolute right-3 top-1/2 -translate-y-1/2 flex items-center gap-1">
|
|
277
|
+
<button type="button" onClick={() => setShowPassword(!showPassword)} className="p-1.5 text-neutral-400 hover:text-primary">
|
|
278
|
+
{showPassword ? <EyeOff size={16} /> : <Eye size={16} />}
|
|
279
|
+
</button>
|
|
280
|
+
<button type="button" onClick={copyToClipboard} className="p-1.5 text-neutral-400 hover:text-primary">
|
|
281
|
+
{copied ? <Check size={16} className="text-emerald-500" /> : <Copy size={16} />}
|
|
282
|
+
</button>
|
|
283
|
+
</div>
|
|
284
|
+
</div>
|
|
285
|
+
<button
|
|
286
|
+
type="button"
|
|
287
|
+
onClick={generateTempPassword}
|
|
288
|
+
className="text-[10px] text-primary font-bold uppercase tracking-wider hover:underline ml-1"
|
|
289
|
+
>
|
|
290
|
+
Generate new password
|
|
291
|
+
</button>
|
|
292
|
+
</div>
|
|
293
|
+
|
|
294
|
+
<div className="space-y-2">
|
|
295
|
+
<label className="text-xs font-bold text-neutral-500 dark:text-neutral-400 uppercase tracking-widest ml-1">Role</label>
|
|
296
|
+
<div className="relative">
|
|
297
|
+
<select
|
|
298
|
+
className="w-full px-5 py-3 bg-neutral-100 dark:bg-neutral-800 border-none rounded-2xl focus:ring-2 focus:ring-primary outline-none text-sm appearance-none cursor-pointer text-neutral-900 dark:text-neutral-100"
|
|
299
|
+
value={formData.role}
|
|
300
|
+
onChange={e => setFormData({ ...formData, role: e.target.value as any })}
|
|
301
|
+
>
|
|
302
|
+
<option value="editor">Editor (Content only)</option>
|
|
303
|
+
<option value="dev">Developer</option>
|
|
304
|
+
<option value="admin">Admin (All settings)</option>
|
|
305
|
+
</select>
|
|
306
|
+
<div className="absolute right-4 top-1/2 -translate-y-1/2 pointer-events-none text-neutral-400">
|
|
307
|
+
<Shield size={16} />
|
|
308
|
+
</div>
|
|
309
|
+
</div>
|
|
310
|
+
</div>
|
|
311
|
+
|
|
312
|
+
<button
|
|
313
|
+
type="submit"
|
|
314
|
+
disabled={isCreating}
|
|
315
|
+
className="w-full py-4 bg-primary text-white rounded-2xl font-bold shadow-lg shadow-primary/20 hover:bg-primary/90 transition-all mt-4 flex items-center justify-center gap-2"
|
|
316
|
+
>
|
|
317
|
+
{isCreating ? <Loader2 className="animate-spin" size={20} /> : "Add User"}
|
|
318
|
+
</button>
|
|
319
|
+
</form>
|
|
320
|
+
</div>
|
|
321
|
+
</div>
|
|
322
|
+
)}
|
|
323
|
+
</div>
|
|
324
|
+
);
|
|
325
|
+
}
|
|
326
|
+
|