4 Commits

432 changed files with 21613 additions and 9993 deletions

14
.gitignore vendored
View File

@@ -15,6 +15,14 @@
# production # production
/build /build
# project-specific build artifacts
/src/Website/build/
/src/Website/storybook-static/
/src/Website/.react-router/
/src/Website/playwright-report/
/src/Website/test-results/
/test-results/
# misc # misc
.DS_Store .DS_Store
*.pem *.pem
@@ -42,6 +50,9 @@ next-env.d.ts
# vscode # vscode
.vscode .vscode
.idea/
*.swp
*.swo
/cloudinary-images /cloudinary-images
@@ -487,3 +498,6 @@ FodyWeavers.xsd
.env.dev .env.dev
.env.test .env.test
.env.prod .env.prod
*storybook.log
storybook-static

File diff suppressed because it is too large Load Diff

285
README.md
View File

@@ -1,261 +1,142 @@
# The Biergarten App # The Biergarten App
A social platform for craft beer enthusiasts to discover breweries, share reviews, and The Biergarten App is a multi-project monorepo with a .NET backend and an active React
connect with fellow beer lovers. Router frontend in `src/Website`. The current website focuses on account flows, theme
switching, shared UI components, Storybook coverage, and integration with the API.
**Documentation** ## Documentation
- [Getting Started](docs/getting-started.md) - Setup and installation - [Getting Started](docs/getting-started.md) - Local setup for backend and active website
- [Architecture](docs/architecture.md) - System design and patterns - [Architecture](docs/architecture.md) - Current backend and frontend architecture
- [Database](docs/database.md) - Schema and stored procedures - [Docker Guide](docs/docker.md) - Container-based backend development and testing
- [Docker Guide](docs/docker.md) - Container deployment - [Testing](docs/testing.md) - Backend and frontend test commands
- [Testing](docs/testing.md) - Test strategy and commands - [Environment Variables](docs/environment-variables.md) - Active configuration reference
- [Environment Variables](docs/environment-variables.md) - Configuration reference - [Token Validation](docs/token-validation.md) - JWT validation architecture
- [Legacy Website Archive](docs/archive/legacy-website-v1.md) - Archived notes for the old Next.js frontend
**Diagrams** ## Diagrams
- [Architecture](docs/diagrams/pdf/architecture.pdf) - Layered architecture - [Architecture](docs/diagrams-out/architecture.svg) - Layered architecture
- [Deployment](docs/diagrams/pdf/deployment.pdf) - Docker topology - [Deployment](docs/diagrams-out/deployment.svg) - Docker topology
- [Authentication Flow](docs/diagrams/pdf/authentication-flow.pdf) - Auth sequence - [Authentication Flow](docs/diagrams-out/authentication-flow.svg) - Auth sequence
- [Database Schema](docs/diagrams/pdf/database-schema.pdf) - Entity relationships - [Database Schema](docs/diagrams-out/database-schema.svg) - Entity relationships
## Project Status ## Current Status
**Active Development** - Transitioning from full-stack Next.js to multi-project monorepo Active areas in the repository:
- Core authentication and user management APIs - .NET 10 backend with layered architecture and SQL Server
- Database schema with migrations and seeding - React Router 7 website in `src/Website`
- Layered architecture (Domain, Service, Infrastructure, Repository, API) - Shared Biergarten theme system with a theme guide route
- Comprehensive test suite (unit + integration) - Storybook stories and browser-based checks for shared UI
- Frontend integration with .NET API (in progress) - Auth demo flows for home, login, register, dashboard, logout, and confirmation
- Migration from Next.js serverless functions - Toast-based feedback for auth outcomes
--- Legacy area retained for reference:
- `src/Website-v1` contains the archived Next.js frontend and is no longer the active website
## Tech Stack ## Tech Stack
**Backend**: .NET 10, ASP.NET Core, SQL Server 2022, DbUp **Frontend**: Next.js 14+, - **Backend**: .NET 10, ASP.NET Core, SQL Server 2022, DbUp
TypeScript, TailwindCSS **Testing**: xUnit, Reqnroll (BDD), FluentAssertions, Moq - **Frontend**: React 19, React Router 7, Vite 7, Tailwind CSS 4, DaisyUI 5
**Infrastructure**: Docker, Docker Compose **Security**: Argon2id password hashing, JWT - **UI Documentation**: Storybook 10, Vitest browser mode, Playwright
(HS256) - **Testing**: xUnit, Reqnroll (BDD), FluentAssertions, Moq
- **Infrastructure**: Docker, Docker Compose
--- - **Security**: Argon2id password hashing, JWT access/refresh/confirmation tokens
## Quick Start ## Quick Start
### Prerequisites ### Backend
- [.NET SDK 10+](https://dotnet.microsoft.com/download)
- [Docker Desktop](https://www.docker.com/products/docker-desktop)
- [Node.js 18+](https://nodejs.org/) (for frontend)
### Start Development Environment
```bash ```bash
# Clone repository
git clone https://github.com/aaronpo97/the-biergarten-app git clone https://github.com/aaronpo97/the-biergarten-app
cd the-biergarten-app cd the-biergarten-app
# Configure environment
cp .env.example .env.dev cp .env.example .env.dev
# Start all services
docker compose -f docker-compose.dev.yaml up -d docker compose -f docker-compose.dev.yaml up -d
# View logs
docker compose -f docker-compose.dev.yaml logs -f
``` ```
**Access**: Backend access:
- API: http://localhost:8080/swagger - API Swagger: http://localhost:8080/swagger
- Health: http://localhost:8080/health - Health Check: http://localhost:8080/health
### Run Tests ### Frontend
```bash ```bash
docker compose -f docker-compose.test.yaml up --abort-on-container-exit cd src/Website
npm install
API_BASE_URL=http://localhost:8080 SESSION_SECRET=dev-secret npm run dev
``` ```
Results are in `./test-results/` Optional frontend tools:
--- ```bash
cd src/Website
npm run storybook
npm run test:storybook
npm run test:storybook:playwright
```
## Repository Structure ## Repository Structure
```text
src/Core/ Backend projects (.NET)
src/Website/ Active React Router frontend
src/Website-v1/ Archived legacy Next.js frontend
docs/ Active project documentation
docs/archive/ Archived legacy documentation
``` ```
src/Core/ # Backend (.NET)
├── API/
│ ├── API.Core/ # ASP.NET Core Web API
│ └── API.Specs/ # Integration tests (Reqnroll)
├── Database/
│ ├── Database.Migrations/ # DbUp migrations
│ └── Database.Seed/ # Data seeding
├── Domain.Entities/ # Domain models
├── Infrastructure/ # Cross-cutting concerns
│ ├── Infrastructure.Jwt/
│ ├── Infrastructure.PasswordHashing/
│ ├── Infrastructure.Email/
│ ├── Infrastructure.Repository/
│ └── Infrastructure.Repository.Tests/
└── Service/ # Business logic
├── Service.Auth/
├── Service.Auth.Tests/
└── Service.UserManagement/
Website/ # Frontend (Next.js)
docs/ # Documentation
docs/diagrams/ # PlantUML diagrams
```
---
## Key Features ## Key Features
### Implemented Implemented today:
- User registration and authentication - User registration and login against the API
- JWT token-based auth - JWT-based auth with access, refresh, and confirmation flows
- Argon2id password hashing - SQL Server migrations and seed projects
- SQL Server with stored procedures - Shared form components and auth screens
- Database migrations (DbUp) - Theme switching with Lager, Stout, Cassis, and Weizen variants
- Docker containerization - Storybook documentation and automated story interaction tests
- Comprehensive test suite - Toast feedback for auth-related outcomes
- Swagger/OpenAPI documentation
- Health checks
### Planned Planned next:
- [ ] Brewery discovery and management - Brewery discovery and management
- [ ] Beer reviews and ratings - Beer reviews and ratings
- [ ] Social following/followers - Social follow relationships
- [ ] Geospatial brewery search - Geospatial brewery experiences
- [ ] Image upload (Cloudinary) - Additional frontend routes beyond the auth demo
- [ ] Email notifications
- [ ] OAuth integration
---
## Architecture Highlights
### Layered Architecture
```
API Layer (Controllers)
Service Layer (Business Logic)
Infrastructure Layer (Repositories, JWT, Email)
Domain Layer (Entities)
Database (SQL Server + Stored Procedures)
```
### SQL-First Approach
- All queries via stored procedures
- No ORM (no Entity Framework)
- Version-controlled schema
### Security
- **Password Hashing**: Argon2id (64MB memory, 4 iterations)
- **JWT Tokens**: HS256 with configurable expiration
- **Credential Rotation**: Built-in password change support
See [Architecture Guide](docs/architecture.md) for details.
---
## Testing ## Testing
The project includes three test suites: Backend suites:
| Suite | Type | Framework | Purpose | - `API.Specs` - integration tests
| ---------------------- | ----------- | -------------- | ---------------------- | - `Infrastructure.Repository.Tests` - repository unit tests
| **API.Specs** | Integration | Reqnroll (BDD) | End-to-end API testing | - `Service.Auth.Tests` - service unit tests
| **Repository.Tests** | Unit | xUnit | Data access layer |
| **Service.Auth.Tests** | Unit | xUnit + Moq | Business logic |
**Run All Tests**: Frontend suites:
- Storybook interaction tests via Vitest
- Storybook browser regression checks via Playwright
Run all backend tests with Docker:
```bash ```bash
docker compose -f docker-compose.test.yaml up --abort-on-container-exit docker compose -f docker-compose.test.yaml up --abort-on-container-exit
``` ```
**Run Individual Test Suite**: See [Testing](docs/testing.md) for the full command list.
```bash
cd src/Core
dotnet test API/API.Specs/API.Specs.csproj
dotnet test Infrastructure/Infrastructure.Repository.Tests/Infrastructure.Repository.Tests.csproj
dotnet test Service/Service.Auth.Tests/Service.Auth.Tests.csproj
```
See [Testing Guide](docs/testing.md) for more information.
---
## Docker Environments
The project uses three Docker Compose configurations:
| File | Purpose | Features |
| ---------------------------- | ------------- | ------------------------------------------------- |
| **docker-compose.dev.yaml** | Development | Persistent data, hot reload, Swagger UI |
| **docker-compose.test.yaml** | CI/CD Testing | Isolated DB, auto-exit, test results export |
| **docker-compose.prod.yaml** | Production | Optimized builds, health checks, restart policies |
**Common Commands**:
```bash
# Development
docker compose -f docker-compose.dev.yaml up -d
docker compose -f docker-compose.dev.yaml logs -f api.core
docker compose -f docker-compose.dev.yaml down -v
# Testing
docker compose -f docker-compose.test.yaml up --abort-on-container-exit
docker compose -f docker-compose.test.yaml down -v
# Build
docker compose -f docker-compose.dev.yaml build
docker compose -f docker-compose.dev.yaml build --no-cache
```
See [Docker Guide](docs/docker.md) for troubleshooting and advanced usage.
---
## Configuration ## Configuration
### Required Environment Variables Common active variables:
**Backend** (`.env.dev`): - Backend: `DB_SERVER`, `DB_NAME`, `DB_USER`, `DB_PASSWORD`, `ACCESS_TOKEN_SECRET`, `REFRESH_TOKEN_SECRET`, `CONFIRMATION_TOKEN_SECRET`
- Frontend: `API_BASE_URL`, `SESSION_SECRET`, `NODE_ENV`
```bash See [Environment Variables](docs/environment-variables.md) for details.
DB_SERVER=sqlserver,1433
DB_NAME=Biergarten
DB_USER=sa
DB_PASSWORD=YourStrong!Passw0rd
JWT_SECRET=<min-32-chars>
```
**Frontend** (`.env.local`):
```bash
BASE_URL=http://localhost:3000
NODE_ENV=development
CONFIRMATION_TOKEN_SECRET=<generated>
RESET_PASSWORD_TOKEN_SECRET=<generated>
SESSION_SECRET=<generated>
# + External services (Cloudinary, Mapbox, SparkPost)
```
See [Environment Variables Guide](docs/environment-variables.md) for complete reference.
---
## Contributing ## Contributing

View File

@@ -94,6 +94,7 @@ services:
ACCESS_TOKEN_SECRET: "${ACCESS_TOKEN_SECRET}" ACCESS_TOKEN_SECRET: "${ACCESS_TOKEN_SECRET}"
REFRESH_TOKEN_SECRET: "${REFRESH_TOKEN_SECRET}" REFRESH_TOKEN_SECRET: "${REFRESH_TOKEN_SECRET}"
CONFIRMATION_TOKEN_SECRET: "${CONFIRMATION_TOKEN_SECRET}" CONFIRMATION_TOKEN_SECRET: "${CONFIRMATION_TOKEN_SECRET}"
WEBSITE_BASE_URL: "${WEBSITE_BASE_URL}"
restart: unless-stopped restart: unless-stopped
networks: networks:
- devnet - devnet

View File

@@ -69,6 +69,7 @@ services:
ACCESS_TOKEN_SECRET: "${ACCESS_TOKEN_SECRET}" ACCESS_TOKEN_SECRET: "${ACCESS_TOKEN_SECRET}"
REFRESH_TOKEN_SECRET: "${REFRESH_TOKEN_SECRET}" REFRESH_TOKEN_SECRET: "${REFRESH_TOKEN_SECRET}"
CONFIRMATION_TOKEN_SECRET: "${CONFIRMATION_TOKEN_SECRET}" CONFIRMATION_TOKEN_SECRET: "${CONFIRMATION_TOKEN_SECRET}"
WEBSITE_BASE_URL: "${WEBSITE_BASE_URL}"
restart: unless-stopped restart: unless-stopped
networks: networks:
- prodnet - prodnet

View File

@@ -88,6 +88,7 @@ services:
ACCESS_TOKEN_SECRET: "${ACCESS_TOKEN_SECRET}" ACCESS_TOKEN_SECRET: "${ACCESS_TOKEN_SECRET}"
REFRESH_TOKEN_SECRET: "${REFRESH_TOKEN_SECRET}" REFRESH_TOKEN_SECRET: "${REFRESH_TOKEN_SECRET}"
CONFIRMATION_TOKEN_SECRET: "${CONFIRMATION_TOKEN_SECRET}" CONFIRMATION_TOKEN_SECRET: "${CONFIRMATION_TOKEN_SECRET}"
WEBSITE_BASE_URL: "${WEBSITE_BASE_URL}"
volumes: volumes:
- ./test-results:/app/test-results - ./test-results:/app/test-results
restart: "no" restart: "no"

View File

@@ -1,28 +1,27 @@
# Architecture # Architecture
This document describes the architecture patterns and design decisions for The Biergarten This document describes the active architecture of The Biergarten App.
App.
## High-Level Overview ## High-Level Overview
The Biergarten App follows a **multi-project monorepo** architecture with clear separation The Biergarten App is a monorepo with a clear split between the backend and the active
between backend and frontend: website:
- **Backend**: .NET 10 Web API with SQL Server - **Backend**: .NET 10 Web API with SQL Server and a layered architecture
- **Frontend**: Next.js with TypeScript - **Frontend**: React 19 + React Router 7 website in `src/Website`
- **Architecture Style**: Layered architecture with SQL-first approach - **Architecture Style**: Layered backend plus server-rendered React frontend
The legacy Next.js frontend has been retained in `src/Website-v1` for reference only and is
documented in [archive/legacy-website-v1.md](archive/legacy-website-v1.md).
## Diagrams ## Diagrams
For visual representations, see: For visual representations, see:
- [architecture.pdf](diagrams/pdf/architecture.pdf) - Layered architecture diagram - [architecture.svg](diagrams-out/architecture.svg) - Layered architecture diagram
- [deployment.pdf](diagrams/pdf/deployment.pdf) - Docker deployment diagram - [deployment.svg](diagrams-out/deployment.svg) - Docker deployment diagram
- [authentication-flow.pdf](diagrams/pdf/authentication-flow.pdf) - Authentication - [authentication-flow.svg](diagrams-out/authentication-flow.svg) - Authentication workflow
workflow - [database-schema.svg](diagrams-out/database-schema.svg) - Database relationships
- [database-schema.pdf](diagrams/pdf/database-schema.pdf) - Database relationships
Generate diagrams with: `make diagrams`
## Backend Architecture ## Backend Architecture
@@ -217,39 +216,49 @@ public interface IAuthRepository
## Frontend Architecture ## Frontend Architecture
### Next.js Application Structure ### Active Website (`src/Website`)
``` The current website is a React Router 7 application with server-side rendering enabled.
Website/src/
├── components/ # React components ```text
├── pages/ # Next.js routes src/Website/
├── contexts/ # React context providers ├── app/
├── hooks/ # Custom React hooks │ ├── components/ Shared UI such as Navbar, FormField, SubmitButton, ToastProvider
├── controllers/ # Business logic layer │ ├── lib/ Auth helpers, schemas, and theme metadata
├── services/ # API communication │ ├── routes/ Route modules for home, login, register, dashboard, confirm, theme
├── requests/ # API request builders ├── root.tsx App shell and global providers
├── validation/ # Form validation schemas │ └── app.css Theme tokens and global styling
├── config/ # Configuration & env vars ├── .storybook/ Storybook config and preview setup
── prisma/ # Database schema (current) ── stories/ Storybook stories for shared UI and themes
├── tests/playwright/ Storybook Playwright coverage
└── package.json Frontend scripts and dependencies
``` ```
### Migration Strategy ### Frontend Responsibilities
The frontend is **transitioning** from a standalone architecture to integrate with the - Render the auth demo and theme guide routes
.NET API: - Manage cookie-backed website session state
- Call the .NET API for login, registration, token refresh, and confirmation
- Provide shared UI building blocks for forms, navigation, themes, and toasts
- Supply Storybook documentation and browser-based component verification
**Current State**: ### Theme System
- Uses Prisma ORM with Postgres (Neon) The active website uses semantic DaisyUI theme tokens backed by four Biergarten themes:
- Has its own server-side API routes
- Direct database access from Next.js
**Target State**: - Biergarten Lager
- Biergarten Stout
- Biergarten Cassis
- Biergarten Weizen
- Pure client-side Next.js app All component styling should prefer semantic tokens such as `primary`, `success`,
- All data via .NET API `surface`, and `highlight` instead of hard-coded color values.
- No server-side database access
- JWT-based authentication ### Legacy Frontend
The previous Next.js frontend has been archived at `src/Website-v1`. Active product and
engineering documentation should point to `src/Website`, while legacy notes live in
[archive/legacy-website-v1.md](archive/legacy-website-v1.md).
## Security Architecture ## Security Architecture
@@ -385,7 +394,7 @@ dependencies
```yaml ```yaml
healthcheck: healthcheck:
test: ['CMD-SHELL', 'sqlcmd health check'] test: ["CMD-SHELL", "sqlcmd health check"]
interval: 10s interval: 10s
retries: 12 retries: 12
start_period: 30s start_period: 30s

View File

@@ -0,0 +1,56 @@
# Legacy Website Archive (`src/Website-v1`)
This archive captures high-level notes about the previous Biergarten frontend so active
project documentation can focus on the current website in `src/Website`.
## Status
- `src/Website-v1` is retained for historical reference only
- It is not the active frontend used by current setup, docs, or testing guidance
- New product and engineering work should target `src/Website`
## Legacy Stack Summary
The archived frontend used a different application model from the current website:
- Next.js 14
- React 18
- Prisma
- Postgres / Neon-hosted database workflows
- Next.js API routes and server-side controllers
- Additional third-party integrations such as Cloudinary, Mapbox, and SparkPost
## Why It Was Archived
The active website moved to a React Router-based frontend that talks directly to the .NET
API. As part of that shift, the main docs were updated to describe:
- `src/Website` as the active frontend
- React Router route modules and server rendering
- Storybook-based component documentation and tests
- Current frontend runtime variables: `API_BASE_URL`, `SESSION_SECRET`, and `NODE_ENV`
## Legacy Documentation Topics Moved Out of Active Docs
The following categories were removed from active documentation and intentionally archived:
- Next.js application structure guidance
- Prisma and Postgres frontend setup
- Legacy frontend environment variables
- External service setup that only applied to `src/Website-v1`
- Old frontend local setup instructions
## When To Use This Archive
Use this file only if you need to:
- inspect the historical frontend implementation
- compare old flows against the current website
- migrate or recover legacy logic from `src/Website-v1`
For all active work, use:
- [Getting Started](../getting-started.md)
- [Architecture](../architecture.md)
- [Environment Variables](../environment-variables.md)
- [Testing](../testing.md)

View File

@@ -1,14 +1,15 @@
# Environment Variables # Environment Variables
Complete documentation for all environment variables used in The Biergarten App. This document covers the active environment variables used by the current Biergarten
stack.
## Overview ## Overview
The application uses environment variables for configuration across: The application uses environment variables for:
- **.NET API Backend** - Database connections, JWT secrets - **.NET API backend** - database connections, token secrets, runtime settings
- **Next.js Frontend** - External services, authentication - **React Router website** - API base URL and session signing
- **Docker Containers** - Runtime configuration - **Docker containers** - environment-specific orchestration
## Configuration Patterns ## Configuration Patterns
@@ -16,10 +17,10 @@ The application uses environment variables for configuration across:
Direct environment variable access via `Environment.GetEnvironmentVariable()`. Direct environment variable access via `Environment.GetEnvironmentVariable()`.
### Frontend (Next.js) ### Frontend (`src/Website`)
Centralized configuration module at `src/Website/src/config/env/index.ts` with Zod The active website reads runtime values from the server environment for its auth and API
validation. integration.
### Docker ### Docker
@@ -71,6 +72,9 @@ REFRESH_TOKEN_SECRET=<generated-secret> # Signs long-lived refresh t
# Confirmation token secret (30-minute tokens) # Confirmation token secret (30-minute tokens)
CONFIRMATION_TOKEN_SECRET=<generated-secret> # Signs email confirmation tokens CONFIRMATION_TOKEN_SECRET=<generated-secret> # Signs email confirmation tokens
# Website base URL (used in confirmation emails)
WEBSITE_BASE_URL=https://thebiergarten.app # Base URL for the website
``` ```
**Security Requirements**: **Security Requirements**:
@@ -125,91 +129,38 @@ ASPNETCORE_URLS=http://0.0.0.0:8080 # Binding address and port
DOTNET_RUNNING_IN_CONTAINER=true # Flag for container execution DOTNET_RUNNING_IN_CONTAINER=true # Flag for container execution
``` ```
## Frontend Variables (Next.js) ## Frontend Variables (`src/Website`)
Create `.env.local` in the `Website/` directory. The active website does not use the old Next.js/Prisma environment model. Its core runtime
variables are:
### Base Configuration
```bash ```bash
BASE_URL=http://localhost:3000 # Application base URL API_BASE_URL=http://localhost:8080 # Base URL for the .NET API
NODE_ENV=development # Environment: development, production, test SESSION_SECRET=<generated-secret> # Cookie session signing secret
NODE_ENV=development # Standard Node runtime mode
``` ```
### Authentication & Sessions ### Frontend Variable Details
```bash #### `API_BASE_URL`
# Token signing secrets (use openssl rand -base64 127)
CONFIRMATION_TOKEN_SECRET=<generated-secret> # Email confirmation tokens
RESET_PASSWORD_TOKEN_SECRET=<generated-secret> # Password reset tokens
SESSION_SECRET=<generated-secret> # Session cookie signing
# Session configuration - **Required**: Yes for local development
SESSION_TOKEN_NAME=biergarten # Cookie name (optional) - **Default in code**: `http://localhost:8080`
SESSION_MAX_AGE=604800 # Cookie max age in seconds (optional, default: 1 week) - **Used by**: `src/Website/app/lib/auth.server.ts`
``` - **Purpose**: Routes website auth actions to the .NET API
**Security Requirements**: #### `SESSION_SECRET`
- All secrets should be 127+ characters - **Required**: Strongly recommended in all environments
- Generate using cryptographically secure random functions - **Default in local code path**: `dev-secret-change-me`
- Never reuse secrets across environments - **Used by**: React Router cookie session storage in `auth.server.ts`
- Rotate secrets periodically in production - **Purpose**: Signs and validates the website session cookie
### Database (Current - Prisma/Postgres) #### `NODE_ENV`
**Note**: Frontend currently uses Neon Postgres. Will migrate to .NET API. - **Required**: No
- **Typical values**: `development`, `production`, `test`
```bash - **Purpose**: Controls secure cookie behavior and runtime mode
POSTGRES_PRISMA_URL=postgresql://user:pass@host/db?pgbouncer=true # Pooled connection
POSTGRES_URL_NON_POOLING=postgresql://user:pass@host/db # Direct connection (migrations)
SHADOW_DATABASE_URL=postgresql://user:pass@host/shadow_db # Prisma shadow DB (optional)
```
### External Services
#### Cloudinary (Image Hosting)
```bash
NEXT_PUBLIC_CLOUDINARY_CLOUD_NAME=your-cloud-name # Public, client-accessible
CLOUDINARY_KEY=your-api-key # Server-side API key
CLOUDINARY_SECRET=your-api-secret # Server-side secret
```
**Setup Steps**:
1. Sign up at [cloudinary.com](https://cloudinary.com)
2. Navigate to Dashboard
3. Copy Cloud Name, API Key, and API Secret
**Note**: `NEXT_PUBLIC_` prefix makes variable accessible in client-side code.
#### Mapbox (Maps & Geocoding)
```bash
MAPBOX_ACCESS_TOKEN=pk.your-public-token
```
**Setup Steps**:
1. Create account at [mapbox.com](https://mapbox.com)
2. Navigate to Account → Tokens
3. Create new token with public scopes
4. Copy access token
#### SparkPost (Email Service)
```bash
SPARKPOST_API_KEY=your-api-key
SPARKPOST_SENDER_ADDRESS=noreply@yourdomain.com
```
**Setup Steps**:
1. Sign up at [sparkpost.com](https://sparkpost.com)
2. Verify sending domain or use sandbox
3. Create API key with "Send via SMTP" permission
4. Configure sender address (must match verified domain)
### Admin Account (Seeding) ### Admin Account (Seeding)
@@ -255,71 +206,42 @@ cp .env.example .env.dev
# Edit .env.dev with your values # Edit .env.dev with your values
``` ```
## Legacy Frontend Variables
Variables for the archived Next.js frontend (`src/Website-v1`) have been removed from this
active reference. See [archive/legacy-website-v1.md](archive/legacy-website-v1.md) if you
need the legacy Prisma, Cloudinary, Mapbox, or SparkPost notes.
**Docker Compose Mapping**: **Docker Compose Mapping**:
- `docker-compose.dev.yaml``.env.dev` - `docker-compose.dev.yaml``.env.dev`
- `docker-compose.test.yaml``.env.test` - `docker-compose.test.yaml``.env.test`
- `docker-compose.prod.yaml``.env.prod` - `docker-compose.prod.yaml``.env.prod`
### Frontend (Website Directory)
```
.env.local # Local development (gitignored)
.env.production # Production (gitignored)
```
**Setup**:
```bash
cd Website
touch .env.local
# Add frontend variables
```
## Variable Reference Table ## Variable Reference Table
| Variable | Backend | Frontend | Docker | Required | Notes | | Variable | Backend | Frontend | Docker | Required | Notes |
| ----------------------------------- | :-----: | :------: | :----: | :------: | ------------------------- | | ----------------------------- | :-----: | :------: | :----: | :------: | -------------------------- |
| **Database** | | `DB_SERVER` | ✓ | | ✓ | Yes\* | SQL Server address |
| `DB_SERVER` | ✓ | | ✓ | Yes\* | SQL Server address | | `DB_NAME` | ✓ | | ✓ | Yes\* | Database name |
| `DB_NAME` | ✓ | | ✓ | Yes\* | Database name | | `DB_USER` | ✓ | | ✓ | Yes\* | SQL username |
| `DB_USER` | ✓ | | ✓ | Yes\* | SQL username | | `DB_PASSWORD` | ✓ | | ✓ | Yes\* | SQL password |
| `DB_PASSWORD` | ✓ | | | Yes\* | SQL password | | `DB_CONNECTION_STRING` | ✓ | | | Yes\* | Alternative to components |
| `DB_CONNECTION_STRING` | ✓ | | | Yes\* | Alternative to components | | `DB_TRUST_SERVER_CERTIFICATE` | ✓ | | | No | Defaults to `True` |
| `DB_TRUST_SERVER_CERTIFICATE` | ✓ | | ✓ | No | Defaults to True | | `ACCESS_TOKEN_SECRET` | ✓ | | ✓ | Yes | Access token signing |
| `SA_PASSWORD` | | | ✓ | Yes | SQL Server container | | `REFRESH_TOKEN_SECRET` | | | ✓ | Yes | Refresh token signing |
| **Authentication (Backend - JWT)** | | `CONFIRMATION_TOKEN_SECRET` | ✓ | | ✓ | Yes | Confirmation token signing |
| `ACCESS_TOKEN_SECRET` | ✓ | | | Yes | Access token secret | | `WEBSITE_BASE_URL` | ✓ | | | Yes | Website URL for emails |
| `REFRESH_TOKEN_SECRET` | | | | Yes | Refresh token secret | | `API_BASE_URL` | | | | Yes | Website-to-API base URL |
| `CONFIRMATION_TOKEN_SECRET` | | | | Yes | Confirmation token secret | | `SESSION_SECRET` | | | | Yes | Website session signing |
| **Authentication (Frontend)** | | `NODE_ENV` | | ✓ | | No | Runtime mode |
| `CONFIRMATION_TOKEN_SECRET` | | | | Yes | Email confirmation | | `CLEAR_DATABASE` | | | | No | Dev/test reset flag |
| `RESET_PASSWORD_TOKEN_SECRET` | | | | Yes | Password reset | | `ASPNETCORE_ENVIRONMENT` | | | | Yes | ASP.NET environment |
| `SESSION_SECRET` | | | | Yes | Session signing | | `ASPNETCORE_URLS` | | | | Yes | API binding address |
| `SESSION_TOKEN_NAME` | | | | No | Default: "biergarten" | | `SA_PASSWORD` | | | | Yes | SQL Server container |
| `SESSION_MAX_AGE` | | | | No | Default: 604800 | | `ACCEPT_EULA` | | | | Yes | SQL Server EULA |
| **Base Configuration** | | `MSSQL_PID` | | | ✓ | No | SQL Server edition |
| `BASE_URL` | | | | Yes | App base URL | | `DOTNET_RUNNING_IN_CONTAINER` | | | ✓ | No | Container flag |
| `NODE_ENV` | | ✓ | | Yes | Node environment |
| `ASPNETCORE_ENVIRONMENT` | ✓ | | ✓ | Yes | ASP.NET environment |
| `ASPNETCORE_URLS` | ✓ | | ✓ | Yes | API binding address |
| **Database (Frontend - Current)** |
| `POSTGRES_PRISMA_URL` | | ✓ | | Yes | Pooled connection |
| `POSTGRES_URL_NON_POOLING` | | ✓ | | Yes | Direct connection |
| `SHADOW_DATABASE_URL` | | ✓ | | No | Prisma shadow DB |
| **External Services** |
| `NEXT_PUBLIC_CLOUDINARY_CLOUD_NAME` | | ✓ | | Yes | Public, client-side |
| `CLOUDINARY_KEY` | | ✓ | | Yes | Server-side |
| `CLOUDINARY_SECRET` | | ✓ | | Yes | Server-side |
| `MAPBOX_ACCESS_TOKEN` | | ✓ | | Yes | Maps/geocoding |
| `SPARKPOST_API_KEY` | | ✓ | | Yes | Email service |
| `SPARKPOST_SENDER_ADDRESS` | | ✓ | | Yes | From address |
| **Other** |
| `ADMIN_PASSWORD` | | ✓ | | No | Seeding only |
| `CLEAR_DATABASE` | ✓ | | ✓ | No | Dev/test only |
| `ACCEPT_EULA` | | | ✓ | Yes | SQL Server EULA |
| `MSSQL_PID` | | | ✓ | No | SQL Server edition |
| `DOTNET_RUNNING_IN_CONTAINER` | ✓ | | ✓ | No | Container flag |
\* Either `DB_CONNECTION_STRING` OR the component variables (`DB_SERVER`, `DB_NAME`, \* Either `DB_CONNECTION_STRING` OR the component variables (`DB_SERVER`, `DB_NAME`,
`DB_USER`, `DB_PASSWORD`) must be provided. `DB_USER`, `DB_PASSWORD`) must be provided.
@@ -336,13 +258,12 @@ Variables are validated at startup:
### Frontend Validation ### Frontend Validation
Zod schemas validate variables at runtime: The active website relies on runtime defaults for local development and the surrounding
server environment in deployed environments.
- Type checking (string, number, URL, etc.) - `API_BASE_URL` defaults to `http://localhost:8080`
- Format validation (email, URL patterns) - `SESSION_SECRET` falls back to a development-only local secret
- Required vs optional enforcement - `NODE_ENV` controls secure cookie behavior
**Location**: `src/Website/src/config/env/index.ts`
## Example Configuration Files ## Example Configuration Files
@@ -359,6 +280,7 @@ DB_PASSWORD=Dev_Password_123!
ACCESS_TOKEN_SECRET=<generated-with-openssl> ACCESS_TOKEN_SECRET=<generated-with-openssl>
REFRESH_TOKEN_SECRET=<generated-with-openssl> REFRESH_TOKEN_SECRET=<generated-with-openssl>
CONFIRMATION_TOKEN_SECRET=<generated-with-openssl> CONFIRMATION_TOKEN_SECRET=<generated-with-openssl>
WEBSITE_BASE_URL=http://localhost:3000
# Migration # Migration
CLEAR_DATABASE=true CLEAR_DATABASE=true
@@ -373,28 +295,10 @@ ACCEPT_EULA=Y
MSSQL_PID=Express MSSQL_PID=Express
``` ```
### `.env.local` (Frontend) ### Frontend local runtime example
```bash ```bash
# Base API_BASE_URL=http://localhost:8080
BASE_URL=http://localhost:3000
NODE_ENV=development
# Authentication
SESSION_SECRET=<generated-with-openssl> SESSION_SECRET=<generated-with-openssl>
NODE_ENV=development
# Database (current Prisma setup)
POSTGRES_PRISMA_URL=postgresql://user:pass@db.neon.tech/biergarten?pgbouncer=true
POSTGRES_URL_NON_POOLING=postgresql://user:pass@db.neon.tech/biergarten
# External Services
NEXT_PUBLIC_CLOUDINARY_CLOUD_NAME=my-cloud
CLOUDINARY_KEY=123456789012345
CLOUDINARY_SECRET=abcdefghijklmnopqrstuvwxyz
MAPBOX_ACCESS_TOKEN=pk.eyJ...
SPARKPOST_API_KEY=abc123...
SPARKPOST_SENDER_ADDRESS=noreply@biergarten.app
# Admin (for seeding)
ADMIN_PASSWORD=Admin_Dev_Password_123!
``` ```

View File

@@ -1,19 +1,16 @@
# Getting Started # Getting Started
This guide will help you set up and run The Biergarten App in your development This guide covers local setup for the current Biergarten stack: the .NET backend in
environment. `src/Core` and the active React Router frontend in `src/Website`.
## Prerequisites ## Prerequisites
Before you begin, ensure you have the following installed: - **.NET SDK 10+**
- **Node.js 18+**
- **Docker Desktop** or equivalent Docker Engine setup
- **Java 8+** if you want to regenerate PlantUML diagrams
- **.NET SDK 10+** - [Download](https://dotnet.microsoft.com/download) ## Recommended Path: Docker for Backend, Node for Frontend
- **Node.js 18+** - [Download](https://nodejs.org/)
- **Docker Desktop** - [Download](https://www.docker.com/products/docker-desktop)
(recommended)
- **Java 8+** - Required for generating diagrams from PlantUML (optional)
## Quick Start with Docker (Recommended)
### 1. Clone the Repository ### 1. Clone the Repository
@@ -22,174 +19,120 @@ git clone <repository-url>
cd the-biergarten-app cd the-biergarten-app
``` ```
### 2. Configure Environment Variables ### 2. Configure Backend Environment Variables
Copy the example environment file:
```bash ```bash
cp .env.example .env.dev cp .env.example .env.dev
``` ```
Edit `.env.dev` with your configuration: At minimum, ensure `.env.dev` includes valid database and token values:
```bash ```bash
# Database (component-based for Docker)
DB_SERVER=sqlserver,1433 DB_SERVER=sqlserver,1433
DB_NAME=Biergarten DB_NAME=Biergarten
DB_USER=sa DB_USER=sa
DB_PASSWORD=YourStrong!Passw0rd DB_PASSWORD=YourStrong!Passw0rd
ACCESS_TOKEN_SECRET=<generated>
# JWT Authentication REFRESH_TOKEN_SECRET=<generated>
JWT_SECRET=your-secret-key-minimum-32-characters-required CONFIRMATION_TOKEN_SECRET=<generated>
WEBSITE_BASE_URL=http://localhost:3000
``` ```
> For a complete list of environment variables, see See [Environment Variables](environment-variables.md) for the full list.
> [Environment Variables](environment-variables.md).
### 3. Start the Development Environment ### 3. Start the Backend Stack
```bash ```bash
docker compose -f docker-compose.dev.yaml up -d docker compose -f docker-compose.dev.yaml up -d
``` ```
This command will: This starts SQL Server, migrations, seeding, and the API.
- Start SQL Server container Available endpoints:
- Run database migrations
- Seed initial data
- Start the API on http://localhost:8080
### 4. Access the API - API Swagger: http://localhost:8080/swagger
- Health Check: http://localhost:8080/health
- **Swagger UI**: http://localhost:8080/swagger ### 4. Start the Active Frontend
- **Health Check**: http://localhost:8080/health
### 5. View Logs
```bash ```bash
# All services cd src/Website
docker compose -f docker-compose.dev.yaml logs -f npm install
API_BASE_URL=http://localhost:8080 SESSION_SECRET=dev-secret-change-me npm run dev
# Specific service
docker compose -f docker-compose.dev.yaml logs -f api.core
``` ```
### 6. Stop the Environment The website will be available at the local address printed by React Router dev.
Required frontend runtime variables for local work:
- `API_BASE_URL` - Base URL for the .NET API
- `SESSION_SECRET` - Cookie session signing secret for the website server
### 5. Optional: Run Storybook
```bash ```bash
docker compose -f docker-compose.dev.yaml down cd src/Website
npm run storybook
```
# Remove volumes (fresh start) Storybook runs at http://localhost:6006 by default.
## Useful Commands
### Backend
```bash
docker compose -f docker-compose.dev.yaml logs -f
docker compose -f docker-compose.dev.yaml down
docker compose -f docker-compose.dev.yaml down -v docker compose -f docker-compose.dev.yaml down -v
``` ```
## Manual Setup (Without Docker) ### Frontend
If you prefer to run services locally without Docker:
### Backend Setup
#### 1. Start SQL Server
You can use a local SQL Server instance or a cloud-hosted one. Ensure it's accessible and
you have the connection details.
#### 2. Set Environment Variables
```bash ```bash
# macOS/Linux cd src/Website
export DB_CONNECTION_STRING="Server=localhost,1433;Database=Biergarten;User Id=sa;Password=YourStrong!Passw0rd;TrustServerCertificate=True;" npm run lint
export JWT_SECRET="your-secret-key-minimum-32-characters-required" npm run typecheck
npm run format:check
# Windows PowerShell npm run test:storybook
$env:DB_CONNECTION_STRING="Server=localhost,1433;Database=Biergarten;User Id=sa;Password=YourStrong!Passw0rd;TrustServerCertificate=True;" npm run test:storybook:playwright
$env:JWT_SECRET="your-secret-key-minimum-32-characters-required"
``` ```
#### 3. Run Database Migrations ## Manual Backend Setup
If you do not want to use Docker, you can run the backend locally.
### 1. Set Environment Variables
```bash
export DB_CONNECTION_STRING="Server=localhost,1433;Database=Biergarten;User Id=sa;Password=YourStrong!Passw0rd;TrustServerCertificate=True;"
export ACCESS_TOKEN_SECRET="<generated>"
export REFRESH_TOKEN_SECRET="<generated>"
export CONFIRMATION_TOKEN_SECRET="<generated>"
export WEBSITE_BASE_URL="http://localhost:3000"
```
### 2. Run Migrations and Seed
```bash ```bash
cd src/Core cd src/Core
dotnet run --project Database/Database.Migrations/Database.Migrations.csproj dotnet run --project Database/Database.Migrations/Database.Migrations.csproj
```
#### 4. Seed the Database
```bash
dotnet run --project Database/Database.Seed/Database.Seed.csproj dotnet run --project Database/Database.Seed/Database.Seed.csproj
``` ```
#### 5. Start the API ### 3. Start the API
```bash ```bash
dotnet run --project API/API.Core/API.Core.csproj dotnet run --project API/API.Core/API.Core.csproj
``` ```
The API will be available at http://localhost:5000 (or the port specified in ## Legacy Frontend Note
launchSettings.json).
### Frontend Setup
> **Note**: The frontend is currently transitioning from its standalone Prisma/Postgres
> backend to the .NET API. Some features may still use the old backend.
#### 1. Navigate to Website Directory
```bash
cd Website
```
#### 2. Create Environment File
Create `.env.local` with frontend variables. See
[Environment Variables - Frontend](environment-variables.md#frontend-variables) for the
complete list.
```bash
BASE_URL=http://localhost:3000
NODE_ENV=development
# Generate secrets
CONFIRMATION_TOKEN_SECRET=$(openssl rand -base64 127)
RESET_PASSWORD_TOKEN_SECRET=$(openssl rand -base64 127)
SESSION_SECRET=$(openssl rand -base64 127)
# External services (you'll need to register for these)
NEXT_PUBLIC_CLOUDINARY_CLOUD_NAME=your-cloud-name
CLOUDINARY_KEY=your-api-key
CLOUDINARY_SECRET=your-api-secret
NEXT_PUBLIC_MAPBOX_KEY=your-mapbox-token
# Database URL (current Prisma setup)
DATABASE_URL=your-postgres-connection-string
```
#### 3. Install Dependencies
```bash
npm install
```
#### 4. Run Prisma Migrations
```bash
npx prisma generate
npx prisma migrate dev
```
#### 5. Start Development Server
```bash
npm run dev
```
The frontend will be available at http://localhost:3000.
The previous Next.js frontend now lives in `src/Website-v1` and is not the active website.
Legacy setup details have been moved to [docs/archive/legacy-website-v1.md](archive/legacy-website-v1.md).
## Next Steps ## Next Steps
- **Test the API**: Visit http://localhost:8080/swagger and try the endpoints - Review [Architecture](architecture.md)
- **Run Tests**: See [Testing Guide](testing.md) - Run backend and frontend checks from [Testing](testing.md)
- **Learn the Architecture**: Read [Architecture Overview](architecture.md) - Use [Docker Guide](docker.md) for container troubleshooting
- **Understand Docker Setup**: See [Docker Guide](docker.md)
- **Database Details**: Check [Database Schema](database.md)

View File

@@ -4,11 +4,13 @@ This document describes the testing strategy and how to run tests for The Bierga
## Overview ## Overview
The project uses a multi-layered testing approach: The project uses a multi-layered testing approach across backend and frontend:
- **API.Specs** - BDD integration tests using Reqnroll (Gherkin) - **API.Specs** - BDD integration tests using Reqnroll (Gherkin)
- **Infrastructure.Repository.Tests** - Unit tests for data access layer - **Infrastructure.Repository.Tests** - Unit tests for data access layer
- **Service.Auth.Tests** - Unit tests for authentication business logic - **Service.Auth.Tests** - Unit tests for authentication business logic
- **Storybook Vitest project** - Browser-based interaction tests for shared website stories
- **Storybook Playwright suite** - Browser checks against Storybook-rendered components
## Running Tests with Docker (Recommended) ## Running Tests with Docker (Recommended)
@@ -86,6 +88,33 @@ dotnet test Service/Service.Auth.Tests/Service.Auth.Tests.csproj
- No database required (uses Moq for mocking) - No database required (uses Moq for mocking)
### Frontend Storybook Tests
```bash
cd src/Website
npm install
npm run test:storybook
```
**Purpose**:
- Verifies shared stories such as form fields, submit buttons, navbar states, toasts, and the theme gallery
- Runs in browser mode via Vitest and Storybook integration
### Frontend Playwright Storybook Tests
```bash
cd src/Website
npm install
npm run test:storybook:playwright
```
**Requirements**:
- Storybook dependencies installed
- Playwright browser dependencies installed
- The command will start or reuse the Storybook server defined in `playwright.storybook.config.ts`
## Test Coverage ## Test Coverage
### Current Coverage ### Current Coverage
@@ -112,6 +141,14 @@ dotnet test Service/Service.Auth.Tests/Service.Auth.Tests.csproj
- Register service with validation - Register service with validation
- Business logic for authentication flow - Business logic for authentication flow
**Frontend UI Coverage**:
- Shared submit button states
- Form field happy path and error presentation
- Navbar guest, authenticated, and mobile behavior
- Theme gallery rendering across Biergarten themes
- Toast interactions and themed notification display
### Planned Coverage ### Planned Coverage
- [ ] Email verification workflow - [ ] Email verification workflow
@@ -121,6 +158,7 @@ dotnet test Service/Service.Auth.Tests/Service.Auth.Tests.csproj
- [ ] Beer post operations - [ ] Beer post operations
- [ ] User follow/unfollow - [ ] User follow/unfollow
- [ ] Image upload service - [ ] Image upload service
- [ ] Frontend route integration coverage beyond Storybook stories
## Testing Frameworks & Tools ## Testing Frameworks & Tools
@@ -254,6 +292,15 @@ Exit codes:
- `0` - All tests passed - `0` - All tests passed
- Non-zero - Test failures occurred - Non-zero - Test failures occurred
Frontend UI checks should also be included in CI for the active website workspace:
```bash
cd src/Website
npm ci
npm run test:storybook
npm run test:storybook:playwright
```
## Troubleshooting ## Troubleshooting
### Tests Failing Due to Database Connection ### Tests Failing Due to Database Connection

View File

@@ -1,6 +1,7 @@
using API.Core.Contracts.Auth; using API.Core.Contracts.Auth;
using API.Core.Contracts.Common; using API.Core.Contracts.Common;
using Domain.Entities; using Domain.Entities;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Service.Auth; using Service.Auth;
@@ -8,6 +9,7 @@ namespace API.Core.Controllers
{ {
[ApiController] [ApiController]
[Route("api/[controller]")] [Route("api/[controller]")]
[Authorize(AuthenticationSchemes = "JWT")]
public class AuthController( public class AuthController(
IRegisterService registerService, IRegisterService registerService,
ILoginService loginService, ILoginService loginService,
@@ -15,6 +17,7 @@ namespace API.Core.Controllers
ITokenService tokenService ITokenService tokenService
) : ControllerBase ) : ControllerBase
{ {
[AllowAnonymous]
[HttpPost("register")] [HttpPost("register")]
public async Task<ActionResult<UserAccount>> Register( public async Task<ActionResult<UserAccount>> Register(
[FromBody] RegisterRequest req [FromBody] RegisterRequest req
@@ -47,6 +50,7 @@ namespace API.Core.Controllers
return Created("/", response); return Created("/", response);
} }
[AllowAnonymous]
[HttpPost("login")] [HttpPost("login")]
public async Task<ActionResult> Login([FromBody] LoginRequest req) public async Task<ActionResult> Login([FromBody] LoginRequest req)
{ {
@@ -82,6 +86,7 @@ namespace API.Core.Controllers
); );
} }
[AllowAnonymous]
[HttpPost("refresh")] [HttpPost("refresh")]
public async Task<ActionResult> Refresh( public async Task<ActionResult> Refresh(
[FromBody] RefreshTokenRequest req [FromBody] RefreshTokenRequest req

View File

@@ -6,6 +6,7 @@ Feature: User Account Confirmation
Given the API is running Given the API is running
And I have registered a new account And I have registered a new account
And I have a valid confirmation token for my account And I have a valid confirmation token for my account
And I have a valid access token for my account
When I submit a confirmation request with the valid token When I submit a confirmation request with the valid token
Then the response has HTTP status 200 Then the response has HTTP status 200
And the response JSON should have "message" containing "is confirmed" And the response JSON should have "message" containing "is confirmed"
@@ -14,6 +15,7 @@ Feature: User Account Confirmation
Given the API is running Given the API is running
And I have registered a new account And I have registered a new account
And I have a valid confirmation token for my account And I have a valid confirmation token for my account
And I have a valid access token for my account
When I submit a confirmation request with the valid token When I submit a confirmation request with the valid token
And I submit the same confirmation request again And I submit the same confirmation request again
Then the response has HTTP status 200 Then the response has HTTP status 200
@@ -21,6 +23,8 @@ Feature: User Account Confirmation
Scenario: Confirmation fails with invalid token Scenario: Confirmation fails with invalid token
Given the API is running Given the API is running
And I have registered a new account
And I have a valid access token for my account
When I submit a confirmation request with an invalid token When I submit a confirmation request with an invalid token
Then the response has HTTP status 401 Then the response has HTTP status 401
And the response JSON should have "message" containing "Invalid token" And the response JSON should have "message" containing "Invalid token"
@@ -29,6 +33,7 @@ Feature: User Account Confirmation
Given the API is running Given the API is running
And I have registered a new account And I have registered a new account
And I have an expired confirmation token for my account And I have an expired confirmation token for my account
And I have a valid access token for my account
When I submit a confirmation request with the expired token When I submit a confirmation request with the expired token
Then the response has HTTP status 401 Then the response has HTTP status 401
And the response JSON should have "message" containing "Invalid token" And the response JSON should have "message" containing "Invalid token"
@@ -37,12 +42,15 @@ Feature: User Account Confirmation
Given the API is running Given the API is running
And I have registered a new account And I have registered a new account
And I have a confirmation token signed with the wrong secret And I have a confirmation token signed with the wrong secret
And I have a valid access token for my account
When I submit a confirmation request with the tampered token When I submit a confirmation request with the tampered token
Then the response has HTTP status 401 Then the response has HTTP status 401
And the response JSON should have "message" containing "Invalid token" And the response JSON should have "message" containing "Invalid token"
Scenario: Confirmation fails when token is missing Scenario: Confirmation fails when token is missing
Given the API is running Given the API is running
And I have registered a new account
And I have a valid access token for my account
When I submit a confirmation request with a missing token When I submit a confirmation request with a missing token
Then the response has HTTP status 400 Then the response has HTTP status 400
@@ -54,6 +62,15 @@ Feature: User Account Confirmation
Scenario: Confirmation fails with malformed token Scenario: Confirmation fails with malformed token
Given the API is running Given the API is running
And I have registered a new account
And I have a valid access token for my account
When I submit a confirmation request with a malformed token When I submit a confirmation request with a malformed token
Then the response has HTTP status 401 Then the response has HTTP status 401
And the response JSON should have "message" containing "Invalid token" And the response JSON should have "message" containing "Invalid token"
Scenario: Confirmation fails without an access token
Given the API is running
And I have registered a new account
And I have a valid confirmation token for my account
When I submit a confirmation request with the valid token without an access token
Then the response has HTTP status 401

View File

@@ -0,0 +1,36 @@
Feature: Resend Confirmation Email
As a user who did not receive the confirmation email
I want to request a resend of the confirmation email
So that I can obtain a working confirmation link while preventing abuse
Scenario: Legitimate resend for an unconfirmed user
Given the API is running
And I have registered a new account
And I have a valid access token for my account
When I submit a resend confirmation request for my account
Then the response has HTTP status 200
And the response JSON should have "message" containing "confirmation email has been resent"
Scenario: Resend is a no-op for an already confirmed user
Given the API is running
And I have registered a new account
And I have a valid confirmation token for my account
And I have a valid access token for my account
And I have confirmed my account
When I submit a resend confirmation request for my account
Then the response has HTTP status 200
And the response JSON should have "message" containing "confirmation email has been resent"
Scenario: Resend is a no-op for a non-existent user
Given the API is running
And I have registered a new account
And I have a valid access token for my account
When I submit a resend confirmation request for a non-existent user
Then the response has HTTP status 200
And the response JSON should have "message" containing "confirmation email has been resent"
Scenario: Resend requires authentication
Given the API is running
And I have registered a new account
When I submit a resend confirmation request without an access token
Then the response has HTTP status 401

View File

@@ -7,6 +7,8 @@ public class MockEmailService : IEmailService
{ {
public List<RegistrationEmail> SentRegistrationEmails { get; } = new(); public List<RegistrationEmail> SentRegistrationEmails { get; } = new();
public List<ResendConfirmationEmail> SentResendConfirmationEmails { get; } = new();
public Task SendRegistrationEmailAsync( public Task SendRegistrationEmailAsync(
UserAccount createdUser, UserAccount createdUser,
string confirmationToken string confirmationToken
@@ -24,9 +26,27 @@ public class MockEmailService : IEmailService
return Task.CompletedTask; return Task.CompletedTask;
} }
public Task SendResendConfirmationEmailAsync(
UserAccount user,
string confirmationToken
)
{
SentResendConfirmationEmails.Add(
new ResendConfirmationEmail
{
UserAccount = user,
ConfirmationToken = confirmationToken,
SentAt = DateTime.UtcNow,
}
);
return Task.CompletedTask;
}
public void Clear() public void Clear()
{ {
SentRegistrationEmails.Clear(); SentRegistrationEmails.Clear();
SentResendConfirmationEmails.Clear();
} }
public class RegistrationEmail public class RegistrationEmail
@@ -35,4 +55,11 @@ public class MockEmailService : IEmailService
public string ConfirmationToken { get; init; } = string.Empty; public string ConfirmationToken { get; init; } = string.Empty;
public DateTime SentAt { get; init; } public DateTime SentAt { get; init; }
} }
public class ResendConfirmationEmail
{
public UserAccount UserAccount { get; init; } = null!;
public string ConfirmationToken { get; init; } = string.Empty;
public DateTime SentAt { get; init; }
}
} }

View File

@@ -457,6 +457,32 @@ public class AuthSteps(ScenarioContext scenario)
await GivenIAmLoggedIn(); await GivenIAmLoggedIn();
} }
[Given("I have a valid access token for my account")]
public void GivenIHaveAValidAccessTokenForMyAccount()
{
var userId = scenario.TryGetValue<Guid>(RegisteredUserIdKey, out var id)
? id
: throw new InvalidOperationException(
"registered user ID not found in scenario"
);
var username = scenario.TryGetValue<string>(
RegisteredUsernameKey,
out var user
)
? user
: throw new InvalidOperationException(
"registered username not found in scenario"
);
var secret = GetRequiredEnvVar("ACCESS_TOKEN_SECRET");
scenario["accessToken"] = GenerateJwtToken(
userId,
username,
secret,
DateTime.UtcNow.AddMinutes(60)
);
}
[Given("I have a valid confirmation token for my account")] [Given("I have a valid confirmation token for my account")]
public void GivenIHaveAValidConfirmationTokenForMyAccount() public void GivenIHaveAValidConfirmationTokenForMyAccount()
{ {
@@ -587,11 +613,16 @@ public class AuthSteps(ScenarioContext scenario)
var token = scenario.TryGetValue<string>("confirmationToken", out var t) var token = scenario.TryGetValue<string>("confirmationToken", out var t)
? t ? t
: "valid-token"; : "valid-token";
var accessToken = scenario.TryGetValue<string>("accessToken", out var at)
? at
: string.Empty;
var requestMessage = new HttpRequestMessage( var requestMessage = new HttpRequestMessage(
HttpMethod.Post, HttpMethod.Post,
$"/api/auth/confirm?token={Uri.EscapeDataString(token)}" $"/api/auth/confirm?token={Uri.EscapeDataString(token)}"
); );
if (!string.IsNullOrEmpty(accessToken))
requestMessage.Headers.Add("Authorization", $"Bearer {accessToken}");
var response = await client.SendAsync(requestMessage); var response = await client.SendAsync(requestMessage);
var responseBody = await response.Content.ReadAsStringAsync(); var responseBody = await response.Content.ReadAsStringAsync();
@@ -606,11 +637,16 @@ public class AuthSteps(ScenarioContext scenario)
var token = scenario.TryGetValue<string>("confirmationToken", out var t) var token = scenario.TryGetValue<string>("confirmationToken", out var t)
? t ? t
: "valid-token"; : "valid-token";
var accessToken = scenario.TryGetValue<string>("accessToken", out var at)
? at
: string.Empty;
var requestMessage = new HttpRequestMessage( var requestMessage = new HttpRequestMessage(
HttpMethod.Post, HttpMethod.Post,
$"/api/auth/confirm?token={Uri.EscapeDataString(token)}" $"/api/auth/confirm?token={Uri.EscapeDataString(token)}"
); );
if (!string.IsNullOrEmpty(accessToken))
requestMessage.Headers.Add("Authorization", $"Bearer {accessToken}");
var response = await client.SendAsync(requestMessage); var response = await client.SendAsync(requestMessage);
var responseBody = await response.Content.ReadAsStringAsync(); var responseBody = await response.Content.ReadAsStringAsync();
@@ -623,11 +659,16 @@ public class AuthSteps(ScenarioContext scenario)
{ {
var client = GetClient(); var client = GetClient();
const string token = "malformed-token-not-jwt"; const string token = "malformed-token-not-jwt";
var accessToken = scenario.TryGetValue<string>("accessToken", out var at)
? at
: string.Empty;
var requestMessage = new HttpRequestMessage( var requestMessage = new HttpRequestMessage(
HttpMethod.Post, HttpMethod.Post,
$"/api/auth/confirm?token={Uri.EscapeDataString(token)}" $"/api/auth/confirm?token={Uri.EscapeDataString(token)}"
); );
if (!string.IsNullOrEmpty(accessToken))
requestMessage.Headers.Add("Authorization", $"Bearer {accessToken}");
var response = await client.SendAsync(requestMessage); var response = await client.SendAsync(requestMessage);
var responseBody = await response.Content.ReadAsStringAsync(); var responseBody = await response.Content.ReadAsStringAsync();
@@ -883,11 +924,16 @@ public class AuthSteps(ScenarioContext scenario)
var token = scenario.TryGetValue<string>("confirmationToken", out var t) var token = scenario.TryGetValue<string>("confirmationToken", out var t)
? t ? t
: "expired-confirmation-token"; : "expired-confirmation-token";
var accessToken = scenario.TryGetValue<string>("accessToken", out var at)
? at
: string.Empty;
var requestMessage = new HttpRequestMessage( var requestMessage = new HttpRequestMessage(
HttpMethod.Post, HttpMethod.Post,
$"/api/auth/confirm?token={Uri.EscapeDataString(token)}" $"/api/auth/confirm?token={Uri.EscapeDataString(token)}"
); );
if (!string.IsNullOrEmpty(accessToken))
requestMessage.Headers.Add("Authorization", $"Bearer {accessToken}");
var response = await client.SendAsync(requestMessage); var response = await client.SendAsync(requestMessage);
var responseBody = await response.Content.ReadAsStringAsync(); var responseBody = await response.Content.ReadAsStringAsync();
@@ -902,11 +948,16 @@ public class AuthSteps(ScenarioContext scenario)
var token = scenario.TryGetValue<string>("confirmationToken", out var t) var token = scenario.TryGetValue<string>("confirmationToken", out var t)
? t ? t
: "tampered-confirmation-token"; : "tampered-confirmation-token";
var accessToken = scenario.TryGetValue<string>("accessToken", out var at)
? at
: string.Empty;
var requestMessage = new HttpRequestMessage( var requestMessage = new HttpRequestMessage(
HttpMethod.Post, HttpMethod.Post,
$"/api/auth/confirm?token={Uri.EscapeDataString(token)}" $"/api/auth/confirm?token={Uri.EscapeDataString(token)}"
); );
if (!string.IsNullOrEmpty(accessToken))
requestMessage.Headers.Add("Authorization", $"Bearer {accessToken}");
var response = await client.SendAsync(requestMessage); var response = await client.SendAsync(requestMessage);
var responseBody = await response.Content.ReadAsStringAsync(); var responseBody = await response.Content.ReadAsStringAsync();
@@ -918,7 +969,13 @@ public class AuthSteps(ScenarioContext scenario)
public async Task WhenISubmitAConfirmationRequestWithAMissingToken() public async Task WhenISubmitAConfirmationRequestWithAMissingToken()
{ {
var client = GetClient(); var client = GetClient();
var accessToken = scenario.TryGetValue<string>("accessToken", out var at)
? at
: string.Empty;
var requestMessage = new HttpRequestMessage(HttpMethod.Post, "/api/auth/confirm"); var requestMessage = new HttpRequestMessage(HttpMethod.Post, "/api/auth/confirm");
if (!string.IsNullOrEmpty(accessToken))
requestMessage.Headers.Add("Authorization", $"Bearer {accessToken}");
var response = await client.SendAsync(requestMessage); var response = await client.SendAsync(requestMessage);
var responseBody = await response.Content.ReadAsStringAsync(); var responseBody = await response.Content.ReadAsStringAsync();
@@ -974,6 +1031,30 @@ public class AuthSteps(ScenarioContext scenario)
{ {
var client = GetClient(); var client = GetClient();
const string token = "invalid-confirmation-token"; const string token = "invalid-confirmation-token";
var accessToken = scenario.TryGetValue<string>("accessToken", out var at)
? at
: string.Empty;
var requestMessage = new HttpRequestMessage(
HttpMethod.Post,
$"/api/auth/confirm?token={Uri.EscapeDataString(token)}"
);
if (!string.IsNullOrEmpty(accessToken))
requestMessage.Headers.Add("Authorization", $"Bearer {accessToken}");
var response = await client.SendAsync(requestMessage);
var responseBody = await response.Content.ReadAsStringAsync();
scenario[ResponseKey] = response;
scenario[ResponseBodyKey] = responseBody;
}
[When("I submit a confirmation request with the valid token without an access token")]
public async Task WhenISubmitAConfirmationRequestWithTheValidTokenWithoutAnAccessToken()
{
var client = GetClient();
var token = scenario.TryGetValue<string>("confirmationToken", out var t)
? t
: "valid-token";
var requestMessage = new HttpRequestMessage( var requestMessage = new HttpRequestMessage(
HttpMethod.Post, HttpMethod.Post,
@@ -1043,4 +1124,91 @@ public class AuthSteps(ScenarioContext scenario)
refreshToken.Should().NotBe(previousRefreshToken); refreshToken.Should().NotBe(previousRefreshToken);
} }
} }
[Given("I have confirmed my account")]
public async Task GivenIHaveConfirmedMyAccount()
{
var client = GetClient();
var token = scenario.TryGetValue<string>("confirmationToken", out var t)
? t
: throw new InvalidOperationException("confirmation token not found");
var accessToken = scenario.TryGetValue<string>("accessToken", out var at)
? at
: string.Empty;
var requestMessage = new HttpRequestMessage(
HttpMethod.Post,
$"/api/auth/confirm?token={Uri.EscapeDataString(token)}"
);
if (!string.IsNullOrEmpty(accessToken))
requestMessage.Headers.Add("Authorization", $"Bearer {accessToken}");
var response = await client.SendAsync(requestMessage);
response.EnsureSuccessStatusCode();
}
[When("I submit a resend confirmation request for my account")]
public async Task WhenISubmitAResendConfirmationRequestForMyAccount()
{
var client = GetClient();
var userId = scenario.TryGetValue<Guid>(RegisteredUserIdKey, out var id)
? id
: throw new InvalidOperationException("registered user ID not found");
var accessToken = scenario.TryGetValue<string>("accessToken", out var at)
? at
: string.Empty;
var requestMessage = new HttpRequestMessage(
HttpMethod.Post,
$"/api/auth/confirm/resend?userId={userId}"
);
if (!string.IsNullOrEmpty(accessToken))
requestMessage.Headers.Add("Authorization", $"Bearer {accessToken}");
var response = await client.SendAsync(requestMessage);
var responseBody = await response.Content.ReadAsStringAsync();
scenario[ResponseKey] = response;
scenario[ResponseBodyKey] = responseBody;
}
[When("I submit a resend confirmation request for a non-existent user")]
public async Task WhenISubmitAResendConfirmationRequestForANonExistentUser()
{
var client = GetClient();
var fakeUserId = Guid.NewGuid();
var accessToken = scenario.TryGetValue<string>("accessToken", out var at)
? at
: string.Empty;
var requestMessage = new HttpRequestMessage(
HttpMethod.Post,
$"/api/auth/confirm/resend?userId={fakeUserId}"
);
if (!string.IsNullOrEmpty(accessToken))
requestMessage.Headers.Add("Authorization", $"Bearer {accessToken}");
var response = await client.SendAsync(requestMessage);
var responseBody = await response.Content.ReadAsStringAsync();
scenario[ResponseKey] = response;
scenario[ResponseBodyKey] = responseBody;
}
[When("I submit a resend confirmation request without an access token")]
public async Task WhenISubmitAResendConfirmationRequestWithoutAnAccessToken()
{
var client = GetClient();
var userId = scenario.TryGetValue<Guid>(RegisteredUserIdKey, out var id)
? id
: Guid.NewGuid();
var requestMessage = new HttpRequestMessage(
HttpMethod.Post,
$"/api/auth/confirm/resend?userId={userId}"
);
var response = await client.SendAsync(requestMessage);
var responseBody = await response.Content.ReadAsStringAsync();
scenario[ResponseKey] = response;
scenario[ResponseBodyKey] = responseBody;
}
} }

View File

@@ -22,7 +22,7 @@ USE Biergarten;
CREATE TABLE dbo.UserAccount CREATE TABLE dbo.UserAccount
( (
UserAccountID UNIQUEIDENTIFIER UserAccountID UNIQUEIDENTIFIER
CONSTRAINT DF_UserAccountID DEFAULT NEWID(), CONSTRAINT DF_UserAccountID DEFAULT NEWID(),
Username VARCHAR(64) NOT NULL, Username VARCHAR(64) NOT NULL,
@@ -37,7 +37,7 @@ CREATE TABLE dbo.UserAccount
UpdatedAt DATETIME, UpdatedAt DATETIME,
DateOfBirth DATETIME NOT NULL, DateOfBirth DATE NOT NULL,
Timer ROWVERSION, Timer ROWVERSION,
@@ -49,7 +49,6 @@ CREATE TABLE dbo.UserAccount
CONSTRAINT AK_Email CONSTRAINT AK_Email
UNIQUE (Email) UNIQUE (Email)
); );
---------------------------------------------------------------------------- ----------------------------------------------------------------------------
@@ -109,7 +108,7 @@ CREATE TABLE UserAvatar -- delete avatar photo when user account is deleted
CONSTRAINT AK_UserAvatar_UserAccountID CONSTRAINT AK_UserAvatar_UserAccountID
UNIQUE (UserAccountID) UNIQUE (UserAccountID)
) );
CREATE NONCLUSTERED INDEX IX_UserAvatar_UserAccount CREATE NONCLUSTERED INDEX IX_UserAvatar_UserAccount
ON UserAvatar(UserAccountID); ON UserAvatar(UserAccountID);
@@ -125,8 +124,7 @@ CREATE TABLE UserVerification -- delete verification data when user account is d
UserAccountID UNIQUEIDENTIFIER NOT NULL, UserAccountID UNIQUEIDENTIFIER NOT NULL,
VerificationDateTime DATETIME NOT NULL VerificationDateTime DATETIME NOT NULL
CONSTRAINT DF_VerificationDateTime CONSTRAINT DF_VerificationDateTime DEFAULT GETDATE(),
DEFAULT GETDATE(),
Timer ROWVERSION, Timer ROWVERSION,
@@ -155,13 +153,13 @@ CREATE TABLE UserCredential -- delete credentials when user account is deleted
UserAccountID UNIQUEIDENTIFIER NOT NULL, UserAccountID UNIQUEIDENTIFIER NOT NULL,
CreatedAt DATETIME CreatedAt DATETIME NOT NULL
CONSTRAINT DF_UserCredential_CreatedAt DEFAULT GETDATE() NOT NULL, CONSTRAINT DF_UserCredential_CreatedAt DEFAULT GETDATE(),
Expiry DATETIME Expiry DATETIME NOT NULL
CONSTRAINT DF_UserCredential_Expiry DEFAULT DATEADD(DAY, 90, GETDATE()) NOT NULL, CONSTRAINT DF_UserCredential_Expiry DEFAULT DATEADD(DAY, 90, GETDATE()),
Hash NVARCHAR(MAX) NOT NULL, Hash NVARCHAR(256) NOT NULL,
-- uses argon2 -- uses argon2
IsRevoked BIT NOT NULL IsRevoked BIT NOT NULL
@@ -177,12 +175,16 @@ CREATE TABLE UserCredential -- delete credentials when user account is deleted
CONSTRAINT FK_UserCredential_UserAccount CONSTRAINT FK_UserCredential_UserAccount
FOREIGN KEY (UserAccountID) FOREIGN KEY (UserAccountID)
REFERENCES UserAccount(UserAccountID) REFERENCES UserAccount(UserAccountID)
ON DELETE CASCADE, ON DELETE CASCADE
); );
CREATE NONCLUSTERED INDEX IX_UserCredential_UserAccount CREATE NONCLUSTERED INDEX IX_UserCredential_UserAccount
ON UserCredential(UserAccountID); ON UserCredential(UserAccountID);
CREATE NONCLUSTERED INDEX IX_UserCredential_Account_Active
ON UserCredential(UserAccountID, IsRevoked, Expiry)
INCLUDE (Hash);
---------------------------------------------------------------------------- ----------------------------------------------------------------------------
---------------------------------------------------------------------------- ----------------------------------------------------------------------------
@@ -195,8 +197,8 @@ CREATE TABLE UserFollow
FollowingID UNIQUEIDENTIFIER NOT NULL, FollowingID UNIQUEIDENTIFIER NOT NULL,
CreatedAt DATETIME CreatedAt DATETIME NOT NULL
CONSTRAINT DF_UserFollow_CreatedAt DEFAULT GETDATE() NOT NULL, CONSTRAINT DF_UserFollow_CreatedAt DEFAULT GETDATE(),
Timer ROWVERSION, Timer ROWVERSION,
@@ -205,11 +207,13 @@ CREATE TABLE UserFollow
CONSTRAINT FK_UserFollow_UserAccount CONSTRAINT FK_UserFollow_UserAccount
FOREIGN KEY (UserAccountID) FOREIGN KEY (UserAccountID)
REFERENCES UserAccount(UserAccountID), REFERENCES UserAccount(UserAccountID)
ON DELETE NO ACTION,
CONSTRAINT FK_UserFollow_UserAccountFollowing CONSTRAINT FK_UserFollow_UserAccountFollowing
FOREIGN KEY (FollowingID) FOREIGN KEY (FollowingID)
REFERENCES UserAccount(UserAccountID), REFERENCES UserAccount(UserAccountID)
ON DELETE NO ACTION,
CONSTRAINT CK_CannotFollowOwnAccount CONSTRAINT CK_CannotFollowOwnAccount
CHECK (UserAccountID != FollowingID) CHECK (UserAccountID != FollowingID)
@@ -221,7 +225,6 @@ CREATE NONCLUSTERED INDEX IX_UserFollow_UserAccount_FollowingID
CREATE NONCLUSTERED INDEX IX_UserFollow_FollowingID_UserAccount CREATE NONCLUSTERED INDEX IX_UserFollow_FollowingID_UserAccount
ON UserFollow(FollowingID, UserAccountID); ON UserFollow(FollowingID, UserAccountID);
---------------------------------------------------------------------------- ----------------------------------------------------------------------------
---------------------------------------------------------------------------- ----------------------------------------------------------------------------
@@ -240,7 +243,7 @@ CREATE TABLE Country
PRIMARY KEY (CountryID), PRIMARY KEY (CountryID),
CONSTRAINT AK_Country_ISO3166_1 CONSTRAINT AK_Country_ISO3166_1
UNIQUE (ISO3166_1) UNIQUE (ISO3166_1)
); );
---------------------------------------------------------------------------- ----------------------------------------------------------------------------
@@ -299,7 +302,6 @@ CREATE TABLE City
CREATE NONCLUSTERED INDEX IX_City_StateProvince CREATE NONCLUSTERED INDEX IX_City_StateProvince
ON City(StateProvinceID); ON City(StateProvinceID);
---------------------------------------------------------------------------- ----------------------------------------------------------------------------
---------------------------------------------------------------------------- ----------------------------------------------------------------------------
@@ -308,6 +310,8 @@ CREATE TABLE BreweryPost -- A user cannot be deleted if they have a post
BreweryPostID UNIQUEIDENTIFIER BreweryPostID UNIQUEIDENTIFIER
CONSTRAINT DF_BreweryPostID DEFAULT NEWID(), CONSTRAINT DF_BreweryPostID DEFAULT NEWID(),
BreweryName NVARCHAR(256) NOT NULL,
PostedByID UNIQUEIDENTIFIER NOT NULL, PostedByID UNIQUEIDENTIFIER NOT NULL,
Description NVARCHAR(512) NOT NULL, Description NVARCHAR(512) NOT NULL,
@@ -325,15 +329,15 @@ CREATE TABLE BreweryPost -- A user cannot be deleted if they have a post
CONSTRAINT FK_BreweryPost_UserAccount CONSTRAINT FK_BreweryPost_UserAccount
FOREIGN KEY (PostedByID) FOREIGN KEY (PostedByID)
REFERENCES UserAccount(UserAccountID) REFERENCES UserAccount(UserAccountID)
ON DELETE NO ACTION, ON DELETE NO ACTION
);
)
CREATE NONCLUSTERED INDEX IX_BreweryPost_PostedByID CREATE NONCLUSTERED INDEX IX_BreweryPost_PostedByID
ON BreweryPost(PostedByID); ON BreweryPost(PostedByID);
---------------------------------------------------------------------------- ----------------------------------------------------------------------------
---------------------------------------------------------------------------- ----------------------------------------------------------------------------
CREATE TABLE BreweryPostLocation CREATE TABLE BreweryPostLocation
( (
BreweryPostLocationID UNIQUEIDENTIFIER BreweryPostLocationID UNIQUEIDENTIFIER
@@ -349,7 +353,7 @@ CREATE TABLE BreweryPostLocation
CityID UNIQUEIDENTIFIER NOT NULL, CityID UNIQUEIDENTIFIER NOT NULL,
Coordinates GEOGRAPHY NOT NULL, Coordinates GEOGRAPHY NULL,
Timer ROWVERSION, Timer ROWVERSION,
@@ -362,7 +366,11 @@ CREATE TABLE BreweryPostLocation
CONSTRAINT FK_BreweryPostLocation_BreweryPost CONSTRAINT FK_BreweryPostLocation_BreweryPost
FOREIGN KEY (BreweryPostID) FOREIGN KEY (BreweryPostID)
REFERENCES BreweryPost(BreweryPostID) REFERENCES BreweryPost(BreweryPostID)
ON DELETE CASCADE ON DELETE CASCADE,
CONSTRAINT FK_BreweryPostLocation_City
FOREIGN KEY (CityID)
REFERENCES City(CityID)
); );
CREATE NONCLUSTERED INDEX IX_BreweryPostLocation_BreweryPost CREATE NONCLUSTERED INDEX IX_BreweryPostLocation_BreweryPost
@@ -371,6 +379,18 @@ CREATE NONCLUSTERED INDEX IX_BreweryPostLocation_BreweryPost
CREATE NONCLUSTERED INDEX IX_BreweryPostLocation_City CREATE NONCLUSTERED INDEX IX_BreweryPostLocation_City
ON BreweryPostLocation(CityID); ON BreweryPostLocation(CityID);
-- To assess when the time comes:
-- This would allow for efficient spatial queries to find breweries within a certain distance of a location, but it adds overhead to insert/update operations.
-- CREATE SPATIAL INDEX SIDX_BreweryPostLocation_Coordinates
-- ON BreweryPostLocation(Coordinates)
-- USING GEOGRAPHY_GRID
-- WITH (
-- GRIDS = (LEVEL_1 = MEDIUM, LEVEL_2 = MEDIUM, LEVEL_3 = MEDIUM, LEVEL_4 = MEDIUM),
-- CELLS_PER_OBJECT = 16
-- );
---------------------------------------------------------------------------- ----------------------------------------------------------------------------
---------------------------------------------------------------------------- ----------------------------------------------------------------------------
@@ -403,13 +423,14 @@ CREATE TABLE BreweryPostPhoto -- All photos linked to a post are deleted if the
); );
CREATE NONCLUSTERED INDEX IX_BreweryPostPhoto_Photo_BreweryPost CREATE NONCLUSTERED INDEX IX_BreweryPostPhoto_Photo_BreweryPost
ON BreweryPostPhoto(PhotoID, BreweryPostID); ON BreweryPostPhoto(PhotoID, BreweryPostID);
CREATE NONCLUSTERED INDEX IX_BreweryPostPhoto_BreweryPost_Photo CREATE NONCLUSTERED INDEX IX_BreweryPostPhoto_BreweryPost_Photo
ON BreweryPostPhoto(BreweryPostID, PhotoID); ON BreweryPostPhoto(BreweryPostID, PhotoID);
---------------------------------------------------------------------------- ----------------------------------------------------------------------------
---------------------------------------------------------------------------- ----------------------------------------------------------------------------
CREATE TABLE BeerStyle CREATE TABLE BeerStyle
( (
BeerStyleID UNIQUEIDENTIFIER BeerStyleID UNIQUEIDENTIFIER
@@ -444,7 +465,7 @@ CREATE TABLE BeerPost
-- Alcohol By Volume (typically 0-67%) -- Alcohol By Volume (typically 0-67%)
IBU INT NOT NULL, IBU INT NOT NULL,
-- International Bitterness Units (typically 0-100) -- International Bitterness Units (typically 0-120)
PostedByID UNIQUEIDENTIFIER NOT NULL, PostedByID UNIQUEIDENTIFIER NOT NULL,
@@ -464,7 +485,8 @@ CREATE TABLE BeerPost
CONSTRAINT FK_BeerPost_PostedBy CONSTRAINT FK_BeerPost_PostedBy
FOREIGN KEY (PostedByID) FOREIGN KEY (PostedByID)
REFERENCES UserAccount(UserAccountID), REFERENCES UserAccount(UserAccountID)
ON DELETE NO ACTION,
CONSTRAINT FK_BeerPost_BeerStyle CONSTRAINT FK_BeerPost_BeerStyle
FOREIGN KEY (BeerStyleID) FOREIGN KEY (BeerStyleID)
@@ -522,10 +544,10 @@ CREATE TABLE BeerPostPhoto -- All photos linked to a beer post are deleted if th
); );
CREATE NONCLUSTERED INDEX IX_BeerPostPhoto_Photo_BeerPost CREATE NONCLUSTERED INDEX IX_BeerPostPhoto_Photo_BeerPost
ON BeerPostPhoto(PhotoID, BeerPostID); ON BeerPostPhoto(PhotoID, BeerPostID);
CREATE NONCLUSTERED INDEX IX_BeerPostPhoto_BeerPost_Photo CREATE NONCLUSTERED INDEX IX_BeerPostPhoto_BeerPost_Photo
ON BeerPostPhoto(BeerPostID, PhotoID); ON BeerPostPhoto(BeerPostID, PhotoID);
---------------------------------------------------------------------------- ----------------------------------------------------------------------------
---------------------------------------------------------------------------- ----------------------------------------------------------------------------
@@ -539,17 +561,35 @@ CREATE TABLE BeerPostComment
BeerPostID UNIQUEIDENTIFIER NOT NULL, BeerPostID UNIQUEIDENTIFIER NOT NULL,
CommentedByID UNIQUEIDENTIFIER NOT NULL,
Rating INT NOT NULL, Rating INT NOT NULL,
CreatedAt DATETIME NOT NULL
CONSTRAINT DF_BeerPostComment_CreatedAt DEFAULT GETDATE(),
UpdatedAt DATETIME NULL,
Timer ROWVERSION, Timer ROWVERSION,
CONSTRAINT PK_BeerPostComment CONSTRAINT PK_BeerPostComment
PRIMARY KEY (BeerPostCommentID), PRIMARY KEY (BeerPostCommentID),
CONSTRAINT FK_BeerPostComment_BeerPost CONSTRAINT FK_BeerPostComment_BeerPost
FOREIGN KEY (BeerPostID) REFERENCES BeerPost(BeerPostID) FOREIGN KEY (BeerPostID)
) REFERENCES BeerPost(BeerPostID),
CONSTRAINT FK_BeerPostComment_UserAccount
FOREIGN KEY (CommentedByID)
REFERENCES UserAccount(UserAccountID)
ON DELETE NO ACTION,
CONSTRAINT CHK_BeerPostComment_Rating
CHECK (Rating BETWEEN 1 AND 5)
);
CREATE NONCLUSTERED INDEX IX_BeerPostComment_BeerPost CREATE NONCLUSTERED INDEX IX_BeerPostComment_BeerPost
ON BeerPostComment(BeerPostID) ON BeerPostComment(BeerPostID);
CREATE NONCLUSTERED INDEX IX_BeerPostComment_CommentedBy
ON BeerPostComment(CommentedByID);

View File

@@ -0,0 +1,45 @@
CREATE OR ALTER PROCEDURE dbo.USP_CreateBrewery(
@BreweryName NVARCHAR(256),
@Description NVARCHAR(512),
@PostedByID UNIQUEIDENTIFIER,
@CityID UNIQUEIDENTIFIER,
@AddressLine1 NVARCHAR(256),
@AddressLine2 NVARCHAR(256) = NULL,
@PostalCode NVARCHAR(20),
@Coordinates GEOGRAPHY = NULL
)
AS
BEGIN
SET NOCOUNT ON;
SET XACT_ABORT ON;
IF @BreweryName IS NULL
THROW 50001, 'Brewery name cannot be null.', 1;
IF @Description IS NULL
THROW 50002, 'Brewery description cannot be null.', 1;
IF NOT EXISTS (SELECT 1
FROM dbo.UserAccount
WHERE UserAccountID = @PostedByID)
THROW 50404, 'User not found.', 1;
IF NOT EXISTS (SELECT 1
FROM dbo.City
WHERE CityID = @CityID)
THROW 50404, 'City not found.', 1;
DECLARE @NewBreweryID UNIQUEIDENTIFIER = NEWID();
BEGIN TRANSACTION;
INSERT INTO dbo.BreweryPost
(BreweryPostID, BreweryName, Description, PostedByID)
VALUES (@NewBreweryID, @BreweryName, @Description, @PostedByID);
INSERT INTO dbo.BreweryPostLocation
(@NewBreweryID, CityID, AddressLine1, AddressLine2, PostalCode, Coordinates)
VALUES (@NewBreweryID, @CityID, @AddressLine1, @AddressLine2, @PostalCode, @Coordinates);
COMMIT TRANSACTION;
END

View File

@@ -0,0 +1,117 @@
@using Infrastructure.Email.Templates.Components
<!DOCTYPE html>
<html lang="en" xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml"
xmlns:o="urn:schemas-microsoft-com:office:office">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="x-apple-disable-message-reformatting">
<title>Resend Confirmation - The Biergarten App</title>
<!--[if mso]>
<style>
* { font-family: Arial, sans-serif !important; }
table { border-collapse: collapse; }
</style>
<![endif]-->
<!--[if !mso]><!-->
<style>
* {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
}
</style>
<!--<![endif]-->
</head>
<body style="margin:0; padding:0; background-color:#f4f4f4; width:100%;">
<table role="presentation" border="0" cellpadding="0" cellspacing="0" width="100%" style="background-color:#f4f4f4;">
<tr>
<td align="center" style="padding:40px 10px;">
<!--[if mso]>
<table border="0" cellpadding="0" cellspacing="0" width="600" style="width:600px;">
<tr><td>
<![endif]-->
<table role="presentation" border="0" cellpadding="0" cellspacing="0" width="100%"
style="max-width:600px; background:#ffffff; border-radius:8px; box-shadow:0 2px 8px rgba(0,0,0,.08);">
<Header />
<tr>
<td style="padding:40px 40px 16px 40px; text-align:center;">
<h1 style="margin:0; color:#333333; font-size:26px; font-weight:700;">
New Confirmation Link
</h1>
</td>
</tr>
<tr>
<td style="padding:0 40px 20px 40px; text-align:center;">
<p style="margin:0; color:#666666; font-size:16px; line-height:24px;">
Hi <strong style="color:#333333;">@Username</strong>, you requested another email confirmation
link.
Use the button below to verify your account.
</p>
</td>
</tr>
<tr>
<td style="padding:8px 40px;">
<table role="presentation" border="0" cellpadding="0" cellspacing="0" width="100%">
<tr>
<td align="center">
<!--[if mso]>
<v:roundrect xmlns:v="urn:schemas-microsoft-com:vml" xmlns:w="urn:schemas-microsoft-com:office:word"
href="@ConfirmationLink" style="height:50px;v-text-anchor:middle;width:260px;"
arcsize="10%" stroke="f" fillcolor="#f59e0b">
<w:anchorlock/>
<center style="color:#ffffff;font-family:Arial,sans-serif;font-size:16px;font-weight:700;">
Confirm Email Again
</center>
</v:roundrect>
<![endif]-->
<!--[if !mso]><!-->
<a href="@ConfirmationLink" target="_blank" rel="noopener noreferrer"
style="display:inline-block; padding:16px 40px; background:#d97706; color:#ffffff; text-decoration:none; border-radius:6px; font-size:16px; font-weight:700;">
Confirm Email Again
</a>
<!--<![endif]-->
</td>
</tr>
</table>
</td>
</tr>
<tr>
<td style="padding:20px 40px 8px 40px; text-align:center;">
<p style="margin:0; color:#999999; font-size:13px; line-height:20px;">
This replacement link expires in 24 hours.
</p>
</td>
</tr>
<tr>
<td style="padding:0 40px 28px 40px; text-align:center;">
<p style="margin:0; color:#999999; font-size:13px; line-height:20px;">
If you did not request this, you can safely ignore this email.
</p>
</td>
</tr>
<EmailFooter FooterText="Cheers, The Biergarten App Team" />
</table>
<!--[if mso]></td></tr></table><![endif]-->
</td>
</tr>
</table>
</body>
</html>
@code {
[Parameter]
public string Username { get; set; } = string.Empty;
[Parameter]
public string ConfirmationLink { get; set; } = string.Empty;
}

View File

@@ -30,6 +30,23 @@ public class EmailTemplateProvider(
return await RenderComponentAsync<UserRegistration>(parameters); return await RenderComponentAsync<UserRegistration>(parameters);
} }
/// <summary>
/// Renders the ResendConfirmation template with the specified parameters.
/// </summary>
public async Task<string> RenderResendConfirmationEmailAsync(
string username,
string confirmationLink
)
{
var parameters = new Dictionary<string, object?>
{
{ nameof(ResendConfirmation.Username), username },
{ nameof(ResendConfirmation.ConfirmationLink), confirmationLink },
};
return await RenderComponentAsync<ResendConfirmation>(parameters);
}
/// <summary> /// <summary>
/// Generic method to render any Razor component to HTML. /// Generic method to render any Razor component to HTML.
/// </summary> /// </summary>

View File

@@ -15,4 +15,15 @@ public interface IEmailTemplateProvider
string username, string username,
string confirmationLink string confirmationLink
); );
/// <summary>
/// Renders the ResendConfirmation template with the specified parameters.
/// </summary>
/// <param name="username">The username to include in the email</param>
/// <param name="confirmationLink">The new confirmation link</param>
/// <returns>The rendered HTML string</returns>
Task<string> RenderResendConfirmationEmailAsync(
string username,
string confirmationLink
);
} }

View File

@@ -159,7 +159,7 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory)
return await GetUserByIdAsync(userAccountId); return await GetUserByIdAsync(userAccountId);
} }
private async Task<bool> IsUserVerifiedAsync(Guid userAccountId) public async Task<bool> IsUserVerifiedAsync(Guid userAccountId)
{ {
await using var connection = await CreateConnection(); await using var connection = await CreateConnection();
await using var command = connection.CreateCommand(); await using var command = connection.CreateCommand();

View File

@@ -75,4 +75,11 @@ public interface IAuthRepository
/// <param name="userAccountId">ID of the user account</param> /// <param name="userAccountId">ID of the user account</param>
/// <returns>UserAccount if found, null otherwise</returns> /// <returns>UserAccount if found, null otherwise</returns>
Task<Domain.Entities.UserAccount?> GetUserByIdAsync(Guid userAccountId); Task<Domain.Entities.UserAccount?> GetUserByIdAsync(Guid userAccountId);
/// <summary>
/// Checks whether a user account has been verified.
/// </summary>
/// <param name="userAccountId">ID of the user account</param>
/// <returns>True if the user has a verification record, false otherwise</returns>
Task<bool> IsUserVerifiedAsync(Guid userAccountId);
} }

View File

@@ -5,151 +5,155 @@ using Domain.Exceptions;
using FluentAssertions; using FluentAssertions;
using Infrastructure.Repository.Auth; using Infrastructure.Repository.Auth;
using Moq; using Moq;
using Service.Emails;
namespace Service.Auth.Tests; namespace Service.Auth.Tests;
public class ConfirmationServiceTest public class ConfirmationServiceTest
{ {
private readonly Mock<IAuthRepository> _authRepositoryMock; private readonly Mock<IAuthRepository> _authRepositoryMock;
private readonly Mock<ITokenService> _tokenServiceMock; private readonly Mock<ITokenService> _tokenServiceMock;
private readonly ConfirmationService _confirmationService; private readonly Mock<IEmailService> _emailServiceMock;
private readonly ConfirmationService _confirmationService;
public ConfirmationServiceTest() public ConfirmationServiceTest()
{ {
_authRepositoryMock = new Mock<IAuthRepository>(); _authRepositoryMock = new Mock<IAuthRepository>();
_tokenServiceMock = new Mock<ITokenService>(); _tokenServiceMock = new Mock<ITokenService>();
_emailServiceMock = new Mock<IEmailService>();
_confirmationService = new ConfirmationService( _confirmationService = new ConfirmationService(
_authRepositoryMock.Object, _authRepositoryMock.Object,
_tokenServiceMock.Object _tokenServiceMock.Object,
); _emailServiceMock.Object
} );
}
[Fact] [Fact]
public async Task ConfirmUserAsync_WithValidConfirmationToken_ConfirmsUser() public async Task ConfirmUserAsync_WithValidConfirmationToken_ConfirmsUser()
{ {
// Arrange // Arrange
var userId = Guid.NewGuid(); var userId = Guid.NewGuid();
const string username = "testuser"; const string username = "testuser";
const string confirmationToken = "valid-confirmation-token"; const string confirmationToken = "valid-confirmation-token";
var claims = new List<Claim> var claims = new List<Claim>
{ {
new(JwtRegisteredClaimNames.Sub, userId.ToString()), new(JwtRegisteredClaimNames.Sub, userId.ToString()),
new(JwtRegisteredClaimNames.UniqueName, username), new(JwtRegisteredClaimNames.UniqueName, username),
new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()), new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
}; };
var claimsIdentity = new ClaimsIdentity(claims); var claimsIdentity = new ClaimsIdentity(claims);
var principal = new ClaimsPrincipal(claimsIdentity); var principal = new ClaimsPrincipal(claimsIdentity);
var validatedToken = new ValidatedToken(userId, username, principal); var validatedToken = new ValidatedToken(userId, username, principal);
var userAccount = new UserAccount var userAccount = new UserAccount
{ {
UserAccountId = userId, UserAccountId = userId,
Username = username, Username = username,
FirstName = "Test", FirstName = "Test",
LastName = "User", LastName = "User",
Email = "test@example.com", Email = "test@example.com",
DateOfBirth = new DateTime(1990, 1, 1), DateOfBirth = new DateTime(1990, 1, 1),
}; };
_tokenServiceMock _tokenServiceMock
.Setup(x => x.ValidateConfirmationTokenAsync(confirmationToken)) .Setup(x => x.ValidateConfirmationTokenAsync(confirmationToken))
.ReturnsAsync(validatedToken); .ReturnsAsync(validatedToken);
_authRepositoryMock _authRepositoryMock
.Setup(x => x.ConfirmUserAccountAsync(userId)) .Setup(x => x.ConfirmUserAccountAsync(userId))
.ReturnsAsync(userAccount); .ReturnsAsync(userAccount);
// Act // Act
var result = var result =
await _confirmationService.ConfirmUserAsync(confirmationToken); await _confirmationService.ConfirmUserAsync(confirmationToken);
// Assert // Assert
result.Should().NotBeNull(); result.Should().NotBeNull();
result.UserId.Should().Be(userId); result.UserId.Should().Be(userId);
result.ConfirmedAt.Should().BeCloseTo(DateTime.UtcNow, TimeSpan.FromSeconds(1)); result.ConfirmedAt.Should().BeCloseTo(DateTime.UtcNow, TimeSpan.FromSeconds(1));
_tokenServiceMock.Verify( _tokenServiceMock.Verify(
x => x.ValidateConfirmationTokenAsync(confirmationToken), x => x.ValidateConfirmationTokenAsync(confirmationToken),
Times.Once Times.Once
); );
_authRepositoryMock.Verify( _authRepositoryMock.Verify(
x => x.ConfirmUserAccountAsync(userId), x => x.ConfirmUserAccountAsync(userId),
Times.Once Times.Once
); );
} }
[Fact] [Fact]
public async Task ConfirmUserAsync_WithInvalidConfirmationToken_ThrowsUnauthorizedException() public async Task ConfirmUserAsync_WithInvalidConfirmationToken_ThrowsUnauthorizedException()
{ {
// Arrange // Arrange
const string invalidToken = "invalid-confirmation-token"; const string invalidToken = "invalid-confirmation-token";
_tokenServiceMock _tokenServiceMock
.Setup(x => x.ValidateConfirmationTokenAsync(invalidToken)) .Setup(x => x.ValidateConfirmationTokenAsync(invalidToken))
.ThrowsAsync(new UnauthorizedException( .ThrowsAsync(new UnauthorizedException(
"Invalid confirmation token" "Invalid confirmation token"
)); ));
// Act & Assert // Act & Assert
await FluentActions.Invoking(async () => await FluentActions.Invoking(async () =>
await _confirmationService.ConfirmUserAsync(invalidToken) await _confirmationService.ConfirmUserAsync(invalidToken)
).Should().ThrowAsync<UnauthorizedException>(); ).Should().ThrowAsync<UnauthorizedException>();
} }
[Fact] [Fact]
public async Task ConfirmUserAsync_WithExpiredConfirmationToken_ThrowsUnauthorizedException() public async Task ConfirmUserAsync_WithExpiredConfirmationToken_ThrowsUnauthorizedException()
{ {
// Arrange // Arrange
const string expiredToken = "expired-confirmation-token"; const string expiredToken = "expired-confirmation-token";
_tokenServiceMock _tokenServiceMock
.Setup(x => x.ValidateConfirmationTokenAsync(expiredToken)) .Setup(x => x.ValidateConfirmationTokenAsync(expiredToken))
.ThrowsAsync(new UnauthorizedException( .ThrowsAsync(new UnauthorizedException(
"Confirmation token has expired" "Confirmation token has expired"
)); ));
// Act & Assert // Act & Assert
await FluentActions.Invoking(async () => await FluentActions.Invoking(async () =>
await _confirmationService.ConfirmUserAsync(expiredToken) await _confirmationService.ConfirmUserAsync(expiredToken)
).Should().ThrowAsync<UnauthorizedException>(); ).Should().ThrowAsync<UnauthorizedException>();
} }
[Fact] [Fact]
public async Task ConfirmUserAsync_WithNonExistentUser_ThrowsUnauthorizedException() public async Task ConfirmUserAsync_WithNonExistentUser_ThrowsUnauthorizedException()
{ {
// Arrange // Arrange
var userId = Guid.NewGuid(); var userId = Guid.NewGuid();
const string username = "nonexistent"; const string username = "nonexistent";
const string confirmationToken = "valid-token-for-nonexistent-user"; const string confirmationToken = "valid-token-for-nonexistent-user";
var claims = new List<Claim> var claims = new List<Claim>
{ {
new(JwtRegisteredClaimNames.Sub, userId.ToString()), new(JwtRegisteredClaimNames.Sub, userId.ToString()),
new(JwtRegisteredClaimNames.UniqueName, username), new(JwtRegisteredClaimNames.UniqueName, username),
new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()), new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
}; };
var claimsIdentity = new ClaimsIdentity(claims); var claimsIdentity = new ClaimsIdentity(claims);
var principal = new ClaimsPrincipal(claimsIdentity); var principal = new ClaimsPrincipal(claimsIdentity);
var validatedToken = new ValidatedToken(userId, username, principal); var validatedToken = new ValidatedToken(userId, username, principal);
_tokenServiceMock _tokenServiceMock
.Setup(x => x.ValidateConfirmationTokenAsync(confirmationToken)) .Setup(x => x.ValidateConfirmationTokenAsync(confirmationToken))
.ReturnsAsync(validatedToken); .ReturnsAsync(validatedToken);
_authRepositoryMock _authRepositoryMock
.Setup(x => x.ConfirmUserAccountAsync(userId)) .Setup(x => x.ConfirmUserAccountAsync(userId))
.ReturnsAsync((UserAccount?)null); .ReturnsAsync((UserAccount?)null);
// Act & Assert // Act & Assert
await FluentActions.Invoking(async () => await FluentActions.Invoking(async () =>
await _confirmationService.ConfirmUserAsync(confirmationToken) await _confirmationService.ConfirmUserAsync(confirmationToken)
).Should().ThrowAsync<UnauthorizedException>() ).Should().ThrowAsync<UnauthorizedException>()
.WithMessage("*User account not found*"); .WithMessage("*User account not found*");
} }
} }

View File

@@ -1,34 +1,53 @@
using Domain.Exceptions; using Domain.Exceptions;
using Infrastructure.Repository.Auth; using Infrastructure.Repository.Auth;
using Service.Emails;
namespace Service.Auth; namespace Service.Auth;
public class ConfirmationService( public class ConfirmationService(
IAuthRepository authRepository, IAuthRepository authRepository,
ITokenService tokenService ITokenService tokenService,
IEmailService emailService
) : IConfirmationService ) : IConfirmationService
{ {
public async Task<ConfirmationServiceReturn> ConfirmUserAsync( public async Task<ConfirmationServiceReturn> ConfirmUserAsync(
string confirmationToken string confirmationToken
) )
{ {
var validatedToken = await tokenService.ValidateConfirmationTokenAsync( var validatedToken = await tokenService.ValidateConfirmationTokenAsync(
confirmationToken confirmationToken
); );
var user = await authRepository.ConfirmUserAccountAsync( var user = await authRepository.ConfirmUserAccountAsync(
validatedToken.UserId validatedToken.UserId
); );
if (user == null) if (user == null)
{ {
throw new UnauthorizedException("User account not found"); throw new UnauthorizedException("User account not found");
} }
return new ConfirmationServiceReturn( return new ConfirmationServiceReturn(
DateTime.UtcNow, DateTime.UtcNow,
user.UserAccountId user.UserAccountId
); );
} }
public async Task ResendConfirmationEmailAsync(Guid userId)
{
var user = await authRepository.GetUserByIdAsync(userId);
if (user == null)
{
return; // Silent return to prevent user enumeration
}
if (await authRepository.IsUserVerifiedAsync(userId))
{
return; // Already confirmed, no-op
}
var confirmationToken = tokenService.GenerateConfirmationToken(user);
await emailService.SendResendConfirmationEmailAsync(user, confirmationToken);
}
} }

View File

@@ -8,4 +8,6 @@ public record ConfirmationServiceReturn(DateTime ConfirmedAt, Guid UserId);
public interface IConfirmationService public interface IConfirmationService
{ {
Task<ConfirmationServiceReturn> ConfirmUserAsync(string confirmationToken); Task<ConfirmationServiceReturn> ConfirmUserAsync(string confirmationToken);
Task ResendConfirmationEmailAsync(Guid userId);
} }

View File

@@ -10,6 +10,11 @@ public interface IEmailService
UserAccount createdUser, UserAccount createdUser,
string confirmationToken string confirmationToken
); );
public Task SendResendConfirmationEmailAsync(
UserAccount user,
string confirmationToken
);
} }
public class EmailService( public class EmailService(
@@ -17,13 +22,17 @@ public class EmailService(
IEmailTemplateProvider emailTemplateProvider IEmailTemplateProvider emailTemplateProvider
) : IEmailService ) : IEmailService
{ {
private static readonly string WebsiteBaseUrl =
Environment.GetEnvironmentVariable("WEBSITE_BASE_URL")
?? throw new InvalidOperationException("WEBSITE_BASE_URL environment variable is not set");
public async Task SendRegistrationEmailAsync( public async Task SendRegistrationEmailAsync(
UserAccount createdUser, UserAccount createdUser,
string confirmationToken string confirmationToken
) )
{ {
var confirmationLink = var confirmationLink =
$"https://thebiergarten.app/confirm?token={confirmationToken}"; $"{WebsiteBaseUrl}/users/confirm?token={confirmationToken}";
var emailHtml = var emailHtml =
await emailTemplateProvider.RenderUserRegisteredEmailAsync( await emailTemplateProvider.RenderUserRegisteredEmailAsync(
@@ -38,4 +47,26 @@ public class EmailService(
isHtml: true isHtml: true
); );
} }
public async Task SendResendConfirmationEmailAsync(
UserAccount user,
string confirmationToken
)
{
var confirmationLink =
$"{WebsiteBaseUrl}/users/confirm?token={confirmationToken}";
var emailHtml =
await emailTemplateProvider.RenderResendConfirmationEmailAsync(
user.FirstName,
confirmationLink
);
await emailProvider.SendAsync(
user.Email,
"Confirm Your Email - The Biergarten App",
emailHtml,
isHtml: true
);
}
} }

10881
src/Website-v1/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,98 @@
{
"name": "biergarten",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"prestart": "npm run build",
"start": "next start",
"lint": "next lint",
"clear-db": "npx ts-node ./src/prisma/seed/clear/index.ts",
"format": "npx prettier . --write; npx prisma format;",
"format-watch": "npx onchange \"**/*\" -- prettier --write --ignore-unknown {{changed}}",
"seed": "npx --max-old-space-size=4096 ts-node ./src/prisma/seed/index.ts"
},
"dependencies": {
"@hapi/iron": "^7.0.1",
"@headlessui/react": "^1.7.15",
"@headlessui/tailwindcss": "^0.2.0",
"@hookform/resolvers": "^3.3.1",
"@mapbox/mapbox-sdk": "^0.15.2",
"@mapbox/search-js-core": "^1.0.0-beta.17",
"@mapbox/search-js-react": "^1.0.0-beta.17",
"@next/bundle-analyzer": "^14.0.3",
"@prisma/client": "^5.7.0",
"@react-email/components": "^0.0.11",
"@react-email/render": "^0.0.9",
"@react-email/tailwind": "^0.0.12",
"@vercel/analytics": "^1.1.0",
"argon2": "^0.31.1",
"classnames": "^2.5.1",
"cloudinary": "^1.41.0",
"cookie": "^0.7.0",
"date-fns": "^2.30.0",
"dotenv": "^16.3.1",
"jsonwebtoken": "^9.0.1",
"lodash": "^4.17.21",
"mapbox-gl": "^3.4.0",
"multer": "^1.4.5-lts.1",
"next": "^14.2.22",
"next-cloudinary": "^5.10.0",
"next-connect": "^1.0.0-next.3",
"passport": "^0.6.0",
"passport-local": "^1.0.0",
"pino": "^10.0.0",
"react": "^18.2.0",
"react-daisyui": "^5.0.0",
"react-dom": "^18.2.0",
"react-email": "^1.9.5",
"react-hook-form": "^7.45.2",
"react-hot-toast": "^2.4.1",
"react-icons": "^4.10.1",
"react-intersection-observer": "^9.5.2",
"react-map-gl": "^7.1.7",
"react-responsive-carousel": "^3.2.23",
"swr": "^2.2.0",
"theme-change": "^2.5.0",
"zod": "^3.21.4"
},
"devDependencies": {
"@faker-js/faker": "^8.3.1",
"@types/cookie": "^0.5.1",
"@types/express": "^4.17.21",
"@types/jsonwebtoken": "^9.0.2",
"@types/lodash": "^4.14.195",
"@types/mapbox__mapbox-sdk": "^0.13.4",
"@types/multer": "^1.4.7",
"@types/node": "^20.4.2",
"@types/passport-local": "^1.0.35",
"@types/react": "^18.2.15",
"@types/react-dom": "^18.2.7",
"@vercel/fetch": "^7.0.0",
"autoprefixer": "^10.4.14",
"daisyui": "^4.7.2",
"dotenv-cli": "^7.2.1",
"eslint": "^8.51.0",
"eslint-config-airbnb-base": "15.0.0",
"eslint-config-airbnb-typescript": "17.1.0",
"eslint-config-next": "^13.5.4",
"eslint-config-prettier": "^9.0.0",
"eslint-plugin-react": "^7.33.2",
"generate-password": "^1.7.1",
"onchange": "^7.1.0",
"postcss": "^8.4.26",
"prettier": "^3.0.0",
"prettier-plugin-jsdoc": "^1.0.2",
"prettier-plugin-tailwindcss": "^0.5.7",
"prisma": "^5.7.0",
"tailwindcss": "^3.4.1",
"tailwindcss-animated": "^1.0.1",
"ts-node": "^10.9.1",
"typescript": "^5.3.2"
},
"prisma": {
"schema": "./src/prisma/schema.prisma",
"seed": "npm run seed"
}
}

View File

@@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

View File

Before

Width:  |  Height:  |  Size: 203 KiB

After

Width:  |  Height:  |  Size: 203 KiB

View File

Before

Width:  |  Height:  |  Size: 7.3 KiB

After

Width:  |  Height:  |  Size: 7.3 KiB

View File

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 26 KiB

View File

Before

Width:  |  Height:  |  Size: 6.5 KiB

After

Width:  |  Height:  |  Size: 6.5 KiB

View File

Before

Width:  |  Height:  |  Size: 515 B

After

Width:  |  Height:  |  Size: 515 B

View File

Before

Width:  |  Height:  |  Size: 961 B

After

Width:  |  Height:  |  Size: 961 B

View File

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

View File

Before

Width:  |  Height:  |  Size: 256 KiB

After

Width:  |  Height:  |  Size: 256 KiB

Some files were not shown because too many files have changed in this diff Show More