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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions backend/src/auth/auth.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ import { ResetPasswordProvider } from './providers/reset-password.provider';
import { MailService } from './providers/mail.service';
import { NonceService } from './providers/nonce.service';

import { GuestSessionProvider } from './providers/guest-session.provider';

@Module({
imports: [
forwardRef(() => UsersModule),
Expand All @@ -36,6 +38,7 @@ import { NonceService } from './providers/nonce.service';
controllers: [AuthController, GoogleAuthenticationController],
providers: [
AuthService,
GuestSessionProvider,
JwtStrategy,
SignInProvider,
RefreshTokensProvider,
Expand All @@ -58,6 +61,7 @@ import { NonceService } from './providers/nonce.service';
exports: [
JwtStrategy,
AuthService,
GuestSessionProvider,
HashingProvider,
GoogleAuthenticationService,
NonceService,
Expand Down
40 changes: 39 additions & 1 deletion backend/src/auth/controllers/auth.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,50 @@ import { StellarWalletLoginDto } from '../dtos/walletLogin.dto';
import { ResetPasswordDto } from '../dtos/reset-password.dto';
import { ForgotPasswordDto } from '../dtos/forgot-password.dto';

import { GuestSessionProvider } from '../providers/guest-session.provider';
import { ConvertGuestDto } from '../dtos/convert-guest.dto';

@Controller('auth')
export class AuthController {
constructor(
// injecting auth service
// injecting auth service & guest session provider
private readonly authservice: AuthService,
private readonly guestSessionProvider: GuestSessionProvider,
) {}

@Post('/guest-session')
@HttpCode(HttpStatus.CREATED)
@ApiOperation({ summary: 'Create a new 15-minute guest session' })
@ApiResponse({ status: 201, description: 'Guest session created successfully' })
public createGuestSession() {
return this.guestSessionProvider.createGuestSession();
}

@Get('/guest-session/:sessionId/status')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Check guest session status and expiration' })
@ApiResponse({ status: 200, description: 'Guest session status retrieved' })
public checkGuestSessionStatus(@Param('sessionId') sessionId: string) {
return this.guestSessionProvider.getGuestSessionStatus(sessionId);
}

@Post('/guest-session/:sessionId/hint')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Request hint for guest session (max 2 allowed)' })
@ApiResponse({ status: 200, description: 'Hint usage validated' })
@ApiResponse({ status: 403, description: 'Hint limit reached' })
public requestGuestHint(@Param('sessionId') sessionId: string) {
return this.guestSessionProvider.validateGuestHintUsage(sessionId);
}

@Post('/convert-guest')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Convert guest session into an authenticated account' })
@ApiResponse({ status: 200, description: 'Guest converted to account' })
public convertGuestAccount(@Body() dto: ConvertGuestDto) {
return this.guestSessionProvider.convertGuestToAccount(dto);
}

@Post('/signIn')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Sign in with email and password' })
Expand Down
25 changes: 25 additions & 0 deletions backend/src/auth/dtos/convert-guest.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsString, IsNotEmpty, IsEmail, IsOptional, MinLength } from 'class-validator';

export class ConvertGuestDto {
@ApiProperty({ description: 'Guest session ID' })
@IsString()
@IsNotEmpty()
guestSessionId: string;

@ApiPropertyOptional({ description: 'User email address' })
@IsOptional()
@IsEmail()
email?: string;

@ApiPropertyOptional({ description: 'Account password' })
@IsOptional()
@IsString()
@MinLength(6)
password?: string;

@ApiPropertyOptional({ description: 'Stellar wallet address' })
@IsOptional()
@IsString()
walletAddress?: string;
}
60 changes: 60 additions & 0 deletions backend/src/auth/providers/guest-session.provider.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { Test, TestingModule } from '@nestjs/testing';
import { ConfigService } from '@nestjs/config';
import { ForbiddenException, UnauthorizedException, NotFoundException } from '@nestjs/common';
import { GuestSessionProvider } from './guest-session.provider';

describe('GuestSessionProvider', () => {
let provider: GuestSessionProvider;

beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
GuestSessionProvider,
{ provide: ConfigService, useValue: { get: jest.fn() } },
],
}).compile();

provider = module.get<GuestSessionProvider>(GuestSessionProvider);
});

it('should create a 15-minute guest session with 2 hints limit', () => {
const session = provider.createGuestSession();
expect(session.sessionId).toContain('guest_');
expect(session.maxHints).toBe(2);
expect(session.expiresAt - session.createdAt).toBe(15 * 60 * 1000);
});

it('should validate guest session status server-side', () => {
const session = provider.createGuestSession();
const status = provider.getGuestSessionStatus(session.sessionId);
expect(status.valid).toBe(true);
expect(status.expired).toBe(false);
});

it('should restrict hints to maximum 2 for guest sessions', () => {
const session = provider.createGuestSession();

expect(provider.validateGuestHintUsage(session.sessionId).hintsRemaining).toBe(1);
expect(provider.validateGuestHintUsage(session.sessionId).hintsRemaining).toBe(0);

expect(() => provider.validateGuestHintUsage(session.sessionId)).toThrow(ForbiddenException);
});

it('should prevent guest users from claiming rewards', () => {
const session = provider.createGuestSession();
expect(() => provider.validateGuestRewardClaim(session.sessionId)).toThrow(ForbiddenException);
});

it('should convert guest session to authenticated account', () => {
const session = provider.createGuestSession();
const result = provider.convertGuestToAccount({
guestSessionId: session.sessionId,
email: 'guest@example.com',
password: 'password123',
});

expect(result.success).toBe(true);
const updatedStatus = provider.getGuestSessionStatus(session.sessionId);
expect(updatedStatus.session?.isConverted).toBe(true);
});
});
146 changes: 146 additions & 0 deletions backend/src/auth/providers/guest-session.provider.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
import {
Injectable,
UnauthorizedException,
ForbiddenException,
BadRequestException,
NotFoundException,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { v4 as uuidv4 } from 'uuid';
import { ConvertGuestDto } from '../dtos/convert-guest.dto';
import { userRole } from '../../users/enums/userRole.enum';

export interface GuestSession {
sessionId: string;
role: userRole;
createdAt: number;
expiresAt: number;
hintsUsed: number;
maxHints: number;
isConverted: boolean;
}

const GUEST_SESSION_TIMEOUT_MS = 15 * 60 * 1000; // 15 minutes
const GUEST_MAX_HINTS = 2;

@Injectable()
export class GuestSessionProvider {
// In-memory store for guest sessions (or backed by Redis/DB)
private readonly guestSessions = new Map<string, GuestSession>();

constructor(private readonly configService: ConfigService) {}

public createGuestSession(): GuestSession {
const sessionId = `guest_${uuidv4()}`;
const now = Date.now();
const session: GuestSession = {
sessionId,
role: userRole.GUEST,
createdAt: now,
expiresAt: now + GUEST_SESSION_TIMEOUT_MS,
hintsUsed: 0,
maxHints: GUEST_MAX_HINTS,
isConverted: false,
};

this.guestSessions.set(sessionId, session);
return session;
}

public getGuestSessionStatus(sessionId: string): {
valid: boolean;
expired: boolean;
session?: GuestSession;
reason?: string;
} {
const session = this.guestSessions.get(sessionId);

if (!session) {
return {
valid: false,
expired: false,
reason: 'Guest session not found',
};
}

if (Date.now() > session.expiresAt) {
return {
valid: false,
expired: true,
session,
reason: 'Guest session has expired (15-minute limit reached)',
};
}

return {
valid: true,
expired: false,
session,
};
}

public validateGuestHintUsage(sessionId: string): {
allowed: boolean;
hintsRemaining: number;
} {
const status = this.getGuestSessionStatus(sessionId);

if (!status.valid || status.expired) {
throw new UnauthorizedException(
status.reason ?? 'Guest session is invalid or expired',
);
}

const session = status.session!;

if (session.hintsUsed >= session.maxHints) {
throw new ForbiddenException(
`Guest users are limited to ${session.maxHints} hints. Please create an account for unlimited hints.`,
);
}

session.hintsUsed += 1;
this.guestSessions.set(sessionId, session);

return {
allowed: true,
hintsRemaining: session.maxHints - session.hintsUsed,
};
}

public validateGuestRewardClaim(sessionId: string): void {
const status = this.getGuestSessionStatus(sessionId);

if (status.session?.role === userRole.GUEST) {
throw new ForbiddenException(
'Guest users cannot claim XP or blockchain rewards. Please convert to a full account to claim rewards.',
);
}
}

public convertGuestToAccount(dto: ConvertGuestDto): {
success: boolean;
message: string;
sessionId: string;
} {
const session = this.guestSessions.get(dto.guestSessionId);

if (!session) {
throw new NotFoundException('Guest session not found');
}

if (session.isConverted) {
throw new BadRequestException('Guest session has already been converted');
}

session.isConverted = true;
session.role = userRole.USER;
this.guestSessions.set(dto.guestSessionId, session);

return {
success: true,
message: 'Guest session successfully converted to authenticated account',
sessionId: dto.guestSessionId,
};
}
}
57 changes: 57 additions & 0 deletions frontend/components/GuestSessionExpiryModal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
'use client';

import React from 'react';

interface GuestSessionExpiryModalProps {
isOpen: boolean;
onClose: () => void;
onSignup: () => void;
reason?: string;
}

export const GuestSessionExpiryModal: React.FC<GuestSessionExpiryModalProps> = ({
isOpen,
onClose,
onSignup,
reason = 'Your 15-minute guest session has expired. Create an account to save progress and claim Web3 rewards!',
}) => {
if (!isOpen) return null;

return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-md p-4">
<div className="relative w-full max-w-md rounded-2xl border border-white/10 bg-slate-900/80 p-6 shadow-2xl backdrop-blur-xl">
<div className="mb-4 flex items-center justify-between">
<h3 className="text-xl font-bold text-transparent bg-clip-text bg-gradient-to-r from-blue-400 via-purple-400 to-teal-300">
Guest Session Expired
</h3>
<button
onClick={onClose}
className="text-slate-400 hover:text-white transition-colors"
aria-label="Close modal"
>
</button>
</div>

<p className="mb-6 text-sm leading-relaxed text-slate-300">
{reason}
</p>

<div className="flex flex-col gap-3">
<button
onClick={onSignup}
className="w-full rounded-xl bg-gradient-to-r from-blue-500 via-purple-500 to-teal-400 py-3 text-sm font-semibold text-white shadow-lg shadow-purple-500/25 transition-all hover:scale-[1.02] active:scale-[0.98]"
>
Create Account & Save Progress
</button>
<button
onClick={onClose}
className="w-full rounded-xl border border-white/10 bg-white/5 py-2.5 text-sm font-medium text-slate-400 hover:bg-white/10 hover:text-white transition-all"
>
Continue Browsing
</button>
</div>
</div>
</div>
);
};
Loading
Loading