Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 6 additions & 3 deletions .env.example
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
NEXT_PUBLIC_API_SERVER_URL=
NEXT_PUBLIC_API_SERVER_URL=http://localhost:4000/

INTANIA_AUTH_APP_ID=
INTANIA_AUTH_REDIRECT_URL=

JWT_SECRET=
JWT_SECRET=secret
JWT_DOMAIN=

NEXT_PUBLIC_NODE_ENV=
NEXT_PUBLIC_NODE_ENV=

DEV_MODE=
DEV_MODE_ROLE=admin # admin | esc | student
43 changes: 42 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,13 +38,33 @@ pnpm install

3. Create .env and paste the env in /api and /web folder

4. Run the app at /api for backend, /web for frontend, or root for both at the same time
4. Create database (Migration will automatically be done when the app start up. (TypeORM `synchronize: true`))

```bash
docker run -d --name postgres-admin -e POSTGRES_USER=admin -e POSTGRES_PASSWORD='AdminPass123!' -e POSTGRES_DB=admin_db -p 5432:5432 -v postgres_data:/var/lib/postgresql/data postgres:17
```

5. Run the app at /api for backend, /web for frontend, or root for both at the same time
_make sure that you are not connected to ChulaWiFi_

```bash
turbo dev
```

### Development mode

For local development without Intania Auth, set these variables in **ALL THREE ENV FILES**

```env
DEV_MODE=true
DEV_MODE_ROLE=admin # Options: admin | esc | student
JWT_SECRET=secret
```

When you're in this mode, Intania Auth is bypassed. Real JWT are generated, but no data is stored in DB.

Logging out have not been implemented yet for mock auth.

## Test

1. Run this command at /api for backend, /web for frontend, or root for both at the same time
Expand All @@ -58,3 +78,24 @@ turbo build
```bash
pnpm install --force
```

## Troubleshooting

### `@repo/shared` module not found

If your IDE give you red squiggly line on `import ... from '@repo/shared';`:

**Solution:**

Run this command at the repo root.

```bash
cd packages/shared
pnpm build
```

Alternatively, rebuild all packages.

```
turbo build
```
11 changes: 7 additions & 4 deletions apps/api/.env.example
Original file line number Diff line number Diff line change
@@ -1,14 +1,17 @@
JWT_SECRET=
JWT_SECRET=secret
JWT_DOMAIN=

CLIENT_URL=
DATABASE_URL=
DATABASE_URL=postgresql://admin:AdminPass123!@localhost:5432/admin_db

PORT=

ACCESS=
SECRET_KEY=
ENDPOINT=
REGION=
REGION=us-east-2
BUCKET_NAME=

INTANIA_AUTH_SECRET=
INTANIA_AUTH_SECRET=
DEV_MODE=true
DEV_MODE_ROLE=admin # admin | esc | student
65 changes: 63 additions & 2 deletions apps/api/src/auth/auth.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { UserService } from '../user_/user.service';
import * as argon2 from 'argon2';
import { User } from '../entities/user.entity';
import { HttpService } from '@nestjs/axios';
import type { IntaniaAuthResponse, JwtPayload, Tokens } from '@repo/shared';
import type { AuthRole, IntaniaAuthResponse, JwtPayload, Tokens } from '@repo/shared';

@Injectable()
export class AuthService {
Expand All @@ -14,9 +14,25 @@ export class AuthService {
private jwtService: JwtService,
private configService: ConfigService,
private readonly httpService: HttpService,
) {}
) { }

async validateUser(token: string): Promise<IntaniaAuthResponse> {

if (this.configService.get<string>('DEV_MODE') === 'true') {
return {
studentId: '6630000021',
name: {
th: {
firstName: 'ชื่อ',
lastName: 'นามสกุล'
},
en: {
firstName: 'FirstName',
lastName: 'LastName'
}
}
}
}
try {
const validatedResponse = await this.httpService.axiosRef.post(
'https://account.intania.org/api/v1/auth/app/validate',
Expand All @@ -39,6 +55,7 @@ export class AuthService {
}

async validateJWT(token: string): Promise<JwtPayload> {

try {
const validatedUser = this.jwtService.verify(token, {
secret: this.configService.get<string>('JWT_SECRET'),
Expand All @@ -51,13 +68,29 @@ export class AuthService {
}

async signIn(token: string): Promise<Tokens> {

const validatedUser = await this.validateUser(token).catch((error) => {
throw new ForbiddenException(error.message);
});

const studentId = validatedUser.studentId;
let username = `${validatedUser.name.th.firstName} ${validatedUser.name.th.lastName}`;

if (this.configService.get<string>('DEV_MODE') === 'true') {
const mockUser = {
id: 'bb64e6eb-ad7e-4a21-a879-d0612b218996',
username: 'mock' + (this.configService.get<string>('DEV_MODE_ROLE') || 'esc'),
studentId: '6630000021',
role: (this.configService.get<string>('DEV_MODE_ROLE') || 'esc') as AuthRole,
}

const tokens = await this.getTokens(mockUser.id,
mockUser.username, mockUser.role
)

return tokens
}

const existedUser = await this.userService.findByStudentID(studentId);

let createdUser: User;
Expand All @@ -82,6 +115,7 @@ export class AuthService {
}

async signOut(accessToken: string) {

const payload = await this.validateJWT(accessToken);
await this.userService.update(payload.sub, { refreshToken: '' });
}
Expand All @@ -96,6 +130,18 @@ export class AuthService {
}

async refreshToken(userId: string, refreshToken: string) {

if (this.configService.get<string>('DEV_MODE') === 'true') {
const mockUser = {
id: 'bb64e6eb-ad7e-4a21-a879-d0612b218996',
username: 'mock' + (this.configService.get<string>('DEV_MODE_ROLE') || 'esc'),
studentId: '6630000021',
role: (this.configService.get<string>('DEV_MODE_ROLE') || 'esc') as AuthRole,
}

return await this.getTokens(mockUser.id, mockUser.username, mockUser.role)
}

const user = await this.userService.findByUserID(userId);
if (!user) {
throw new ForbiddenException('User not found');
Expand Down Expand Up @@ -125,6 +171,10 @@ export class AuthService {
}

async updateRefreshToken(userId: string, refreshToken: string) {
if (this.configService.get<string>('DEV_MODE') === 'true') {
return
}

const hashedRefreshToken = await this.hashData(refreshToken);
await this.userService.update(userId, {
refreshToken: hashedRefreshToken,
Expand All @@ -136,6 +186,7 @@ export class AuthService {
username: string,
role: string,
): Promise<Tokens> {

const [accessToken, refreshToken] = await Promise.all([
this.jwtService.signAsync(
{
Expand Down Expand Up @@ -168,6 +219,16 @@ export class AuthService {
}

parseJwt(token: string): JwtPayload {

if (this.configService.get<string>('DEV_MODE') === 'true') {
return {
sub: 'bb64e6eb-ad7e-4a21-a879-d0612b218996',
username: 'mock' + (this.configService.get<string>('DEV_MODE_ROLE') || 'esc'),
role: (this.configService.get<string>('DEV_MODE_ROLE') || 'esc') as AuthRole,
iat: Math.floor(Date.now() / 1000),
exp: Math.floor(Date.now() / 1000) + 60 * 60 * 24 * 7
}
}
try {
const base64Url = token.split('.')[1];
const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/');
Expand Down
20 changes: 18 additions & 2 deletions apps/api/src/document_/document.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
Inject,
Injectable,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { InjectRepository } from '@nestjs/typeorm';
import { Document } from '../entities/document.entity';
import { In, Not, Repository } from 'typeorm';
Expand All @@ -14,7 +15,7 @@ import { validate as isUUID } from 'uuid';
import { Filing } from '../entities/filing.entity';
import { CreateDocumentDTO } from './document.dto';
import { FilingService } from '../filing/filing.service';
import { DocumentActivity, DocumentStatus, FilingStatus } from '@repo/shared';
import { AuthRole, DocumentActivity, DocumentStatus, FilingStatus } from '@repo/shared';

@Injectable()
export class DocumentService {
Expand All @@ -25,6 +26,7 @@ export class DocumentService {
private readonly userService: UserService,
@Inject(forwardRef(() => FilingService))
private readonly filingService: FilingService,
private readonly configService: ConfigService,
) {}

async findByDocID(id: string): Promise<Document | null> {
Expand All @@ -48,7 +50,21 @@ export class DocumentService {

async findByUserID(id: string): Promise<Document[]> {
if (!isUUID(id)) throw new BadRequestException('Id is not in UUID format.');
const foundUser = await this.userService.findByUserID(id);

let foundUser = await this.userService.findByUserID(id);
if (this.configService.get<string>('DEV_MODE') === 'true') {
foundUser = {
id: 'bb64e6eb-ad7e-4a21-a879-d0612b218996',
username: 'mock' + (this.configService.get<string>('DEV_MODE_ROLE') || 'esc'),
studentId: '6630000021',
role: (this.configService.get<string>('DEV_MODE_ROLE') || 'esc') as AuthRole,
tel: '0812345678',
refreshToken: 'mock-refresh-token',
createdAt: new Date(),
updatedAt: new Date()
}
}

if (!foundUser) throw new BadRequestException('User Not Found!');

const projects = await this.projectService.findByUserID(id);
Expand Down
20 changes: 18 additions & 2 deletions apps/api/src/filing/filing.service.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import { BadRequestException, Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { InjectRepository } from '@nestjs/typeorm';
import { Filing } from '../entities/filing.entity';
import { Repository } from 'typeorm';
import { validate as isUUID } from 'uuid';
import { ProjectService } from '../project_/project_.service';
import { UserService } from '../user_/user.service';
import { FilingStatus, FilingSubType } from '@repo/shared';
import { AuthRole, FilingStatus, FilingSubType } from '@repo/shared';
import { CountFilingService } from '../count-filing/count-filing.service';
import { FilingFieldTranslate } from '../constant/translate';
import { UserProjService } from '../user-proj/user-proj.service';
Expand All @@ -19,6 +20,7 @@ export class FilingService {
private readonly userService: UserService,
private readonly countFilingService: CountFilingService,
private readonly userProjService: UserProjService,
private readonly configService: ConfigService,
) {}

findByFilingID(id: string) {
Expand All @@ -43,7 +45,21 @@ export class FilingService {

async findByUserID(id: string): Promise<Filing[]> {
if (!isUUID(id)) throw new BadRequestException('Id is not in UUID format.');
const foundUser = await this.userService.findByUserID(id);

let foundUser = await this.userService.findByUserID(id);
if (this.configService.get<string>('DEV_MODE') === 'true') {
foundUser = {
id: 'bb64e6eb-ad7e-4a21-a879-d0612b218996',
username: 'mock' + (this.configService.get<string>('DEV_MODE_ROLE') || 'esc'),
studentId: '6630000021',
role: (this.configService.get<string>('DEV_MODE_ROLE') || 'esc') as AuthRole,
tel: '0812345678',
refreshToken: 'mock-refresh-token',
createdAt: new Date(),
updatedAt: new Date()
}
}

if (!foundUser) throw new BadRequestException('User Not Found!');
const projectIds =
await this.userProjService.findJoinedProjectsByUserId(id);
Expand Down
7 changes: 6 additions & 1 deletion apps/api/src/trpc/trpc.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ export class TrpcService {
private readonly documentService: DocumentService,
private readonly gendocService: GendocService,
private configService: ConfigService,
) {}
) { }
readonly jwtCookieOptions: CookieOptions = {
httpOnly: true,
secure: true,
Expand Down Expand Up @@ -78,6 +78,11 @@ export class TrpcService {
});
adminProcedure = this.protectedProcedure.use(async (opts) => {
try {
if (this.configService.get<string>('DEV_MODE') === 'true') {
if (this.configService.get<string>('DEV_MODE_ROLE') === 'admin') {
return opts.next();
}
}
const { ctx } = opts;
if (ctx.payload.role !== 'admin')
throw new TRPCError({ code: 'UNAUTHORIZED', message: 'Admin Only' });
Expand Down
15 changes: 15 additions & 0 deletions apps/api/src/user_/user.service.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,17 @@
import { BadRequestException, Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { ConfigService } from '@nestjs/config';
import { User } from '../entities/user.entity';
import { FindOptionsWhere, Repository } from 'typeorm';
import { validate as isUUID } from 'uuid';
import { CreateUserDTO, UpdateUserDTO } from './dto/user.dto';
import { AuthRole } from '@repo/shared';

@Injectable()
export class UserService {
constructor(
@InjectRepository(User) private readonly userRepository: Repository<User>,
private readonly configService: ConfigService,
) {}

async createUser(user: CreateUserDTO) {
Expand All @@ -20,6 +23,18 @@ export class UserService {
}

async findByUserID(id: string) {
if (this.configService.get<string>('DEV_MODE') === 'true') {
return {
id: 'bb64e6eb-ad7e-4a21-a879-d0612b218996',
username: 'mock' + (this.configService.get<string>('DEV_MODE_ROLE') || 'esc'),
studentId: '6630000021',
role: (this.configService.get<string>('DEV_MODE_ROLE') || 'esc') as AuthRole,
tel: '0812345678',
refreshToken: 'mock-refresh-token',
createdAt: new Date(),
updatedAt: new Date()
}
}
if (!isUUID(id)) {
throw new BadRequestException('Id is not in UUID format');
}
Expand Down
Loading