Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
0c80c80
chore: remove dependabot auto-PRs, keep security alerts only
Rowee13 Mar 6, 2026
421e573
feat: add customizable subdomain slug in project creation
Rowee13 Mar 6, 2026
ae41ecb
docs: add demo account design
Rowee13 Apr 14, 2026
55122fe
docs: add demo account implementation plan
Rowee13 Apr 14, 2026
4783f91
feat(api): add isDemo flag to users
Rowee13 Apr 14, 2026
6185351
feat(api): expose isDemo on authenticated user
Rowee13 Apr 14, 2026
265b5fe
test(api): add jwt-cookie strategy isDemo coverage
Rowee13 Apr 14, 2026
1f72bf6
feat(api): scaffold demo module
Rowee13 Apr 14, 2026
0453daa
feat(api): add demo mode config
Rowee13 Apr 14, 2026
b80d140
feat(api): add demo seeder registry
Rowee13 Apr 14, 2026
8679e4d
refactor(api): drop unused DEMO_SEEDER symbol
Rowee13 Apr 14, 2026
523f499
feat(api): add BlockDemo decorator and guard
Rowee13 Apr 14, 2026
b937f3e
fix(api): make BlockDemoGuard fail-closed on missing auth
Rowee13 Apr 14, 2026
66e3c04
feat(api): add passwordless demo account endpoint
Rowee13 Apr 14, 2026
8839403
fix(api): address Task 7 review findings
Rowee13 Apr 14, 2026
76f6996
feat(api): add DevInbox demo seeder
Rowee13 Apr 15, 2026
ebb74ca
test(api): tighten DevInboxDemoSeeder contract assertions
Rowee13 Apr 15, 2026
9a170ca
feat(api): enforce 2-project cap for demo accounts
Rowee13 Apr 15, 2026
3d544d0
feat(api): add demo inject-test-email endpoint
Rowee13 Apr 15, 2026
80d3cea
feat(api): add demo cleanup cron
Rowee13 Apr 15, 2026
fb0cfda
feat(api): surface demo expiresAt on /auth/me
Rowee13 Apr 15, 2026
8b75fb3
feat(web): add Try Demo button on login page
Rowee13 Apr 15, 2026
e187127
feat(web): add demo countdown banner
Rowee13 Apr 15, 2026
1339fe9
feat(web): demo cap indicators and inject-email button
Rowee13 Apr 15, 2026
8dbbede
refactor(api): shorten demo project slug to demo-<id>
Rowee13 Apr 15, 2026
f923321
refactor(web): promote demo banner to root layout
Rowee13 Apr 15, 2026
1f05708
fix(web): make "Mark as Read/Unread" button text visible
Rowee13 Apr 15, 2026
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
16 changes: 0 additions & 16 deletions .github/dependabot.yml

This file was deleted.

7 changes: 7 additions & 0 deletions apps/api/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -29,3 +29,10 @@ CORS_ORIGINS="http://localhost:4001,http://localhost:4000"
# Swagger Documentation
SWAGGER_USER="admin"
SWAGGER_PASSWORD="admin123"

# Demo mode (opt-in). When true, exposes POST /api/auth/demo and runs cleanup cron.
DEMO_MODE_ENABLED=false
# Demo account TTL in minutes
DEMO_TTL_MINUTES=60
# Max demo account creations per IP per hour
DEMO_RATE_LIMIT_PER_HOUR=2
1 change: 1 addition & 0 deletions apps/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
"@nestjs/jwt": "^10.2.0",
"@nestjs/passport": "^10.0.3",
"@nestjs/platform-express": "^11.1.16",
"@nestjs/schedule": "^6.1.1",
"@nestjs/swagger": "^11.2.6",
"@nestjs/throttler": "^6.5.0",
"@prisma/client": "^6.19.2",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
-- AlterTable
ALTER TABLE "users" ADD COLUMN "is_demo" BOOLEAN NOT NULL DEFAULT false;

-- CreateIndex
CREATE INDEX "users_is_demo_created_at_idx" ON "users"("is_demo", "created_at");
14 changes: 8 additions & 6 deletions apps/api/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -18,15 +18,17 @@ datasource db {
model User {
id String @id @default(uuid())
email String @unique
password String // bcrypt hashed
password String // bcrypt hashed
name String?
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
isDemo Boolean @default(false) @map("is_demo")

// Relations
projects Project[]
refreshTokens RefreshToken[]

@@index([isDemo, createdAt])
@@map("users")
}

Expand All @@ -41,10 +43,10 @@ model RefreshToken {
// Relations
user User @relation(fields: [userId], references: [id], onDelete: Cascade)

@@map("refresh_tokens")
@@index([userId])
@@index([token])
@@index([expiresAt])
@@map("refresh_tokens")
}

model Project {
Expand All @@ -60,8 +62,8 @@ model Project {
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
emails Email[]

@@map("projects")
@@index([userId])
@@map("projects")
}

model Email {
Expand All @@ -72,7 +74,7 @@ model Email {
subject String?
bodyText String? @map("body_text")
bodyHtml String? @map("body_html")
headers Json // Store email headers as JSON
headers Json // Store email headers as JSON
rawMime String? @map("raw_mime") // Full RFC822 content
receivedAt DateTime @default(now()) @map("received_at")
isRead Boolean @default(false) @map("is_read")
Expand All @@ -81,9 +83,9 @@ model Email {
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
attachments Attachment[]

@@map("emails")
@@index([projectId])
@@index([receivedAt])
@@map("emails")
}

model Attachment {
Expand All @@ -97,6 +99,6 @@ model Attachment {
// Relations
email Email @relation(fields: [emailId], references: [id], onDelete: Cascade)

@@map("attachments")
@@index([emailId])
@@map("attachments")
}
2 changes: 2 additions & 0 deletions apps/api/src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { AppController } from './app.controller';
import { AppService } from './app.service';
import { AuthModule } from './auth/auth.module';
import { JwtAuthGuard } from './auth/guards/jwt-auth.guard';
import { DemoModule } from './demo/demo.module';
import { EmailsModule } from './emails/emails.module';
import { PrismaModule } from './prisma/prisma.module';
import { ProjectsModule } from './projects/projects.module';
Expand All @@ -21,6 +22,7 @@ import { SmtpModule } from './smtp/smtp.module';
ProjectsModule,
EmailsModule,
SmtpModule,
DemoModule,
],
controllers: [AppController],
providers: [
Expand Down
2 changes: 2 additions & 0 deletions apps/api/src/auth/auth.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
} from '@nestjs/swagger';
import { Throttle, ThrottlerGuard } from '@nestjs/throttler';
import { Request, Response } from 'express';
import { BlockDemo } from '../demo/decorators/block-demo.decorator';
import { AuthService } from './auth.service';
import { Public } from './decorators/public.decorator';
import { ChangePasswordDto } from './dto/change-password.dto';
Expand Down Expand Up @@ -159,6 +160,7 @@ export class AuthController {
return { message: 'Logout successful' };
}

@BlockDemo()
@Post('change-password')
@HttpCode(HttpStatus.OK)
@ApiBearerAuth()
Expand Down
93 changes: 93 additions & 0 deletions apps/api/src/auth/auth.service.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import { Test } from '@nestjs/testing';
import { UnauthorizedException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { JwtService } from '@nestjs/jwt';
import { AuthService } from './auth.service';
import { PrismaService } from '../prisma/prisma.service';

describe('AuthService.getCurrentUser', () => {
const createdAt = new Date('2026-04-15T10:00:00.000Z');
const updatedAt = new Date('2026-04-15T10:05:00.000Z');

const makeService = async (userRow: any) => {
const prisma = {
user: { findUnique: jest.fn().mockResolvedValue(userRow) },
};
const jwt = {};
const config = { get: jest.fn() };
const module = await Test.createTestingModule({
providers: [
AuthService,
{ provide: PrismaService, useValue: prisma },
{ provide: JwtService, useValue: jwt },
{ provide: ConfigService, useValue: config },
],
}).compile();
return { svc: module.get(AuthService), prisma };
};

afterEach(() => {
delete process.env.DEMO_TTL_MINUTES;
});

it('throws UnauthorizedException when user does not exist', async () => {
const { svc } = await makeService(null);
await expect(svc.getCurrentUser('missing')).rejects.toThrow(
UnauthorizedException,
);
});

it('returns user without expiresAt for non-demo users', async () => {
const { svc } = await makeService({
id: 'u1',
email: 'alice@example.com',
name: 'Alice',
isDemo: false,
createdAt,
updatedAt,
});
const result = await svc.getCurrentUser('u1');
expect(result).toEqual({
id: 'u1',
email: 'alice@example.com',
name: 'Alice',
isDemo: false,
createdAt,
updatedAt,
});
expect((result as any).expiresAt).toBeUndefined();
});

it('returns expiresAt = createdAt + ttlMinutes*60000 for demo users', async () => {
process.env.DEMO_TTL_MINUTES = '45';
const { svc } = await makeService({
id: 'u2',
email: 'demo-abcd1234@demo.local',
name: 'Demo User',
isDemo: true,
createdAt,
updatedAt,
});
const result: any = await svc.getCurrentUser('u2');
expect(result.isDemo).toBe(true);
expect(result.expiresAt).toBeInstanceOf(Date);
expect(result.expiresAt.getTime()).toBe(
createdAt.getTime() + 45 * 60_000,
);
});

it('defaults ttlMinutes to 60 when DEMO_TTL_MINUTES unset', async () => {
const { svc } = await makeService({
id: 'u3',
email: 'demo-zzzz1111@demo.local',
name: 'Demo User',
isDemo: true,
createdAt,
updatedAt,
});
const result: any = await svc.getCurrentUser('u3');
expect(result.expiresAt.getTime()).toBe(
createdAt.getTime() + 60 * 60_000,
);
});
});
31 changes: 30 additions & 1 deletion apps/api/src/auth/auth.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -252,6 +252,7 @@ export class AuthService {
id: true,
email: true,
name: true,
isDemo: true,
createdAt: true,
updatedAt: true,
},
Expand All @@ -261,7 +262,35 @@ export class AuthService {
throw new UnauthorizedException('User not found');
}

return user;
const ttlMinutes = parseInt(process.env.DEMO_TTL_MINUTES || '60', 10);

return {
...user,
...(user.isDemo
? {
expiresAt: new Date(
user.createdAt.getTime() + ttlMinutes * 60_000,
),
}
: {}),
};
}

/**
* Issue access + refresh tokens for an already-known user (no password check).
*
* Used by flows that authenticate via a mechanism other than credentials,
* e.g. the passwordless demo account endpoint.
*/
async issueTokensForUser(user: {
id: string;
email: string;
name: string | null;
}) {
const accessToken = await this.generateAccessToken(user.id, user.email);
const refreshToken = await this.generateRefreshToken(user.id);
this.logger.log(`Tokens issued for user: ${user.email}`);
return { accessToken, refreshToken, user };
}

/**
Expand Down
9 changes: 8 additions & 1 deletion apps/api/src/auth/guards/jwt-auth.guard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,14 @@ export class JwtAuthGuard extends AuthGuard(['jwt-cookie', 'jwt']) {
return super.canActivate(context);
}

handleRequest<TUser = { id: string; email: string; name: string | null }>(
handleRequest<
TUser = {
id: string;
email: string;
name: string | null;
isDemo: boolean;
},
>(
err: Error | null,
user: TUser | false,
): TUser {
Expand Down
41 changes: 41 additions & 0 deletions apps/api/src/auth/strategies/jwt-cookie.strategy.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { Test } from '@nestjs/testing';
import { ConfigService } from '@nestjs/config';
import { JwtCookieStrategy } from './jwt-cookie.strategy';
import { PrismaService } from '../../prisma/prisma.service';

describe('JwtCookieStrategy', () => {
let strategy: JwtCookieStrategy;
let prisma: { user: { findUnique: jest.Mock } };

beforeEach(async () => {
prisma = { user: { findUnique: jest.fn() } };
const module = await Test.createTestingModule({
providers: [
JwtCookieStrategy,
{ provide: PrismaService, useValue: prisma },
{ provide: ConfigService, useValue: { get: () => 'secret' } },
],
}).compile();
strategy = module.get(JwtCookieStrategy);
});

it('returns isDemo on the authenticated user', async () => {
prisma.user.findUnique.mockResolvedValue({
id: 'u1',
email: 'a@b.c',
name: 'A',
isDemo: true,
});
const result = await strategy.validate({ userId: 'u1', email: 'a@b.c' });
expect(result).toEqual({
id: 'u1',
email: 'a@b.c',
name: 'A',
isDemo: true,
});
expect(prisma.user.findUnique).toHaveBeenCalledWith({
where: { id: 'u1' },
select: { id: true, email: true, name: true, isDemo: true },
});
});
});
2 changes: 1 addition & 1 deletion apps/api/src/auth/strategies/jwt-cookie.strategy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ export class JwtCookieStrategy extends PassportStrategy(
// Fetch user from database to ensure they still exist
const user = await this.prisma.user.findUnique({
where: { id: payload.userId },
select: { id: true, email: true, name: true },
select: { id: true, email: true, name: true, isDemo: true },
});

if (!user) {
Expand Down
41 changes: 41 additions & 0 deletions apps/api/src/auth/strategies/jwt.strategy.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { Test } from '@nestjs/testing';
import { ConfigService } from '@nestjs/config';
import { JwtStrategy } from './jwt.strategy';
import { PrismaService } from '../../prisma/prisma.service';

describe('JwtStrategy', () => {
let strategy: JwtStrategy;
let prisma: { user: { findUnique: jest.Mock } };

beforeEach(async () => {
prisma = { user: { findUnique: jest.fn() } };
const module = await Test.createTestingModule({
providers: [
JwtStrategy,
{ provide: PrismaService, useValue: prisma },
{ provide: ConfigService, useValue: { get: () => 'secret' } },
],
}).compile();
strategy = module.get(JwtStrategy);
});

it('returns isDemo on the authenticated user', async () => {
prisma.user.findUnique.mockResolvedValue({
id: 'u1',
email: 'a@b.c',
name: 'A',
isDemo: true,
});
const result = await strategy.validate({ userId: 'u1', email: 'a@b.c' });
expect(result).toEqual({
id: 'u1',
email: 'a@b.c',
name: 'A',
isDemo: true,
});
expect(prisma.user.findUnique).toHaveBeenCalledWith({
where: { id: 'u1' },
select: { id: true, email: true, name: true, isDemo: true },
});
});
});
2 changes: 1 addition & 1 deletion apps/api/src/auth/strategies/jwt.strategy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
async validate(payload: { userId: string; email: string }) {
const user = await this.prisma.user.findUnique({
where: { id: payload.userId },
select: { id: true, email: true, name: true },
select: { id: true, email: true, name: true, isDemo: true },
});

if (!user) {
Expand Down
Loading
Loading