lapeh 2.2.6 → 2.2.7
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/api-testing-sepuluh/.env.example +19 -0
- package/api-testing-sepuluh/doc/ARCHITECTURE_GUIDE.md +73 -0
- package/api-testing-sepuluh/doc/CHANGELOG.md +77 -0
- package/api-testing-sepuluh/doc/CHEATSHEET.md +94 -0
- package/api-testing-sepuluh/doc/CLI.md +106 -0
- package/api-testing-sepuluh/doc/CONTRIBUTING.md +105 -0
- package/api-testing-sepuluh/doc/DEPLOYMENT.md +122 -0
- package/api-testing-sepuluh/doc/FAQ.md +81 -0
- package/api-testing-sepuluh/doc/FEATURES.md +165 -0
- package/api-testing-sepuluh/doc/GETTING_STARTED.md +108 -0
- package/api-testing-sepuluh/doc/INTRODUCTION.md +60 -0
- package/api-testing-sepuluh/doc/PACKAGES.md +66 -0
- package/api-testing-sepuluh/doc/PERFORMANCE.md +91 -0
- package/api-testing-sepuluh/doc/ROADMAP.md +93 -0
- package/api-testing-sepuluh/doc/SECURITY.md +93 -0
- package/api-testing-sepuluh/doc/STRUCTURE.md +90 -0
- package/api-testing-sepuluh/doc/TUTORIAL.md +192 -0
- package/api-testing-sepuluh/docker-compose.yml +24 -0
- package/api-testing-sepuluh/eslint.config.mjs +26 -0
- package/api-testing-sepuluh/framework.md +168 -0
- package/api-testing-sepuluh/nodemon.json +6 -0
- package/api-testing-sepuluh/package-lock.json +5539 -0
- package/api-testing-sepuluh/package.json +103 -0
- package/api-testing-sepuluh/prisma/base.prisma.template +7 -0
- package/api-testing-sepuluh/prisma/migrations/20251227034737_init_setup/migration.sql +248 -0
- package/api-testing-sepuluh/prisma/migrations/migration_lock.toml +3 -0
- package/api-testing-sepuluh/prisma/schema.prisma +183 -0
- package/api-testing-sepuluh/prisma/seed.ts +411 -0
- package/api-testing-sepuluh/prisma.config.ts +15 -0
- package/api-testing-sepuluh/readme.md +414 -0
- package/api-testing-sepuluh/scripts/check-update.js +92 -0
- package/api-testing-sepuluh/scripts/compile-schema.js +29 -0
- package/api-testing-sepuluh/scripts/config-clear.js +45 -0
- package/api-testing-sepuluh/scripts/generate-jwt-secret.js +38 -0
- package/api-testing-sepuluh/scripts/init-project.js +178 -0
- package/api-testing-sepuluh/scripts/make-controller.js +205 -0
- package/api-testing-sepuluh/scripts/make-model.js +42 -0
- package/api-testing-sepuluh/scripts/make-module.js +158 -0
- package/api-testing-sepuluh/scripts/verify-rbac-functional.js +187 -0
- package/api-testing-sepuluh/src/controllers/authController.ts +469 -0
- package/api-testing-sepuluh/src/controllers/petController.ts +194 -0
- package/api-testing-sepuluh/src/controllers/rbacController.ts +478 -0
- package/api-testing-sepuluh/src/models/core.prisma +163 -0
- package/api-testing-sepuluh/src/models/pets.prisma +9 -0
- package/api-testing-sepuluh/src/routes/auth.ts +74 -0
- package/api-testing-sepuluh/src/routes/index.ts +10 -0
- package/api-testing-sepuluh/src/routes/pets.ts +13 -0
- package/api-testing-sepuluh/src/routes/rbac.ts +42 -0
- package/api-testing-sepuluh/storage/logs/.gitkeep +0 -0
- package/api-testing-sepuluh/tsconfig.json +39 -0
- package/bin/index.js +68 -13
- package/package.json +1 -1
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
|
|
2
|
+
// const fetch = require('node:fetch'); // Or native fetch in Node 18+
|
|
3
|
+
|
|
4
|
+
const BASE_URL = 'http://localhost:8000/api';
|
|
5
|
+
let TOKEN = '';
|
|
6
|
+
let USER_ID = '';
|
|
7
|
+
let ROLE_ID = '';
|
|
8
|
+
let PERMISSION_ID = '';
|
|
9
|
+
|
|
10
|
+
async function request(method, endpoint, body = null, token = null) {
|
|
11
|
+
const headers = { 'Content-Type': 'application/json' };
|
|
12
|
+
if (token) headers['Authorization'] = `Bearer ${token}`;
|
|
13
|
+
|
|
14
|
+
const config = { method, headers };
|
|
15
|
+
if (body) config.body = JSON.stringify(body);
|
|
16
|
+
|
|
17
|
+
try {
|
|
18
|
+
const res = await fetch(`${BASE_URL}${endpoint}`, config);
|
|
19
|
+
const data = await res.json();
|
|
20
|
+
return { status: res.status, data };
|
|
21
|
+
} catch (err) {
|
|
22
|
+
console.error(`Error requesting ${endpoint}:`, err.message);
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async function run() {
|
|
28
|
+
console.log('🚀 Starting RBAC Functional Verification...');
|
|
29
|
+
|
|
30
|
+
// 0. Login as Super Admin
|
|
31
|
+
console.log('\n0️⃣ Logging in as Super Admin...');
|
|
32
|
+
const saLogin = await request('POST', '/auth/login', {
|
|
33
|
+
email: 'sa@sa.com',
|
|
34
|
+
password: 'string'
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
let SA_TOKEN = '';
|
|
38
|
+
if (saLogin?.status === 200) {
|
|
39
|
+
console.log('✅ SA Login successful');
|
|
40
|
+
SA_TOKEN = saLogin.data.data.token;
|
|
41
|
+
} else {
|
|
42
|
+
console.error('❌ SA Login failed:', JSON.stringify(saLogin?.data));
|
|
43
|
+
process.exit(1);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// 1. Register User
|
|
47
|
+
console.log('\n1️⃣ Registering User (Tester)...');
|
|
48
|
+
const regRes = await request('POST', '/auth/register', {
|
|
49
|
+
email: 'testrbac@example.com',
|
|
50
|
+
name: 'RBAC Tester',
|
|
51
|
+
password: 'password123',
|
|
52
|
+
confirmPassword: 'password123'
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
if (regRes?.status === 200 || regRes?.status === 201) {
|
|
56
|
+
console.log('✅ Registered successfully');
|
|
57
|
+
USER_ID = regRes.data.data.id;
|
|
58
|
+
} else if (regRes?.status === 422 && regRes.data.message.includes('Validation error')) {
|
|
59
|
+
console.log('ℹ️ User might already exist, trying login to get ID...');
|
|
60
|
+
// Login to get ID
|
|
61
|
+
const loginRes = await request('POST', '/auth/login', {
|
|
62
|
+
email: 'testrbac@example.com',
|
|
63
|
+
password: 'password123'
|
|
64
|
+
});
|
|
65
|
+
if (loginRes?.status === 200) {
|
|
66
|
+
const meRes = await request('GET', '/auth/me', null, loginRes.data.data.token);
|
|
67
|
+
USER_ID = meRes.data.data.id;
|
|
68
|
+
console.log('✅ Got existing User ID');
|
|
69
|
+
}
|
|
70
|
+
} else {
|
|
71
|
+
console.error('❌ Registration failed:', JSON.stringify(regRes?.data));
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// 2. Login (as Tester) - to verify later
|
|
75
|
+
console.log('\n2️⃣ Logging in as Tester...');
|
|
76
|
+
const loginRes = await request('POST', '/auth/login', {
|
|
77
|
+
email: 'testrbac@example.com',
|
|
78
|
+
password: 'password123'
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
if (loginRes?.status === 200) {
|
|
82
|
+
console.log('✅ Login successful');
|
|
83
|
+
TOKEN = loginRes.data.data.token;
|
|
84
|
+
} else {
|
|
85
|
+
console.error('❌ Login failed:', JSON.stringify(loginRes?.data));
|
|
86
|
+
process.exit(1);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// 3. Create Role (Using SA Token)
|
|
90
|
+
console.log('\n3️⃣ Creating Role "tester-role"...');
|
|
91
|
+
const roleRes = await request('POST', '/rbac/roles', {
|
|
92
|
+
name: 'Tester Role',
|
|
93
|
+
slug: 'tester-role',
|
|
94
|
+
description: 'Role for automated testing'
|
|
95
|
+
}, SA_TOKEN);
|
|
96
|
+
|
|
97
|
+
if (roleRes?.status === 201 || roleRes?.status === 200) {
|
|
98
|
+
console.log('✅ Role created');
|
|
99
|
+
ROLE_ID = roleRes.data.data.id;
|
|
100
|
+
} else if (roleRes?.status === 422) { // Duplicate?
|
|
101
|
+
console.log('ℹ️ Role might already exist.');
|
|
102
|
+
const listRes = await request('GET', '/rbac/roles', null, SA_TOKEN);
|
|
103
|
+
const found = listRes.data.data.find(r => r.slug === 'tester-role');
|
|
104
|
+
if (found) {
|
|
105
|
+
ROLE_ID = found.id;
|
|
106
|
+
console.log('✅ Found existing role');
|
|
107
|
+
} else {
|
|
108
|
+
console.error('❌ Could not create or find role');
|
|
109
|
+
process.exit(1);
|
|
110
|
+
}
|
|
111
|
+
} else {
|
|
112
|
+
console.error('❌ Create role failed:', JSON.stringify(roleRes?.data));
|
|
113
|
+
process.exit(1);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// 4. Create Permission (Using SA Token)
|
|
117
|
+
console.log('\n4️⃣ Creating Permission "test.exec"...');
|
|
118
|
+
const permRes = await request('POST', '/rbac/permissions', {
|
|
119
|
+
name: 'Test Execute',
|
|
120
|
+
slug: 'test.exec',
|
|
121
|
+
description: 'Permission to execute tests'
|
|
122
|
+
}, SA_TOKEN);
|
|
123
|
+
|
|
124
|
+
if (permRes?.status === 201 || permRes?.status === 200) {
|
|
125
|
+
console.log('✅ Permission created');
|
|
126
|
+
PERMISSION_ID = permRes.data.data.id;
|
|
127
|
+
} else if (permRes?.status === 422) {
|
|
128
|
+
const listRes = await request('GET', '/rbac/permissions', null, SA_TOKEN);
|
|
129
|
+
const found = listRes.data.data.find(p => p.slug === 'test.exec');
|
|
130
|
+
if (found) {
|
|
131
|
+
PERMISSION_ID = found.id;
|
|
132
|
+
console.log('✅ Found existing permission');
|
|
133
|
+
} else {
|
|
134
|
+
console.error('❌ Could not create or find permission');
|
|
135
|
+
process.exit(1);
|
|
136
|
+
}
|
|
137
|
+
} else {
|
|
138
|
+
console.error('❌ Create permission failed:', JSON.stringify(permRes?.data));
|
|
139
|
+
process.exit(1);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// 5. Assign Permission to Role (Using SA Token)
|
|
143
|
+
console.log('\n5️⃣ Assigning Permission to Role...');
|
|
144
|
+
const assignP2R = await request('POST', '/rbac/roles/assign-permission', {
|
|
145
|
+
roleId: ROLE_ID,
|
|
146
|
+
permissionId: PERMISSION_ID
|
|
147
|
+
}, SA_TOKEN);
|
|
148
|
+
|
|
149
|
+
if (assignP2R?.status === 200) {
|
|
150
|
+
console.log('✅ Permission assigned to role');
|
|
151
|
+
} else {
|
|
152
|
+
console.error('❌ Assign permission failed:', JSON.stringify(assignP2R?.data));
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// 6. Assign Role to User (Using SA Token)
|
|
156
|
+
console.log('\n6️⃣ Assigning Role to User...');
|
|
157
|
+
const assignR2U = await request('POST', '/rbac/users/assign-role', {
|
|
158
|
+
userId: USER_ID,
|
|
159
|
+
roleId: ROLE_ID
|
|
160
|
+
}, SA_TOKEN);
|
|
161
|
+
|
|
162
|
+
if (assignR2U?.status === 200) {
|
|
163
|
+
console.log('✅ Role assigned to user');
|
|
164
|
+
} else {
|
|
165
|
+
console.error('❌ Assign role failed:', JSON.stringify(assignR2U?.data));
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// 7. Verify via Profile (Using Tester Token)
|
|
169
|
+
console.log('\n7️⃣ Verifying Profile...');
|
|
170
|
+
const profile = await request('GET', '/auth/me', null, TOKEN);
|
|
171
|
+
console.log('👤 User Profile:', JSON.stringify(profile.data.data));
|
|
172
|
+
|
|
173
|
+
// 8. Cleanup (Using SA Token)
|
|
174
|
+
console.log('\n8️⃣ Cleaning up...');
|
|
175
|
+
|
|
176
|
+
const delRole = await request('DELETE', `/rbac/roles/${ROLE_ID}`, null, SA_TOKEN);
|
|
177
|
+
if (delRole?.status === 200) console.log('✅ Role deleted');
|
|
178
|
+
else console.error('❌ Delete role failed', JSON.stringify(delRole?.data));
|
|
179
|
+
|
|
180
|
+
const delPerm = await request('DELETE', `/rbac/permissions/${PERMISSION_ID}`, null, SA_TOKEN);
|
|
181
|
+
if (delPerm?.status === 200) console.log('✅ Permission deleted');
|
|
182
|
+
else console.error('❌ Delete permission failed');
|
|
183
|
+
|
|
184
|
+
console.log('\n🎉 Verification Complete!');
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
run();
|
|
@@ -0,0 +1,469 @@
|
|
|
1
|
+
import { Request, Response } from "express";
|
|
2
|
+
import bcrypt from "bcryptjs";
|
|
3
|
+
import jwt from "jsonwebtoken";
|
|
4
|
+
import { v4 as uuidv4 } from "uuid";
|
|
5
|
+
import { prisma } from "@lapeh/core/database";
|
|
6
|
+
import { sendError, sendFastSuccess } from "@lapeh/utils/response";
|
|
7
|
+
import { Validator } from "@lapeh/utils/validator";
|
|
8
|
+
import { getSerializer, createResponseSchema } from "@lapeh/core/serializer";
|
|
9
|
+
|
|
10
|
+
export const ACCESS_TOKEN_EXPIRES_IN_SECONDS = 7 * 24 * 60 * 60;
|
|
11
|
+
|
|
12
|
+
// --- Serializers ---
|
|
13
|
+
|
|
14
|
+
const registerSchema = {
|
|
15
|
+
type: "object",
|
|
16
|
+
properties: {
|
|
17
|
+
id: { type: "string" },
|
|
18
|
+
email: { type: "string" },
|
|
19
|
+
name: { type: "string" },
|
|
20
|
+
role: { type: "string" },
|
|
21
|
+
},
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
const loginSchema = {
|
|
25
|
+
type: "object",
|
|
26
|
+
properties: {
|
|
27
|
+
token: { type: "string" },
|
|
28
|
+
refreshToken: { type: "string" },
|
|
29
|
+
expiresIn: { type: "integer" },
|
|
30
|
+
expiresAt: { type: "string" },
|
|
31
|
+
name: { type: "string" },
|
|
32
|
+
role: { type: "string" },
|
|
33
|
+
},
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const userProfileSchema = {
|
|
37
|
+
type: "object",
|
|
38
|
+
properties: {
|
|
39
|
+
id: { type: "string" },
|
|
40
|
+
name: { type: "string" },
|
|
41
|
+
email: { type: "string" },
|
|
42
|
+
role: { type: "string" },
|
|
43
|
+
avatar: { type: "string", nullable: true },
|
|
44
|
+
avatar_url: { type: "string", nullable: true },
|
|
45
|
+
email_verified_at: { type: "string", format: "date-time", nullable: true },
|
|
46
|
+
created_at: { type: "string", format: "date-time", nullable: true },
|
|
47
|
+
updated_at: { type: "string", format: "date-time", nullable: true },
|
|
48
|
+
},
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const refreshTokenSchema = {
|
|
52
|
+
type: "object",
|
|
53
|
+
properties: {
|
|
54
|
+
token: { type: "string" },
|
|
55
|
+
expiresIn: { type: "integer" },
|
|
56
|
+
expiresAt: { type: "string" },
|
|
57
|
+
name: { type: "string" },
|
|
58
|
+
role: { type: "string" },
|
|
59
|
+
},
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
const registerSerializer = getSerializer(
|
|
63
|
+
"auth-register",
|
|
64
|
+
createResponseSchema(registerSchema)
|
|
65
|
+
);
|
|
66
|
+
const loginSerializer = getSerializer(
|
|
67
|
+
"auth-login",
|
|
68
|
+
createResponseSchema(loginSchema)
|
|
69
|
+
);
|
|
70
|
+
const userProfileSerializer = getSerializer(
|
|
71
|
+
"auth-profile",
|
|
72
|
+
createResponseSchema(userProfileSchema)
|
|
73
|
+
);
|
|
74
|
+
const refreshTokenSerializer = getSerializer(
|
|
75
|
+
"auth-refresh",
|
|
76
|
+
createResponseSchema(refreshTokenSchema)
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
const voidSerializer = getSerializer(
|
|
80
|
+
"void",
|
|
81
|
+
createResponseSchema({ type: "null" })
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
// --- Controllers ---
|
|
85
|
+
|
|
86
|
+
export async function register(req: Request, res: Response) {
|
|
87
|
+
const validator = Validator.make(req.body || {}, {
|
|
88
|
+
email: "required|email|unique:users,email",
|
|
89
|
+
name: "required|min:1",
|
|
90
|
+
password: "required|min:4",
|
|
91
|
+
confirmPassword: "required|min:4|same:password",
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
if (await validator.fails()) {
|
|
95
|
+
sendError(res, 422, "Validation error", validator.errors());
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
const { email, name, password } = await validator.validated();
|
|
99
|
+
// Manual unique check removed as it is handled by validator
|
|
100
|
+
const hash = await bcrypt.hash(password, 10);
|
|
101
|
+
const user = await prisma.users.create({
|
|
102
|
+
data: {
|
|
103
|
+
email,
|
|
104
|
+
name,
|
|
105
|
+
password: hash,
|
|
106
|
+
uuid: uuidv4(),
|
|
107
|
+
created_at: new Date(),
|
|
108
|
+
updated_at: new Date(),
|
|
109
|
+
},
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
const defaultRole = await prisma.roles.findUnique({
|
|
113
|
+
where: { slug: "user" },
|
|
114
|
+
});
|
|
115
|
+
if (defaultRole) {
|
|
116
|
+
await prisma.user_roles.create({
|
|
117
|
+
data: {
|
|
118
|
+
user_id: user.id,
|
|
119
|
+
role_id: defaultRole.id,
|
|
120
|
+
created_at: new Date(),
|
|
121
|
+
},
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
sendFastSuccess(res, 200, registerSerializer, {
|
|
126
|
+
status: "success",
|
|
127
|
+
message: "Registration successful",
|
|
128
|
+
data: {
|
|
129
|
+
id: user.id.toString(),
|
|
130
|
+
email: user.email,
|
|
131
|
+
name: user.name,
|
|
132
|
+
role: defaultRole ? defaultRole.slug : "user",
|
|
133
|
+
},
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export async function login(req: Request, res: Response) {
|
|
138
|
+
const validator = Validator.make(req.body || {}, {
|
|
139
|
+
email: "required|email",
|
|
140
|
+
password: "required|min:4",
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
if (await validator.fails()) {
|
|
144
|
+
sendError(res, 422, "Validation error", validator.errors());
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
const { email, password } = await validator.validated();
|
|
148
|
+
const user = await prisma.users.findUnique({
|
|
149
|
+
where: { email },
|
|
150
|
+
include: {
|
|
151
|
+
user_roles: {
|
|
152
|
+
include: {
|
|
153
|
+
role: true,
|
|
154
|
+
},
|
|
155
|
+
},
|
|
156
|
+
},
|
|
157
|
+
});
|
|
158
|
+
if (!user) {
|
|
159
|
+
sendError(res, 401, "Email not registered", {
|
|
160
|
+
field: "email",
|
|
161
|
+
message: "Email is not registered, please register first",
|
|
162
|
+
});
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
const ok = await bcrypt.compare(password, user.password);
|
|
166
|
+
if (!ok) {
|
|
167
|
+
sendError(res, 401, "Invalid credentials", {
|
|
168
|
+
field: "password",
|
|
169
|
+
message: "The password you entered is incorrect",
|
|
170
|
+
});
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
const secret = process.env.JWT_SECRET;
|
|
174
|
+
if (!secret) {
|
|
175
|
+
sendError(res, 500, "Server misconfigured");
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
const primaryUserRole =
|
|
179
|
+
user.user_roles && user.user_roles.length > 0 && user.user_roles[0].role
|
|
180
|
+
? user.user_roles[0].role.slug
|
|
181
|
+
: "user";
|
|
182
|
+
const accessExpiresInSeconds = ACCESS_TOKEN_EXPIRES_IN_SECONDS;
|
|
183
|
+
const accessExpiresAt = new Date(
|
|
184
|
+
Date.now() + accessExpiresInSeconds * 1000
|
|
185
|
+
).toISOString();
|
|
186
|
+
const token = jwt.sign(
|
|
187
|
+
{ userId: user.id.toString(), role: primaryUserRole },
|
|
188
|
+
secret,
|
|
189
|
+
{ expiresIn: accessExpiresInSeconds }
|
|
190
|
+
);
|
|
191
|
+
const refreshExpiresInSeconds = 30 * 24 * 60 * 60;
|
|
192
|
+
const refreshToken = jwt.sign(
|
|
193
|
+
{
|
|
194
|
+
userId: user.id.toString(),
|
|
195
|
+
role: primaryUserRole,
|
|
196
|
+
tokenType: "refresh",
|
|
197
|
+
},
|
|
198
|
+
secret,
|
|
199
|
+
{ expiresIn: refreshExpiresInSeconds }
|
|
200
|
+
);
|
|
201
|
+
sendFastSuccess(res, 200, loginSerializer, {
|
|
202
|
+
status: "success",
|
|
203
|
+
message: "Login successful",
|
|
204
|
+
data: {
|
|
205
|
+
token,
|
|
206
|
+
refreshToken,
|
|
207
|
+
expiresIn: accessExpiresInSeconds,
|
|
208
|
+
expiresAt: accessExpiresAt,
|
|
209
|
+
name: user.name,
|
|
210
|
+
role: primaryUserRole,
|
|
211
|
+
},
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
export async function me(req: Request, res: Response) {
|
|
216
|
+
const payload = (req as any).user as { userId: string; role: string };
|
|
217
|
+
if (!payload || !payload.userId) {
|
|
218
|
+
sendError(res, 401, "Unauthorized");
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
const user = await prisma.users.findUnique({
|
|
222
|
+
where: { id: BigInt(payload.userId) },
|
|
223
|
+
include: {
|
|
224
|
+
user_roles: {
|
|
225
|
+
include: {
|
|
226
|
+
role: true,
|
|
227
|
+
},
|
|
228
|
+
},
|
|
229
|
+
},
|
|
230
|
+
});
|
|
231
|
+
if (!user) {
|
|
232
|
+
sendError(res, 404, "User not found");
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
const { password, remember_token, ...rest } = user as any;
|
|
236
|
+
sendFastSuccess(res, 200, userProfileSerializer, {
|
|
237
|
+
status: "success",
|
|
238
|
+
message: "User profile",
|
|
239
|
+
data: {
|
|
240
|
+
...rest,
|
|
241
|
+
id: user.id.toString(),
|
|
242
|
+
role:
|
|
243
|
+
user.user_roles && user.user_roles.length > 0 && user.user_roles[0].role
|
|
244
|
+
? user.user_roles[0].role.slug
|
|
245
|
+
: "user",
|
|
246
|
+
},
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
export async function logout(_req: Request, res: Response) {
|
|
251
|
+
// In a stateless JWT setup, logout is client-side (delete token).
|
|
252
|
+
// If using a whitelist/blacklist in Redis, invalidate the token here.
|
|
253
|
+
// For now, just return success.
|
|
254
|
+
sendFastSuccess(res, 200, voidSerializer, {
|
|
255
|
+
status: "success",
|
|
256
|
+
message: "Logout successful",
|
|
257
|
+
data: null,
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
export async function refreshToken(req: Request, res: Response) {
|
|
262
|
+
const validator = Validator.make(req.body || {}, {
|
|
263
|
+
refreshToken: "required|min:1",
|
|
264
|
+
});
|
|
265
|
+
if (await validator.fails()) {
|
|
266
|
+
sendError(res, 422, "Validation error", validator.errors());
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
269
|
+
const secret = process.env.JWT_SECRET;
|
|
270
|
+
if (!secret) {
|
|
271
|
+
sendError(res, 500, "Server misconfigured");
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
try {
|
|
275
|
+
const validatedData = await validator.validated();
|
|
276
|
+
const decoded = jwt.verify(validatedData.refreshToken, secret) as {
|
|
277
|
+
userId: string;
|
|
278
|
+
role: string;
|
|
279
|
+
tokenType?: string;
|
|
280
|
+
iat: number;
|
|
281
|
+
exp: number;
|
|
282
|
+
};
|
|
283
|
+
if (decoded.tokenType !== "refresh") {
|
|
284
|
+
sendError(res, 401, "Invalid refresh token");
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
const user = await prisma.users.findUnique({
|
|
288
|
+
where: { id: BigInt(decoded.userId) },
|
|
289
|
+
include: {
|
|
290
|
+
user_roles: {
|
|
291
|
+
include: {
|
|
292
|
+
role: true,
|
|
293
|
+
},
|
|
294
|
+
},
|
|
295
|
+
},
|
|
296
|
+
});
|
|
297
|
+
if (!user) {
|
|
298
|
+
sendError(res, 401, "Invalid refresh token");
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
301
|
+
const primaryUserRole =
|
|
302
|
+
user.user_roles && user.user_roles.length > 0 && user.user_roles[0].role
|
|
303
|
+
? user.user_roles[0].role.slug
|
|
304
|
+
: "user";
|
|
305
|
+
const accessExpiresInSeconds = ACCESS_TOKEN_EXPIRES_IN_SECONDS;
|
|
306
|
+
const accessExpiresAt = new Date(
|
|
307
|
+
Date.now() + accessExpiresInSeconds * 1000
|
|
308
|
+
).toISOString();
|
|
309
|
+
const token = jwt.sign(
|
|
310
|
+
{ userId: user.id.toString(), role: primaryUserRole },
|
|
311
|
+
secret,
|
|
312
|
+
{ expiresIn: accessExpiresInSeconds }
|
|
313
|
+
);
|
|
314
|
+
sendFastSuccess(res, 200, refreshTokenSerializer, {
|
|
315
|
+
status: "success",
|
|
316
|
+
message: "Token refreshed",
|
|
317
|
+
data: {
|
|
318
|
+
token,
|
|
319
|
+
expiresIn: accessExpiresInSeconds,
|
|
320
|
+
expiresAt: accessExpiresAt,
|
|
321
|
+
name: user.name,
|
|
322
|
+
role: primaryUserRole,
|
|
323
|
+
},
|
|
324
|
+
});
|
|
325
|
+
} catch {
|
|
326
|
+
sendError(res, 401, "Invalid refresh token");
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
export async function updateAvatar(req: Request, res: Response) {
|
|
331
|
+
const payload = (req as any).user as { userId: string; role: string };
|
|
332
|
+
if (!payload || !payload.userId) {
|
|
333
|
+
sendError(res, 401, "Unauthorized");
|
|
334
|
+
return;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
const data = {
|
|
338
|
+
avatar: (req as any).file,
|
|
339
|
+
};
|
|
340
|
+
|
|
341
|
+
const validator = Validator.make(data, {
|
|
342
|
+
avatar: "nullable|image|mimes:jpeg,png,jpg,gif|max:2048",
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
if (await validator.fails()) {
|
|
346
|
+
sendError(res, 422, "Validation error", validator.errors());
|
|
347
|
+
return;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
const { avatar: file } = await validator.validated();
|
|
351
|
+
|
|
352
|
+
if (!file) {
|
|
353
|
+
sendError(res, 400, "Avatar file is required");
|
|
354
|
+
return;
|
|
355
|
+
}
|
|
356
|
+
const userId = BigInt(payload.userId);
|
|
357
|
+
const avatar = file.filename;
|
|
358
|
+
const avatar_url =
|
|
359
|
+
process.env.AVATAR_BASE_URL || `/uploads/avatars/${file.filename}`;
|
|
360
|
+
const updated = await prisma.users.update({
|
|
361
|
+
where: { id: userId },
|
|
362
|
+
data: {
|
|
363
|
+
avatar,
|
|
364
|
+
avatar_url,
|
|
365
|
+
updated_at: new Date(),
|
|
366
|
+
},
|
|
367
|
+
});
|
|
368
|
+
const { password, remember_token, ...rest } = updated as any;
|
|
369
|
+
// Note: user_roles might not be fetched in update, so role defaults to "user" or fetched if needed.
|
|
370
|
+
// Ideally we should refetch or pass existing role.
|
|
371
|
+
// For now assuming role is preserved or handled by frontend state, but API should return it.
|
|
372
|
+
// Let's rely on nullable role or simple "user" fallback if not present in `updated`.
|
|
373
|
+
// Actually `update` returns what was updated. Relations are not included unless specified.
|
|
374
|
+
// For now we will return it compatible with userProfileSchema.
|
|
375
|
+
|
|
376
|
+
sendFastSuccess(res, 200, userProfileSerializer, {
|
|
377
|
+
status: "success",
|
|
378
|
+
message: "Avatar updated successfully",
|
|
379
|
+
data: {
|
|
380
|
+
...rest,
|
|
381
|
+
id: updated.id.toString(),
|
|
382
|
+
role: payload.role, // Use role from JWT payload as it shouldn't change here
|
|
383
|
+
},
|
|
384
|
+
});
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
export async function updatePassword(req: Request, res: Response) {
|
|
388
|
+
const payload = (req as any).user as { userId: string; role: string };
|
|
389
|
+
if (!payload || !payload.userId) {
|
|
390
|
+
sendError(res, 401, "Unauthorized");
|
|
391
|
+
return;
|
|
392
|
+
}
|
|
393
|
+
const validator = Validator.make(req.body || {}, {
|
|
394
|
+
currentPassword: "required|min:4",
|
|
395
|
+
newPassword: "required|min:4",
|
|
396
|
+
confirmPassword: "required|min:4|same:newPassword",
|
|
397
|
+
});
|
|
398
|
+
if (await validator.fails()) {
|
|
399
|
+
sendError(res, 422, "Validation error", validator.errors());
|
|
400
|
+
return;
|
|
401
|
+
}
|
|
402
|
+
const { currentPassword, newPassword } = await validator.validated();
|
|
403
|
+
const user = await prisma.users.findUnique({
|
|
404
|
+
where: { id: BigInt(payload.userId) },
|
|
405
|
+
});
|
|
406
|
+
if (!user) {
|
|
407
|
+
sendError(res, 404, "User not found");
|
|
408
|
+
return;
|
|
409
|
+
}
|
|
410
|
+
const ok = await bcrypt.compare(currentPassword, user.password);
|
|
411
|
+
if (!ok) {
|
|
412
|
+
sendError(res, 401, "Invalid credentials", {
|
|
413
|
+
field: "currentPassword",
|
|
414
|
+
message: "Current password is incorrect",
|
|
415
|
+
});
|
|
416
|
+
return;
|
|
417
|
+
}
|
|
418
|
+
const hash = await bcrypt.hash(newPassword, 10);
|
|
419
|
+
await prisma.users.update({
|
|
420
|
+
where: { id: user.id },
|
|
421
|
+
data: {
|
|
422
|
+
password: hash,
|
|
423
|
+
updated_at: new Date(),
|
|
424
|
+
},
|
|
425
|
+
});
|
|
426
|
+
sendFastSuccess(res, 200, voidSerializer, {
|
|
427
|
+
status: "success",
|
|
428
|
+
message: "Password updated successfully",
|
|
429
|
+
data: null,
|
|
430
|
+
});
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
export async function updateProfile(req: Request, res: Response) {
|
|
434
|
+
const payload = (req as any).user as { userId: string; role: string };
|
|
435
|
+
if (!payload || !payload.userId) {
|
|
436
|
+
sendError(res, 401, "Unauthorized");
|
|
437
|
+
return;
|
|
438
|
+
}
|
|
439
|
+
const validator = Validator.make(req.body || {}, {
|
|
440
|
+
name: "required|min:1",
|
|
441
|
+
email: `required|email|unique:users,email,${payload.userId}`,
|
|
442
|
+
});
|
|
443
|
+
if (await validator.fails()) {
|
|
444
|
+
sendError(res, 422, "Validation error", validator.errors());
|
|
445
|
+
return;
|
|
446
|
+
}
|
|
447
|
+
const { name, email } = await validator.validated();
|
|
448
|
+
const userId = BigInt(payload.userId);
|
|
449
|
+
// Manual unique check removed as it is handled by validator
|
|
450
|
+
|
|
451
|
+
const updated = await prisma.users.update({
|
|
452
|
+
where: { id: userId },
|
|
453
|
+
data: {
|
|
454
|
+
name,
|
|
455
|
+
email,
|
|
456
|
+
updated_at: new Date(),
|
|
457
|
+
},
|
|
458
|
+
});
|
|
459
|
+
const { password, remember_token, ...rest } = updated as any;
|
|
460
|
+
sendFastSuccess(res, 200, userProfileSerializer, {
|
|
461
|
+
status: "success",
|
|
462
|
+
message: "Profile updated successfully",
|
|
463
|
+
data: {
|
|
464
|
+
...rest,
|
|
465
|
+
id: updated.id.toString(),
|
|
466
|
+
role: payload.role, // Use role from JWT payload
|
|
467
|
+
},
|
|
468
|
+
});
|
|
469
|
+
}
|