Skip to content

Commit 472095f

Browse files
committed
Add role system to auth + remember me functionality
1 parent bec9209 commit 472095f

File tree

17 files changed

+280
-30
lines changed

17 files changed

+280
-30
lines changed

apps/backend/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
"@repo/shared-config": "workspace:*",
3333
"@repo/shared-types": "workspace:*",
3434
"@scalar/nestjs-api-reference": "^1.0.4",
35+
"bcryptjs": "^3.0.2",
3536
"better-auth": "^1.3.27",
3637
"dotenv-flow": "^4.1.0",
3738
"helmet": "^8.1.0",
@@ -48,6 +49,7 @@
4849
"@nestjs/schematics": "^11.0.7",
4950
"@nestjs/testing": "^11.1.6",
5051
"@repo/shared-utils": "workspace:*",
52+
"@types/bcryptjs": "^3.0.0",
5153
"@types/express": "^5.0.0",
5254
"@types/node": "^24.7.1",
5355
"@typescript-eslint/eslint-plugin": "^8.20.0",
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
-- CreateEnum
2+
CREATE TYPE "Role" AS ENUM ('user', 'admin', 'super_admin');
3+
4+
-- AlterTable
5+
ALTER TABLE "users" ADD COLUMN "role" "Role" NOT NULL DEFAULT 'user';

apps/backend/prisma/schema.prisma

Lines changed: 20 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -10,13 +10,24 @@ datasource db {
1010
url = env("DATABASE_URL")
1111
}
1212

13+
// Role enum for user permissions
14+
enum Role {
15+
user
16+
admin
17+
super_admin
18+
}
19+
1320
// User model with better-auth support
1421
model User {
1522
id String @id @default(cuid())
1623
email String @unique
1724
emailVerified Boolean @default(false)
1825
name String?
1926
image String?
27+
role Role @default(user)
28+
banned Boolean @default(false)
29+
banReason String?
30+
banExpires DateTime?
2031
createdAt DateTime @default(now())
2132
updatedAt DateTime @updatedAt
2233
@@ -28,14 +39,15 @@ model User {
2839

2940
// Session model for better-auth
3041
model Session {
31-
id String @id @default(cuid())
32-
userId String
33-
expiresAt DateTime
34-
token String @unique
35-
ipAddress String?
36-
userAgent String?
37-
createdAt DateTime @default(now())
38-
updatedAt DateTime @updatedAt
42+
id String @id @default(cuid())
43+
userId String
44+
expiresAt DateTime
45+
token String @unique
46+
ipAddress String?
47+
userAgent String?
48+
impersonatedBy String?
49+
createdAt DateTime @default(now())
50+
updatedAt DateTime @updatedAt
3951
4052
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
4153

apps/backend/prisma/seed.ts

Lines changed: 51 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,60 @@
1-
import { PrismaClient } from '@prisma/client';
1+
import { PrismaClient, Role } from '@prisma/client';
2+
import * as bcrypt from 'bcryptjs';
23

34
const prisma = new PrismaClient();
45

56
async function main() {
67
console.log('🌱 Seeding database...');
78

9+
const superAdminEmail = 'admin@example.com';
10+
const superAdminPassword = 'Admin@123456';
11+
12+
const hashedPassword = await bcrypt.hash(superAdminPassword, 10);
13+
14+
const superAdmin = await prisma.user.upsert({
15+
where: { email: superAdminEmail },
16+
update: {
17+
role: Role.super_admin,
18+
},
19+
create: {
20+
email: superAdminEmail,
21+
name: 'Super Admin',
22+
role: Role.super_admin,
23+
emailVerified: true,
24+
},
25+
});
26+
27+
await prisma.account.upsert({
28+
where: {
29+
providerId_accountId: {
30+
providerId: 'credential',
31+
accountId: superAdmin.id,
32+
},
33+
},
34+
update: {
35+
password: hashedPassword,
36+
},
37+
create: {
38+
userId: superAdmin.id,
39+
accountId: superAdmin.id,
40+
providerId: 'credential',
41+
password: hashedPassword,
42+
},
43+
});
44+
45+
console.log('✅ Super admin user created:');
46+
console.log(` Email: ${superAdminEmail}`);
47+
console.log(` Password: ${superAdminPassword}`);
48+
console.log(` Role: ${superAdmin.role}`);
49+
console.log('');
50+
console.log(
51+
'⚠️ IMPORTANT: Change the super_admin password after first login!'
52+
);
53+
854
const usersData = [
9-
{ email: 'alice@example.com', name: 'Alice Johnson' },
10-
{ email: 'bob@example.com', name: 'Bob Smith' },
11-
{ email: 'charlie@example.com', name: 'Charlie Davis' },
55+
{ email: 'alice@example.com', name: 'Alice Johnson', role: Role.user },
56+
{ email: 'bob@example.com', name: 'Bob Smith', role: Role.admin },
57+
{ email: 'charlie@example.com', name: 'Charlie Davis', role: Role.user },
1258
];
1359

1460
for (const user of usersData) {
@@ -19,7 +65,7 @@ async function main() {
1965
});
2066
}
2167

22-
console.log(`✅ Seeded ${usersData.length} users`);
68+
console.log(`✅ Seeded ${usersData.length} additional users`);
2369
}
2470

2571
main()

apps/backend/src/auth/auth.config.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { PrismaClient } from '@prisma/client';
22
import { betterAuth } from 'better-auth';
33
import { prismaAdapter } from 'better-auth/adapters/prisma';
4+
import { admin } from 'better-auth/plugins';
45

56
import { PrismaService } from '@/database/prisma/prisma.service';
67

@@ -17,8 +18,12 @@ export function createAuthInstance(prisma: PrismaService) {
1718
requireEmailVerification: false, // IMPORTANT: Set to true in production with email service
1819
},
1920
session: {
20-
expiresIn: 60 * 60 * 24 * 7, // 7 days
21+
expiresIn: 60 * 60 * 24 * 7, // 7 days (default session)
2122
updateAge: 60 * 60 * 24, // Update session every 24 hours
23+
cookieCache: {
24+
enabled: true,
25+
maxAge: 60 * 60 * 24 * 30, // 30 days when remember me is enabled
26+
},
2227
},
2328
trustedOrigins: [process.env.FRONTEND_URL || 'http://localhost:3000'],
2429
socialProviders: {
@@ -32,6 +37,12 @@ export function createAuthInstance(prisma: PrismaService) {
3237
// clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
3338
// },
3439
},
40+
plugins: [
41+
admin({
42+
defaultRole: 'user',
43+
adminRoles: ['admin', 'super_admin'],
44+
}),
45+
],
3546
});
3647
}
3748

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,26 @@
11
import { Module } from '@nestjs/common';
2+
import { APP_GUARD } from '@nestjs/core';
23

34
import { AuthController } from '@/auth/auth.controller';
45
import { AuthService } from '@/auth/auth.service';
6+
import { AuthGuard } from '@/auth/guards/auth.guard';
7+
import { RolesGuard } from '@/auth/guards/roles.guard';
58
import { PrismaModule } from '@/database/prisma/prisma.module';
69

710
@Module({
811
imports: [PrismaModule],
912
controllers: [AuthController],
10-
providers: [AuthService],
13+
providers: [
14+
AuthService,
15+
{
16+
provide: APP_GUARD,
17+
useClass: AuthGuard,
18+
},
19+
{
20+
provide: APP_GUARD,
21+
useClass: RolesGuard,
22+
},
23+
],
1124
exports: [AuthService],
1225
})
1326
export class AuthModule {}
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
import { SetMetadata } from '@nestjs/common';
2+
import type { Role } from '@repo/shared-types';
3+
4+
export const ROLES_KEY = 'roles';
5+
export const Roles = (...roles: Role[]) => SetMetadata(ROLES_KEY, roles);
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import {
2+
CanActivate,
3+
ExecutionContext,
4+
ForbiddenException,
5+
Injectable,
6+
} from '@nestjs/common';
7+
import { Reflector } from '@nestjs/core';
8+
import type { Role } from '@repo/shared-types';
9+
import type { Request } from 'express';
10+
11+
import { ROLES_KEY } from '@/auth/decorators/roles.decorator';
12+
13+
@Injectable()
14+
export class RolesGuard implements CanActivate {
15+
constructor(private readonly reflector: Reflector) {}
16+
17+
canActivate(context: ExecutionContext): boolean {
18+
const requiredRoles = this.reflector.getAllAndOverride<Role[]>(ROLES_KEY, [
19+
context.getHandler(),
20+
context.getClass(),
21+
]);
22+
23+
if (!requiredRoles || requiredRoles.length === 0) {
24+
return true;
25+
}
26+
27+
const request = context.switchToHttp().getRequest<Request>();
28+
const user = (request as any).user;
29+
30+
if (!user) {
31+
throw new ForbiddenException('User not authenticated');
32+
}
33+
34+
const hasRole = requiredRoles.includes(user.role);
35+
36+
if (!hasRole) {
37+
throw new ForbiddenException(
38+
`Insufficient permissions. Required roles: ${requiredRoles.join(', ')}`
39+
);
40+
}
41+
42+
return true;
43+
}
44+
}

apps/backend/src/modules/users/users.controller.spec.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,11 @@ describe('UsersController', () => {
3131
});
3232

3333
it('should return paginated users from service', () => {
34-
service.createUser({ email: 'test@example.com', name: 'Test User' });
34+
service.createUser({
35+
email: 'test@example.com',
36+
name: 'Test User',
37+
role: 'user',
38+
});
3539

3640
const result = controller.getUsers(defaultQuery);
3741

@@ -55,6 +59,7 @@ describe('UsersController', () => {
5559
const created = service.createUser({
5660
email: 'test@example.com',
5761
name: 'Test User',
62+
role: 'user',
5863
});
5964

6065
const result = controller.getUserById({ id: created.id });
@@ -68,6 +73,7 @@ describe('UsersController', () => {
6873
const created = service.createUser({
6974
email: 'test@example.com',
7075
name: 'Test User',
76+
role: 'user',
7177
});
7278
const spy = vi.spyOn(service, 'getUserById');
7379

@@ -83,6 +89,7 @@ describe('UsersController', () => {
8389
const createUserDto = {
8490
email: 'newuser@example.com',
8591
name: 'New User',
92+
role: 'user' as const,
8693
};
8794

8895
const result = controller.createUser(createUserDto);
@@ -99,6 +106,7 @@ describe('UsersController', () => {
99106
const createUserDto = {
100107
email: 'valid@example.com',
101108
name: 'Valid User',
109+
role: 'user' as const,
102110
};
103111

104112
expect(() => controller.createUser(createUserDto)).not.toThrow();
@@ -109,6 +117,7 @@ describe('UsersController', () => {
109117
const createUserDto = {
110118
email: 'test@example.com',
111119
name: 'Test User',
120+
role: 'user' as const,
112121
};
113122

114123
controller.createUser(createUserDto);
@@ -123,6 +132,7 @@ describe('UsersController', () => {
123132
const created = service.createUser({
124133
email: 'test@example.com',
125134
name: 'Test User',
135+
role: 'user',
126136
});
127137

128138
const updateUserDto = {
@@ -141,6 +151,7 @@ describe('UsersController', () => {
141151
const created = service.createUser({
142152
email: 'test@example.com',
143153
name: 'Test User',
154+
role: 'user',
144155
});
145156
const spy = vi.spyOn(service, 'updateUser');
146157
const updateUserDto = { name: 'Updated Name' };
@@ -157,6 +168,7 @@ describe('UsersController', () => {
157168
const created = service.createUser({
158169
email: 'test@example.com',
159170
name: 'Test User',
171+
role: 'user',
160172
});
161173

162174
const result = controller.deleteUser({ id: created.id });
@@ -169,6 +181,7 @@ describe('UsersController', () => {
169181
const created = service.createUser({
170182
email: 'test@example.com',
171183
name: 'Test User',
184+
role: 'user',
172185
});
173186
const spy = vi.spyOn(service, 'deleteUser');
174187

apps/backend/src/modules/users/users.controller.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import { User } from '@repo/shared-types';
2626
import { ApiResponse } from '@repo/shared-types';
2727
import { createZodDto } from 'nestjs-zod';
2828

29+
import { Roles } from '@/auth/decorators/roles.decorator';
2930
import { UsersService } from '@/modules/users/users.service';
3031

3132
class CreateUserDto extends createZodDto(CreateUserSchema) {}
@@ -86,8 +87,10 @@ export class UsersController {
8687
}
8788

8889
@Delete(':id')
89-
@ApiOperation({ summary: 'Delete user by ID' })
90+
@Roles('admin', 'super_admin')
91+
@ApiOperation({ summary: 'Delete user by ID (Admin only)' })
9092
@SwaggerResponse({ status: 200, description: 'User deleted successfully' })
93+
@SwaggerResponse({ status: 403, description: 'Insufficient permissions' })
9194
@SwaggerResponse({ status: 404, description: 'User not found' })
9295
deleteUser(@Param() params: GetUserByIdDto): ApiResponse<void> {
9396
this.usersService.deleteUser(params.id);

0 commit comments

Comments
 (0)