Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
61 commits
Select commit Hold shift + click to select a range
c92740c
test: add vitest + rtl + msw + playwright harness with mocked api
vernu Jul 10, 2026
ea1b00c
chore: upgrade to next 16 + react 19
vernu Jul 10, 2026
8072904
chore: migrate to tailwind v4
vernu Jul 10, 2026
89f2f78
chore: bump remaining deps and remove dead prisma dependency
vernu Jul 10, 2026
497a140
refactor: centralize data fetching into typed react-query hooks
vernu Jul 10, 2026
dbea68a
refactor: modernize nextauth wrapper and provider setup
vernu Jul 10, 2026
55ad2a6
feat: rebrand design tokens, dark-mode depth, typography, motion
vernu Jul 10, 2026
d906fb1
feat: responsive app shell with sidebar, command palette, mobile nav
vernu Jul 10, 2026
c1ac054
feat: single-accent recolor and token sweep on subscription + pro alert
vernu Jul 10, 2026
6fe0a5d
refactor: sweep dashboard, auth and header off hardcoded gray/white o…
vernu Jul 10, 2026
f821f48
chore: migrate lint to eslint 9 flat config
vernu Jul 10, 2026
e34dd7a
test: add messaging screen e2e guard (tabs + history render)
vernu Jul 11, 2026
9111ca0
refactor: decompose 1051-line message-history into focused modules
vernu Jul 11, 2026
d1548b4
refactor: decompose 652-line get-started into focused modules
vernu Jul 11, 2026
2623118
refactor: decompose 578-line webhooks-history into focused modules
vernu Jul 11, 2026
eba534d
feat: shared empty states, button press micro-interaction, gradient r…
vernu Jul 11, 2026
79e6dce
fix: add rel attributes to external status link in footer
vernu Jul 11, 2026
dda3820
fix: eliminate mobile horizontal overflow and add scroll-width e2e guard
vernu Jul 11, 2026
e6bb6e2
feat: split nav config into 4-item mobile bar + 5-item desktop sidebar
vernu Jul 11, 2026
6c36361
feat: messaging subroutes (send/bulk/history/api) with full-width his…
vernu Jul 11, 2026
516e626
feat: webhooks page owns subscriptions + deliveries subroutes
vernu Jul 11, 2026
bdae87a
feat: redesign dashboard home into overview with quick actions
vernu Jul 11, 2026
8a5f12a
feat: redesign onboarding into a conversion-focused guided setup
vernu Jul 11, 2026
f5815e1
feat: merge account into settings layout with billing/profile/securit…
vernu Jul 11, 2026
60e59f0
fix(web): point Contribute links to canonical marketing page
vernu Jul 14, 2026
4ce6d00
fix: resolve hydration, script-tag and invalid HTML nesting console e…
vernu Jul 18, 2026
50d0902
refactor: make transitions and entrance animations subtle
vernu Jul 18, 2026
0ea85c0
feat: clean up app chrome, rebuild footer and fix sidebar overlap
vernu Jul 18, 2026
02bd1d4
feat: make every page searchable with keyword matching
vernu Jul 18, 2026
a949729
feat: rebuild dashboard home around real usage and activity data
vernu Jul 18, 2026
99a3a79
feat: rebuild bulk send as a guided 4-step flow with validation
vernu Jul 18, 2026
1979e2f
feat: drop recent activity, correct usage wording, rebuild the API guide
vernu Jul 18, 2026
9cb1213
feat: show relative timestamps with the exact time on hover
vernu Jul 18, 2026
ffbbabd
fix: stack footer links vertically on mobile
vernu Jul 18, 2026
4cdcce6
feat(api): search message history by body, recipient or sender
vernu Jul 18, 2026
8c81835
feat: redesign message history around a scannable list
vernu Jul 18, 2026
9913b7c
feat: rebuild the send page with recipient chips and segment counting
vernu Jul 18, 2026
1999593
fix: day header overlap, mobile footer alignment, modal animation
vernu Jul 18, 2026
628e5b0
feat: rework the SMS detail modal around chips and per-field copy
vernu Jul 18, 2026
1349134
fix: give every messaging tab the same content column
vernu Jul 18, 2026
05143b8
fix: give the dashboard Add device button the guided flow
vernu Jul 18, 2026
6da80ac
fix: correct five CSV handling defects in bulk send
vernu Jul 18, 2026
f65af50
fix: give every webhook its own signing secret
vernu Jul 18, 2026
ad7bcd9
fix: stop the billing page asserting things it does not know
vernu Jul 18, 2026
022a6bd
fix: repair form labelling and navigation landmarks
vernu Jul 18, 2026
2b766e8
fix: drop the phantom device status and share one error state
vernu Jul 18, 2026
b2acba4
fix: tidy the community page and close mobile layout gaps
vernu Jul 18, 2026
e75dbc7
feat: compact the API key rows and align the logo with the brand orange
vernu Jul 18, 2026
5d43866
fix: show every plan in onboarding and let finished steps be reopened
vernu Jul 18, 2026
469aa00
fix: regrade the logo to the brand orange and crop its dead space
vernu Jul 19, 2026
366a479
feat: redesign the billing page and stop calling free users "Unknown"
vernu Jul 19, 2026
c1942a4
fix: repair two cache invalidations that silently matched nothing
vernu Jul 19, 2026
3e45972
refactor: turn on strict TypeScript and fix the 63 errors it found
vernu Jul 19, 2026
5bae4ff
ci: run the web test suites instead of only building
vernu Jul 19, 2026
06295ae
refactor: read the shared data hooks instead of refetching by hand
vernu Jul 19, 2026
290149c
fix(security): stop sending the password hash to the browser
vernu Jul 19, 2026
8297344
refactor: move mutations behind the shared data hooks
vernu Jul 19, 2026
9c08260
refactor: group dashboard components into feature folders
vernu Jul 19, 2026
bd80833
refactor: split bulk-send into a state hook and per-step components
vernu Jul 19, 2026
dcb12c6
chore: delete two unused components
vernu Jul 19, 2026
670d081
chore: drop an over-specific test comment
vernu Jul 19, 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
44 changes: 42 additions & 2 deletions .github/workflows/build-and-test.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -66,11 +66,51 @@ jobs:
pnpm run build
pnpm test

- name: Build web
# Web previously ran `build` only, so its unit and e2e suites never
# gated a merge. Lint, typecheck and unit tests are fast and
# deterministic, so they block.
- name: Install web dependencies
run: |
cd web
pnpm install
pnpm run build

- name: Lint web
run: |
cd web
pnpm lint

- name: Typecheck web
run: |
cd web
pnpm typecheck

- name: Unit test web
run: |
cd web
pnpm test

- name: Build web
run: |
cd web
pnpm run build

# E2e is fully mocked (e2e/mock-api.ts intercepts every backend call),
# but it drives a real browser, so it is reported without blocking until
# it has proven stable across a few merges.
#
# TODO: remove continue-on-error once that is established. It is here to
# avoid wedging the merge queue on a browser flake, not because e2e
# failures are acceptable.
- name: Install Playwright browser
run: |
cd web
pnpm exec playwright install --with-deps chromium

- name: E2e test web (non-blocking for now)
continue-on-error: true
run: |
cd web
pnpm test:e2e

build-and-test-android:
name: Build and Test Android
Expand Down
20 changes: 15 additions & 5 deletions api/src/auth/auth.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,12 @@ import {
EmailVerificationDocument,
} from './schemas/email-verification.schema'

// For register and login, which hold the hash in memory before responding.
export const withoutPassword = (user: UserDocument) => {
const { password, ...safe } = user.toObject()
return safe
}

@Injectable()
export class AuthService {
constructor(
Expand All @@ -39,7 +45,9 @@ export class AuthService {
async login(userData: any) {
await this.turnstileService.verify(userData.turnstileToken)

const user = await this.usersService.findOne({ email: userData.email })
const user = await this.usersService.findOneWithPassword({
email: userData.email,
})
if (!user) {
throw new HttpException(
{ error: 'Invalid credentials' },
Expand All @@ -60,7 +68,7 @@ export class AuthService {
const payload = { email: user.email, sub: user._id }
return {
accessToken: this.jwtService.sign(payload),
user,
user: withoutPassword(user),
}
}

Expand Down Expand Up @@ -101,7 +109,7 @@ export class AuthService {
const payload = { email: user.email, sub: user._id }
return {
accessToken: this.jwtService.sign(payload),
user,
user: withoutPassword(user),
}
}

Expand Down Expand Up @@ -140,7 +148,7 @@ export class AuthService {

return {
accessToken: this.jwtService.sign(payload),
user,
user: withoutPassword(user),
}
}

Expand Down Expand Up @@ -240,7 +248,9 @@ export class AuthService {
input: { oldPassword: string; newPassword: string },
user: UserDocument,
) {
const userToUpdate = await this.usersService.findOne({ _id: user._id })
const userToUpdate = await this.usersService.findOneWithPassword({
_id: user._id,
})
if (!userToUpdate) {
throw new HttpException({ error: 'User not found' }, HttpStatus.NOT_FOUND)
}
Expand Down
41 changes: 41 additions & 0 deletions api/src/auth/without-password.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { withoutPassword } from './auth.service'
import type { UserDocument } from '../users/schemas/user.schema'

// Fake document: only toObject() matters to the helper.
const doc = (fields: Record<string, unknown>) =>
({ toObject: () => ({ ...fields }) }) as unknown as UserDocument

describe('withoutPassword', () => {
it('removes the password hash', () => {
const safe = withoutPassword(
doc({ email: 'a@b.com', password: '$2a$10$hashedvalue' })
)

expect(safe).not.toHaveProperty('password')
})

it('keeps everything else the client needs', () => {
const safe = withoutPassword(
doc({
_id: 'u1',
email: 'a@b.com',
name: 'Ada',
role: 'regular',
password: '$2a$10$hashedvalue',
})
)

expect(safe).toEqual({
_id: 'u1',
email: 'a@b.com',
name: 'Ada',
role: 'regular',
})
})

it('is harmless when the hash was never loaded', () => {
const safe = withoutPassword(doc({ email: 'a@b.com' }))

expect(safe).toEqual({ email: 'a@b.com' })
})
})
35 changes: 35 additions & 0 deletions api/src/gateway/escape-regexp.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { escapeRegExp } from './escape-regexp'

describe('escapeRegExp', () => {
it('leaves plain text untouched', () => {
expect(escapeRegExp('hello world')).toBe('hello world')
expect(escapeRegExp('+14155550101')).toBe('\\+14155550101')
})

it('makes an unbalanced paren safe to compile', () => {
// Unescaped, `new RegExp('(')` throws and would fail the whole request.
expect(() => new RegExp(escapeRegExp('('))).not.toThrow()
expect(() => new RegExp(escapeRegExp('a)b['))).not.toThrow()
})

it('treats wildcards as literal characters', () => {
const pattern = new RegExp(escapeRegExp('.*'), 'i')
// A literal ".*" must not match arbitrary text.
expect(pattern.test('anything at all')).toBe(false)
expect(pattern.test('literally .* here')).toBe(true)
})

it('treats a dot as a dot, not "any character"', () => {
const pattern = new RegExp(escapeRegExp('a.c'), 'i')
expect(pattern.test('abc')).toBe(false)
expect(pattern.test('a.c')).toBe(true)
})

it('defuses a nested quantifier (ReDoS shape)', () => {
const pattern = new RegExp(escapeRegExp('(a+)+$'), 'i')
const start = Date.now()
pattern.test('a'.repeat(2000) + 'b')
// As a literal it is a plain substring scan, not catastrophic backtracking.
expect(Date.now() - start).toBeLessThan(100)
})
})
11 changes: 11 additions & 0 deletions api/src/gateway/escape-regexp.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
/**
* Escape every regular-expression metacharacter in a string so it can be
* embedded in a RegExp as a literal.
*
* Search terms come straight from user input. Without this, "(" alone throws
* a SyntaxError and fails the request, "." matches any character instead of a
* dot, and a nested quantifier is a ReDoS vector.
*/
export function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
}
5 changes: 3 additions & 2 deletions api/src/gateway/gateway.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -160,8 +160,9 @@ export class GatewayController {
const page = req.query.page ? parseInt(req.query.page, 10) : 1;
const limit = req.query.limit ? Math.min(parseInt(req.query.limit, 10), 100) : 50;
const type = req.query.type || '';

const result = await this.gatewayService.getMessages(deviceId, type, page, limit);
const search = req.query.search || '';

const result = await this.gatewayService.getMessages(deviceId, type, page, limit, search);
return result;
}

Expand Down
59 changes: 59 additions & 0 deletions api/src/gateway/gateway.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -802,6 +802,65 @@ describe('GatewayService', () => {
HttpException,
)
})

it('should search across message body, recipient and sender', async () => {
await service.getMessages(mockDeviceId, '', 1, 10, 'alice')

const expectedQuery = {
device: mockDevice._id,
$or: [
{ message: /alice/i },
{ recipient: /alice/i },
{ sender: /alice/i },
],
}

expect(mockSmsModel.countDocuments).toHaveBeenCalledWith(expectedQuery)
expect(mockSmsModel.find).toHaveBeenCalledWith(
expectedQuery,
null,
expect.any(Object),
)
})

it('should combine search with the type filter', async () => {
await service.getMessages(mockDeviceId, 'sent', 1, 10, 'alice')

expect(mockSmsModel.find).toHaveBeenCalledWith(
expect.objectContaining({
device: mockDevice._id,
type: SMSType.SENT,
$or: expect.any(Array),
}),
null,
expect.any(Object),
)
})

it('should ignore an empty or whitespace-only search', async () => {
await service.getMessages(mockDeviceId, '', 1, 10, ' ')

expect(mockSmsModel.find).toHaveBeenCalledWith(
{ device: mockDevice._id },
null,
expect.any(Object),
)
})

it('should escape regex metacharacters in the search term', async () => {
// Unescaped, this throws a SyntaxError and fails the request.
await expect(
service.getMessages(mockDeviceId, '', 1, 10, '('),
).resolves.toBeDefined()

// A wildcard must be matched literally, not treated as "any character".
await service.getMessages(mockDeviceId, '', 1, 10, '.*')

const call = mockSmsModel.find.mock.calls.at(-1)
const messagePattern = call[0].$or[0].message as RegExp
expect(messagePattern.test('anything at all')).toBe(false)
expect(messagePattern.test('contains .* literally')).toBe(true)
})
})

describe('getStatsForUser', () => {
Expand Down
15 changes: 15 additions & 0 deletions api/src/gateway/gateway.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import { WebhookEvent } from '../webhook/webhook-event.enum'
import { WebhookService } from '../webhook/webhook.service'
import { BillingService } from '../billing/billing.service'
import { SmsQueueService } from './queue/sms-queue.service'
import { escapeRegExp } from './escape-regexp'

@Injectable()
export class GatewayService {
Expand Down Expand Up @@ -947,6 +948,7 @@ export class GatewayService {
type = '',
page = 1,
limit = 50,
search = '',
): Promise<{ data: any[]; meta: any }> {
const device = await this.deviceModel.findById(deviceId)

Expand All @@ -972,6 +974,19 @@ export class GatewayService {
query.type = SMSType.RECEIVED
}

// Free-text search across the message body and either party's number.
// The input is escaped before it reaches RegExp: an unescaped "(" throws
// and fails the request, and a crafted pattern is a ReDoS vector.
const trimmedSearch = search?.trim()
if (trimmedSearch) {
const pattern = new RegExp(escapeRegExp(trimmedSearch), 'i')
query.$or = [
{ message: pattern },
{ recipient: pattern },
{ sender: pattern },
]
}

// Get total count for pagination metadata
const total = await this.smsModel.countDocuments(query)

Expand Down
14 changes: 14 additions & 0 deletions api/src/users/schemas/user.schema.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { UserSchema } from './user.schema'

describe('User schema', () => {
it('never selects the password hash by default', () => {
expect(UserSchema.path('password').options.select).toBe(false)
})

it.each(['email', 'name', 'role', 'emailVerifiedAt'])(
'still returns %s by default',
(field) => {
expect(UserSchema.path(field).options.select).toBeUndefined()
}
)
})
3 changes: 2 additions & 1 deletion api/src/users/schemas/user.schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,8 @@ export class User {
@Prop({ type: String, trim: true })
phone?: string

@Prop({ type: String })
// Never loaded unless a caller asks with .select('+password').
@Prop({ type: String, select: false })
password: string

@Prop({ type: String, default: UserRole.REGULAR })
Expand Down
5 changes: 5 additions & 0 deletions api/src/users/users.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,11 @@ export class UsersService {
return await this.userModel.findOne(params)
}

// Only for flows that verify a password. Never return this to a client.
async findOneWithPassword(params) {
return await this.userModel.findOne(params).select('+password')
}

async findAll() {
return await this.userModel.find()
}
Expand Down
7 changes: 4 additions & 3 deletions web/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,10 @@ NEXT_PUBLIC_LATEST_APP_VERSION_CODE=17
NEXT_PUBLIC_GOOGLE_CLIENT_ID=
NEXT_PUBLIC_TAWKTO_EMBED_URL=

AUTH_SECRET= # https://generate-secret.vercel.app/32

DATABASE_URL=mongodb://adminUser:adminPassword@textbee-db:27017/textbee?authSource=admin
# Used by NextAuth (next-auth v4) to sign/verify the session JWT. Must match
# the value read by proxy.ts (process.env.NEXTAUTH_SECRET).
NEXTAUTH_SECRET= # https://generate-secret.vercel.app/32
NEXTAUTH_URL=http://localhost:3000

MAIL_HOST=
MAIL_PORT=
Expand Down
6 changes: 0 additions & 6 deletions web/.eslintrc.json

This file was deleted.

3 changes: 3 additions & 0 deletions web/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@

# testing
/coverage
/test-results
/playwright-report
/playwright/.cache

# next.js
/.next/
Expand Down
Loading
Loading