update authentication

This commit is contained in:
nkhangg 2025-03-27 15:43:21 +07:00
parent 1e214a8162
commit 46979c6b9e
34 changed files with 771 additions and 55 deletions

View File

@ -19,6 +19,20 @@ export const login = async (credentials: { username: string; password: string })
}
};
export const checkStatus = async () => {
try {
const { data } = await axios({
url: 'auth/check-status',
withCredentials: true,
method: 'POST',
});
return data as IResponse<boolean>;
} catch (error) {
handleError(error);
}
};
export const logout = async () => {
try {
const { data } = await axios({

View File

@ -0,0 +1,67 @@
import { generateNestParams, handleError, handleSuccess } from '.';
import axios from '../lib/axios';
import { IKey } from '../system/type';
export const getKeys = async (params: Record<string, string | number>) => {
return await axios({
url: 'keys',
params: generateNestParams(params),
withCredentials: true,
method: 'GET',
});
};
export const createKey = async () => {
try {
const { data } = await axios({
url: 'keys',
withCredentials: true,
method: 'POST',
});
handleSuccess(data);
return data;
} catch (error) {
handleError(error);
}
};
export const deleteKey = async (key: IKey) => {
try {
const { data } = await axios({
url: 'keys/' + key.id,
withCredentials: true,
method: 'DELETE',
});
handleSuccess(data);
return data;
} catch (error) {
handleError(error);
}
};
export const deletesKey = async (keys: IKey[]) => {
const ids = keys.reduce((prev, cur) => {
prev.push(cur.id);
return prev;
}, [] as number[]);
try {
const { data } = await axios({
url: 'keys/deletes',
withCredentials: true,
method: 'POST',
data: {
ids,
},
});
handleSuccess(data);
return data;
} catch (error) {
handleError(error);
}
};

View File

@ -1,13 +1,14 @@
import { Avatar, Button, LoadingOverlay, Menu, Modal, PasswordInput } from '@mantine/core';
import { useForm, zodResolver } from '@mantine/form';
import { useDisclosure } from '@mantine/hooks';
import { IconLogout, IconSettings, IconUser } from '@tabler/icons-react';
import { IconKey, IconLogout, IconSettings, IconUser } from '@tabler/icons-react';
import { useState } from 'react';
import { useNavigate } from 'react-router';
import { Link } from 'react-router-dom';
import { z } from 'zod';
import { changePassword, logout } from '../apis/auth';
import { useConfirmStore } from '../lib/zustand/use-confirm';
import Links from '../system/links';
import { useState } from 'react';
const schema = z
.object({
@ -92,6 +93,9 @@ export default function UserMenu() {
<Menu.Item onClick={open} leftSection={<IconSettings size={14} />}>
Change password
</Menu.Item>
<Menu.Item component={Link} to={Links.GENERATE_KEYS} leftSection={<IconKey size={14} />}>
Keys
</Menu.Item>
<Menu.Divider />

View File

@ -29,7 +29,7 @@ export default function PrivateLayout() {
<Logo />
<Box className="flex items-center gap-4">
{Links.MENUS.map((menu, index) => (
{Links.MENUS.filter((i) => i.show).map((menu, index) => (
<Button
size="xs"
component={Link}

View File

@ -1,11 +1,12 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { useEffect, useState } from 'react';
import { Box, Text, Title } from '@mantine/core';
import { useEffect, useRef, useState } from 'react';
import io from 'socket.io-client';
import { WorkingPage } from '../components/dashboard';
import { IBid, IWebBid } from '../system/type';
import { checkStatus } from '../apis/auth';
const socket = io(import.meta.env.VITE_SOCKET_URL, {
const socket = io(`${import.meta.env.VITE_SOCKET_URL}/admin-bid-ws`, {
autoConnect: true,
transports: ['websocket'],
});
@ -13,18 +14,24 @@ const socket = io(import.meta.env.VITE_SOCKET_URL, {
export default function DashBoard() {
const [workingData, setWorkingData] = useState<(IWebBid & { type: string })[] | (IBid & { type: string })[]>([]);
const RETRY_CONNECT = useRef(2);
useEffect(() => {
socket.connect();
socket.on('connect', () => {
console.log('✅ WebSocket connected:', socket.id);
// 🔥 Gửi yêu cầu lấy dữ liệu ngay khi kết nối
socket.emit('getBidsData');
});
socket.on('disconnect', () => {
console.log('❌ WebSocket disconnected');
socket.on('disconnect', async () => {
if (RETRY_CONNECT.current > 0) {
await checkStatus();
socket.connect();
RETRY_CONNECT.current--;
return;
}
});
socket.on('adminBidsUpdated', (data: IWebBid[]) => {

View File

@ -0,0 +1,168 @@
import { ActionIcon, Box, CopyButton, Menu, TextInput } from '@mantine/core';
import { IconCopy, IconMenu, IconTrash } from '@tabler/icons-react';
import { useMemo, useRef } from 'react';
import { createKey, deleteKey, deletesKey, getKeys } from '../apis/generate-key';
import Table from '../lib/table/table';
import { IColumn, TRefTableFn } from '../lib/table/type';
import { useConfirmStore } from '../lib/zustand/use-confirm';
import { IKey } from '../system/type';
import { formatTime } from '../utils';
export default function GenerateKeys() {
const refTableFn: TRefTableFn<IKey> = useRef({});
const { setConfirm } = useConfirmStore();
const columns: IColumn<IKey>[] = [
{
key: 'id',
title: 'ID',
typeFilter: 'number',
},
{
key: 'client_key',
title: 'Client key',
typeFilter: 'text',
renderRow(row) {
return (
<CopyButton value={row.client_key}>
{({ copied, copy }) => (
<TextInput
size="xs"
value={row.client_key}
rightSection={
<ActionIcon className="z-30 cursor-pointer" size="xs" color={copied ? 'teal' : 'blue'} onClick={copy}>
<IconCopy size={12} />
</ActionIcon>
}
/>
)}
</CopyButton>
);
},
},
{
key: 'created_at',
title: 'Create at',
typeFilter: 'none',
renderRow(row) {
return <span className="text-sm">{formatTime(row.created_at)}</span>;
},
},
];
const handleDelete = (bid: IKey) => {
setConfirm({
title: 'Delete ?',
message: 'This key will be delete',
handleOk: async () => {
await deleteKey(bid);
if (refTableFn.current?.fetchData) {
refTableFn.current.fetchData();
}
},
});
};
const table = useMemo(() => {
return (
<Table
actionsOptions={{
actions: [
{
key: 'create',
title: 'New key',
callback: async () => {
setConfirm({
okButton: {
color: 'lime',
value: 'Ok',
},
title: 'A new key will generate',
handleOk: async () => {
await createKey();
if (!refTableFn.current?.fetchData) return;
refTableFn.current.fetchData();
},
});
},
},
{
key: 'delete',
title: 'Delete',
callback: (data) => {
if (!data.length) return;
setConfirm({
title: 'Delete',
message: `${data.length} keys will be delete`,
handleOk: async () => {
const result = await deletesKey(data);
if (!result) return;
if (refTableFn.current.fetchData) {
refTableFn.current.fetchData();
}
},
});
},
disabled: (data) => data.length <= 0,
},
],
}}
refTableFn={refTableFn}
striped
showLoading={true}
highlightOnHover
styleDefaultHead={{
justifyContent: 'flex-start',
width: 'fit-content',
}}
options={{
query: getKeys,
pathToData: 'data.data',
keyOptions: {
last_page: 'lastPage',
per_page: 'perPage',
from: 'from',
to: 'to',
total: 'total',
},
}}
actions={{
title: <Box className="w-full text-center">Action</Box>,
body: (row) => {
return (
<Menu shadow="md" width={200}>
<Menu.Target>
<Box onClick={(e) => e.stopPropagation()} className="flex w-full items-center justify-center">
<ActionIcon size="sm" variant="light">
<IconMenu size={14} />
</ActionIcon>
</Box>
</Menu.Target>
<Menu.Dropdown onClick={(e) => e.stopPropagation()}>
<Menu.Item onClick={() => handleDelete(row)} leftSection={<IconTrash color="red" size={14} />}>
Delete
</Menu.Item>
</Menu.Dropdown>
</Menu>
);
},
}}
rows={[]}
withColumnBorders
showChooses={true}
withTableBorder
columns={columns}
rowKey="id"
/>
);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return <Box>{table}</Box>;
}

View File

@ -1,14 +1,16 @@
import { IconHammer, IconHome2, IconMessage, IconOutlet, IconPageBreak, IconUserCheck } from '@tabler/icons-react';
import { IconHammer, IconHome2, IconKey, IconMessage, IconOutlet, IconPageBreak, IconUserCheck } from '@tabler/icons-react';
import { Bids, Dashboard, OutBidsLog } from '../pages';
import WebBids from '../pages/web-bids';
import SendMessageHistories from '../pages/send-message-histories';
import Admins from '../pages/admins';
import GenerateKeys from '../pages/generate-keys';
export default class Links {
public static DASHBOARD = '/dashboard';
public static BIDS = '/bids';
public static WEBS = '/webs';
public static OUT_BIDS_LOG = '/out-bids-log';
public static SEND_MESSAGE_HISTORIES = '/send-message-histories';
public static GENERATE_KEYS = '/generate-keys';
public static ADMINS = '/admins';
public static HOME = '/';
@ -20,36 +22,49 @@ export default class Links {
title: 'Dashboard',
icon: IconHome2,
element: Dashboard,
show: true,
},
{
path: this.ADMINS,
title: 'Admins',
icon: IconUserCheck,
element: Admins,
show: true,
},
{
path: this.WEBS,
title: 'Webs',
icon: IconPageBreak,
element: WebBids,
show: true,
},
{
path: this.BIDS,
title: 'Bids',
icon: IconHammer,
element: Bids,
show: true,
},
{
path: this.OUT_BIDS_LOG,
title: 'Out bids log',
icon: IconOutlet,
element: OutBidsLog,
show: true,
},
{
path: this.SEND_MESSAGE_HISTORIES,
title: 'Send message histories',
icon: IconMessage,
element: SendMessageHistories,
show: true,
},
{
path: this.GENERATE_KEYS,
title: 'Generate keys',
icon: IconKey,
element: GenerateKeys,
show: false,
},
];
}

View File

@ -8,6 +8,11 @@ export interface IAdmin extends ITimestamp {
permissions: [];
}
export interface IKey extends ITimestamp {
id: number;
client_key: string;
}
export interface ITimestamp {
created_at: string;
updated_at: string;

View File

@ -24,6 +24,7 @@
"bcrypt": "^5.1.1",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.1",
"cookie": "^1.0.2",
"cookie-parser": "^1.4.7",
"dayjs": "^1.11.13",
"multer": "^1.4.5-lts.1",
@ -32,13 +33,15 @@
"reflect-metadata": "^0.2.0",
"rxjs": "^7.8.1",
"sharp": "^0.33.5",
"typeorm": "^0.3.21"
"typeorm": "^0.3.21",
"uuid": "^11.1.0"
},
"devDependencies": {
"@nestjs/cli": "^10.0.0",
"@nestjs/schematics": "^10.0.0",
"@nestjs/testing": "^10.0.0",
"@types/bcrypt": "^5.0.2",
"@types/cookie": "^0.6.0",
"@types/cookie-parser": "^1.4.8",
"@types/express": "^4.17.17",
"@types/jest": "^29.5.2",
@ -2823,6 +2826,13 @@
"@types/node": "*"
}
},
"node_modules/@types/cookie": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz",
"integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/cookie-parser": {
"version": "1.4.8",
"resolved": "https://registry.npmjs.org/@types/cookie-parser/-/cookie-parser-1.4.8.tgz",
@ -4719,12 +4729,12 @@
"license": "MIT"
},
"node_modules/cookie": {
"version": "0.7.1",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.1.tgz",
"integrity": "sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==",
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-1.0.2.tgz",
"integrity": "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
"node": ">=18"
}
},
"node_modules/cookie-parser": {
@ -5736,6 +5746,15 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/express/node_modules/cookie": {
"version": "0.7.1",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.1.tgz",
"integrity": "sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/express/node_modules/debug": {
"version": "2.6.9",
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
@ -8088,16 +8107,6 @@
"set-cookie-parser": "^2.6.0"
}
},
"node_modules/light-my-request/node_modules/cookie": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-1.0.2.tgz",
"integrity": "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/lines-and-columns": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz",

View File

@ -40,6 +40,7 @@
"bcrypt": "^5.1.1",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.1",
"cookie": "^1.0.2",
"cookie-parser": "^1.4.7",
"dayjs": "^1.11.13",
"multer": "^1.4.5-lts.1",
@ -48,13 +49,15 @@
"reflect-metadata": "^0.2.0",
"rxjs": "^7.8.1",
"sharp": "^0.33.5",
"typeorm": "^0.3.21"
"typeorm": "^0.3.21",
"uuid": "^11.1.0"
},
"devDependencies": {
"@nestjs/cli": "^10.0.0",
"@nestjs/schematics": "^10.0.0",
"@nestjs/testing": "^10.0.0",
"@types/bcrypt": "^5.0.2",
"@types/cookie": "^0.6.0",
"@types/cookie-parser": "^1.4.8",
"@types/express": "^4.17.17",
"@types/jest": "^29.5.2",

View File

@ -6,8 +6,13 @@ import { AppValidatorsModule } from './modules/app-validators/app-validators.mod
import { AuthModule } from './modules/auth/auth.module';
import { AdminsModule } from './modules/admins/admins.module';
import { AuthenticationMiddleware } from './modules/auth/middlewares/authentication.middleware';
import { excludeAuth, excludeAuthor } from './system/routes/exclude-route';
import {
clientExcludeAuth,
excludeAuth,
excludeAuthor,
} from './system/routes/exclude-route';
import { AuthorizationMiddleware } from './modules/admins/middlewares/authorization.middleware';
import { ClientAuthenticationMiddleware } from './modules/auth/middlewares/client-authentication.middleware';
@Module({
imports: [
@ -32,5 +37,10 @@ export class AppModule {
.apply(AuthorizationMiddleware)
.exclude(...excludeAuthor)
.forRoutes({ path: 'admin/*', method: RequestMethod.ALL });
consumer
.apply(ClientAuthenticationMiddleware)
.exclude(...clientExcludeAuth)
.forRoutes({ path: '/*', method: RequestMethod.ALL });
}
}

View File

@ -12,7 +12,7 @@ async function bootstrap() {
const prefix_version = '/api/v1';
app.enableCors({
origin: process.env.CORS.split(', '),
origin: process.env.CORS.split(', ') || '*',
methods: ['GET', 'PUT', 'POST', 'DELETE', 'PATCH'],
allowedHeaders: 'Content-Type, Authorization',
credentials: true,

View File

@ -6,11 +6,18 @@ import { AdminsController } from './controllers/admins.controller';
import Permission from './entities/permission.entity';
import { PermissionService } from './services/permission.service';
import { PermissionsController } from './controllers/permissions.controller';
import { GenerateKey } from './entities/generate-key.entity';
import { GenerateKeysController } from './controllers/generate-keys.controller';
import { GenerateKeysService } from './services/generate-key.service';
@Module({
imports: [TypeOrmModule.forFeature([Admin, Permission])],
providers: [AdminsService, PermissionService],
exports: [AdminsService, PermissionService],
controllers: [AdminsController, PermissionsController],
imports: [TypeOrmModule.forFeature([Admin, Permission, GenerateKey])],
providers: [AdminsService, PermissionService, GenerateKeysService],
exports: [AdminsService, PermissionService, GenerateKeysService],
controllers: [
AdminsController,
PermissionsController,
GenerateKeysController,
],
})
export class AdminsModule {}

View File

@ -0,0 +1,47 @@
import {
Body,
Controller,
Delete,
Get,
Param,
Post,
Put,
Req,
UseGuards,
} from '@nestjs/common';
import { AdminsService } from '../services/admins.service';
import { Paginate, PaginateQuery } from 'nestjs-paginate';
import Admin from '../entities/admin.entity';
import { UpdateDto } from '../dtos/update.dto';
import { DeletesDto } from '../dtos/deletes.dto';
import { Request } from 'express';
import { CreateDto } from '../dtos/create.dto';
import Permission from '../entities/permission.entity';
import { GrantNewPasswordDto } from '../dtos/grant-new-password.dto';
import { SystemAccountGuard } from '@/system/guards/system-account.guard';
import { GenerateKeysService } from '../services/generate-key.service';
@Controller('admin/keys')
export class GenerateKeysController {
constructor(private readonly generateKeyService: GenerateKeysService) {}
@Get()
async index(@Paginate() query: PaginateQuery) {
return await this.generateKeyService.index(query);
}
@Delete(':id')
async delete(@Param('id') id: Admin['id']) {
return await this.generateKeyService.delete(id);
}
@Post('')
async create(@Req() request: Request) {
return await this.generateKeyService.create(request);
}
@Post('deletes')
async deletes(@Body() data: DeletesDto) {
return await this.generateKeyService.deletes(data.ids);
}
}

View File

@ -1,7 +1,15 @@
import { Exclude } from 'class-transformer';
import { Column, Entity, ManyToMany, PrimaryGeneratedColumn } from 'typeorm';
import {
Column,
Entity,
ManyToMany,
ManyToOne,
OneToMany,
PrimaryGeneratedColumn,
} from 'typeorm';
import Permission from './permission.entity';
import { Timestamp } from './timestamp';
import { GenerateKey } from './generate-key.entity';
@Entity('admins')
export default class Admin extends Timestamp {
@ -29,4 +37,9 @@ export default class Admin extends Timestamp {
onDelete: 'CASCADE',
})
permissions: Permission[];
@OneToMany(() => GenerateKey, (key) => key.admin, {
cascade: true,
})
generateKeys: GenerateKey[];
}

View File

@ -0,0 +1,18 @@
import { Exclude } from 'class-transformer';
import { Column, Entity, ManyToOne, PrimaryGeneratedColumn } from 'typeorm';
import Admin from './admin.entity';
import { Timestamp } from './timestamp';
@Entity('generate_keys')
export class GenerateKey extends Timestamp {
@PrimaryGeneratedColumn('increment')
id: number;
@Column({ unique: true })
client_key: string;
@ManyToOne(() => Admin, (admin) => admin.generateKeys, {
onDelete: 'CASCADE',
})
admin: Admin;
}

View File

@ -119,6 +119,7 @@ export class AdminsService {
| 'is_system_account'
| 'permissions'
| 'posts'
| 'generateKeys'
>) {
const hashPassword = await bcrypt.hash(password, 10);

View File

@ -0,0 +1,106 @@
import AppResponse from '@/response/app-response';
import {
BadRequestException,
HttpStatus,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Request } from 'express';
import {
FilterOperator,
FilterSuffix,
paginate,
PaginateQuery,
} from 'nestjs-paginate';
import { Column } from 'nestjs-paginate/lib/helper';
import { In, Repository } from 'typeorm';
import { v1 as uuidV1 } from 'uuid';
import Admin from '../entities/admin.entity';
import { GenerateKey } from '../entities/generate-key.entity';
import Permission from '../entities/permission.entity';
@Injectable()
export class GenerateKeysService {
constructor(
@InjectRepository(GenerateKey)
readonly generateKeyRepo: Repository<GenerateKey>,
@InjectRepository(Permission)
readonly permissionRepo: Repository<Permission>,
) {}
async index(query: PaginateQuery) {
const filterableColumns: {
[key in Column<Admin> | (string & {})]?:
| (FilterOperator | FilterSuffix)[]
| true;
} = {
id: true,
client_key: [FilterOperator.ILIKE],
};
query.filter = AppResponse.processFilters(query.filter, filterableColumns);
const data = await paginate(query, this.generateKeyRepo, {
sortableColumns: ['id', 'client_key'],
searchableColumns: ['id', 'client_key'],
defaultLimit: 15,
filterableColumns,
defaultSortBy: [['id', 'DESC']],
maxLimit: 100,
});
return AppResponse.toPagination<GenerateKey>(data, true, GenerateKey);
}
async delete(id: Admin['id']) {
const admin = await this.generateKeyRepo.findOne({ where: { id } });
if (!admin)
throw new NotFoundException(
AppResponse.toResponse(false, {
message: 'Key is not found',
status_code: HttpStatus.NOT_FOUND,
}),
);
await this.generateKeyRepo.delete({ id: admin.id });
return AppResponse.toResponse(true, { message: 'Delete success !' });
}
async deletes(ids: Admin['id'][]) {
const result = await this.generateKeyRepo.delete({
id: In(ids),
});
if (!result.affected) {
throw new BadRequestException(
AppResponse.toResponse(false, {
message: 'No items have been deleted yet.',
status_code: HttpStatus.BAD_REQUEST,
}),
);
}
return AppResponse.toResponse(true, { message: 'Delete success !' });
}
async create(request: Request) {
const admin = request['admin'];
const clientKey = uuidV1();
const result = await this.generateKeyRepo.save({
admin: { id: admin.id },
client_key: `client-${clientKey}-${admin.id}`,
});
if (!result)
throw new BadRequestException(
AppResponse.toResponse(null, { message: `Can't create key` }),
);
return AppResponse.toResponse(result);
}
}

View File

@ -1,6 +1,6 @@
import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt';
import { AuthController } from './auth.controller';
import { AuthController } from './controllers/auth.controller';
import { AuthService } from './auth.service';
import { AdminsModule } from '../admins/admins.module';

View File

@ -14,12 +14,14 @@ import { Request, Response } from 'express';
import { Constant } from './ultils/constant';
import { ChangePasswordDto } from './dto/change-password.dto';
import AppResponse from '@/response/app-response';
import { GenerateKeysService } from '../admins/services/generate-key.service';
@Injectable()
export class AuthService {
constructor(
private jwtService: JwtService,
private readonly adminService: AdminsService,
private readonly generateKeyService: GenerateKeysService,
) {}
async validateAdmin(

View File

@ -7,10 +7,13 @@ import {
Res,
Get,
} from '@nestjs/common';
import { AuthService } from './auth.service';
import { LoginDto } from './dto/login.dto';
import { AuthService } from '../auth.service';
import { LoginDto } from '../dto/login.dto';
import { Request, Response } from 'express';
import { ChangePasswordDto } from './dto/change-password.dto';
import { ChangePasswordDto } from '../dto/change-password.dto';
import AppResponse from '@/response/app-response';
import { Throttle } from '@nestjs/throttler';
import { GenerateTokenDto } from '../dto/generate-token.dto';
@Controller('admin/auth')
export class AuthController {
@ -26,6 +29,12 @@ export class AuthController {
.then((admin) => this.authService.login(admin, response));
}
@Post('check-status')
@Throttle({ default: { limit: 1, ttl: 30 * 1000 } })
async checkStatus() {
return AppResponse.toResponse(true);
}
@Post('logout')
async logout(@Res({ passthrough: true }) response: Response) {
return await this.authService.logout(response);

View File

@ -0,0 +1,6 @@
import { IsString } from 'class-validator';
export class GenerateTokenDto {
@IsString()
client_key: string;
}

View File

@ -0,0 +1,44 @@
// auth.middleware.ts
import { GenerateKeysService } from '@/modules/admins/services/generate-key.service';
import AppResponse from '@/response/app-response';
import {
HttpStatus,
Injectable,
NestMiddleware,
UnauthorizedException,
} from '@nestjs/common';
import { NextFunction, Request, Response } from 'express';
@Injectable()
export class ClientAuthenticationMiddleware implements NestMiddleware {
constructor(private readonly generateKeysService: GenerateKeysService) {}
async getKey(key: string) {
if (!key) return null;
const data = await this.generateKeysService.generateKeyRepo.findOne({
where: { client_key: key },
});
return data;
}
async use(req: Request, res: Response, next: NextFunction) {
const client_key: string = req.headers.authorization;
const key = await this.getKey(client_key);
if (!key) {
return next(
new UnauthorizedException(
AppResponse.toResponse(null, {
message: 'Unauthorized',
status_code: HttpStatus.UNAUTHORIZED,
}),
),
);
}
next();
}
}

View File

@ -0,0 +1,25 @@
import { GenerateKeysService } from '@/modules/admins/services/generate-key.service';
import { Socket } from 'socket.io';
export async function clientGetWayMiddleware(
client: Socket,
generateKeysService: GenerateKeysService,
): Promise<void> {
const client_key = client.handshake.headers.authorization;
if (!client_key) {
client.disconnect();
return;
}
const key = await generateKeysService.generateKeyRepo.findOne({
where: { client_key },
});
if (!key) {
client.disconnect();
return;
}
console.log(`✅ WebSocket authenticated: Client ID ${key.client_key}`);
}

View File

@ -0,0 +1,50 @@
import { JwtService, TokenExpiredError } from '@nestjs/jwt';
import { AdminsService } from '@/modules/admins/services/admins.service';
import * as cookie from 'cookie';
import { Socket } from 'socket.io';
import { plainToClass } from 'class-transformer';
import Admin from '@/modules/admins/entities/admin.entity';
export async function getWayMiddleware(
client: Socket,
jwtService: JwtService,
adminService: AdminsService,
): Promise<void> {
try {
const cookies = cookie.parse(client.handshake.headers.cookie || '');
const accessToken = cookies['access_token'];
const refreshToken = cookies['refresh_token'];
if (!accessToken && !refreshToken) {
console.log('🚫 No tokens provided, disconnecting WebSocket');
client.disconnect();
return;
}
let admin: Admin | null = null;
try {
const result = await jwtService.verify(accessToken);
if (!result?._id || result.refresh_key) {
throw new Error('Invalid access token');
}
admin = await adminService.adminRepo.findOne({
where: { id: result._id },
});
if (!admin) throw new Error('Admin not found');
} catch (error) {
console.log('❗ Invalid access token, disconnecting WebSocket');
client.disconnect();
return;
}
client.data.admin = plainToClass(Admin, admin);
console.log(`✅ WebSocket authenticated: Admin ID ${admin.id}`);
} catch (error) {
console.log('❌ WebSocket authentication error:', error);
client.disconnect();
}
}

View File

@ -22,6 +22,9 @@ import { GraysApi } from './apis/grays.api';
import { SendMessageHistory } from './entities/send-message-histories.entity';
import { SendMessageHistoriesService } from './services/send-message-histories.service';
import { AdminSendMessageHistoriesController } from './controllers/admin/admin-send-message-histories.controller';
import { AuthModule } from '../auth/auth.module';
import { AdminsModule } from '../admins/admins.module';
import { AdminBidGateway } from './getways/admin-bid-getway';
@Module({
imports: [
@ -35,6 +38,8 @@ import { AdminSendMessageHistoriesController } from './controllers/admin/admin-s
EventEmitterModule.forRoot({
wildcard: true,
}),
// AuthModule,
AdminsModule,
],
controllers: [
BidsController,
@ -50,6 +55,7 @@ import { AdminSendMessageHistoriesController } from './controllers/admin/admin-s
BidsService,
BidHistoriesService,
BidGateway,
AdminBidGateway,
OutBidLogsService,
WebBidsService,
BotTelegramApi,

View File

@ -0,0 +1,57 @@
import {
WebSocketGateway,
WebSocketServer,
SubscribeMessage,
OnGatewayConnection,
} from '@nestjs/websockets';
import { Server, Socket } from 'socket.io';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { BidsService } from '../services/bids.service';
import { WebBidsService } from '../services/web-bids.service';
import { plainToClass } from 'class-transformer';
import { WebBid } from '../entities/wed-bid.entity';
import * as cookie from 'cookie';
import { Constant } from '@/modules/auth/ultils/constant';
import { getWayMiddleware } from '@/modules/auth/middlewares/get-way.middleware';
import { AdminsService } from '@/modules/admins/services/admins.service';
import { JwtService } from '@nestjs/jwt';
@WebSocketGateway({
namespace: 'admin-bid-ws',
cors: {
origin: '*',
methods: ['GET', 'POST'],
credentials: true,
},
})
export class AdminBidGateway implements OnGatewayConnection {
@WebSocketServer()
server: Server;
constructor(
private eventEmitter: EventEmitter2,
private readonly jwtService: JwtService,
private webBidsService: WebBidsService,
private readonly adminService: AdminsService,
) {}
async onModuleInit() {
this.eventEmitter.on('bids.updated', (data) => {
this.server.emit('adminBidsUpdated', plainToClass(WebBid, data));
});
this.eventEmitter.on('working', (data) => {
this.server.emit('working', data);
});
}
async handleConnection(client: Socket) {
await getWayMiddleware(client, this.jwtService, this.adminService);
console.log(`📢 Client connected: ${client.id}`);
const data = await this.webBidsService.getDataClient();
// Gửi dữ liệu bids ngay khi client kết nối
client.emit('adminBidsUpdated', plainToClass(WebBid, data));
}
}

View File

@ -1,17 +1,17 @@
import { GenerateKeysService } from '@/modules/admins/services/generate-key.service';
import { clientGetWayMiddleware } from '@/modules/auth/middlewares/client-get-way.middleware copy';
import { EventEmitter2 } from '@nestjs/event-emitter';
import {
OnGatewayConnection,
WebSocketGateway,
WebSocketServer,
SubscribeMessage,
OnGatewayConnection,
} from '@nestjs/websockets';
import { Server, Socket } from 'socket.io';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { BidsService } from '../services/bids.service';
import { WebBidsService } from '../services/web-bids.service';
import { plainToClass } from 'class-transformer';
import { WebBid } from '../entities/wed-bid.entity';
@WebSocketGateway({
namespace: 'bid-ws',
cors: {
origin: '*',
methods: ['GET', 'POST'],
@ -25,30 +25,26 @@ export class BidGateway implements OnGatewayConnection {
constructor(
private eventEmitter: EventEmitter2,
private webBidsService: WebBidsService,
private bidsService: BidsService,
private generateKeysService: GenerateKeysService,
) {}
async onModuleInit() {
this.eventEmitter.on('bids.updated', (data) => {
this.server.emit('bidsUpdated', data);
this.server.emit('adminBidsUpdated', plainToClass(WebBid, data));
});
this.eventEmitter.on('web.updated', (data) => {
this.server.emit('webUpdated', data);
});
this.eventEmitter.on('working', (data) => {
this.server.emit('working', data);
});
}
async handleConnection(client: Socket) {
await clientGetWayMiddleware(client, this.generateKeysService);
console.log(`📢 Client connected: ${client.id}`);
const data = await this.webBidsService.getDataClient();
// Gửi dữ liệu bids ngay khi client kết nối
client.emit('bidsUpdated', data);
client.emit('adminBidsUpdated', plainToClass(WebBid, data));
}
}

View File

@ -12,6 +12,14 @@ export const excludeAuth = [
},
] as (string | RouteInfo)[];
export const clientExcludeAuth = [
{ path: '/admin/(.*)', method: RequestMethod.ALL },
{
path: 'auth/generate-token',
method: RequestMethod.POST,
},
] as (string | RouteInfo)[];
export const excludeAuthor = [
...excludeAuth,
{

View File

@ -1,3 +1,4 @@
ENVIRONMENT = 'prod'
SOCKET_URL = 'http://localhost:4000'
BASE_URL = 'http://localhost:4000/api/v1/'
CLIENT_KEY = ''

View File

@ -1,11 +1,11 @@
import 'dotenv/config';
import _ from 'lodash';
import pLimit from 'p-limit';
import { io } from 'socket.io-client';
import { createApiBid, createBidProduct, deleteProfile, shouldUpdateProductTab } from './service/app-service.js';
import browser from './system/browser.js';
import configs from './system/config.js';
import { delay, isTimeReached, safeClosePage } from './system/utils.js';
import pLimit from 'p-limit';
let MANAGER_BIDS = [];
@ -53,7 +53,6 @@ const tracking = async () => {
console.log('🔍 Scanning active bids...');
const productTabs = _.flatMap(MANAGER_BIDS, 'children');
// Lắng nghe sự kiện của API bids (chạy song song)
await Promise.allSettled(
MANAGER_BIDS.filter((bid) => !bid.page_context).map((apiBid) => {
console.log(`🎧 Listening to events for API Bid ID: ${apiBid.id}`);
@ -196,9 +195,12 @@ const workTracking = async () => {
};
(async () => {
const socket = io(configs.SOCKET_URL, {
const socket = io(`${configs.SOCKET_URL}/bid-ws`, {
transports: ['websocket'],
reconnection: true,
extraHeaders: {
Authorization: process.env.CLIENT_KEY,
},
});
// listen connect
@ -207,6 +209,11 @@ const workTracking = async () => {
console.log('🔗 Socket ID:', socket.id);
});
// listen connect
socket.on('disconnect', () => {
console.log('❌Client key is valid. Disconnected');
});
// listen event
socket.on('bidsUpdated', async (data) => {
console.log('📢 Bids Data:', data);

View File

@ -1,3 +1,4 @@
import BID_TYPE from '../system/bid-type.js';
import CONSTANTS from '../system/constants.js';
import { takeSnapshot } from '../system/utils.js';
import _ from 'lodash';
@ -20,7 +21,7 @@ export class Bid {
try {
// await this.page_context.waitForSelector('#pageContainer', { timeout: 10000 });
console.log(`✅ Page fully loaded. Taking snapshot for Product ID: ${this.id}`);
console.log(`✅ Page fully loaded. Taking snapshot for ${this.type === BID_TYPE.PRODUCT_TAB ? 'Product ID' : 'Tracking ID'}: ${this.id}`);
takeSnapshot(this.page_context, this, 'working', CONSTANTS.TYPE_IMAGE.WORK);
} catch (error) {
console.error(`❌ Error taking snapshot for Product ID: ${this.id}:`, error.message);

View File

@ -218,6 +218,13 @@ export class GraysProductBid extends ProductBid {
return;
}
const bidHistoriesItem = _.maxBy(this.histories, 'price');
if (bidHistoriesItem && bidHistoriesItem.price === this.current_price) {
console.log(`🔄 You have already bid on this item! (Bid Price: ${bidHistoriesItem.price})`);
return;
}
if (price_value != bid_price) {
console.log(`✍️ Updating bid price from ${price_value}${bid_price}`);
await this.handleWritePrice(page, bid_price);

View File

@ -3,6 +3,9 @@ import ax from 'axios';
const axios = ax.create({
// baseURL: 'http://172.18.2.125/api/v1/',
baseURL: process.env.BASE_URL,
headers: {
Authorization: process.env.CLIENT_KEY,
},
});
export default axios;