updated login page with qwen

This commit is contained in:
Berkay 2025-07-29 23:06:15 +03:00
parent ccb5c172ae
commit 0ce522d04a
35 changed files with 1708 additions and 80 deletions

View File

@ -48,3 +48,14 @@ npx prisma db push # update remote schema # not good for production creates no s
npx prisma validate npx prisma validate
npx prisma format npx prisma format
# Frontend
npx create-next-app@latest
!npm install next-intl now towking with latest next js
npm install --save nestjs-i18n
npm install ioredis
npm install -D daisyui@latest
npm install tailwindcss @tailwindcss/postcss daisyui@latest
npm install lucide-react

View File

@ -3508,6 +3508,7 @@ model staff {
/// This model or at least one of its fields has comments in the database, and requires an additional setup for migrations: Read more: https://pris.ly/d/database-comments /// This model or at least one of its fields has comments in the database, and requires an additional setup for migrations: Read more: https://pris.ly/d/database-comments
model users { model users {
user_tag String @default("") @db.VarChar(64) user_tag String @default("") @db.VarChar(64)
user_type String @default("employee") @db.VarChar(32)
email String @default("") @db.VarChar(128) email String @default("") @db.VarChar(128)
phone_number String @default("") @db.VarChar phone_number String @default("") @db.VarChar
via String @default("111") @db.VarChar via String @default("111") @db.VarChar

View File

@ -31,8 +31,8 @@ export class AuthController {
@Post('select') @Post('select')
@HttpCode(200) @HttpCode(200)
@UseGuards(AuthControlGuard) @UseGuards(AuthControlGuard)
async select(@Body() query: userSelectValidator) { async select(@Body() query: userSelectValidator, @Req() req: Request) {
return { message: 'Logout successful' }; return await this.authService.select(query, req);
} }
@Post('/password/create') @Post('/password/create')

View File

@ -32,8 +32,8 @@ export class AuthService {
return await this.logoutService.run(dto); return await this.logoutService.run(dto);
} }
async select(dto: userSelectValidator) { async select(dto: userSelectValidator, req: Request) {
return await this.selectService.run(dto); return await this.selectService.run(dto, req);
} }
async createPassword(dto: any) { async createPassword(dto: any) {

View File

@ -18,9 +18,9 @@ export class LoginService {
where: { email: dto.accessKey }, where: { email: dto.accessKey },
}); });
// if (foundUser.password_token) { if (foundUser.password_token) {
// throw new Error('Password need to be set first'); throw new Error('Password need to be set first');
// } }
const isPasswordValid = this.passHandlers.check_password( const isPasswordValid = this.passHandlers.check_password(
foundUser.uu_id, foundUser.uu_id,
@ -28,14 +28,13 @@ export class LoginService {
foundUser.hash_password, foundUser.hash_password,
); );
// if (!isPasswordValid) { if (!isPasswordValid) {
// throw new Error('Invalid password'); throw new Error('Invalid password');
// } }
const foundPerson = await this.prisma.people.findFirstOrThrow({ const foundPerson = await this.prisma.people.findFirstOrThrow({
where: { id: foundUser.id }, where: { id: foundUser.id },
}); });
const redisData = AuthTokenSchema.parse({ const redisData = AuthTokenSchema.parse({
people: foundPerson, people: foundPerson,
users: foundUser, users: foundUser,
@ -43,8 +42,8 @@ export class LoginService {
person_id: foundPerson.id, person_id: foundPerson.id,
person_name: foundPerson.firstname, person_name: foundPerson.firstname,
}, },
selectionList: [],
}); });
const accessToken = await this.redis.setLoginToRedis( const accessToken = await this.redis.setLoginToRedis(
redisData, redisData,
foundUser.uu_id, foundUser.uu_id,

View File

@ -29,5 +29,4 @@ export class VerifyOtpService {
// // controller veya resolver içinden // // controller veya resolver içinden
// const { secret, otpauthUrl } = authService.generate2FASecret('mehmet'); // const { secret, otpauthUrl } = authService.generate2FASecret('mehmet');
// const qrCodeImage = await authService.generateQRCode(otpauthUrl); // const qrCodeImage = await authService.generateQRCode(otpauthUrl);
// // qrCodeImage → frontende gönder, <img src="data:image/png;base64,..."> diye gösterilebilir // // qrCodeImage → frontende gönder, <img src="data:image/png;base64,..."> diye gösterilebilir

View File

@ -1,9 +1,124 @@
import { Injectable } from '@nestjs/common'; import {
Injectable,
BadRequestException,
UnauthorizedException,
NotAcceptableException,
} from '@nestjs/common';
import { userSelectValidator } from '@/src/auth/select/dtoValidator'; import { userSelectValidator } from '@/src/auth/select/dtoValidator';
import { RedisHandlers } from '@/src/utils/auth/redis_handlers';
import {
EmployeeTokenSchema,
OccupantTokenSchema,
TokenDictInterface,
UserType,
} from '@/src/types/auth/token';
import { PrismaService } from '@/src/prisma.service';
@Injectable() @Injectable()
export class SelectService { export class SelectService {
async run(dto: userSelectValidator) { constructor(
return dto; private readonly redis: RedisHandlers,
private readonly prisma: PrismaService,
) {}
async run(dto: userSelectValidator, req: Request) {
const accessObject = await this.redis.getLoginFromRedis(req);
if (!accessObject) {
throw new UnauthorizedException(
'Authorization failed. Please login to continue',
);
}
const accessToken = accessObject.key.split(':')[1];
console.log('accessToken', accessToken);
const userType = accessObject.value.users.user_type;
if (userType === 'employee') {
const employee = await this.prisma.employees.findFirstOrThrow({
where: { uu_id: dto.selected_uu_id },
});
const staff = await this.prisma.staff.findFirstOrThrow({
where: { id: employee.staff_id },
});
const duties = await this.prisma.duties.findFirstOrThrow({
where: { id: staff.duties_id },
});
const department = await this.prisma.departments.findFirstOrThrow({
where: { id: duties.department_id },
});
const duty = await this.prisma.duty.findFirstOrThrow({
where: { id: duties.duties_id },
});
const company = await this.prisma.companies.findFirstOrThrow({
where: { id: duties.company_id },
});
const employeeToken = EmployeeTokenSchema.parse({
company: company,
department: department,
duty: duty,
employee: employee,
staff: staff,
menu: null,
pages: null,
config: null,
caches: null,
selection: null,
functionsRetriever: staff.function_retriever,
kind: UserType.employee,
});
const tokenSelect = await this.redis.setSelectToRedis(
accessToken,
employeeToken,
accessObject.value.users.uu_id,
dto.selected_uu_id,
);
return {
message: 'Select successful',
token: tokenSelect,
};
} else if (userType === 'occupant') {
const livingSpace = await this.prisma.build_living_space.findFirstOrThrow(
{ where: { uu_id: dto.selected_uu_id } },
);
const occupantType = await this.prisma.occupant_types.findFirstOrThrow({
where: { id: livingSpace.occupant_type_id },
});
const part = await this.prisma.build_parts.findFirstOrThrow({
where: { id: livingSpace.build_parts_id },
});
const build = await this.prisma.build.findFirstOrThrow({
where: { id: part.build_id },
});
const company = await this.prisma.companies.findFirstOrThrow({
where: { uu_id: accessObject.value.users.related_company },
});
const occupantToken = OccupantTokenSchema.parse({
livingSpace: livingSpace,
occupant: occupantType,
build: build,
part: part,
company: company,
menu: null,
pages: null,
config: null,
caches: null,
selection: null,
functionsRetriever: occupantType.function_retriever,
kind: UserType.occupant,
});
const tokenSelect = await this.redis.setSelectToRedis(
accessToken,
occupantToken,
accessObject.value.users.uu_id,
dto.selected_uu_id,
);
return {
message: 'Select successful',
token: tokenSelect,
};
} else {
throw new NotAcceptableException('Invalid user type');
}
} }
} }

View File

@ -14,14 +14,6 @@ export class AuthControlGuard implements CanActivate {
const req = context.switchToHttp().getRequest(); const req = context.switchToHttp().getRequest();
const accessToken = this.cacheService.mergeLoginKey(req); const accessToken = this.cacheService.mergeLoginKey(req);
console.log('AuthControlGuard', accessToken); console.log('AuthControlGuard', accessToken);
// const hasAccess = accessObject.permissions?.some(
// (p: any) => p.method === method && p.url === path,
// );
// if (!hasAccess) {
// throw new ForbiddenException('Access denied to this route');
// }
return true; return true;
} }
} }
@ -32,18 +24,11 @@ export class EndpointControlGuard implements CanActivate {
async canActivate(context: ExecutionContext): Promise<boolean> { async canActivate(context: ExecutionContext): Promise<boolean> {
const req = context.switchToHttp().getRequest(); const req = context.switchToHttp().getRequest();
const selectToken = this.cacheService.mergeSelectKey(req); // const selectToken = this.cacheService.mergeSelectKey(req);
const method = req.method; // const method = req.method;
const path = req.route?.path; // const path = req.route?.path;
console.log('EndpointControlGuard', selectToken, method, path); const accessObject = await this.cacheService.getSelectFromRedis(req);
// const hasAccess = accessObject.permissions?.some( console.log('EndpointControlGuard', accessObject);
// (p: any) => p.method === method && p.url === path,
// );
// if (!hasAccess) {
// throw new ForbiddenException('Access denied to this route');
// }
return true; return true;
} }
} }

View File

@ -26,7 +26,7 @@ export const AuthTokenSchema = z.object({
father_name: z.string(), father_name: z.string(),
mother_name: z.string(), mother_name: z.string(),
country_code: z.string(), country_code: z.string(),
national_identity_id: z.string(), // national_identity_id: z.string(),
birth_place: z.string(), birth_place: z.string(),
birth_date: z.date(), birth_date: z.date(),
tax_no: z.string(), tax_no: z.string(),
@ -52,10 +52,11 @@ export const AuthTokenSchema = z.object({
users: z.object({ users: z.object({
user_tag: z.string(), user_tag: z.string(),
email: z.string(), email: z.string(),
user_type: z.string(),
phone_number: z.string(), phone_number: z.string(),
via: z.string(), via: z.string(),
avatar: z.string(), avatar: z.string(),
hash_password: z.string(), // hash_password: z.string(),
password_token: z.string(), password_token: z.string(),
remember_me: z.boolean(), remember_me: z.boolean(),
password_expires_day: z.number(), password_expires_day: z.number(),
@ -85,33 +86,173 @@ export const AuthTokenSchema = z.object({
default_language: z.string(), default_language: z.string(),
}), }),
credentials: CredentialsSchema, credentials: CredentialsSchema,
selectionList: z.array(z.any()).optional().default([]),
}); });
export type AuthToken = z.infer<typeof AuthTokenSchema>; export type AuthToken = z.infer<typeof AuthTokenSchema>;
export const EmployeeTokenSchema = z.object({ export const EmployeeTokenSchema = z.object({
company: z.object({
id: z.number(),
uu_id: z.string(),
formal_name: z.string(),
company_type: z.string(),
commercial_type: z.string(),
tax_no: z.string(),
public_name: z.string(),
company_tag: z.string(),
default_lang_type: z.string(),
default_money_type: z.string(),
is_commercial: z.boolean(),
is_blacklist: z.boolean(),
parent_id: z.number().nullable(),
workplace_no: z.string().nullable(),
official_address_id: z.number().nullable(),
official_address_uu_id: z.string().nullable(),
top_responsible_company_id: z.number().nullable(),
top_responsible_company_uu_id: z.string().nullable(),
ref_id: z.string().nullable(),
replication_id: z.number(),
cryp_uu_id: z.string().nullable(),
created_credentials_token: z.string().nullable(),
updated_credentials_token: z.string().nullable(),
confirmed_credentials_token: z.string().nullable(),
is_confirmed: z.boolean(),
deleted: z.boolean(),
active: z.boolean(),
is_notification_send: z.boolean(),
is_email_send: z.boolean(),
expiry_starts: z.date(),
expiry_ends: z.date(),
created_at: z.date(),
updated_at: z.date(),
ref_int: z.number().nullable(),
}),
department: z.object({
id: z.number(),
uu_id: z.string(),
parent_department_id: z.number().nullable(),
department_code: z.string(),
department_name: z.string(),
department_description: z.string(),
company_id: z.number(),
company_uu_id: z.string(),
ref_id: z.string().nullable(),
replication_id: z.number(),
cryp_uu_id: z.string().nullable(),
created_credentials_token: z.string().nullable(),
updated_credentials_token: z.string().nullable(),
confirmed_credentials_token: z.string().nullable(),
is_confirmed: z.boolean(),
deleted: z.boolean(),
active: z.boolean(),
is_notification_send: z.boolean(),
is_email_send: z.boolean(),
expiry_starts: z.date(),
expiry_ends: z.date(),
created_at: z.date(),
updated_at: z.date(),
ref_int: z.number().nullable(),
}),
duty: z.object({
id: z.number(),
uu_id: z.string(),
duty_name: z.string(),
duty_code: z.string(),
duty_description: z.string(),
ref_id: z.string().nullable(),
replication_id: z.number(),
cryp_uu_id: z.string().nullable(),
created_credentials_token: z.string().nullable(),
updated_credentials_token: z.string().nullable(),
confirmed_credentials_token: z.string().nullable(),
is_confirmed: z.boolean(),
deleted: z.boolean(),
active: z.boolean(),
is_notification_send: z.boolean(),
is_email_send: z.boolean(),
expiry_starts: z.date(),
expiry_ends: z.date(),
created_at: z.date(),
updated_at: z.date(),
ref_int: z.number().nullable(),
}),
employee: z.object({
id: z.number(),
uu_id: z.string(),
staff_id: z.number(),
staff_uu_id: z.string(),
people_id: z.number(),
people_uu_id: z.string(),
ref_id: z.string().nullable(),
replication_id: z.number(),
cryp_uu_id: z.string().nullable(),
created_credentials_token: z.string().nullable(),
updated_credentials_token: z.string().nullable(),
confirmed_credentials_token: z.string().nullable(),
is_confirmed: z.boolean(),
deleted: z.boolean(),
active: z.boolean(),
is_notification_send: z.boolean(),
is_email_send: z.boolean(),
expiry_starts: z.date(),
expiry_ends: z.date(),
created_at: z.date(),
updated_at: z.date(),
ref_int: z.number().nullable(),
}),
staff: z.object({
id: z.number(),
uu_id: z.string(),
staff_description: z.string(),
staff_name: z.string(),
staff_code: z.string(),
duties_id: z.number(),
duties_uu_id: z.string(),
function_retriever: z.string().nullable(),
ref_id: z.string().nullable(),
replication_id: z.number(),
cryp_uu_id: z.string().nullable(),
created_credentials_token: z.string().nullable(),
updated_credentials_token: z.string().nullable(),
confirmed_credentials_token: z.string().nullable(),
is_confirmed: z.boolean(),
deleted: z.boolean(),
active: z.boolean(),
is_notification_send: z.boolean(),
is_email_send: z.boolean(),
expiry_starts: z.date(),
expiry_ends: z.date(),
created_at: z.date(),
updated_at: z.date(),
ref_int: z.number().nullable(),
}),
menu: z.array(z.object({})).nullable(),
pages: z.array(z.string()).nullable(),
// config: z.record(z.string(), z.unknown()).nullable(),
// caches: z.record(z.string(), z.unknown()).nullable(),
selection: z.record(z.string(), z.unknown()).nullable(),
functionsRetriever: z.string(), functionsRetriever: z.string(),
companies: z.object({}),
department: z.object({}),
duties: z.object({}),
employee: z.object({}),
staffs: z.object({}),
reachable_event_codes: z.array(z.object({})),
reachable_app_codes: z.array(z.object({})),
kind: z.literal(UserType.employee), kind: z.literal(UserType.employee),
}); });
export const OccupantTokenSchema = z.object({ export const OccupantTokenSchema = z.object({
functionsRetriever: z.string(),
livingSpace: z.object({}), livingSpace: z.object({}),
occupantType: z.object({}), occupant: z.object({}),
build: z.object({}), build: z.object({}),
buildPart: z.object({}), part: z.object({}),
responsibleCompany: z.object({}).optional(), company: z.object({}).optional(),
responsibleEmployee: z.object({}).optional(),
menu: z.array(z.object({})).nullable(),
pages: z.array(z.string()).nullable(),
// config: z.record(z.string(), z.unknown()).nullable(),
// caches: z.record(z.string(), z.unknown()).nullable(),
selection: z.record(z.string(), z.unknown()).nullable(),
functionsRetriever: z.string(),
kind: z.literal(UserType.occupant), kind: z.literal(UserType.occupant),
reachable_event_codes: z.array(z.object({})),
reachable_app_codes: z.array(z.object({})),
}); });
export const TokenDictTypes = z.discriminatedUnion('kind', [ export const TokenDictTypes = z.discriminatedUnion('kind', [

View File

@ -2,10 +2,9 @@ import {
TokenDictTypes, TokenDictTypes,
TokenDictInterface, TokenDictInterface,
AuthToken, AuthToken,
UserType, AuthTokenSchema,
} from '@/src/types/auth/token'; } from '@/src/types/auth/token';
import { CacheService } from '@/src/cache.service'; import { CacheService } from '@/src/cache.service';
import { users } from '@prisma/client';
import { PasswordHandlers } from './login_handler'; import { PasswordHandlers } from './login_handler';
import { Injectable, ForbiddenException } from '@nestjs/common'; import { Injectable, ForbiddenException } from '@nestjs/common';
@ -22,6 +21,7 @@ interface SelectFromRedis {
@Injectable() @Injectable()
export class RedisHandlers { export class RedisHandlers {
AUTH_TOKEN = 'AUTH_TOKEN'; AUTH_TOKEN = 'AUTH_TOKEN';
SELECT_TOKEN = 'SELECT_TOKEN';
constructor( constructor(
private readonly cacheService: CacheService, private readonly cacheService: CacheService,
private readonly passwordService: PasswordHandlers, private readonly passwordService: PasswordHandlers,
@ -32,10 +32,10 @@ export class RedisHandlers {
* Format: AUTH_TOKEN:token:token:UUID or AUTH_TOKEN:token:token:*:* * Format: AUTH_TOKEN:token:token:UUID or AUTH_TOKEN:token:token:*:*
this.AUTH_TOKEN:token:token:UUID:UUID this.AUTH_TOKEN:token:token:UUID:UUID
*/ */
private validateRedisKey(redisKey: string): boolean { private validateRedisKey(redisKey: string, type: string): boolean {
if (!redisKey.startsWith(this.AUTH_TOKEN + ':')) { if (!redisKey.startsWith(type + ':')) {
throw new ForbiddenException( throw new ForbiddenException(
`Invalid Redis key format. Must start with ${this.AUTH_TOKEN}:`, `Invalid Redis key format. Must start with ${type}:`,
); );
} }
const colonCount = (redisKey.match(/:/g) || []).length; const colonCount = (redisKey.match(/:/g) || []).length;
@ -53,7 +53,7 @@ export class RedisHandlers {
throw new ForbiddenException('Access token header is missing'); throw new ForbiddenException('Access token header is missing');
} }
const mergedRedisKey = `${this.AUTH_TOKEN}:${acsToken}:${acsToken}:*:*`; const mergedRedisKey = `${this.AUTH_TOKEN}:${acsToken}:${acsToken}:*:*`;
this.validateRedisKey(mergedRedisKey); this.validateRedisKey(mergedRedisKey, this.AUTH_TOKEN);
return mergedRedisKey; return mergedRedisKey;
} }
@ -66,8 +66,8 @@ export class RedisHandlers {
if (!slcToken) { if (!slcToken) {
throw new ForbiddenException('Select token header is missing'); throw new ForbiddenException('Select token header is missing');
} }
const mergedRedisKey = `${this.AUTH_TOKEN}:${acsToken}:${slcToken}:*:*`; const mergedRedisKey = `${this.SELECT_TOKEN}:${acsToken}:${slcToken}:*:*`;
this.validateRedisKey(mergedRedisKey); this.validateRedisKey(mergedRedisKey, this.SELECT_TOKEN);
return mergedRedisKey; return mergedRedisKey;
} }
@ -79,8 +79,8 @@ export class RedisHandlers {
return this.passwordService.generateAccessToken(); return this.passwordService.generateAccessToken();
} }
private async scanKeys(pattern: string): Promise<string[]> { private async scanKeys(pattern: string, type: string): Promise<string[]> {
this.validateRedisKey(pattern); this.validateRedisKey(pattern, type);
const client = (this.cacheService as any).client; const client = (this.cacheService as any).client;
if (!client) throw new Error('Redis client not available'); if (!client) throw new Error('Redis client not available');
@ -103,7 +103,7 @@ export class RedisHandlers {
async getLoginFromRedis(req: Request): Promise<LoginFromRedis | null> { async getLoginFromRedis(req: Request): Promise<LoginFromRedis | null> {
const mergedKey = this.mergeLoginKey(req); const mergedKey = this.mergeLoginKey(req);
if (mergedKey.includes('*')) { if (mergedKey.includes('*')) {
const keys = await this.scanKeys(mergedKey); const keys = await this.scanKeys(mergedKey, this.AUTH_TOKEN);
if (keys.length === 0) { if (keys.length === 0) {
throw new ForbiddenException('Authorization failed - No matching keys'); throw new ForbiddenException('Authorization failed - No matching keys');
} }
@ -122,13 +122,15 @@ export class RedisHandlers {
} }
const value = await this.cacheService.get(mergedKey); const value = await this.cacheService.get(mergedKey);
return value ? { key: mergedKey, value } : null; return value
? { key: mergedKey, value: AuthTokenSchema.parse(value) }
: null;
} }
async getSelectFromRedis(req: Request): Promise<SelectFromRedis | null> { async getSelectFromRedis(req: Request): Promise<SelectFromRedis | null> {
const mergedKey = this.mergeSelectKey(req); const mergedKey = this.mergeSelectKey(req);
if (mergedKey.includes('*')) { if (mergedKey.includes('*')) {
const keys = await this.scanKeys(mergedKey); const keys = await this.scanKeys(mergedKey, this.SELECT_TOKEN);
if (keys.length === 0) { if (keys.length === 0) {
throw new ForbiddenException( throw new ForbiddenException(
@ -147,7 +149,9 @@ export class RedisHandlers {
} }
const value = await this.cacheService.get(mergedKey); const value = await this.cacheService.get(mergedKey);
return value ? { key: mergedKey, value } : null; return value
? { key: mergedKey, value: TokenDictTypes.parse(value) }
: null;
} }
async deleteLoginFromRedis(req: Request): Promise<any> { async deleteLoginFromRedis(req: Request): Promise<any> {
@ -186,7 +190,7 @@ export class RedisHandlers {
livingUUID: string, livingUUID: string,
): Promise<any> { ): Promise<any> {
const selectToken = this.generateSelectToken(accessToken, userUUID); const selectToken = this.generateSelectToken(accessToken, userUUID);
const redisKey = `${this.AUTH_TOKEN}:${accessToken}:${selectToken}:${userUUID}:${livingUUID}`; const redisKey = `${this.SELECT_TOKEN}:${accessToken}:${selectToken}:${userUUID}:${livingUUID}`;
await this.cacheService.set_with_ttl(redisKey, token, 60 * 30); await this.cacheService.set_with_ttl(redisKey, token, 60 * 30);
return selectToken; return selectToken;
} }

View File

@ -9,17 +9,21 @@
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"clsx": "^2.1.1", "clsx": "^2.1.1",
"ioredis": "^5.6.1",
"lucide-react": "^0.533.0",
"next": "15.4.4", "next": "15.4.4",
"next-intl": "^4.3.4", "next-intl": "^4.3.4",
"react": "19.1.0", "react": "19.1.0",
"react-dom": "19.1.0" "react-dom": "19.1.0",
"zod": "^4.0.10"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/postcss": "^4", "@tailwindcss/postcss": "^4.1.11",
"@types/node": "^20", "@types/node": "^20",
"@types/react": "^19", "@types/react": "^19",
"@types/react-dom": "^19", "@types/react-dom": "^19",
"tailwindcss": "^4", "daisyui": "^5.0.50",
"tailwindcss": "^4.1.11",
"typescript": "^5" "typescript": "^5"
} }
}, },
@ -538,6 +542,12 @@
"url": "https://opencollective.com/libvips" "url": "https://opencollective.com/libvips"
} }
}, },
"node_modules/@ioredis/commands": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.3.0.tgz",
"integrity": "sha512-M/T6Zewn7sDaBQEqIZ8Rb+i9y8qfGmq+5SDFSf9sA2lUZTmdDLVdOiQaeDp+Q4wElZ9HG1GAX5KhDaidp6LQsQ==",
"license": "MIT"
},
"node_modules/@isaacs/fs-minipass": { "node_modules/@isaacs/fs-minipass": {
"version": "4.0.1", "version": "4.0.1",
"resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz",
@ -1090,6 +1100,15 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/cluster-key-slot": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz",
"integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/color": { "node_modules/color": {
"version": "4.2.3", "version": "4.2.3",
"resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz", "resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz",
@ -1142,12 +1161,48 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/daisyui": {
"version": "5.0.50",
"resolved": "https://registry.npmjs.org/daisyui/-/daisyui-5.0.50.tgz",
"integrity": "sha512-c1PweK5RI1C76q58FKvbS4jzgyNJSP6CGTQ+KkZYzADdJoERnOxFoeLfDHmQgxLpjEzlYhFMXCeodQNLCC9bow==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://github.com/saadeghi/daisyui?sponsor=1"
}
},
"node_modules/debug": {
"version": "4.4.1",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz",
"integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==",
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/decimal.js": { "node_modules/decimal.js": {
"version": "10.6.0", "version": "10.6.0",
"resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz",
"integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/denque": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz",
"integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.10"
}
},
"node_modules/detect-libc": { "node_modules/detect-libc": {
"version": "2.0.4", "version": "2.0.4",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.4.tgz", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.4.tgz",
@ -1191,6 +1246,30 @@
"tslib": "^2.8.0" "tslib": "^2.8.0"
} }
}, },
"node_modules/ioredis": {
"version": "5.6.1",
"resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.6.1.tgz",
"integrity": "sha512-UxC0Yv1Y4WRJiGQxQkP0hfdL0/5/6YvdfOOClRgJ0qppSarkhneSa6UvkMkms0AkdGimSH3Ikqm+6mkMmX7vGA==",
"license": "MIT",
"dependencies": {
"@ioredis/commands": "^1.1.1",
"cluster-key-slot": "^1.1.0",
"debug": "^4.3.4",
"denque": "^2.1.0",
"lodash.defaults": "^4.2.0",
"lodash.isarguments": "^3.1.0",
"redis-errors": "^1.2.0",
"redis-parser": "^3.0.0",
"standard-as-callback": "^2.1.0"
},
"engines": {
"node": ">=12.22.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/ioredis"
}
},
"node_modules/is-arrayish": { "node_modules/is-arrayish": {
"version": "0.3.2", "version": "0.3.2",
"resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.2.tgz", "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.2.tgz",
@ -1447,6 +1526,27 @@
"url": "https://opencollective.com/parcel" "url": "https://opencollective.com/parcel"
} }
}, },
"node_modules/lodash.defaults": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz",
"integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==",
"license": "MIT"
},
"node_modules/lodash.isarguments": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz",
"integrity": "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==",
"license": "MIT"
},
"node_modules/lucide-react": {
"version": "0.533.0",
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.533.0.tgz",
"integrity": "sha512-XwRo6CQowPRe1cfBJITmHytjR3XS4AZpV6rrBFEzoghARgyU2RK3yNRSnRkSFFSQJWFdQ8f4Wk1awgLLSy1NCQ==",
"license": "ISC",
"peerDependencies": {
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/magic-string": { "node_modules/magic-string": {
"version": "0.30.17", "version": "0.30.17",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.17.tgz", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.17.tgz",
@ -1496,6 +1596,12 @@
"url": "https://github.com/sponsors/isaacs" "url": "https://github.com/sponsors/isaacs"
} }
}, },
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/nanoid": { "node_modules/nanoid": {
"version": "3.3.11", "version": "3.3.11",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
@ -1686,6 +1792,27 @@
"react": "^19.1.0" "react": "^19.1.0"
} }
}, },
"node_modules/redis-errors": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz",
"integrity": "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==",
"license": "MIT",
"engines": {
"node": ">=4"
}
},
"node_modules/redis-parser": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/redis-parser/-/redis-parser-3.0.0.tgz",
"integrity": "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==",
"license": "MIT",
"dependencies": {
"redis-errors": "^1.0.0"
},
"engines": {
"node": ">=4"
}
},
"node_modules/scheduler": { "node_modules/scheduler": {
"version": "0.26.0", "version": "0.26.0",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.26.0.tgz", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.26.0.tgz",
@ -1767,6 +1894,12 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/standard-as-callback": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz",
"integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==",
"license": "MIT"
},
"node_modules/styled-jsx": { "node_modules/styled-jsx": {
"version": "5.1.6", "version": "5.1.6",
"resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz", "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz",
@ -1875,6 +2008,15 @@
"engines": { "engines": {
"node": ">=18" "node": ">=18"
} }
},
"node_modules/zod": {
"version": "4.0.10",
"resolved": "https://registry.npmjs.org/zod/-/zod-4.0.10.tgz",
"integrity": "sha512-3vB+UU3/VmLL2lvwcY/4RV2i9z/YU0DTV/tDuYjrwmx5WeJ7hwy+rGEEx8glHp6Yxw7ibRbKSaIFBgReRPe5KA==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
} }
} }
} }

View File

@ -10,17 +10,21 @@
}, },
"dependencies": { "dependencies": {
"clsx": "^2.1.1", "clsx": "^2.1.1",
"ioredis": "^5.6.1",
"lucide-react": "^0.533.0",
"next": "15.4.4", "next": "15.4.4",
"next-intl": "^4.3.4", "next-intl": "^4.3.4",
"react": "19.1.0", "react": "19.1.0",
"react-dom": "19.1.0" "react-dom": "19.1.0",
"zod": "^4.0.10"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/postcss": "^4", "@tailwindcss/postcss": "^4.1.11",
"@types/node": "^20", "@types/node": "^20",
"@types/react": "^19", "@types/react": "^19",
"@types/react-dom": "^19", "@types/react-dom": "^19",
"tailwindcss": "^4", "daisyui": "^5.0.50",
"tailwindcss": "^4.1.11",
"typescript": "^5" "typescript": "^5"
} }
} }

View File

@ -0,0 +1,246 @@
'use client';
import Link from 'next/link';
import { useState } from 'react';
import { useTranslations } from 'next-intl';
import { z } from 'zod';
import { Eye, EyeOff, Lock, Mail, User } from "lucide-react";
const loginSchema = z.object({
email: z.string().email('Invalid email'),
password: z.string().min(6, 'Password too short')
});
type LoginFormData = z.infer<typeof loginSchema>;
const getRandomColor = () => {
const colors = ['bg-indigo-500', 'bg-purple-500', 'bg-pink-500', 'bg-blue-500', 'bg-teal-500'];
return colors[Math.floor(Math.random() * colors.length)];
};
export default function LoginPage() {
const t = useTranslations('Login');
const [formData, setFormData] = useState<LoginFormData>({
email: '',
password: ''
});
const [errors, setErrors] = useState<Partial<LoginFormData>>({});
const [showPassword, setShowPassword] = useState(false);
const [isAccordionOpen, setIsAccordionOpen] = useState(false);
const recentUser = {
name: 'Mika Lee',
initial: 'M',
color: getRandomColor()
};
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setFormData({ ...formData, [e.target.name]: e.target.value });
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
try {
loginSchema.parse(formData);
setErrors({});
console.log('Form submitted:', formData);
// Here you would typically call your authentication API
} catch (err) {
if (err instanceof z.ZodError) {
const fieldErrors: Partial<LoginFormData> = {};
Object.entries(err.flatten().fieldErrors).forEach(([key, value]) => {
if (Array.isArray(value) && value.length > 0) {
const fieldKey = key as keyof LoginFormData;
fieldErrors[fieldKey] = value[0];
}
});
setErrors(fieldErrors);
}
}
};
return (
<div className="min-h-screen bg-gradient-to-br from-indigo-50 via-white to-cyan-50 flex items-center justify-center p-2 sm:p-4">
<div className="w-full max-w-7xl mx-auto">
{/* Recent logins accordion */}
{/* <div className="card bg-white/90 backdrop-blur-sm shadow-xl border border-indigo-100 rounded-2xl w-full mb-4">
<div className="card-body p-4 sm:p-6">
<div
className="flex items-center justify-between cursor-pointer"
onClick={() => setIsAccordionOpen(!isAccordionOpen)}
>
<div className="flex items-center gap-2 sm:gap-3">
<div className="avatar placeholder">
<div className="w-8 h-8 sm:w-10 sm:h-10 rounded-xl bg-gradient-to-r from-indigo-500 to-purple-600 text-white flex items-center justify-center">
<User className="w-4 h-4 sm:w-5 sm:h-5" />
</div>
</div>
<div>
<h2 className="text-lg sm:text-xl font-bold text-gray-800">{t('recentLogins')}</h2>
</div>
</div>
<svg
className={`w-5 h-5 text-gray-500 transition-transform duration-300 ${isAccordionOpen ? 'rotate-180' : ''}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</div>
{isAccordionOpen && (
<div className="mt-4 pt-4 border-t border-gray-200">
<p className="text-xs sm:text-sm text-gray-500 mb-4">{t('clickPictureOrAdd')}</p>
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2 sm:gap-3 w-full">
<div className="flex flex-col items-center cursor-pointer group">
<div className="w-full aspect-square mb-1 sm:mb-2 bg-gradient-to-br from-gray-100 to-gray-200 rounded-2xl flex items-center justify-center transition-all duration-300 group-hover:scale-105 group-hover:shadow-lg border-2 border-dashed border-indigo-200 group-hover:border-indigo-400">
<span className="text-xl sm:text-2xl text-indigo-400 group-hover:text-indigo-600 transition-colors">+</span>
</div>
<span className="text-xs sm:text-sm font-medium text-gray-600 group-hover:text-indigo-600 transition-colors">
{t('addAccount')}
</span>
</div>
</div>
</div>
)}
</div>
</div> */}
<div className="flex flex-col lg:flex-row gap-4 sm:gap-6 md:gap-8 items-center justify-center w-full min-h-[60vh] md:min-h-[70vh] lg:min-h-[80vh]">
{/* Left side - Login form (now takes full width) */}
<div className="card bg-white/90 backdrop-blur-sm shadow-xl border border-indigo-100 rounded-2xl w-full overflow-auto">
<div className="card-body p-4 sm:p-6 md:p-8 lg:p-10 xl:p-12">
<form onSubmit={handleSubmit} className="space-y-4 sm:space-y-5 md:space-y-6 max-w-md mx-auto w-full">
<div className="text-center mb-4 sm:mb-6 md:mb-8">
<h1 className="text-xl sm:text-2xl md:text-3xl font-bold text-gray-800 mb-1 sm:mb-2">{t('welcomeBack')}</h1>
<p className="text-sm sm:text-base text-gray-600">{t('continueJourney')}</p>
</div>
<div className="form-control w-full">
<label className="label p-0 mb-1 sm:mb-1.5 md:mb-2">
<span className="label-text font-medium text-gray-700 text-sm sm:text-base">{t('email')}</span>
</label>
<div className="relative">
<input
type="email"
name="email"
value={formData.email}
onChange={handleChange}
className={`input input-bordered rounded-2xl text-black h-14 bg-white w-full pl-8 sm:pl-10 md:pl-12 py-3 sm:py-4 transition-all
duration-300 border-gray-200 focus:border-indigo-500 focus:ring-2 focus:ring-indigo-200 ${errors.email ? 'border-red-500 focus:border-red-500 focus:ring-red-200' : ''}`}
placeholder={t('email')}
style={{
WebkitBackgroundClip: 'text',
WebkitTextFillColor: 'black',
}}
/>
<Mail className="absolute left-2 sm:left-3 md:left-4 top-1/2 transform -translate-y-1/2 w-3 h-3 sm:w-4 sm:h-4 md:w-5 md:h-5 text-gray-400 pointer-events-none z-10" />
</div>
{errors.email && (
<div className="label p-0 pt-1">
<span className="label-text-alt text-red-500 flex items-center gap-1 text-xs sm:text-sm">
<svg className="w-2.5 h-2.5 sm:w-3 sm:h-3 md:w-4 md:h-4" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7 4a1 1 0 11-2 0 1 1 0 012 0zm-1-9a1 1 0 00-1 1v4a1 1 0 102 0V6a1 1 0 00-1-1z" clipRule="evenodd" />
</svg>
{errors.email}
</span>
</div>
)}
</div>
<div className="form-control w-full">
<label className="label p-0 mb-1 sm:mb-1.5 md:mb-2">
<span className="label-text font-medium text-gray-700 text-sm sm:text-base">{t('password')}</span>
</label>
<div className="relative">
<input
type={showPassword ? 'text' : 'password'}
name="password"
value={formData.password}
onChange={handleChange}
className={`input input-bordered rounded-2xl text-black h-14 bg-white w-full pl-8 sm:pl-10 md:pl-12 pr-8 sm:pr-10 md:pr-12 py-3 sm:py-4 transition-all duration-300 border-gray-200 focus:border-indigo-500 focus:ring-2 focus:ring-indigo-200 ${errors.password ? 'border-red-500 focus:border-red-500 focus:ring-red-200' : ''}`}
placeholder="••••••••"
style={{
WebkitBackgroundClip: 'text',
WebkitTextFillColor: 'black',
}}
/>
<Lock className="absolute left-2 sm:left-3 md:left-4 top-1/2 transform -translate-y-1/2 w-3 h-3 sm:w-4 sm:h-4 md:w-5 md:h-5 text-gray-400 pointer-events-none z-10" />
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-2 sm:right-3 md:right-4 top-1/2 transform -translate-y-1/2 text-gray-400 hover:text-gray-600 transition-colors z-10"
aria-label={showPassword ? "Hide password" : "Show password"}
>
{showPassword ? <EyeOff size={14} className="sm:w-4 sm:h-4 md:w-5 md:h-5" /> : <Eye size={14} className="sm:w-4 sm:h-4 md:w-5 md:h-5" />}
</button>
</div>
{errors.password && (
<div className="label p-0 pt-1">
<span className="label-text-alt text-red-500 flex items-center gap-1 text-xs sm:text-sm">
<svg className="w-2.5 h-2.5 sm:w-3 sm:h-3 md:w-4 md:h-4" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7 4a1 1 0 11-2 0 1 1 0 012 0zm-1-9a1 1 0 00-1 1v4a1 1 0 102 0V6a1 1 0 00-1-1z" clipRule="evenodd" />
</svg>
{errors.password}
</span>
</div>
)}
</div>
<div className="flex items-center justify-between flex-wrap gap-2">
<label className="label cursor-pointer flex items-center gap-1.5 sm:gap-2 p-0">
<input type="checkbox" className="checkbox checkbox-primary checkbox-sm [--chkbg:theme(colors.indigo.500)] [--chkfg:theme(colors.white)] border-indigo-300" />
<span className="label-text text-gray-700 text-xs sm:text-sm">{t('rememberMe')}</span>
</label>
<Link href="/forgot-password" className="text-indigo-600 hover:text-indigo-800 text-xs sm:text-sm font-medium transition-colors">
{t('forgotPassword')}
</Link>
</div>
<button
type="submit"
className="btn bg-indigo-600 hover:bg-indigo-700 border-0 w-full mt-2 sm:mt-3 md:mt-4 py-2 sm:py-2.5 md:py-3 shadow-md hover:shadow-lg transition-all duration-300 text-white font-medium text-sm sm:text-base"
>
{t('login')}
</button>
<div className="divider my-3 sm:my-4 md:my-6 text-gray-400 before:bg-gray-200 after:bg-gray-200 text-xs sm:text-sm">{t('orContinueWith')}</div>
<div className="grid grid-cols gap-3 sm:gap-3 md:gap-6">
<button
type="button"
className="btn btn-outline border-gray-300 text-gray-700 hover:bg-gray-50 py-2 sm:py-2.5 md:py-3 flex items-center justify-center gap-1.5 sm:gap-2 text-xs sm:text-sm"
>
<svg className="w-3 h-3 sm:w-4 sm:h-4 md:w-5 md:h-5" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z" fill="#4285F4" />
<path d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z" fill="#34A853" />
<path d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z" fill="#FBBC05" />
<path d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z" fill="#EA4335" />
</svg>
Google
</button>
<button
type="button"
className="btn btn-outline border-gray-300 text-gray-700 hover:bg-gray-50 py-2 sm:py-2.5 md:py-3 flex items-center justify-center gap-1.5 sm:gap-2 text-xs sm:text-sm"
>
<svg className="w-3 h-3 sm:w-4 sm:h-4 md:w-5 md:h-5" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path d="M24 12.073c0-5.8-4.702-10.5-10.5-10.5s-10.5 4.7-10.5 10.5c0 5.24 3.84 9.584 8.86 10.373v-7.337h-2.666v-3.037h2.666V9.458c0-2.63 1.568-4.085 3.966-4.085 1.15 0 2.35.205 2.35.205v2.584h-1.322c-1.304 0-1.71.81-1.71 1.64v1.97h2.912l-.465 3.036H15.14v7.337c5.02-.788 8.859-5.131 8.859-10.372z" fill="#1877F2" />
</svg>
Facebook
</button>
</div>
</form>
</div>
</div>
</div>
<div className="text-center mt-4 sm:mt-6 md:mt-8 text-gray-500 text-xs sm:text-sm">
<p>{t('copyright')}</p>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,77 @@
"use client";
export default function FromFigma() {
return (
<div className="min-h-screen bg-[#f6f6f6] flex flex-col">
<div className="flex-1 flex justify-center items-center">
<div className="w-full max-w-6xl bg-white rounded-xl shadow-none flex flex-col md:flex-row gap-12 p-12 mx-4">
{/* Left: Recent logins */}
<div className="flex-1 flex flex-col items-start justify-center">
<div className="w-14 h-14 rounded-full bg-gray-300 mb-8" />
<h2 className="text-3xl font-semibold text-gray-800 mb-2">Recent logins</h2>
<div className="text-gray-500 text-sm mb-7">Click your picture or add an account</div>
<div className="flex gap-5">
{/* User card */}
<div className="flex flex-col items-center bg-white rounded-lg border border-gray-200 w-40 shadow-sm">
<div className="relative w-full aspect-square rounded-t-lg overflow-hidden">
<img src="https://images.unsplash.com/photo-1494790108377-be9c29b29330?ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D&auto=format&fit=crop&w=256&q=80" alt="Mika Lee" className="w-full h-full object-cover" />
<button className="absolute top-2 left-2 w-4 h-4 bg-white rounded-full flex items-center justify-center text-xs text-gray-400">×</button>
</div>
<div className="w-full text-center text-sm text-gray-700 py-2 border-t border-gray-200">Mika Lee</div>
</div>
{/* Add account card */}
<div className="flex flex-col items-center bg-white rounded-lg border border-gray-200 w-40 shadow-sm cursor-pointer">
<div className="flex flex-col justify-center items-center w-full aspect-square rounded-t-lg">
<span className="text-3xl text-gray-400"></span>
</div>
<div className="w-full text-center text-sm text-gray-500 py-2 border-t border-gray-200">Add an account</div>
</div>
</div>
</div>
{/* Right: Login form */}
<div className="flex-1 flex flex-col justify-center">
<div className="bg-white rounded-xl border border-gray-200 px-8 py-8 shadow-none">
<form className="space-y-6">
<div>
<label className="block text-sm text-gray-500 mb-1">Your email</label>
<input type="email" className="w-full border border-gray-300 rounded-md px-4 py-3 text-gray-700 bg-white focus:outline-none focus:ring-2 focus:ring-gray-200" />
</div>
<div>
<div className="flex items-center justify-between mb-1">
<label className="block text-sm text-gray-500">Your password</label>
<div className="flex items-center gap-1 text-gray-400 text-sm cursor-pointer select-none">
<svg className="w-4 h-4" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" d="M13.875 18.825A10.05 10.05 0 0 1 12 19c-5.523 0-10-4.477-10-10 0-1.657.403-3.22 1.125-4.575m1.35-2.025A9.959 9.959 0 0 1 12 3c5.523 0 10 4.477 10 10 0 1.657-.403 3.22-1.125 4.575" /></svg>
Hide
</div>
</div>
<input type="password" className="w-full border border-gray-300 rounded-md px-4 py-3 text-gray-700 bg-white focus:outline-none focus:ring-2 focus:ring-gray-200" />
</div>
<button type="button" className="w-full bg-gray-300 text-gray-500 rounded-full py-3 text-lg font-medium cursor-not-allowed" disabled>Log in</button>
<div className="text-center mt-2">
<a href="#" className="text-gray-600 underline text-sm">Forget your password?</a>
</div>
</form>
</div>
<button className="w-full border border-gray-400 rounded-full py-3 mt-8 text-lg font-medium text-gray-700 bg-white hover:bg-gray-50 transition">Create an account</button>
</div>
</div>
</div>
{/* Footer */}
<footer className="w-full border-t border-gray-200 bg-white py-4 px-8 flex flex-wrap items-center justify-between text-xs text-gray-500 gap-2">
<div className="flex flex-wrap gap-6">
<a href="#" className="hover:underline">Sign up</a>
<a href="#" className="hover:underline">Log in</a>
<a href="#" className="hover:underline">Help Center</a>
<a href="#" className="hover:underline">Terms of Service</a>
<a href="#" className="hover:underline">Privacy Policy</a>
<a href="#" className="hover:underline">About</a>
<a href="#" className="hover:underline">Settings</a>
</div>
<div className="flex items-center gap-2">
English (united States)
<svg className="w-4 h-4 ml-1" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M19 9l-7 7-7-7" /></svg>
</div>
</footer>
</div>
);
}

View File

@ -0,0 +1,21 @@
'use server';
import LocaleSwitcherServer from '@/components/LocaleSwitcherServer';
import LoginPage from './LoginPage';
import FromFigma from './fromFigma';
type Props = {
params: Promise<{ locale: string }>;
};
export default async function PageLogin({ params }: Props) {
const { locale } = await params;
return (
<div>
<div className='absolute top-2 right-2'>
<LocaleSwitcherServer locale={locale} pathname="/login" />
</div>
<LoginPage />
{/* <FromFigma /> */}
</div>
);
}

View File

@ -1,5 +1,33 @@
import { ReactNode } from 'react'; import { ReactNode } from 'react';
import { headers } from 'next/headers';
import { removeLocaleFromPath } from '@/lib/helpers';
export default function ProtectedLayout({ children }: { children: ReactNode }) { function removeSubStringFromPath(headersList: Headers) {
const host = headersList.get('host') || '';
const referer = headersList.get('referer') || '';
let currentRoute = '';
if (referer) {
const hostPart = `http://${host}`;
if (referer.startsWith(hostPart)) {
currentRoute = referer.substring(hostPart.length);
} else {
const secureHostPart = `https://${host}`;
if (referer.startsWith(secureHostPart)) {
currentRoute = referer.substring(secureHostPart.length);
}
}
}
return removeLocaleFromPath(currentRoute);
}
export default async function ProtectedLayout({
children,
}: {
children: ReactNode,
}) {
// Server component approach to get URL
const headersList = await headers();
const removedLocaleRoute = removeSubStringFromPath(headersList);
console.log('Removed locale route:', removedLocaleRoute);
return <>{children}</>; return <>{children}</>;
} }

View File

@ -0,0 +1,3 @@
export default function TrialPage() {
return <div>TrialPage</div>;
}

View File

@ -0,0 +1,279 @@
'use client';
import { z } from 'zod';
import { usePathname } from "next/navigation";
import { castTextToTypeGiven, removeLocaleFromPath } from "@/lib/helpers";
import { useState } from 'react';
import { buildCacheKey } from "@/lib/helpers";
import { apiPostFetcher } from '@/lib/fetcher';
const createFormSchema = z.object({
inputSelectName: z.string().min(1, "Name is required"),
inputSelectNumber: z.string().refine((val: string) => !isNaN(Number(val)), {
message: "Number must be a valid number",
}),
});
const selectFormSchema = z.object({
inputSelectName: z.string().min(1, "Name is required"),
inputSelectNumber: z.string().refine((val: string) => !isNaN(Number(val)), {
message: "Number must be a valid number",
}),
});
const searchFormSchema = z.object({
inputSearchName: z.string().min(1, "Name is required"),
inputSearchNumber: z.string().refine((val: string) => !isNaN(Number(val)), {
message: "Number must be a valid number",
}),
});
type SelectFormData = z.infer<typeof selectFormSchema>;
type CreateFormData = z.infer<typeof createFormSchema>;
type SearchFormData = z.infer<typeof searchFormSchema>;
export default function TrialPage() {
const pathname = usePathname();
const cleanPathname = removeLocaleFromPath(pathname);
const cacheKeyCreateForm = buildCacheKey({ url: cleanPathname, form: 'trialCreateForm', field: 'trialCreateField' });
const cacheKeySelectForm = buildCacheKey({ url: cleanPathname, form: 'trialSelectForm', field: 'trialSelectField' });
const cacheKeySearchForm = buildCacheKey({ url: cleanPathname, form: 'trialSearchForm', field: 'trialSearchField' });
const [createFormErrors, setCreateFormErrors] = useState<Record<string, string>>({});
const [selectFormErrors, setSelectFormErrors] = useState<Record<string, string>>({});
const [searchFormErrors, setSearchFormErrors] = useState<Record<string, string>>({});
const submitCreateForm = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const form = e.currentTarget;
const formData = new FormData(form);
const formDataObj: Record<string, string> = {};
formData.forEach((value, key) => {
formDataObj[key] = value.toString();
});
const result: z.ZodSafeParseResult<CreateFormData> = createFormSchema.safeParse(formDataObj);
if (result.success) {
console.log('Form validation succeeded:', result.data);
setCreateFormErrors({});
try {
await apiPostFetcher({
url: 'http://localhost:3000/api/cache/delete',
isNoCache: true,
body: { key: cacheKeyCreateForm }
});
console.log('Form data saved to Redis');
} catch (error) {
console.error('Error saving form data:', error);
setCreateFormErrors({
error: "Error saving form data"
});
}
} else {
const errors: Record<string, string> = {};
result.error.issues.forEach((error: any) => {
const path = error.path.join('.');
errors[path] = error.message;
});
console.log('Form validation failed:', errors);
setCreateFormErrors(errors);
}
};
const submitSelectForm = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const form = e.currentTarget;
const formName = form.getAttribute('name');
console.log('Form name:', formName);
const formData = new FormData(form);
const formDataObj: Record<string, string> = {};
formData.forEach((value, key) => {
formDataObj[key] = value.toString();
});
const result: z.ZodSafeParseResult<SelectFormData> = selectFormSchema.safeParse(formDataObj);
if (result.success) {
console.log('Form validation succeeded:', result.data);
setSelectFormErrors({});
try {
await apiPostFetcher({
url: 'http://localhost:3000/api/cache/delete',
isNoCache: true,
body: { key: cacheKeySelectForm }
});
console.log('Form data saved to Redis');
} catch (error) {
console.error('Error saving form data:', error);
}
} else {
const errors: Record<string, string> = {};
result.error.issues.forEach((error: any) => {
const path = error.path.join('.');
errors[path] = error.message;
});
console.log('Form validation failed:', errors);
setSelectFormErrors(errors);
}
};
const submitSearchForm = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const form = e.currentTarget;
const formName = form.getAttribute('name');
console.log('Form name:', formName);
const formData = new FormData(form);
const formDataObj: Record<string, string> = {};
formData.forEach((value, key) => { formDataObj[key] = value.toString() });
const result: z.ZodSafeParseResult<SearchFormData> = searchFormSchema.safeParse(formDataObj);
if (result.success) {
console.log('Form validation succeeded:', result.data);
setSearchFormErrors({});
try {
await apiPostFetcher({
url: 'http://localhost:3000/api/cache/delete',
isNoCache: true,
body: { key: cacheKeySearchForm }
});
console.log('Form data saved to Redis');
} catch (error) {
console.error('Error saving form data:', error);
}
} else {
const errors: Record<string, string> = {};
result.error.issues.forEach((error: any) => {
const path = error.path.join('.');
errors[path] = error.message;
});
console.log('Form validation failed:', errors);
setSearchFormErrors(errors);
}
};
const handleOnBlurSelectForm = (e: React.FocusEvent<HTMLInputElement>) => {
const name = e.target.getAttribute('name');
const fieldType = e.target.getAttribute('type')?.toString()
const value = e.target.value;
if (!value) {
return;
}
const castedValue = castTextToTypeGiven(value, fieldType as string);
apiPostFetcher(
{
url: 'http://localhost:3000/api/cache/renew',
isNoCache: true,
body: { key: cacheKeySelectForm, value: castedValue, field: name }
}
).then((res) => {
console.log('Select form onBlur Response', res);
}).catch((err) => {
console.log('Select form onBlur Error', err);
});
};
const handleOnBlurSearchForm = (e: React.FocusEvent<HTMLInputElement>) => {
const name = e.target.getAttribute('name');
const fieldType = e.target.getAttribute('type')?.toString()
const value = e.target.value;
if (!value) {
return;
}
const castedValue = castTextToTypeGiven(value, fieldType as string);
apiPostFetcher(
{
url: 'http://localhost:3000/api/cache/renew',
isNoCache: true,
body: { key: cacheKeySearchForm, value: castedValue, field: name }
}
).then((res) => {
console.log('Search form onBlur Response', res);
}).catch((err) => {
console.log('Search form onBlur Error', err);
});
};
const handleOnBlurCreateForm = (e: React.FocusEvent<HTMLInputElement>) => {
const name = e.target.getAttribute('name');
const fieldType = e.target.getAttribute('type')?.toString()
const value = e.target.value;
if (!value) {
return;
}
const castedValue = castTextToTypeGiven(value, fieldType as string);
apiPostFetcher(
{
url: 'http://localhost:3000/api/cache/renew',
isNoCache: true,
body: { key: cacheKeyCreateForm, value: castedValue, field: name }
}
).then((res) => {
console.log('Create form onBlur Response', res);
}).catch((err) => {
console.log('Create form onBlur Error', err);
});
};
return <div className="flex flex-col gap-4">
<div className="flex flex-row gap-4">
<h1 className="text-2xl font-bold">Form Keys</h1>
</div>
<div className="flex justify-center gap-4">
<div className="flex flex-col align-center">
<div className="text-lg my-2">CreateForm: {cacheKeyCreateForm}</div>
<div className="text-lg my-2">SelectForm: {cacheKeySelectForm}</div>
<div className="text-lg my-2">SearchForm: {cacheKeySearchForm}</div>
</div>
</div>
{/* Create a simple Form */}
<div className="flex justify-center gap-4">
<form className="flex flex-col w-1/2 gap-2 p-4 border border-gray-300 rounded" name={cacheKeyCreateForm} onSubmit={submitCreateForm}>
<label className="input w-full my-1">
<p className="text-lg w-24">Name</p>
<input className="w-full h-24" onBlur={handleOnBlurCreateForm} name="inputSelectName" type="text" placeholder="Name Input here" />
<span className="badge badge-neutral badge-xs">Optional</span>
</label>
{createFormErrors.inputSelectName && <p className="text-red-500 text-sm">{createFormErrors.inputSelectName}</p>}
<label className="input w-full my-1 mb-2">
<p className="text-lg w-24">Number</p>
<input className="w-full h-24" onBlur={handleOnBlurCreateForm} name="inputSelectNumber" type="decimal" placeholder="Number Input here" />
<span className="badge badge-neutral badge-xs">Optional</span>
</label>
{createFormErrors.inputSelectNumber && <p className="text-red-500 text-sm">{createFormErrors.inputSelectNumber}</p>}
<button className="bg-blue-500 hover:bg-blue-600 text-white py-2 px-4 rounded" type="submit">Submit</button>
</form>
{/* Select a simple Form */}
<form className="flex flex-col gap-2 p-4 border border-gray-300 rounded w-1/2" name={cacheKeySelectForm} onSubmit={submitSelectForm}>
<label className="input w-full my-1 mb-2">
<p className="text-lg w-24">Name</p>
<input className="w-full h-24" onBlur={handleOnBlurSelectForm} name="inputSelectName" type="text" placeholder="Name Input here" />
<span className="badge badge-neutral badge-xs">Optional</span>
</label>
{selectFormErrors.inputSelectName && <p className="text-red-500 text-sm">{selectFormErrors.inputSelectName}</p>}
<label className="input w-full my-1 mb-2">
<p className="text-lg w-24">Number</p>
<input className="w-full h-24" onBlur={handleOnBlurSelectForm} name="inputSelectNumber" type="decimal" placeholder="Number Input here" />
<span className="badge badge-neutral badge-xs">Optional</span>
</label>
{selectFormErrors.inputSelectNumber && <p className="text-red-500 text-sm">{selectFormErrors.inputSelectNumber}</p>}
<button className="bg-blue-500 hover:bg-blue-600 text-white py-2 px-4 rounded" type="submit">Submit</button>
</form>
{/* Search a simple Form */}
<form className="flex flex-col gap-2 p-4 border border-gray-300 rounded w-1/2" name={cacheKeySearchForm} onSubmit={submitSearchForm}>
<label className="input w-full my-1 mb-2">
<p className="text-lg w-24">Name</p>
<input className="w-full h-24" onBlur={handleOnBlurSearchForm} name="inputSearchName" type="text" placeholder="Name Input here" />
<span className="badge badge-neutral badge-xs">Optional</span>
</label>
{searchFormErrors.inputSearchName && <p className="text-red-500 text-sm">{searchFormErrors.inputSearchName}</p>}
<label className="input w-full my-1 mb-2">
<p className="text-lg w-24">Number</p>
<input className="w-full h-24" onBlur={handleOnBlurSearchForm} name="inputSearchNumber" type="decimal" placeholder="Number Input here" />
<span className="badge badge-neutral badge-xs">Optional</span>
</label>
{searchFormErrors.inputSearchNumber && <p className="text-red-500 text-sm">{searchFormErrors.inputSearchNumber}</p>}
<button className="bg-blue-500 hover:bg-blue-600 text-white py-2 px-4 rounded" type="submit">Submit</button>
</form>
</div>
</div>;
}

View File

@ -1,19 +1,14 @@
// Server Component import LocaleSwitcherServer from '@/components/LocaleSwitcherServer';
import { getTranslations } from 'next-intl/server'; import { getTranslations } from 'next-intl/server';
import { Link } from '@/i18n/navigation'; import { Link } from '@/i18n/navigation';
import { Locale } from '@/i18n/locales'; import { Locale } from '@/i18n/locales';
import LocaleSwitcherServer from '@/components/LocaleSwitcherServer';
// Define the props type to get the locale parameter
type Props = { type Props = {
params: Promise<{ locale: string }>; params: Promise<{ locale: string }>;
}; };
export default async function HomePage({ params }: Props) { export default async function HomePage({ params }: Props) {
// Get the locale from params
const { locale } = await params; const { locale } = await params;
// Get translations with the correct locale
const t = await getTranslations({ locale: locale as Locale, namespace: 'Index' }); const t = await getTranslations({ locale: locale as Locale, namespace: 'Index' });
return ( return (

View File

@ -0,0 +1,8 @@
import { NextRequest, NextResponse } from "next/server";
import { deleteKey } from "@/libss/redisService";
export async function POST(req: NextRequest) {
const { key } = await req.json();
await deleteKey({ key });
return NextResponse.json({ status: "ok", message: `Deleted "${key}"` });
}

View File

@ -0,0 +1,8 @@
import { NextRequest, NextResponse } from "next/server";
import { exists } from "@/libss/redisService";
export async function POST(req: NextRequest) {
const { key } = await req.json();
const result = await exists({ key });
return NextResponse.json({ status: "ok", exists: result });
}

View File

@ -0,0 +1,11 @@
import { NextRequest, NextResponse } from "next/server";
import { getJSON } from "@/libss/redisService";
export async function POST(req: NextRequest) {
const { key } = await req.json();
const result = await getJSON({ key });
return result
? NextResponse.json({ status: "ok", data: result })
: NextResponse.json({ status: "not_found" }, { status: 404 });
}

View File

@ -0,0 +1,12 @@
import { NextRequest, NextResponse } from "next/server";
import { updateField } from "@/libss/redisService";
export async function POST(req: NextRequest) {
try {
const { key, field, value } = await req.json();
await updateField({ key, field, value });
return NextResponse.json({ status: "ok", message: `Updated "${key}"` });
} catch (err: any) {
return NextResponse.json({ error: err.message }, { status: 500 });
}
}

View File

@ -0,0 +1,45 @@
import { NextRequest, NextResponse } from "next/server";
import {
setJSON,
getJSON,
updateJSON,
deleteKey,
exists,
} from "@/libss/redisService";
export async function POST(req: NextRequest) {
try {
const { action, key, value, ttlSeconds } = await req.json();
switch (action) {
case "set":
await setJSON({ key, value, ttlSeconds });
return NextResponse.json({ status: "ok", message: `Set ${key}` });
case "get": {
const result = await getJSON({ key });
return result
? NextResponse.json({ status: "ok", data: result })
: NextResponse.json({ status: "not_found" }, { status: 404 });
}
case "update":
await updateJSON({ key, value });
return NextResponse.json({ status: "ok", message: `Updated ${key}` });
case "delete":
await deleteKey({ key });
return NextResponse.json({ status: "ok", message: `Deleted ${key}` });
case "exists":
const doesExist = await exists({ key });
return NextResponse.json({ status: "ok", exists: doesExist });
default:
return NextResponse.json({ error: "Invalid action" }, { status: 400 });
}
} catch (e: any) {
console.error("[redis-api] error:", e.message);
return NextResponse.json({ error: e.message }, { status: 500 });
}
}

View File

@ -0,0 +1,8 @@
import { NextRequest, NextResponse } from "next/server";
import { setJSON } from "@/libss/redisService";
export async function POST(req: NextRequest) {
const { key, value, ttlSeconds } = await req.json();
await setJSON({ key, value, ttlSeconds });
return NextResponse.json({ status: "ok", message: `Set "${key}"` });
}

View File

@ -0,0 +1,12 @@
import { NextRequest, NextResponse } from "next/server";
import { updateJSON } from "@/libss/redisService";
export async function POST(req: NextRequest) {
try {
const { key, value } = await req.json();
await updateJSON({ key, value });
return NextResponse.json({ status: "ok", message: `Updated "${key}"` });
} catch (err: any) {
return NextResponse.json({ error: err.message }, { status: 500 });
}
}

View File

@ -1,4 +1,10 @@
@import "tailwindcss"; @import "tailwindcss";
@import "tailwindcss";
@plugin "daisyui";
/* @plugin "daisyui" {
themes: all;
} */
:root { :root {
--background: #ffffff; --background: #ffffff;

View File

@ -17,5 +17,30 @@
}, },
"LocaleLayout": { "LocaleLayout": {
"title": "My Application" "title": "My Application"
},
"Login": {
"title": "Login",
"description": "Login to your account",
"email": "Your email",
"password": "Your password",
"login": "Log in",
"register": "Register",
"createAccount": "Create an account",
"forgotPassword": "Forgot your password?",
"recentLogins": "Recent logins",
"clickPictureOrAdd": "Click your picture or add an account",
"addAccount": "Add an account",
"show": "Show",
"hide": "Hide",
"signInWithGoogle": "Sign in with Google",
"signInWithFacebook": "Sign in with Facebook",
"enterCredentials": "Enter your credentials to continue",
"rememberMe": "Remember me",
"orContinueWith": "or continue with",
"noAccount": "Don't have an account?",
"signUp": "Sign up",
"copyright": "© 2023 Your Company. All rights reserved.",
"welcomeBack": "Welcome Back",
"continueJourney": "Sign in to continue your journey with us"
} }
} }

View File

@ -17,5 +17,30 @@
}, },
"LocaleLayout": { "LocaleLayout": {
"title": "Uygulamam" "title": "Uygulamam"
},
"Login": {
"title": "Giriş Yap",
"description": "Hesabınıza giriş yap",
"email": "E-posta adresiniz",
"password": "Parolanız",
"login": "Giriş yap",
"register": "Kayıt Ol",
"createAccount": "Hesap oluştur",
"forgotPassword": "Parolanızı mı unuttunuz?",
"recentLogins": "Son girişler",
"clickPictureOrAdd": "Resminize tıklayın veya hesap ekleyin",
"addAccount": "Hesap ekle",
"show": "Göster",
"hide": "Gizle",
"signInWithGoogle": "Google ile giriş yap",
"signInWithFacebook": "Facebook ile giriş yap",
"enterCredentials": "Devam etmek için bilgilerinizi girin",
"rememberMe": "Beni hatırla",
"orContinueWith": "veya şununla devam et",
"noAccount": "Hesabınız yok mu?",
"signUp": "Kayıt ol",
"copyright": "© 2023 Şirketiniz. Tüm hakları saklıdır.",
"welcomeBack": "Tekrar Hoşgeldiniz",
"continueJourney": "Yolculuğunuza devam etmek için giriş yapın"
} }
} }

View File

@ -0,0 +1,140 @@
interface FetcherRequest {
url: string;
isNoCache: boolean;
}
interface PostFetcherRequest<T> extends FetcherRequest {
body: Record<string, T>;
}
interface GetFetcherRequest extends FetcherRequest {
url: string;
}
interface DeleteFetcherRequest extends GetFetcherRequest { }
interface PutFetcherRequest<T> extends PostFetcherRequest<T> { }
interface PatchFetcherRequest<T> extends PostFetcherRequest<T> { }
interface FetcherRespose {
success: boolean;
}
interface PaginationResponse {
onPage: number;
onPageCount: number;
totalPage: number;
totalCount: number;
next: boolean;
back: boolean;
}
interface FetcherDataResponse<T> extends FetcherRespose {
data: Record<string, T> | null;
pagination?: PaginationResponse;
}
async function apiPostFetcher<T>({
url,
isNoCache,
body,
}: PostFetcherRequest<T>): Promise<FetcherDataResponse<T>> {
try {
const response = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json", "no-cache": isNoCache ? "true" : "false" },
body: JSON.stringify(body),
})
if (response.status === 200) {
const result = await response.json();
return { success: true, data: result.data, pagination: result.pagination }
}
} catch (error) { }
return { success: false, data: null }
}
async function apiGetFetcher<T>({
url,
isNoCache,
}: GetFetcherRequest): Promise<FetcherDataResponse<T>> {
try {
const response = await fetch(url, {
method: "GET",
headers: { "Content-Type": "application/json", "no-cache": isNoCache ? "true" : "false" },
})
console.log("apiGetFetcher status", await response.text());
if (response.status === 200) {
const result = await response.json();
return { success: true, data: result.data }
}
} catch (error) { }
return { success: false, data: null }
}
async function apiDeleteFetcher<T>({
url,
isNoCache,
}: DeleteFetcherRequest): Promise<FetcherDataResponse<T>> {
try {
const response = await fetch(url, {
method: "DELETE",
headers: { "Content-Type": "application/json", "no-cache": isNoCache ? "true" : "false" },
})
if (response.status === 200) {
const result = await response.json();
return {
success: true,
data: result.data,
}
}
} catch (error) { }
return { success: false, data: null }
}
async function apiPutFetcher<T>({
url,
isNoCache,
body,
}: PutFetcherRequest<T>): Promise<FetcherDataResponse<T>> {
try {
const response = await fetch(url, {
method: "PUT",
headers: { "Content-Type": "application/json", "no-cache": isNoCache ? "true" : "false" },
body: JSON.stringify(body),
})
if (response.status === 200) {
const result = await response.json();
return {
success: true,
data: result.data,
}
}
} catch (error) { }
return { success: false, data: null }
}
async function apiPatchFetcher<T>({
url,
isNoCache,
body,
}: PatchFetcherRequest<T>): Promise<FetcherDataResponse<T>> {
try {
const response = await fetch(url, {
method: "PATCH",
headers: { "Content-Type": "application/json", "no-cache": isNoCache ? "true" : "false" },
body: JSON.stringify(body),
})
if (response.status === 200) {
const result = await response.json();
return {
success: true,
data: result.data,
}
}
} catch (error) { }
return { success: false, data: null }
}
export { apiPostFetcher, apiGetFetcher, apiDeleteFetcher, apiPutFetcher, apiPatchFetcher };

View File

@ -0,0 +1,30 @@
import crypto from 'crypto';
export const buildCacheKey = ({ url, form, field }: { url: string, form: string, field: string }) => {
const raw = `${url}::${form}::${field}`;
const hash = crypto.createHash('sha1').update(raw).digest('hex');
return `${hash}`;
};
export function removeLocaleFromPath(path: string) {
return '/' + path.split('/').slice(2).join('/');
}
export function castTextToTypeGiven(value: string, type: string) {
try {
switch (type) {
case 'decimal':
return parseFloat(value);
case 'number':
return Number(value);
case 'boolean':
return Boolean(value);
default:
return value;
}
} catch (error) {
console.log(error);
return value;
}
}

View File

@ -0,0 +1,17 @@
import Redis from "ioredis";
const redis = new Redis({
host: process.env.REDIS_HOST,
port: parseInt(process.env.REDIS_PORT || "6379", 10),
password: process.env.REDIS_PASSWORD || "",
db: parseInt(process.env.REDIS_DB || "0", 10),
connectTimeout: 5000,
maxRetriesPerRequest: 2,
retryStrategy: (times) => Math.min(times * 50, 2000),
reconnectOnError: (err) => err.message.includes("READONLY"),
});
redis.on("connect", () => console.log("[redis] Connected"));
redis.on("error", (err) => console.error("[redis] Error:", err));
export default redis;

View File

@ -0,0 +1,102 @@
import redis from "./redis";
interface SScanParams {
rKey: string;
}
interface DScanParams {
rKey: string;
sKey: string;
}
interface SetParams<T> {
key: string;
value: T;
ttlSeconds?: number;
}
interface GetParams {
key: string;
}
interface UpdateParams<T> {
key: string;
value: T;
}
interface UpdateFieldParams<T> {
key: string;
field: string;
value: T;
}
type RScan = Promise<string | null>;
type RExists = Promise<boolean>;
type RUpdate = Promise<void>;
type RDelete = Promise<void>;
type RSet = Promise<void>;
type RJSON<T> = Promise<T | null>;
export async function setJSON<T>(params: SetParams<T>): RSet {
const val = JSON.stringify(params.value);
if (params.ttlSeconds) {
await redis.set(params.key, val, "EX", params.ttlSeconds);
} else {
await redis.set(params.key, val);
}
}
export async function getJSON<T>(params: GetParams): RJSON<T> {
const val = await redis.get(params.key);
if (!val) return null;
try {
return JSON.parse(val);
} catch {
return null;
}
}
export async function exists(params: GetParams): RExists {
const result = await redis.exists(params.key);
return result === 1;
}
export async function updateJSON<T>(params: UpdateParams<T>): RUpdate {
const keyExists = await redis.exists(params.key);
if (!keyExists) throw new Error("Key not found");
await redis.set(params.key, JSON.stringify(params.value));
}
export async function updateField<T>(params: UpdateFieldParams<T>): RExists {
const isExists = await exists({ key: params.key });
if (!isExists) {
await setJSON({
key: params.key,
value: { [params.field as string]: params.value },
});
return true;
}
const valueFromRedis = await getJSON({ key: params.key });
if (!valueFromRedis) throw new Error("Key not found");
await updateJSON({
key: params.key,
value: { ...valueFromRedis, [params.field as string]: params.value },
});
return true;
}
export async function deleteKey(params: GetParams): RDelete {
await redis.del(params.key);
}
export async function scanByRKeySingle(params: SScanParams): RScan {
const pattern = `X:${params.rKey}:${params.rKey}:*:*`;
const [_, results] = await redis.scan("0", "MATCH", pattern, "COUNT", 1);
return results.length > 0 ? results[0] : null;
}
export async function scanByRKeyDouble(params: DScanParams): RScan {
const pattern = `X:${params.rKey}:${params.sKey}:*:*`;
const [_, results] = await redis.scan("0", "MATCH", pattern, "COUNT", 1);
return results.length > 0 ? results[0] : null;
}

129
proxy-md.md Normal file
View File

@ -0,0 +1,129 @@
```docker-compose.yml
version: '3.8'
services:
nginx:
image: owasp/modsecurity:nginx
container_name: secure_nginx
ports:
- "80:80"
volumes:
- ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
- ./nginx/html:/usr/share/nginx/html:ro
- ./nginx/modsecurity/modsecurity.conf:/etc/modsecurity/modsecurity.conf:ro
- ./nginx/log:/var/log/nginx
restart: always
fail2ban:
image: crazymax/fail2ban:latest
container_name: fail2ban
volumes:
- ./fail2ban/jail.local:/data/jail.local:ro
- ./fail2ban/filter.d:/data/filter.d:ro
- ./nginx/log:/var/log/nginx:ro
- fail2ban-data:/data
restart: always
cap_add:
- NET_ADMIN
- NET_RAW
volumes:
fail2ban-data:
```
```conf
worker_processes 1;
events {
worker_connections 1024;
}
http {
include mime.types;
default_type application/octet-stream;
modsecurity on;
modsecurity_rules_file /etc/modsecurity/modsecurity.conf;
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" '
'"$http_user_agent" "$http_x_forwarded_for"';
access_log /var/log/nginx/access.log main;
limit_req_zone $binary_remote_addr zone=limit1:10m rate=5r/s;
server {
listen 80;
server_name localhost;
root /usr/share/nginx/html;
index index.html;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "no-referrer-when-downgrade" always;
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
location / {
limit_req zone=limit1 burst=10 nodelay;
try_files $uri $uri/ =404;
}
}
}
```
nginx/html/index.html
```
<!DOCTYPE html>
<html>
<head>
<title>ModSecurity + Fail2Ban</title>
</head>
<body>
<h1>Güvenlik Duvarı Aktif!</h1>
</body>
</html>
```
nginx/modsecurity/modsecurity.conf
```
SecRuleEngine On
SecRequestBodyAccess On
SecResponseBodyAccess Off
SecRule REQUEST_HEADERS:User-Agent "curl" "id:10001,phase:1,deny,status:403,msg:'Curl client blocked'"
# Basit rate limit sayacı
SecAction "id:10010,phase:1,initcol:ip=%{REMOTE_ADDR},pass,nolog"
SecRule IP:REQCOUNT "@gt 20" "id:10011,phase:2,deny,status:429,msg:'Too many requests'"
SecAction "id:10012,phase:2,pass,nolog,setvar:ip.reqcount=+1"
```
fail2ban/jail.local
```
[nginx-req-limit]
enabled = true
filter = nginx-req-limit
action = iptables[name=HTTP, port=http, protocol=tcp]
logpath = /var/log/nginx/access.log
maxretry = 5
findtime = 60
bantime = 3600
```
fail2ban/filter.d/nginx-req-limit.conf
```
[Definition]
failregex = <HOST> -.* "(GET|POST).*HTTP.*" 429
ignoreregex =
```
mkdir -p nginx/html nginx/modsecurity fail2ban/filter.d
docker-compose up -d