Compare commits
13 Commits
main-2.0
...
3fd531c9f0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3fd531c9f0 | ||
|
|
ef27d6f553 | ||
|
|
4b3f3dc50a | ||
|
|
7c97825f91 | ||
|
|
4e48089c18 | ||
|
|
f6dc64b88b | ||
|
|
769c717405 | ||
|
|
c5571fcf47 | ||
|
|
c20be03f89 | ||
|
|
d1fedc72af | ||
|
|
b850d1047e | ||
|
|
250e5f2c9c | ||
|
|
0ab2eaaec9 |
14
.gitignore
vendored
@@ -15,14 +15,6 @@
|
||||
# production
|
||||
/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
|
||||
.DS_Store
|
||||
*.pem
|
||||
@@ -50,9 +42,6 @@ next-env.d.ts
|
||||
|
||||
# vscode
|
||||
.vscode
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
/cloudinary-images
|
||||
|
||||
@@ -498,6 +487,3 @@ FodyWeavers.xsd
|
||||
.env.dev
|
||||
.env.test
|
||||
.env.prod
|
||||
|
||||
*storybook.log
|
||||
storybook-static
|
||||
|
||||
959
LICENSE.md
325
README.md
@@ -1,142 +1,261 @@
|
||||
# The Biergarten App
|
||||
|
||||
The Biergarten App is a multi-project monorepo with a .NET backend and an active React
|
||||
Router frontend in `src/Website`. The current website focuses on account flows, theme
|
||||
switching, shared UI components, Storybook coverage, and integration with the API.
|
||||
A social platform for craft beer enthusiasts to discover breweries, share reviews, and
|
||||
connect with fellow beer lovers.
|
||||
|
||||
## Documentation
|
||||
**Documentation**
|
||||
|
||||
- [Getting Started](docs/getting-started.md) - Local setup for backend and active website
|
||||
- [Architecture](docs/architecture.md) - Current backend and frontend architecture
|
||||
- [Docker Guide](docs/docker.md) - Container-based backend development and testing
|
||||
- [Testing](docs/testing.md) - Backend and frontend test commands
|
||||
- [Environment Variables](docs/environment-variables.md) - Active 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
|
||||
- [Getting Started](docs/getting-started.md) - Setup and installation
|
||||
- [Architecture](docs/architecture.md) - System design and patterns
|
||||
- [Database](docs/database.md) - Schema and stored procedures
|
||||
- [Docker Guide](docs/docker.md) - Container deployment
|
||||
- [Testing](docs/testing.md) - Test strategy and commands
|
||||
- [Environment Variables](docs/environment-variables.md) - Configuration reference
|
||||
|
||||
## Diagrams
|
||||
**Diagrams**
|
||||
|
||||
- [Architecture](docs/diagrams-out/architecture.svg) - Layered architecture
|
||||
- [Deployment](docs/diagrams-out/deployment.svg) - Docker topology
|
||||
- [Authentication Flow](docs/diagrams-out/authentication-flow.svg) - Auth sequence
|
||||
- [Database Schema](docs/diagrams-out/database-schema.svg) - Entity relationships
|
||||
- [Architecture](docs/diagrams/pdf/architecture.pdf) - Layered architecture
|
||||
- [Deployment](docs/diagrams/pdf/deployment.pdf) - Docker topology
|
||||
- [Authentication Flow](docs/diagrams/pdf/authentication-flow.pdf) - Auth sequence
|
||||
- [Database Schema](docs/diagrams/pdf/database-schema.pdf) - Entity relationships
|
||||
|
||||
## Current Status
|
||||
## Project Status
|
||||
|
||||
Active areas in the repository:
|
||||
**Active Development** - Transitioning from full-stack Next.js to multi-project monorepo
|
||||
|
||||
- .NET 10 backend with layered architecture and SQL Server
|
||||
- React Router 7 website in `src/Website`
|
||||
- Shared Biergarten theme system with a theme guide route
|
||||
- Storybook stories and browser-based checks for shared UI
|
||||
- Auth demo flows for home, login, register, dashboard, logout, and confirmation
|
||||
- Toast-based feedback for auth outcomes
|
||||
- Core authentication and user management APIs
|
||||
- Database schema with migrations and seeding
|
||||
- Layered architecture (Domain, Service, Infrastructure, Repository, API)
|
||||
- Comprehensive test suite (unit + integration)
|
||||
- Frontend integration with .NET API (in progress)
|
||||
- Migration from Next.js serverless functions
|
||||
|
||||
Legacy area retained for reference:
|
||||
|
||||
- `src/Website-v1` contains the archived Next.js frontend and is no longer the active website
|
||||
---
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- **Backend**: .NET 10, ASP.NET Core, SQL Server 2022, DbUp
|
||||
- **Frontend**: React 19, React Router 7, Vite 7, Tailwind CSS 4, DaisyUI 5
|
||||
- **UI Documentation**: Storybook 10, Vitest browser mode, Playwright
|
||||
- **Testing**: xUnit, Reqnroll (BDD), FluentAssertions, Moq
|
||||
- **Infrastructure**: Docker, Docker Compose
|
||||
- **Security**: Argon2id password hashing, JWT access/refresh/confirmation tokens
|
||||
**Backend**: .NET 10, ASP.NET Core, SQL Server 2022, DbUp **Frontend**: Next.js 14+,
|
||||
TypeScript, TailwindCSS **Testing**: xUnit, Reqnroll (BDD), FluentAssertions, Moq
|
||||
**Infrastructure**: Docker, Docker Compose **Security**: Argon2id password hashing, JWT
|
||||
(HS256)
|
||||
|
||||
---
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Backend
|
||||
### Prerequisites
|
||||
|
||||
- [.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
|
||||
# Clone repository
|
||||
git clone https://github.com/aaronpo97/the-biergarten-app
|
||||
cd the-biergarten-app
|
||||
|
||||
# Configure environment
|
||||
cp .env.example .env.dev
|
||||
|
||||
# Start all services
|
||||
docker compose -f docker-compose.dev.yaml up -d
|
||||
|
||||
# View logs
|
||||
docker compose -f docker-compose.dev.yaml logs -f
|
||||
```
|
||||
|
||||
Backend access:
|
||||
**Access**:
|
||||
|
||||
- API Swagger: http://localhost:8080/swagger
|
||||
- Health Check: http://localhost:8080/health
|
||||
- API: http://localhost:8080/swagger
|
||||
- Health: http://localhost:8080/health
|
||||
|
||||
### Frontend
|
||||
|
||||
```bash
|
||||
cd src/Website
|
||||
npm install
|
||||
API_BASE_URL=http://localhost:8080 SESSION_SECRET=dev-secret npm run dev
|
||||
```
|
||||
|
||||
Optional frontend tools:
|
||||
|
||||
```bash
|
||||
cd src/Website
|
||||
npm run storybook
|
||||
npm run test:storybook
|
||||
npm run test:storybook:playwright
|
||||
```
|
||||
|
||||
## 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
|
||||
```
|
||||
|
||||
## Key Features
|
||||
|
||||
Implemented today:
|
||||
|
||||
- User registration and login against the API
|
||||
- JWT-based auth with access, refresh, and confirmation flows
|
||||
- SQL Server migrations and seed projects
|
||||
- Shared form components and auth screens
|
||||
- Theme switching with Lager, Stout, Cassis, and Weizen variants
|
||||
- Storybook documentation and automated story interaction tests
|
||||
- Toast feedback for auth-related outcomes
|
||||
|
||||
Planned next:
|
||||
|
||||
- Brewery discovery and management
|
||||
- Beer reviews and ratings
|
||||
- Social follow relationships
|
||||
- Geospatial brewery experiences
|
||||
- Additional frontend routes beyond the auth demo
|
||||
|
||||
## Testing
|
||||
|
||||
Backend suites:
|
||||
|
||||
- `API.Specs` - integration tests
|
||||
- `Infrastructure.Repository.Tests` - repository unit tests
|
||||
- `Service.Auth.Tests` - service unit tests
|
||||
|
||||
Frontend suites:
|
||||
|
||||
- Storybook interaction tests via Vitest
|
||||
- Storybook browser regression checks via Playwright
|
||||
|
||||
Run all backend tests with Docker:
|
||||
### Run Tests
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.test.yaml up --abort-on-container-exit
|
||||
```
|
||||
|
||||
See [Testing](docs/testing.md) for the full command list.
|
||||
Results are in `./test-results/`
|
||||
|
||||
---
|
||||
|
||||
## Repository Structure
|
||||
|
||||
```
|
||||
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
|
||||
|
||||
### Implemented
|
||||
|
||||
- User registration and authentication
|
||||
- JWT token-based auth
|
||||
- Argon2id password hashing
|
||||
- SQL Server with stored procedures
|
||||
- Database migrations (DbUp)
|
||||
- Docker containerization
|
||||
- Comprehensive test suite
|
||||
- Swagger/OpenAPI documentation
|
||||
- Health checks
|
||||
|
||||
### Planned
|
||||
|
||||
- [ ] Brewery discovery and management
|
||||
- [ ] Beer reviews and ratings
|
||||
- [ ] Social following/followers
|
||||
- [ ] Geospatial brewery search
|
||||
- [ ] Image upload (Cloudinary)
|
||||
- [ ] 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
|
||||
|
||||
The project includes three test suites:
|
||||
|
||||
| Suite | Type | Framework | Purpose |
|
||||
| ---------------------- | ----------- | -------------- | ---------------------- |
|
||||
| **API.Specs** | Integration | Reqnroll (BDD) | End-to-end API testing |
|
||||
| **Repository.Tests** | Unit | xUnit | Data access layer |
|
||||
| **Service.Auth.Tests** | Unit | xUnit + Moq | Business logic |
|
||||
|
||||
**Run All Tests**:
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.test.yaml up --abort-on-container-exit
|
||||
```
|
||||
|
||||
**Run Individual Test Suite**:
|
||||
|
||||
```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
|
||||
|
||||
Common active variables:
|
||||
### Required Environment Variables
|
||||
|
||||
- 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`
|
||||
**Backend** (`.env.dev`):
|
||||
|
||||
See [Environment Variables](docs/environment-variables.md) for details.
|
||||
```bash
|
||||
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
|
||||
|
||||
|
||||
@@ -1,27 +1,28 @@
|
||||
# Architecture
|
||||
|
||||
This document describes the active architecture of The Biergarten App.
|
||||
This document describes the architecture patterns and design decisions for The Biergarten
|
||||
App.
|
||||
|
||||
## High-Level Overview
|
||||
|
||||
The Biergarten App is a monorepo with a clear split between the backend and the active
|
||||
website:
|
||||
The Biergarten App follows a **multi-project monorepo** architecture with clear separation
|
||||
between backend and frontend:
|
||||
|
||||
- **Backend**: .NET 10 Web API with SQL Server and a layered architecture
|
||||
- **Frontend**: React 19 + React Router 7 website in `src/Website`
|
||||
- **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).
|
||||
- **Backend**: .NET 10 Web API with SQL Server
|
||||
- **Frontend**: Next.js with TypeScript
|
||||
- **Architecture Style**: Layered architecture with SQL-first approach
|
||||
|
||||
## Diagrams
|
||||
|
||||
For visual representations, see:
|
||||
|
||||
- [architecture.svg](diagrams-out/architecture.svg) - Layered architecture diagram
|
||||
- [deployment.svg](diagrams-out/deployment.svg) - Docker deployment diagram
|
||||
- [authentication-flow.svg](diagrams-out/authentication-flow.svg) - Authentication workflow
|
||||
- [database-schema.svg](diagrams-out/database-schema.svg) - Database relationships
|
||||
- [architecture.pdf](diagrams/pdf/architecture.pdf) - Layered architecture diagram
|
||||
- [deployment.pdf](diagrams/pdf/deployment.pdf) - Docker deployment diagram
|
||||
- [authentication-flow.pdf](diagrams/pdf/authentication-flow.pdf) - Authentication
|
||||
workflow
|
||||
- [database-schema.pdf](diagrams/pdf/database-schema.pdf) - Database relationships
|
||||
|
||||
Generate diagrams with: `make diagrams`
|
||||
|
||||
## Backend Architecture
|
||||
|
||||
@@ -216,49 +217,39 @@ public interface IAuthRepository
|
||||
|
||||
## Frontend Architecture
|
||||
|
||||
### Active Website (`src/Website`)
|
||||
### Next.js Application Structure
|
||||
|
||||
The current website is a React Router 7 application with server-side rendering enabled.
|
||||
|
||||
```text
|
||||
src/Website/
|
||||
├── app/
|
||||
│ ├── components/ Shared UI such as Navbar, FormField, SubmitButton, ToastProvider
|
||||
│ ├── lib/ Auth helpers, schemas, and theme metadata
|
||||
│ ├── routes/ Route modules for home, login, register, dashboard, confirm, theme
|
||||
│ ├── root.tsx App shell and global providers
|
||||
│ └── app.css Theme tokens and global styling
|
||||
├── .storybook/ Storybook config and preview setup
|
||||
├── stories/ Storybook stories for shared UI and themes
|
||||
├── tests/playwright/ Storybook Playwright coverage
|
||||
└── package.json Frontend scripts and dependencies
|
||||
```
|
||||
Website/src/
|
||||
├── components/ # React components
|
||||
├── pages/ # Next.js routes
|
||||
├── contexts/ # React context providers
|
||||
├── hooks/ # Custom React hooks
|
||||
├── controllers/ # Business logic layer
|
||||
├── services/ # API communication
|
||||
├── requests/ # API request builders
|
||||
├── validation/ # Form validation schemas
|
||||
├── config/ # Configuration & env vars
|
||||
└── prisma/ # Database schema (current)
|
||||
```
|
||||
|
||||
### Frontend Responsibilities
|
||||
### Migration Strategy
|
||||
|
||||
- Render the auth demo and theme guide routes
|
||||
- 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
|
||||
The frontend is **transitioning** from a standalone architecture to integrate with the
|
||||
.NET API:
|
||||
|
||||
### Theme System
|
||||
**Current State**:
|
||||
|
||||
The active website uses semantic DaisyUI theme tokens backed by four Biergarten themes:
|
||||
- Uses Prisma ORM with Postgres (Neon)
|
||||
- Has its own server-side API routes
|
||||
- Direct database access from Next.js
|
||||
|
||||
- Biergarten Lager
|
||||
- Biergarten Stout
|
||||
- Biergarten Cassis
|
||||
- Biergarten Weizen
|
||||
**Target State**:
|
||||
|
||||
All component styling should prefer semantic tokens such as `primary`, `success`,
|
||||
`surface`, and `highlight` instead of hard-coded color values.
|
||||
|
||||
### 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).
|
||||
- Pure client-side Next.js app
|
||||
- All data via .NET API
|
||||
- No server-side database access
|
||||
- JWT-based authentication
|
||||
|
||||
## Security Architecture
|
||||
|
||||
@@ -394,7 +385,7 @@ dependencies
|
||||
|
||||
```yaml
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "sqlcmd health check"]
|
||||
test: ['CMD-SHELL', 'sqlcmd health check']
|
||||
interval: 10s
|
||||
retries: 12
|
||||
start_period: 30s
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
# 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)
|
||||
@@ -1,15 +1,14 @@
|
||||
# Environment Variables
|
||||
|
||||
This document covers the active environment variables used by the current Biergarten
|
||||
stack.
|
||||
Complete documentation for all environment variables used in The Biergarten App.
|
||||
|
||||
## Overview
|
||||
|
||||
The application uses environment variables for:
|
||||
The application uses environment variables for configuration across:
|
||||
|
||||
- **.NET API backend** - database connections, token secrets, runtime settings
|
||||
- **React Router website** - API base URL and session signing
|
||||
- **Docker containers** - environment-specific orchestration
|
||||
- **.NET API Backend** - Database connections, JWT secrets
|
||||
- **Next.js Frontend** - External services, authentication
|
||||
- **Docker Containers** - Runtime configuration
|
||||
|
||||
## Configuration Patterns
|
||||
|
||||
@@ -17,10 +16,10 @@ The application uses environment variables for:
|
||||
|
||||
Direct environment variable access via `Environment.GetEnvironmentVariable()`.
|
||||
|
||||
### Frontend (`src/Website`)
|
||||
### Frontend (Next.js)
|
||||
|
||||
The active website reads runtime values from the server environment for its auth and API
|
||||
integration.
|
||||
Centralized configuration module at `src/Website/src/config/env/index.ts` with Zod
|
||||
validation.
|
||||
|
||||
### Docker
|
||||
|
||||
@@ -129,38 +128,91 @@ ASPNETCORE_URLS=http://0.0.0.0:8080 # Binding address and port
|
||||
DOTNET_RUNNING_IN_CONTAINER=true # Flag for container execution
|
||||
```
|
||||
|
||||
## Frontend Variables (`src/Website`)
|
||||
## Frontend Variables (Next.js)
|
||||
|
||||
The active website does not use the old Next.js/Prisma environment model. Its core runtime
|
||||
variables are:
|
||||
Create `.env.local` in the `Website/` directory.
|
||||
|
||||
### Base Configuration
|
||||
|
||||
```bash
|
||||
API_BASE_URL=http://localhost:8080 # Base URL for the .NET API
|
||||
SESSION_SECRET=<generated-secret> # Cookie session signing secret
|
||||
NODE_ENV=development # Standard Node runtime mode
|
||||
BASE_URL=http://localhost:3000 # Application base URL
|
||||
NODE_ENV=development # Environment: development, production, test
|
||||
```
|
||||
|
||||
### Frontend Variable Details
|
||||
### Authentication & Sessions
|
||||
|
||||
#### `API_BASE_URL`
|
||||
```bash
|
||||
# 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
|
||||
|
||||
- **Required**: Yes for local development
|
||||
- **Default in code**: `http://localhost:8080`
|
||||
- **Used by**: `src/Website/app/lib/auth.server.ts`
|
||||
- **Purpose**: Routes website auth actions to the .NET API
|
||||
# Session configuration
|
||||
SESSION_TOKEN_NAME=biergarten # Cookie name (optional)
|
||||
SESSION_MAX_AGE=604800 # Cookie max age in seconds (optional, default: 1 week)
|
||||
```
|
||||
|
||||
#### `SESSION_SECRET`
|
||||
**Security Requirements**:
|
||||
|
||||
- **Required**: Strongly recommended in all environments
|
||||
- **Default in local code path**: `dev-secret-change-me`
|
||||
- **Used by**: React Router cookie session storage in `auth.server.ts`
|
||||
- **Purpose**: Signs and validates the website session cookie
|
||||
- All secrets should be 127+ characters
|
||||
- Generate using cryptographically secure random functions
|
||||
- Never reuse secrets across environments
|
||||
- Rotate secrets periodically in production
|
||||
|
||||
#### `NODE_ENV`
|
||||
### Database (Current - Prisma/Postgres)
|
||||
|
||||
- **Required**: No
|
||||
- **Typical values**: `development`, `production`, `test`
|
||||
- **Purpose**: Controls secure cookie behavior and runtime mode
|
||||
**Note**: Frontend currently uses Neon Postgres. Will migrate to .NET API.
|
||||
|
||||
```bash
|
||||
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)
|
||||
|
||||
@@ -206,42 +258,72 @@ cp .env.example .env.dev
|
||||
# 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.dev.yaml` → `.env.dev`
|
||||
- `docker-compose.test.yaml` → `.env.test`
|
||||
- `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 | Backend | Frontend | Docker | Required | Notes |
|
||||
| ----------------------------- | :-----: | :------: | :----: | :------: | -------------------------- |
|
||||
| `DB_SERVER` | ✓ | | ✓ | Yes\* | SQL Server address |
|
||||
| `DB_NAME` | ✓ | | ✓ | Yes\* | Database name |
|
||||
| `DB_USER` | ✓ | | ✓ | Yes\* | SQL username |
|
||||
| `DB_PASSWORD` | ✓ | | ✓ | Yes\* | SQL password |
|
||||
| `DB_CONNECTION_STRING` | ✓ | | | Yes\* | Alternative to components |
|
||||
| `DB_TRUST_SERVER_CERTIFICATE` | ✓ | | ✓ | No | Defaults to `True` |
|
||||
| `ACCESS_TOKEN_SECRET` | ✓ | | ✓ | Yes | Access token signing |
|
||||
| `REFRESH_TOKEN_SECRET` | ✓ | | ✓ | Yes | Refresh token signing |
|
||||
| `CONFIRMATION_TOKEN_SECRET` | ✓ | | ✓ | Yes | Confirmation token signing |
|
||||
| `WEBSITE_BASE_URL` | ✓ | | | Yes | Website URL for emails |
|
||||
| `API_BASE_URL` | | ✓ | | Yes | Website-to-API base URL |
|
||||
| `SESSION_SECRET` | | ✓ | | Yes | Website session signing |
|
||||
| `NODE_ENV` | | ✓ | | No | Runtime mode |
|
||||
| `CLEAR_DATABASE` | ✓ | | ✓ | No | Dev/test reset flag |
|
||||
| `ASPNETCORE_ENVIRONMENT` | ✓ | | ✓ | Yes | ASP.NET environment |
|
||||
| `ASPNETCORE_URLS` | ✓ | | ✓ | Yes | API binding address |
|
||||
| `SA_PASSWORD` | | | ✓ | Yes | SQL Server container |
|
||||
| `ACCEPT_EULA` | | | ✓ | Yes | SQL Server EULA |
|
||||
| `MSSQL_PID` | | | ✓ | No | SQL Server edition |
|
||||
| `DOTNET_RUNNING_IN_CONTAINER` | ✓ | | ✓ | No | Container flag |
|
||||
| Variable | Backend | Frontend | Docker | Required | Notes |
|
||||
| ----------------------------------- | :-----: | :------: | :----: | :------: | ------------------------- |
|
||||
| **Database** |
|
||||
| `DB_SERVER` | ✓ | | ✓ | Yes\* | SQL Server address |
|
||||
| `DB_NAME` | ✓ | | ✓ | Yes\* | Database name |
|
||||
| `DB_USER` | ✓ | | ✓ | Yes\* | SQL username |
|
||||
| `DB_PASSWORD` | ✓ | | ✓ | Yes\* | SQL password |
|
||||
| `DB_CONNECTION_STRING` | ✓ | | | Yes\* | Alternative to components |
|
||||
| `DB_TRUST_SERVER_CERTIFICATE` | ✓ | | ✓ | No | Defaults to True |
|
||||
| `SA_PASSWORD` | | | ✓ | Yes | SQL Server container |
|
||||
| **Authentication (Backend - JWT)** |
|
||||
| `ACCESS_TOKEN_SECRET` | ✓ | | ✓ | Yes | Access token secret |
|
||||
| `REFRESH_TOKEN_SECRET` | ✓ | | ✓ | Yes | Refresh token secret |
|
||||
| `CONFIRMATION_TOKEN_SECRET` | ✓ | | ✓ | Yes | Confirmation token secret |
|
||||
| `WEBSITE_BASE_URL` | ✓ | | | Yes | Website URL for emails |
|
||||
| **Authentication (Frontend)** |
|
||||
| `CONFIRMATION_TOKEN_SECRET` | | ✓ | | Yes | Email confirmation |
|
||||
| `RESET_PASSWORD_TOKEN_SECRET` | | ✓ | | Yes | Password reset |
|
||||
| `SESSION_SECRET` | | ✓ | | Yes | Session signing |
|
||||
| `SESSION_TOKEN_NAME` | | ✓ | | No | Default: "biergarten" |
|
||||
| `SESSION_MAX_AGE` | | ✓ | | No | Default: 604800 |
|
||||
| **Base Configuration** |
|
||||
| `BASE_URL` | | ✓ | | Yes | App base URL |
|
||||
| `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`,
|
||||
`DB_USER`, `DB_PASSWORD`) must be provided.
|
||||
@@ -258,12 +340,13 @@ Variables are validated at startup:
|
||||
|
||||
### Frontend Validation
|
||||
|
||||
The active website relies on runtime defaults for local development and the surrounding
|
||||
server environment in deployed environments.
|
||||
Zod schemas validate variables at runtime:
|
||||
|
||||
- `API_BASE_URL` defaults to `http://localhost:8080`
|
||||
- `SESSION_SECRET` falls back to a development-only local secret
|
||||
- `NODE_ENV` controls secure cookie behavior
|
||||
- Type checking (string, number, URL, etc.)
|
||||
- Format validation (email, URL patterns)
|
||||
- Required vs optional enforcement
|
||||
|
||||
**Location**: `src/Website/src/config/env/index.ts`
|
||||
|
||||
## Example Configuration Files
|
||||
|
||||
@@ -295,10 +378,28 @@ ACCEPT_EULA=Y
|
||||
MSSQL_PID=Express
|
||||
```
|
||||
|
||||
### Frontend local runtime example
|
||||
### `.env.local` (Frontend)
|
||||
|
||||
```bash
|
||||
API_BASE_URL=http://localhost:8080
|
||||
SESSION_SECRET=<generated-with-openssl>
|
||||
# Base
|
||||
BASE_URL=http://localhost:3000
|
||||
NODE_ENV=development
|
||||
|
||||
# Authentication
|
||||
SESSION_SECRET=<generated-with-openssl>
|
||||
|
||||
# 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!
|
||||
```
|
||||
|
||||
@@ -1,16 +1,19 @@
|
||||
# Getting Started
|
||||
|
||||
This guide covers local setup for the current Biergarten stack: the .NET backend in
|
||||
`src/Core` and the active React Router frontend in `src/Website`.
|
||||
This guide will help you set up and run The Biergarten App in your development
|
||||
environment.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- **.NET SDK 10+**
|
||||
- **Node.js 18+**
|
||||
- **Docker Desktop** or equivalent Docker Engine setup
|
||||
- **Java 8+** if you want to regenerate PlantUML diagrams
|
||||
Before you begin, ensure you have the following installed:
|
||||
|
||||
## Recommended Path: Docker for Backend, Node for Frontend
|
||||
- **.NET SDK 10+** - [Download](https://dotnet.microsoft.com/download)
|
||||
- **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
|
||||
|
||||
@@ -19,120 +22,174 @@ git clone <repository-url>
|
||||
cd the-biergarten-app
|
||||
```
|
||||
|
||||
### 2. Configure Backend Environment Variables
|
||||
### 2. Configure Environment Variables
|
||||
|
||||
Copy the example environment file:
|
||||
|
||||
```bash
|
||||
cp .env.example .env.dev
|
||||
```
|
||||
|
||||
At minimum, ensure `.env.dev` includes valid database and token values:
|
||||
Edit `.env.dev` with your configuration:
|
||||
|
||||
```bash
|
||||
# Database (component-based for Docker)
|
||||
DB_SERVER=sqlserver,1433
|
||||
DB_NAME=Biergarten
|
||||
DB_USER=sa
|
||||
DB_PASSWORD=YourStrong!Passw0rd
|
||||
ACCESS_TOKEN_SECRET=<generated>
|
||||
REFRESH_TOKEN_SECRET=<generated>
|
||||
CONFIRMATION_TOKEN_SECRET=<generated>
|
||||
WEBSITE_BASE_URL=http://localhost:3000
|
||||
|
||||
# JWT Authentication
|
||||
JWT_SECRET=your-secret-key-minimum-32-characters-required
|
||||
```
|
||||
|
||||
See [Environment Variables](environment-variables.md) for the full list.
|
||||
> For a complete list of environment variables, see
|
||||
> [Environment Variables](environment-variables.md).
|
||||
|
||||
### 3. Start the Backend Stack
|
||||
### 3. Start the Development Environment
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.dev.yaml up -d
|
||||
```
|
||||
|
||||
This starts SQL Server, migrations, seeding, and the API.
|
||||
This command will:
|
||||
|
||||
Available endpoints:
|
||||
- Start SQL Server container
|
||||
- Run database migrations
|
||||
- Seed initial data
|
||||
- Start the API on http://localhost:8080
|
||||
|
||||
- API Swagger: http://localhost:8080/swagger
|
||||
- Health Check: http://localhost:8080/health
|
||||
### 4. Access the API
|
||||
|
||||
### 4. Start the Active Frontend
|
||||
|
||||
```bash
|
||||
cd src/Website
|
||||
npm install
|
||||
API_BASE_URL=http://localhost:8080 SESSION_SECRET=dev-secret-change-me npm run dev
|
||||
```
|
||||
|
||||
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
|
||||
cd src/Website
|
||||
npm run storybook
|
||||
```
|
||||
|
||||
Storybook runs at http://localhost:6006 by default.
|
||||
|
||||
## Useful Commands
|
||||
|
||||
### Backend
|
||||
- **Swagger UI**: http://localhost:8080/swagger
|
||||
- **Health Check**: http://localhost:8080/health
|
||||
|
||||
### 5. View Logs
|
||||
|
||||
```bash
|
||||
# All services
|
||||
docker compose -f docker-compose.dev.yaml logs -f
|
||||
|
||||
# Specific service
|
||||
docker compose -f docker-compose.dev.yaml logs -f api.core
|
||||
```
|
||||
|
||||
### 6. Stop the Environment
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.dev.yaml down
|
||||
|
||||
# Remove volumes (fresh start)
|
||||
docker compose -f docker-compose.dev.yaml down -v
|
||||
```
|
||||
|
||||
### Frontend
|
||||
|
||||
```bash
|
||||
cd src/Website
|
||||
npm run lint
|
||||
npm run typecheck
|
||||
npm run format:check
|
||||
npm run test:storybook
|
||||
npm run test:storybook:playwright
|
||||
```
|
||||
|
||||
## Manual Backend Setup
|
||||
|
||||
If you do not want to use Docker, you can run the backend locally.
|
||||
|
||||
### 1. Set Environment Variables
|
||||
## Manual Setup (Without Docker)
|
||||
|
||||
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
|
||||
# macOS/Linux
|
||||
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"
|
||||
export JWT_SECRET="your-secret-key-minimum-32-characters-required"
|
||||
|
||||
# Windows PowerShell
|
||||
$env:DB_CONNECTION_STRING="Server=localhost,1433;Database=Biergarten;User Id=sa;Password=YourStrong!Passw0rd;TrustServerCertificate=True;"
|
||||
$env:JWT_SECRET="your-secret-key-minimum-32-characters-required"
|
||||
```
|
||||
|
||||
### 2. Run Migrations and Seed
|
||||
#### 3. Run Database Migrations
|
||||
|
||||
```bash
|
||||
cd src/Core
|
||||
dotnet run --project Database/Database.Migrations/Database.Migrations.csproj
|
||||
```
|
||||
|
||||
#### 4. Seed the Database
|
||||
|
||||
```bash
|
||||
dotnet run --project Database/Database.Seed/Database.Seed.csproj
|
||||
```
|
||||
|
||||
### 3. Start the API
|
||||
#### 5. Start the API
|
||||
|
||||
```bash
|
||||
dotnet run --project API/API.Core/API.Core.csproj
|
||||
```
|
||||
|
||||
## Legacy Frontend Note
|
||||
The API will be available at http://localhost:5000 (or the port specified in
|
||||
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
|
||||
|
||||
- Review [Architecture](architecture.md)
|
||||
- Run backend and frontend checks from [Testing](testing.md)
|
||||
- Use [Docker Guide](docker.md) for container troubleshooting
|
||||
- **Test the API**: Visit http://localhost:8080/swagger and try the endpoints
|
||||
- **Run Tests**: See [Testing Guide](testing.md)
|
||||
- **Learn the Architecture**: Read [Architecture Overview](architecture.md)
|
||||
- **Understand Docker Setup**: See [Docker Guide](docker.md)
|
||||
- **Database Details**: Check [Database Schema](database.md)
|
||||
|
||||
@@ -4,13 +4,11 @@ This document describes the testing strategy and how to run tests for The Bierga
|
||||
|
||||
## Overview
|
||||
|
||||
The project uses a multi-layered testing approach across backend and frontend:
|
||||
The project uses a multi-layered testing approach:
|
||||
|
||||
- **API.Specs** - BDD integration tests using Reqnroll (Gherkin)
|
||||
- **Infrastructure.Repository.Tests** - Unit tests for data access layer
|
||||
- **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)
|
||||
|
||||
@@ -88,33 +86,6 @@ dotnet test Service/Service.Auth.Tests/Service.Auth.Tests.csproj
|
||||
|
||||
- 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
|
||||
|
||||
### Current Coverage
|
||||
@@ -141,14 +112,6 @@ npm run test:storybook:playwright
|
||||
- Register service with validation
|
||||
- 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
|
||||
|
||||
- [ ] Email verification workflow
|
||||
@@ -158,7 +121,6 @@ npm run test:storybook:playwright
|
||||
- [ ] Beer post operations
|
||||
- [ ] User follow/unfollow
|
||||
- [ ] Image upload service
|
||||
- [ ] Frontend route integration coverage beyond Storybook stories
|
||||
|
||||
## Testing Frameworks & Tools
|
||||
|
||||
@@ -292,15 +254,6 @@ Exit codes:
|
||||
- `0` - All tests passed
|
||||
- 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
|
||||
|
||||
### Tests Failing Due to Database Connection
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
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
|
||||
@@ -7,8 +7,6 @@ public class MockEmailService : IEmailService
|
||||
{
|
||||
public List<RegistrationEmail> SentRegistrationEmails { get; } = new();
|
||||
|
||||
public List<ResendConfirmationEmail> SentResendConfirmationEmails { get; } = new();
|
||||
|
||||
public Task SendRegistrationEmailAsync(
|
||||
UserAccount createdUser,
|
||||
string confirmationToken
|
||||
@@ -26,27 +24,9 @@ public class MockEmailService : IEmailService
|
||||
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()
|
||||
{
|
||||
SentRegistrationEmails.Clear();
|
||||
SentResendConfirmationEmails.Clear();
|
||||
}
|
||||
|
||||
public class RegistrationEmail
|
||||
@@ -55,11 +35,4 @@ public class MockEmailService : IEmailService
|
||||
public string ConfirmationToken { get; init; } = string.Empty;
|
||||
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; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1124,91 +1124,4 @@ public class AuthSteps(ScenarioContext scenario)
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ USE Biergarten;
|
||||
CREATE TABLE dbo.UserAccount
|
||||
(
|
||||
UserAccountID UNIQUEIDENTIFIER
|
||||
CONSTRAINT DF_UserAccountID DEFAULT NEWID(),
|
||||
CONSTRAINT DF_UserAccountID DEFAULT NEWID(),
|
||||
|
||||
Username VARCHAR(64) NOT NULL,
|
||||
|
||||
@@ -37,7 +37,7 @@ CREATE TABLE dbo.UserAccount
|
||||
|
||||
UpdatedAt DATETIME,
|
||||
|
||||
DateOfBirth DATE NOT NULL,
|
||||
DateOfBirth DATETIME NOT NULL,
|
||||
|
||||
Timer ROWVERSION,
|
||||
|
||||
@@ -49,6 +49,7 @@ CREATE TABLE dbo.UserAccount
|
||||
|
||||
CONSTRAINT AK_Email
|
||||
UNIQUE (Email)
|
||||
|
||||
);
|
||||
|
||||
----------------------------------------------------------------------------
|
||||
@@ -108,7 +109,7 @@ CREATE TABLE UserAvatar -- delete avatar photo when user account is deleted
|
||||
|
||||
CONSTRAINT AK_UserAvatar_UserAccountID
|
||||
UNIQUE (UserAccountID)
|
||||
);
|
||||
)
|
||||
|
||||
CREATE NONCLUSTERED INDEX IX_UserAvatar_UserAccount
|
||||
ON UserAvatar(UserAccountID);
|
||||
@@ -124,7 +125,8 @@ CREATE TABLE UserVerification -- delete verification data when user account is d
|
||||
UserAccountID UNIQUEIDENTIFIER NOT NULL,
|
||||
|
||||
VerificationDateTime DATETIME NOT NULL
|
||||
CONSTRAINT DF_VerificationDateTime DEFAULT GETDATE(),
|
||||
CONSTRAINT DF_VerificationDateTime
|
||||
DEFAULT GETDATE(),
|
||||
|
||||
Timer ROWVERSION,
|
||||
|
||||
@@ -153,13 +155,13 @@ CREATE TABLE UserCredential -- delete credentials when user account is deleted
|
||||
|
||||
UserAccountID UNIQUEIDENTIFIER NOT NULL,
|
||||
|
||||
CreatedAt DATETIME NOT NULL
|
||||
CONSTRAINT DF_UserCredential_CreatedAt DEFAULT GETDATE(),
|
||||
CreatedAt DATETIME
|
||||
CONSTRAINT DF_UserCredential_CreatedAt DEFAULT GETDATE() NOT NULL,
|
||||
|
||||
Expiry DATETIME NOT NULL
|
||||
CONSTRAINT DF_UserCredential_Expiry DEFAULT DATEADD(DAY, 90, GETDATE()),
|
||||
Expiry DATETIME
|
||||
CONSTRAINT DF_UserCredential_Expiry DEFAULT DATEADD(DAY, 90, GETDATE()) NOT NULL,
|
||||
|
||||
Hash NVARCHAR(256) NOT NULL,
|
||||
Hash NVARCHAR(MAX) NOT NULL,
|
||||
-- uses argon2
|
||||
|
||||
IsRevoked BIT NOT NULL
|
||||
@@ -175,16 +177,12 @@ CREATE TABLE UserCredential -- delete credentials when user account is deleted
|
||||
CONSTRAINT FK_UserCredential_UserAccount
|
||||
FOREIGN KEY (UserAccountID)
|
||||
REFERENCES UserAccount(UserAccountID)
|
||||
ON DELETE CASCADE
|
||||
ON DELETE CASCADE,
|
||||
);
|
||||
|
||||
CREATE NONCLUSTERED INDEX IX_UserCredential_UserAccount
|
||||
ON UserCredential(UserAccountID);
|
||||
|
||||
CREATE NONCLUSTERED INDEX IX_UserCredential_Account_Active
|
||||
ON UserCredential(UserAccountID, IsRevoked, Expiry)
|
||||
INCLUDE (Hash);
|
||||
|
||||
----------------------------------------------------------------------------
|
||||
----------------------------------------------------------------------------
|
||||
|
||||
@@ -197,8 +195,8 @@ CREATE TABLE UserFollow
|
||||
|
||||
FollowingID UNIQUEIDENTIFIER NOT NULL,
|
||||
|
||||
CreatedAt DATETIME NOT NULL
|
||||
CONSTRAINT DF_UserFollow_CreatedAt DEFAULT GETDATE(),
|
||||
CreatedAt DATETIME
|
||||
CONSTRAINT DF_UserFollow_CreatedAt DEFAULT GETDATE() NOT NULL,
|
||||
|
||||
Timer ROWVERSION,
|
||||
|
||||
@@ -207,13 +205,11 @@ CREATE TABLE UserFollow
|
||||
|
||||
CONSTRAINT FK_UserFollow_UserAccount
|
||||
FOREIGN KEY (UserAccountID)
|
||||
REFERENCES UserAccount(UserAccountID)
|
||||
ON DELETE NO ACTION,
|
||||
REFERENCES UserAccount(UserAccountID),
|
||||
|
||||
CONSTRAINT FK_UserFollow_UserAccountFollowing
|
||||
FOREIGN KEY (FollowingID)
|
||||
REFERENCES UserAccount(UserAccountID)
|
||||
ON DELETE NO ACTION,
|
||||
REFERENCES UserAccount(UserAccountID),
|
||||
|
||||
CONSTRAINT CK_CannotFollowOwnAccount
|
||||
CHECK (UserAccountID != FollowingID)
|
||||
@@ -225,6 +221,7 @@ CREATE NONCLUSTERED INDEX IX_UserFollow_UserAccount_FollowingID
|
||||
CREATE NONCLUSTERED INDEX IX_UserFollow_FollowingID_UserAccount
|
||||
ON UserFollow(FollowingID, UserAccountID);
|
||||
|
||||
|
||||
----------------------------------------------------------------------------
|
||||
----------------------------------------------------------------------------
|
||||
|
||||
@@ -243,7 +240,7 @@ CREATE TABLE Country
|
||||
PRIMARY KEY (CountryID),
|
||||
|
||||
CONSTRAINT AK_Country_ISO3166_1
|
||||
UNIQUE (ISO3166_1)
|
||||
UNIQUE (ISO3166_1)
|
||||
);
|
||||
|
||||
----------------------------------------------------------------------------
|
||||
@@ -302,6 +299,7 @@ CREATE TABLE City
|
||||
CREATE NONCLUSTERED INDEX IX_City_StateProvince
|
||||
ON City(StateProvinceID);
|
||||
|
||||
|
||||
----------------------------------------------------------------------------
|
||||
----------------------------------------------------------------------------
|
||||
|
||||
@@ -310,8 +308,6 @@ CREATE TABLE BreweryPost -- A user cannot be deleted if they have a post
|
||||
BreweryPostID UNIQUEIDENTIFIER
|
||||
CONSTRAINT DF_BreweryPostID DEFAULT NEWID(),
|
||||
|
||||
BreweryName NVARCHAR(256) NOT NULL,
|
||||
|
||||
PostedByID UNIQUEIDENTIFIER NOT NULL,
|
||||
|
||||
Description NVARCHAR(512) NOT NULL,
|
||||
@@ -329,15 +325,15 @@ CREATE TABLE BreweryPost -- A user cannot be deleted if they have a post
|
||||
CONSTRAINT FK_BreweryPost_UserAccount
|
||||
FOREIGN KEY (PostedByID)
|
||||
REFERENCES UserAccount(UserAccountID)
|
||||
ON DELETE NO ACTION
|
||||
);
|
||||
ON DELETE NO ACTION,
|
||||
|
||||
)
|
||||
|
||||
CREATE NONCLUSTERED INDEX IX_BreweryPost_PostedByID
|
||||
ON BreweryPost(PostedByID);
|
||||
|
||||
----------------------------------------------------------------------------
|
||||
----------------------------------------------------------------------------
|
||||
|
||||
CREATE TABLE BreweryPostLocation
|
||||
(
|
||||
BreweryPostLocationID UNIQUEIDENTIFIER
|
||||
@@ -353,7 +349,7 @@ CREATE TABLE BreweryPostLocation
|
||||
|
||||
CityID UNIQUEIDENTIFIER NOT NULL,
|
||||
|
||||
Coordinates GEOGRAPHY NULL,
|
||||
Coordinates GEOGRAPHY NOT NULL,
|
||||
|
||||
Timer ROWVERSION,
|
||||
|
||||
@@ -366,11 +362,7 @@ CREATE TABLE BreweryPostLocation
|
||||
CONSTRAINT FK_BreweryPostLocation_BreweryPost
|
||||
FOREIGN KEY (BreweryPostID)
|
||||
REFERENCES BreweryPost(BreweryPostID)
|
||||
ON DELETE CASCADE,
|
||||
|
||||
CONSTRAINT FK_BreweryPostLocation_City
|
||||
FOREIGN KEY (CityID)
|
||||
REFERENCES City(CityID)
|
||||
ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE NONCLUSTERED INDEX IX_BreweryPostLocation_BreweryPost
|
||||
@@ -379,18 +371,6 @@ CREATE NONCLUSTERED INDEX IX_BreweryPostLocation_BreweryPost
|
||||
CREATE NONCLUSTERED INDEX IX_BreweryPostLocation_City
|
||||
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
|
||||
-- );
|
||||
|
||||
----------------------------------------------------------------------------
|
||||
----------------------------------------------------------------------------
|
||||
|
||||
@@ -423,14 +403,13 @@ CREATE TABLE BreweryPostPhoto -- All photos linked to a post are deleted if the
|
||||
);
|
||||
|
||||
CREATE NONCLUSTERED INDEX IX_BreweryPostPhoto_Photo_BreweryPost
|
||||
ON BreweryPostPhoto(PhotoID, BreweryPostID);
|
||||
ON BreweryPostPhoto(PhotoID, BreweryPostID);
|
||||
|
||||
CREATE NONCLUSTERED INDEX IX_BreweryPostPhoto_BreweryPost_Photo
|
||||
ON BreweryPostPhoto(BreweryPostID, PhotoID);
|
||||
ON BreweryPostPhoto(BreweryPostID, PhotoID);
|
||||
|
||||
----------------------------------------------------------------------------
|
||||
----------------------------------------------------------------------------
|
||||
|
||||
CREATE TABLE BeerStyle
|
||||
(
|
||||
BeerStyleID UNIQUEIDENTIFIER
|
||||
@@ -465,7 +444,7 @@ CREATE TABLE BeerPost
|
||||
-- Alcohol By Volume (typically 0-67%)
|
||||
|
||||
IBU INT NOT NULL,
|
||||
-- International Bitterness Units (typically 0-120)
|
||||
-- International Bitterness Units (typically 0-100)
|
||||
|
||||
PostedByID UNIQUEIDENTIFIER NOT NULL,
|
||||
|
||||
@@ -485,8 +464,7 @@ CREATE TABLE BeerPost
|
||||
|
||||
CONSTRAINT FK_BeerPost_PostedBy
|
||||
FOREIGN KEY (PostedByID)
|
||||
REFERENCES UserAccount(UserAccountID)
|
||||
ON DELETE NO ACTION,
|
||||
REFERENCES UserAccount(UserAccountID),
|
||||
|
||||
CONSTRAINT FK_BeerPost_BeerStyle
|
||||
FOREIGN KEY (BeerStyleID)
|
||||
@@ -544,10 +522,10 @@ CREATE TABLE BeerPostPhoto -- All photos linked to a beer post are deleted if th
|
||||
);
|
||||
|
||||
CREATE NONCLUSTERED INDEX IX_BeerPostPhoto_Photo_BeerPost
|
||||
ON BeerPostPhoto(PhotoID, BeerPostID);
|
||||
ON BeerPostPhoto(PhotoID, BeerPostID);
|
||||
|
||||
CREATE NONCLUSTERED INDEX IX_BeerPostPhoto_BeerPost_Photo
|
||||
ON BeerPostPhoto(BeerPostID, PhotoID);
|
||||
ON BeerPostPhoto(BeerPostID, PhotoID);
|
||||
|
||||
----------------------------------------------------------------------------
|
||||
----------------------------------------------------------------------------
|
||||
@@ -561,35 +539,17 @@ CREATE TABLE BeerPostComment
|
||||
|
||||
BeerPostID UNIQUEIDENTIFIER NOT NULL,
|
||||
|
||||
CommentedByID UNIQUEIDENTIFIER NOT NULL,
|
||||
|
||||
Rating INT NOT NULL,
|
||||
|
||||
CreatedAt DATETIME NOT NULL
|
||||
CONSTRAINT DF_BeerPostComment_CreatedAt DEFAULT GETDATE(),
|
||||
|
||||
UpdatedAt DATETIME NULL,
|
||||
|
||||
Timer ROWVERSION,
|
||||
|
||||
CONSTRAINT PK_BeerPostComment
|
||||
PRIMARY KEY (BeerPostCommentID),
|
||||
PRIMARY KEY (BeerPostCommentID),
|
||||
|
||||
CONSTRAINT FK_BeerPostComment_BeerPost
|
||||
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)
|
||||
);
|
||||
FOREIGN KEY (BeerPostID) REFERENCES BeerPost(BeerPostID)
|
||||
)
|
||||
|
||||
CREATE NONCLUSTERED INDEX IX_BeerPostComment_BeerPost
|
||||
ON BeerPostComment(BeerPostID);
|
||||
ON BeerPostComment(BeerPostID)
|
||||
|
||||
CREATE NONCLUSTERED INDEX IX_BeerPostComment_CommentedBy
|
||||
ON BeerPostComment(CommentedByID);
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
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
|
||||
@@ -1,117 +0,0 @@
|
||||
@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;
|
||||
}
|
||||
@@ -30,23 +30,6 @@ public class EmailTemplateProvider(
|
||||
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>
|
||||
/// Generic method to render any Razor component to HTML.
|
||||
/// </summary>
|
||||
|
||||
@@ -15,15 +15,4 @@ public interface IEmailTemplateProvider
|
||||
string username,
|
||||
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
|
||||
);
|
||||
}
|
||||
|
||||
@@ -159,7 +159,7 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory)
|
||||
return await GetUserByIdAsync(userAccountId);
|
||||
}
|
||||
|
||||
public async Task<bool> IsUserVerifiedAsync(Guid userAccountId)
|
||||
private async Task<bool> IsUserVerifiedAsync(Guid userAccountId)
|
||||
{
|
||||
await using var connection = await CreateConnection();
|
||||
await using var command = connection.CreateCommand();
|
||||
|
||||
@@ -75,11 +75,4 @@ public interface IAuthRepository
|
||||
/// <param name="userAccountId">ID of the user account</param>
|
||||
/// <returns>UserAccount if found, null otherwise</returns>
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -5,155 +5,151 @@ using Domain.Exceptions;
|
||||
using FluentAssertions;
|
||||
using Infrastructure.Repository.Auth;
|
||||
using Moq;
|
||||
using Service.Emails;
|
||||
|
||||
namespace Service.Auth.Tests;
|
||||
|
||||
public class ConfirmationServiceTest
|
||||
{
|
||||
private readonly Mock<IAuthRepository> _authRepositoryMock;
|
||||
private readonly Mock<ITokenService> _tokenServiceMock;
|
||||
private readonly Mock<IEmailService> _emailServiceMock;
|
||||
private readonly ConfirmationService _confirmationService;
|
||||
private readonly Mock<IAuthRepository> _authRepositoryMock;
|
||||
private readonly Mock<ITokenService> _tokenServiceMock;
|
||||
private readonly ConfirmationService _confirmationService;
|
||||
|
||||
public ConfirmationServiceTest()
|
||||
{
|
||||
_authRepositoryMock = new Mock<IAuthRepository>();
|
||||
_tokenServiceMock = new Mock<ITokenService>();
|
||||
_emailServiceMock = new Mock<IEmailService>();
|
||||
public ConfirmationServiceTest()
|
||||
{
|
||||
_authRepositoryMock = new Mock<IAuthRepository>();
|
||||
_tokenServiceMock = new Mock<ITokenService>();
|
||||
|
||||
_confirmationService = new ConfirmationService(
|
||||
_authRepositoryMock.Object,
|
||||
_tokenServiceMock.Object,
|
||||
_emailServiceMock.Object
|
||||
);
|
||||
}
|
||||
_confirmationService = new ConfirmationService(
|
||||
_authRepositoryMock.Object,
|
||||
_tokenServiceMock.Object
|
||||
);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ConfirmUserAsync_WithValidConfirmationToken_ConfirmsUser()
|
||||
{
|
||||
// Arrange
|
||||
var userId = Guid.NewGuid();
|
||||
const string username = "testuser";
|
||||
const string confirmationToken = "valid-confirmation-token";
|
||||
[Fact]
|
||||
public async Task ConfirmUserAsync_WithValidConfirmationToken_ConfirmsUser()
|
||||
{
|
||||
// Arrange
|
||||
var userId = Guid.NewGuid();
|
||||
const string username = "testuser";
|
||||
const string confirmationToken = "valid-confirmation-token";
|
||||
|
||||
var claims = new List<Claim>
|
||||
var claims = new List<Claim>
|
||||
{
|
||||
new(JwtRegisteredClaimNames.Sub, userId.ToString()),
|
||||
new(JwtRegisteredClaimNames.UniqueName, username),
|
||||
new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
|
||||
};
|
||||
|
||||
var claimsIdentity = new ClaimsIdentity(claims);
|
||||
var principal = new ClaimsPrincipal(claimsIdentity);
|
||||
var claimsIdentity = new ClaimsIdentity(claims);
|
||||
var principal = new ClaimsPrincipal(claimsIdentity);
|
||||
|
||||
var validatedToken = new ValidatedToken(userId, username, principal);
|
||||
var userAccount = new UserAccount
|
||||
{
|
||||
UserAccountId = userId,
|
||||
Username = username,
|
||||
FirstName = "Test",
|
||||
LastName = "User",
|
||||
Email = "test@example.com",
|
||||
DateOfBirth = new DateTime(1990, 1, 1),
|
||||
};
|
||||
var validatedToken = new ValidatedToken(userId, username, principal);
|
||||
var userAccount = new UserAccount
|
||||
{
|
||||
UserAccountId = userId,
|
||||
Username = username,
|
||||
FirstName = "Test",
|
||||
LastName = "User",
|
||||
Email = "test@example.com",
|
||||
DateOfBirth = new DateTime(1990, 1, 1),
|
||||
};
|
||||
|
||||
_tokenServiceMock
|
||||
.Setup(x => x.ValidateConfirmationTokenAsync(confirmationToken))
|
||||
.ReturnsAsync(validatedToken);
|
||||
_tokenServiceMock
|
||||
.Setup(x => x.ValidateConfirmationTokenAsync(confirmationToken))
|
||||
.ReturnsAsync(validatedToken);
|
||||
|
||||
_authRepositoryMock
|
||||
.Setup(x => x.ConfirmUserAccountAsync(userId))
|
||||
.ReturnsAsync(userAccount);
|
||||
_authRepositoryMock
|
||||
.Setup(x => x.ConfirmUserAccountAsync(userId))
|
||||
.ReturnsAsync(userAccount);
|
||||
|
||||
// Act
|
||||
var result =
|
||||
await _confirmationService.ConfirmUserAsync(confirmationToken);
|
||||
// Act
|
||||
var result =
|
||||
await _confirmationService.ConfirmUserAsync(confirmationToken);
|
||||
|
||||
// Assert
|
||||
result.Should().NotBeNull();
|
||||
result.UserId.Should().Be(userId);
|
||||
result.ConfirmedAt.Should().BeCloseTo(DateTime.UtcNow, TimeSpan.FromSeconds(1));
|
||||
// Assert
|
||||
result.Should().NotBeNull();
|
||||
result.UserId.Should().Be(userId);
|
||||
result.ConfirmedAt.Should().BeCloseTo(DateTime.UtcNow, TimeSpan.FromSeconds(1));
|
||||
|
||||
_tokenServiceMock.Verify(
|
||||
x => x.ValidateConfirmationTokenAsync(confirmationToken),
|
||||
Times.Once
|
||||
);
|
||||
_tokenServiceMock.Verify(
|
||||
x => x.ValidateConfirmationTokenAsync(confirmationToken),
|
||||
Times.Once
|
||||
);
|
||||
|
||||
_authRepositoryMock.Verify(
|
||||
x => x.ConfirmUserAccountAsync(userId),
|
||||
Times.Once
|
||||
);
|
||||
}
|
||||
_authRepositoryMock.Verify(
|
||||
x => x.ConfirmUserAccountAsync(userId),
|
||||
Times.Once
|
||||
);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ConfirmUserAsync_WithInvalidConfirmationToken_ThrowsUnauthorizedException()
|
||||
{
|
||||
// Arrange
|
||||
const string invalidToken = "invalid-confirmation-token";
|
||||
[Fact]
|
||||
public async Task ConfirmUserAsync_WithInvalidConfirmationToken_ThrowsUnauthorizedException()
|
||||
{
|
||||
// Arrange
|
||||
const string invalidToken = "invalid-confirmation-token";
|
||||
|
||||
_tokenServiceMock
|
||||
.Setup(x => x.ValidateConfirmationTokenAsync(invalidToken))
|
||||
.ThrowsAsync(new UnauthorizedException(
|
||||
"Invalid confirmation token"
|
||||
));
|
||||
_tokenServiceMock
|
||||
.Setup(x => x.ValidateConfirmationTokenAsync(invalidToken))
|
||||
.ThrowsAsync(new UnauthorizedException(
|
||||
"Invalid confirmation token"
|
||||
));
|
||||
|
||||
// Act & Assert
|
||||
await FluentActions.Invoking(async () =>
|
||||
await _confirmationService.ConfirmUserAsync(invalidToken)
|
||||
).Should().ThrowAsync<UnauthorizedException>();
|
||||
}
|
||||
// Act & Assert
|
||||
await FluentActions.Invoking(async () =>
|
||||
await _confirmationService.ConfirmUserAsync(invalidToken)
|
||||
).Should().ThrowAsync<UnauthorizedException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ConfirmUserAsync_WithExpiredConfirmationToken_ThrowsUnauthorizedException()
|
||||
{
|
||||
// Arrange
|
||||
const string expiredToken = "expired-confirmation-token";
|
||||
[Fact]
|
||||
public async Task ConfirmUserAsync_WithExpiredConfirmationToken_ThrowsUnauthorizedException()
|
||||
{
|
||||
// Arrange
|
||||
const string expiredToken = "expired-confirmation-token";
|
||||
|
||||
_tokenServiceMock
|
||||
.Setup(x => x.ValidateConfirmationTokenAsync(expiredToken))
|
||||
.ThrowsAsync(new UnauthorizedException(
|
||||
"Confirmation token has expired"
|
||||
));
|
||||
_tokenServiceMock
|
||||
.Setup(x => x.ValidateConfirmationTokenAsync(expiredToken))
|
||||
.ThrowsAsync(new UnauthorizedException(
|
||||
"Confirmation token has expired"
|
||||
));
|
||||
|
||||
// Act & Assert
|
||||
await FluentActions.Invoking(async () =>
|
||||
await _confirmationService.ConfirmUserAsync(expiredToken)
|
||||
).Should().ThrowAsync<UnauthorizedException>();
|
||||
}
|
||||
// Act & Assert
|
||||
await FluentActions.Invoking(async () =>
|
||||
await _confirmationService.ConfirmUserAsync(expiredToken)
|
||||
).Should().ThrowAsync<UnauthorizedException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ConfirmUserAsync_WithNonExistentUser_ThrowsUnauthorizedException()
|
||||
{
|
||||
// Arrange
|
||||
var userId = Guid.NewGuid();
|
||||
const string username = "nonexistent";
|
||||
const string confirmationToken = "valid-token-for-nonexistent-user";
|
||||
[Fact]
|
||||
public async Task ConfirmUserAsync_WithNonExistentUser_ThrowsUnauthorizedException()
|
||||
{
|
||||
// Arrange
|
||||
var userId = Guid.NewGuid();
|
||||
const string username = "nonexistent";
|
||||
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.UniqueName, username),
|
||||
new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
|
||||
};
|
||||
|
||||
var claimsIdentity = new ClaimsIdentity(claims);
|
||||
var principal = new ClaimsPrincipal(claimsIdentity);
|
||||
var claimsIdentity = new ClaimsIdentity(claims);
|
||||
var principal = new ClaimsPrincipal(claimsIdentity);
|
||||
|
||||
var validatedToken = new ValidatedToken(userId, username, principal);
|
||||
var validatedToken = new ValidatedToken(userId, username, principal);
|
||||
|
||||
_tokenServiceMock
|
||||
.Setup(x => x.ValidateConfirmationTokenAsync(confirmationToken))
|
||||
.ReturnsAsync(validatedToken);
|
||||
_tokenServiceMock
|
||||
.Setup(x => x.ValidateConfirmationTokenAsync(confirmationToken))
|
||||
.ReturnsAsync(validatedToken);
|
||||
|
||||
_authRepositoryMock
|
||||
.Setup(x => x.ConfirmUserAccountAsync(userId))
|
||||
.ReturnsAsync((UserAccount?)null);
|
||||
_authRepositoryMock
|
||||
.Setup(x => x.ConfirmUserAccountAsync(userId))
|
||||
.ReturnsAsync((UserAccount?)null);
|
||||
|
||||
// Act & Assert
|
||||
await FluentActions.Invoking(async () =>
|
||||
await _confirmationService.ConfirmUserAsync(confirmationToken)
|
||||
).Should().ThrowAsync<UnauthorizedException>()
|
||||
.WithMessage("*User account not found*");
|
||||
}
|
||||
// Act & Assert
|
||||
await FluentActions.Invoking(async () =>
|
||||
await _confirmationService.ConfirmUserAsync(confirmationToken)
|
||||
).Should().ThrowAsync<UnauthorizedException>()
|
||||
.WithMessage("*User account not found*");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,53 +1,34 @@
|
||||
|
||||
using Domain.Exceptions;
|
||||
using Infrastructure.Repository.Auth;
|
||||
using Service.Emails;
|
||||
|
||||
namespace Service.Auth;
|
||||
|
||||
public class ConfirmationService(
|
||||
IAuthRepository authRepository,
|
||||
ITokenService tokenService,
|
||||
IEmailService emailService
|
||||
ITokenService tokenService
|
||||
) : IConfirmationService
|
||||
{
|
||||
public async Task<ConfirmationServiceReturn> ConfirmUserAsync(
|
||||
string confirmationToken
|
||||
)
|
||||
{
|
||||
var validatedToken = await tokenService.ValidateConfirmationTokenAsync(
|
||||
confirmationToken
|
||||
);
|
||||
public async Task<ConfirmationServiceReturn> ConfirmUserAsync(
|
||||
string confirmationToken
|
||||
)
|
||||
{
|
||||
var validatedToken = await tokenService.ValidateConfirmationTokenAsync(
|
||||
confirmationToken
|
||||
);
|
||||
|
||||
var user = await authRepository.ConfirmUserAccountAsync(
|
||||
validatedToken.UserId
|
||||
);
|
||||
var user = await authRepository.ConfirmUserAccountAsync(
|
||||
validatedToken.UserId
|
||||
);
|
||||
|
||||
if (user == null)
|
||||
{
|
||||
throw new UnauthorizedException("User account not found");
|
||||
}
|
||||
if (user == null)
|
||||
{
|
||||
throw new UnauthorizedException("User account not found");
|
||||
}
|
||||
|
||||
return new ConfirmationServiceReturn(
|
||||
DateTime.UtcNow,
|
||||
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);
|
||||
}
|
||||
return new ConfirmationServiceReturn(
|
||||
DateTime.UtcNow,
|
||||
user.UserAccountId
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,4 @@ public record ConfirmationServiceReturn(DateTime ConfirmedAt, Guid UserId);
|
||||
public interface IConfirmationService
|
||||
{
|
||||
Task<ConfirmationServiceReturn> ConfirmUserAsync(string confirmationToken);
|
||||
Task ResendConfirmationEmailAsync(Guid userId);
|
||||
|
||||
}
|
||||
|
||||
@@ -10,11 +10,6 @@ public interface IEmailService
|
||||
UserAccount createdUser,
|
||||
string confirmationToken
|
||||
);
|
||||
|
||||
public Task SendResendConfirmationEmailAsync(
|
||||
UserAccount user,
|
||||
string confirmationToken
|
||||
);
|
||||
}
|
||||
|
||||
public class EmailService(
|
||||
@@ -47,26 +42,4 @@ public class EmailService(
|
||||
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
@@ -1,98 +0,0 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
@@ -1,42 +0,0 @@
|
||||
//themes
|
||||
|
||||
const myThemes = {
|
||||
dark: {
|
||||
primary: 'hsl(227, 10%, 25%)',
|
||||
secondary: 'hsl(255, 9%, 69%)',
|
||||
error: 'hsl(9, 52%, 57%)',
|
||||
accent: 'hsl(316, 96%, 60%)',
|
||||
neutral: 'hsl(240, 11%, 8%)',
|
||||
info: 'hsl(187, 11%, 60%)',
|
||||
success: 'hsl(117, 25%, 80%)',
|
||||
warning: 'hsl(50, 98%, 50%)',
|
||||
'primary-content': 'hsl(0, 0%, 98%)',
|
||||
'error-content': 'hsl(0, 0%, 98%)',
|
||||
'base-content': 'hsl(227, 0%, 60%)',
|
||||
'base-100': 'hsl(227, 10%, 20%)',
|
||||
'base-200': 'hsl(227, 10%, 10%)',
|
||||
'base-300': 'hsl(227, 10%, 8%)',
|
||||
},
|
||||
};
|
||||
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
module.exports = {
|
||||
content: [
|
||||
'./src/**/*.{js,ts,jsx,tsx}',
|
||||
'node_modules/daisyui/dist/**/*.js',
|
||||
'node_modules/react-daisyui/dist/**/*.js',
|
||||
],
|
||||
theme: {
|
||||
extend: {},
|
||||
},
|
||||
plugins: [
|
||||
require('@headlessui/tailwindcss'),
|
||||
require('daisyui'),
|
||||
require('tailwindcss-animated'),
|
||||
require('autoprefixer'),
|
||||
],
|
||||
daisyui: {
|
||||
logs: false,
|
||||
themes: [myThemes],
|
||||
},
|
||||
};
|
||||
@@ -1,25 +0,0 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "es5",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "CommonJS",
|
||||
"moduleResolution": "node",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "preserve",
|
||||
"incremental": true,
|
||||
"baseUrl": ".",
|
||||
"downlevelIteration": true,
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
build
|
||||
node_modules
|
||||
.react-router
|
||||
package-lock.json
|
||||
storybook-static
|
||||
test-results
|
||||
debug-storybook.log
|
||||
@@ -1,11 +0,0 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/prettierrc",
|
||||
"printWidth": 100,
|
||||
"singleQuote": true,
|
||||
"trailingComma": "all",
|
||||
"semi": true,
|
||||
"tabWidth": 3,
|
||||
"useTabs": false,
|
||||
"arrowParens": "always",
|
||||
"endOfLine": "lf"
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
// Generated by React Router
|
||||
|
||||
import "react-router";
|
||||
|
||||
declare module "react-router" {
|
||||
interface Future {
|
||||
v8_middleware: false
|
||||
}
|
||||
}
|
||||
@@ -1,105 +0,0 @@
|
||||
// Generated by React Router
|
||||
|
||||
import "react-router"
|
||||
|
||||
declare module "react-router" {
|
||||
interface Register {
|
||||
pages: Pages
|
||||
routeFiles: RouteFiles
|
||||
routeModules: RouteModules
|
||||
}
|
||||
}
|
||||
|
||||
type Pages = {
|
||||
"/": {
|
||||
params: {};
|
||||
};
|
||||
"/theme": {
|
||||
params: {};
|
||||
};
|
||||
"/login": {
|
||||
params: {};
|
||||
};
|
||||
"/register": {
|
||||
params: {};
|
||||
};
|
||||
"/logout": {
|
||||
params: {};
|
||||
};
|
||||
"/dashboard": {
|
||||
params: {};
|
||||
};
|
||||
"/confirm": {
|
||||
params: {};
|
||||
};
|
||||
"/beers": {
|
||||
params: {};
|
||||
};
|
||||
"/breweries": {
|
||||
params: {};
|
||||
};
|
||||
"/beer-styles": {
|
||||
params: {};
|
||||
};
|
||||
};
|
||||
|
||||
type RouteFiles = {
|
||||
"root.tsx": {
|
||||
id: "root";
|
||||
page: "/" | "/theme" | "/login" | "/register" | "/logout" | "/dashboard" | "/confirm" | "/beers" | "/breweries" | "/beer-styles";
|
||||
};
|
||||
"routes/home.tsx": {
|
||||
id: "routes/home";
|
||||
page: "/";
|
||||
};
|
||||
"routes/theme.tsx": {
|
||||
id: "routes/theme";
|
||||
page: "/theme";
|
||||
};
|
||||
"routes/login.tsx": {
|
||||
id: "routes/login";
|
||||
page: "/login";
|
||||
};
|
||||
"routes/register.tsx": {
|
||||
id: "routes/register";
|
||||
page: "/register";
|
||||
};
|
||||
"routes/logout.tsx": {
|
||||
id: "routes/logout";
|
||||
page: "/logout";
|
||||
};
|
||||
"routes/dashboard.tsx": {
|
||||
id: "routes/dashboard";
|
||||
page: "/dashboard";
|
||||
};
|
||||
"routes/confirm.tsx": {
|
||||
id: "routes/confirm";
|
||||
page: "/confirm";
|
||||
};
|
||||
"routes/beers.tsx": {
|
||||
id: "routes/beers";
|
||||
page: "/beers";
|
||||
};
|
||||
"routes/breweries.tsx": {
|
||||
id: "routes/breweries";
|
||||
page: "/breweries";
|
||||
};
|
||||
"routes/beer-styles.tsx": {
|
||||
id: "routes/beer-styles";
|
||||
page: "/beer-styles";
|
||||
};
|
||||
};
|
||||
|
||||
type RouteModules = {
|
||||
"root": typeof import("./app/root.tsx");
|
||||
"routes/home": typeof import("./app/routes/home.tsx");
|
||||
"routes/theme": typeof import("./app/routes/theme.tsx");
|
||||
"routes/login": typeof import("./app/routes/login.tsx");
|
||||
"routes/register": typeof import("./app/routes/register.tsx");
|
||||
"routes/logout": typeof import("./app/routes/logout.tsx");
|
||||
"routes/dashboard": typeof import("./app/routes/dashboard.tsx");
|
||||
"routes/confirm": typeof import("./app/routes/confirm.tsx");
|
||||
"routes/beers": typeof import("./app/routes/beers.tsx");
|
||||
"routes/breweries": typeof import("./app/routes/breweries.tsx");
|
||||
"routes/beer-styles": typeof import("./app/routes/beer-styles.tsx");
|
||||
};
|
||||
@@ -1,18 +0,0 @@
|
||||
// Generated by React Router
|
||||
|
||||
declare module "virtual:react-router/server-build" {
|
||||
import { ServerBuild } from "react-router";
|
||||
export const assets: ServerBuild["assets"];
|
||||
export const assetsBuildDirectory: ServerBuild["assetsBuildDirectory"];
|
||||
export const basename: ServerBuild["basename"];
|
||||
export const entry: ServerBuild["entry"];
|
||||
export const future: ServerBuild["future"];
|
||||
export const isSpaMode: ServerBuild["isSpaMode"];
|
||||
export const prerender: ServerBuild["prerender"];
|
||||
export const publicPath: ServerBuild["publicPath"];
|
||||
export const routeDiscovery: ServerBuild["routeDiscovery"];
|
||||
export const routes: ServerBuild["routes"];
|
||||
export const ssr: ServerBuild["ssr"];
|
||||
export const allowedActionOrigins: ServerBuild["allowedActionOrigins"];
|
||||
export const unstable_getCriticalCss: ServerBuild["unstable_getCriticalCss"];
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
// Generated by React Router
|
||||
|
||||
import type { GetInfo, GetAnnotations } from "react-router/internal";
|
||||
|
||||
type Module = typeof import("../root.js")
|
||||
|
||||
type Info = GetInfo<{
|
||||
file: "root.tsx",
|
||||
module: Module
|
||||
}>
|
||||
|
||||
type Matches = [{
|
||||
id: "root";
|
||||
module: typeof import("../root.js");
|
||||
}];
|
||||
|
||||
type Annotations = GetAnnotations<Info & { module: Module, matches: Matches }, false>;
|
||||
|
||||
export namespace Route {
|
||||
// links
|
||||
export type LinkDescriptors = Annotations["LinkDescriptors"];
|
||||
export type LinksFunction = Annotations["LinksFunction"];
|
||||
|
||||
// meta
|
||||
export type MetaArgs = Annotations["MetaArgs"];
|
||||
export type MetaDescriptors = Annotations["MetaDescriptors"];
|
||||
export type MetaFunction = Annotations["MetaFunction"];
|
||||
|
||||
// headers
|
||||
export type HeadersArgs = Annotations["HeadersArgs"];
|
||||
export type HeadersFunction = Annotations["HeadersFunction"];
|
||||
|
||||
// middleware
|
||||
export type MiddlewareFunction = Annotations["MiddlewareFunction"];
|
||||
|
||||
// clientMiddleware
|
||||
export type ClientMiddlewareFunction = Annotations["ClientMiddlewareFunction"];
|
||||
|
||||
// loader
|
||||
export type LoaderArgs = Annotations["LoaderArgs"];
|
||||
|
||||
// clientLoader
|
||||
export type ClientLoaderArgs = Annotations["ClientLoaderArgs"];
|
||||
|
||||
// action
|
||||
export type ActionArgs = Annotations["ActionArgs"];
|
||||
|
||||
// clientAction
|
||||
export type ClientActionArgs = Annotations["ClientActionArgs"];
|
||||
|
||||
// HydrateFallback
|
||||
export type HydrateFallbackProps = Annotations["HydrateFallbackProps"];
|
||||
|
||||
// Component
|
||||
export type ComponentProps = Annotations["ComponentProps"];
|
||||
|
||||
// ErrorBoundary
|
||||
export type ErrorBoundaryProps = Annotations["ErrorBoundaryProps"];
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
// Generated by React Router
|
||||
|
||||
import type { GetInfo, GetAnnotations } from "react-router/internal";
|
||||
|
||||
type Module = typeof import("../beer-styles.js")
|
||||
|
||||
type Info = GetInfo<{
|
||||
file: "routes/beer-styles.tsx",
|
||||
module: Module
|
||||
}>
|
||||
|
||||
type Matches = [{
|
||||
id: "root";
|
||||
module: typeof import("../../root.js");
|
||||
}, {
|
||||
id: "routes/beer-styles";
|
||||
module: typeof import("../beer-styles.js");
|
||||
}];
|
||||
|
||||
type Annotations = GetAnnotations<Info & { module: Module, matches: Matches }, false>;
|
||||
|
||||
export namespace Route {
|
||||
// links
|
||||
export type LinkDescriptors = Annotations["LinkDescriptors"];
|
||||
export type LinksFunction = Annotations["LinksFunction"];
|
||||
|
||||
// meta
|
||||
export type MetaArgs = Annotations["MetaArgs"];
|
||||
export type MetaDescriptors = Annotations["MetaDescriptors"];
|
||||
export type MetaFunction = Annotations["MetaFunction"];
|
||||
|
||||
// headers
|
||||
export type HeadersArgs = Annotations["HeadersArgs"];
|
||||
export type HeadersFunction = Annotations["HeadersFunction"];
|
||||
|
||||
// middleware
|
||||
export type MiddlewareFunction = Annotations["MiddlewareFunction"];
|
||||
|
||||
// clientMiddleware
|
||||
export type ClientMiddlewareFunction = Annotations["ClientMiddlewareFunction"];
|
||||
|
||||
// loader
|
||||
export type LoaderArgs = Annotations["LoaderArgs"];
|
||||
|
||||
// clientLoader
|
||||
export type ClientLoaderArgs = Annotations["ClientLoaderArgs"];
|
||||
|
||||
// action
|
||||
export type ActionArgs = Annotations["ActionArgs"];
|
||||
|
||||
// clientAction
|
||||
export type ClientActionArgs = Annotations["ClientActionArgs"];
|
||||
|
||||
// HydrateFallback
|
||||
export type HydrateFallbackProps = Annotations["HydrateFallbackProps"];
|
||||
|
||||
// Component
|
||||
export type ComponentProps = Annotations["ComponentProps"];
|
||||
|
||||
// ErrorBoundary
|
||||
export type ErrorBoundaryProps = Annotations["ErrorBoundaryProps"];
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
// Generated by React Router
|
||||
|
||||
import type { GetInfo, GetAnnotations } from "react-router/internal";
|
||||
|
||||
type Module = typeof import("../beers.js")
|
||||
|
||||
type Info = GetInfo<{
|
||||
file: "routes/beers.tsx",
|
||||
module: Module
|
||||
}>
|
||||
|
||||
type Matches = [{
|
||||
id: "root";
|
||||
module: typeof import("../../root.js");
|
||||
}, {
|
||||
id: "routes/beers";
|
||||
module: typeof import("../beers.js");
|
||||
}];
|
||||
|
||||
type Annotations = GetAnnotations<Info & { module: Module, matches: Matches }, false>;
|
||||
|
||||
export namespace Route {
|
||||
// links
|
||||
export type LinkDescriptors = Annotations["LinkDescriptors"];
|
||||
export type LinksFunction = Annotations["LinksFunction"];
|
||||
|
||||
// meta
|
||||
export type MetaArgs = Annotations["MetaArgs"];
|
||||
export type MetaDescriptors = Annotations["MetaDescriptors"];
|
||||
export type MetaFunction = Annotations["MetaFunction"];
|
||||
|
||||
// headers
|
||||
export type HeadersArgs = Annotations["HeadersArgs"];
|
||||
export type HeadersFunction = Annotations["HeadersFunction"];
|
||||
|
||||
// middleware
|
||||
export type MiddlewareFunction = Annotations["MiddlewareFunction"];
|
||||
|
||||
// clientMiddleware
|
||||
export type ClientMiddlewareFunction = Annotations["ClientMiddlewareFunction"];
|
||||
|
||||
// loader
|
||||
export type LoaderArgs = Annotations["LoaderArgs"];
|
||||
|
||||
// clientLoader
|
||||
export type ClientLoaderArgs = Annotations["ClientLoaderArgs"];
|
||||
|
||||
// action
|
||||
export type ActionArgs = Annotations["ActionArgs"];
|
||||
|
||||
// clientAction
|
||||
export type ClientActionArgs = Annotations["ClientActionArgs"];
|
||||
|
||||
// HydrateFallback
|
||||
export type HydrateFallbackProps = Annotations["HydrateFallbackProps"];
|
||||
|
||||
// Component
|
||||
export type ComponentProps = Annotations["ComponentProps"];
|
||||
|
||||
// ErrorBoundary
|
||||
export type ErrorBoundaryProps = Annotations["ErrorBoundaryProps"];
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
// Generated by React Router
|
||||
|
||||
import type { GetInfo, GetAnnotations } from "react-router/internal";
|
||||
|
||||
type Module = typeof import("../breweries.js")
|
||||
|
||||
type Info = GetInfo<{
|
||||
file: "routes/breweries.tsx",
|
||||
module: Module
|
||||
}>
|
||||
|
||||
type Matches = [{
|
||||
id: "root";
|
||||
module: typeof import("../../root.js");
|
||||
}, {
|
||||
id: "routes/breweries";
|
||||
module: typeof import("../breweries.js");
|
||||
}];
|
||||
|
||||
type Annotations = GetAnnotations<Info & { module: Module, matches: Matches }, false>;
|
||||
|
||||
export namespace Route {
|
||||
// links
|
||||
export type LinkDescriptors = Annotations["LinkDescriptors"];
|
||||
export type LinksFunction = Annotations["LinksFunction"];
|
||||
|
||||
// meta
|
||||
export type MetaArgs = Annotations["MetaArgs"];
|
||||
export type MetaDescriptors = Annotations["MetaDescriptors"];
|
||||
export type MetaFunction = Annotations["MetaFunction"];
|
||||
|
||||
// headers
|
||||
export type HeadersArgs = Annotations["HeadersArgs"];
|
||||
export type HeadersFunction = Annotations["HeadersFunction"];
|
||||
|
||||
// middleware
|
||||
export type MiddlewareFunction = Annotations["MiddlewareFunction"];
|
||||
|
||||
// clientMiddleware
|
||||
export type ClientMiddlewareFunction = Annotations["ClientMiddlewareFunction"];
|
||||
|
||||
// loader
|
||||
export type LoaderArgs = Annotations["LoaderArgs"];
|
||||
|
||||
// clientLoader
|
||||
export type ClientLoaderArgs = Annotations["ClientLoaderArgs"];
|
||||
|
||||
// action
|
||||
export type ActionArgs = Annotations["ActionArgs"];
|
||||
|
||||
// clientAction
|
||||
export type ClientActionArgs = Annotations["ClientActionArgs"];
|
||||
|
||||
// HydrateFallback
|
||||
export type HydrateFallbackProps = Annotations["HydrateFallbackProps"];
|
||||
|
||||
// Component
|
||||
export type ComponentProps = Annotations["ComponentProps"];
|
||||
|
||||
// ErrorBoundary
|
||||
export type ErrorBoundaryProps = Annotations["ErrorBoundaryProps"];
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
// Generated by React Router
|
||||
|
||||
import type { GetInfo, GetAnnotations } from "react-router/internal";
|
||||
|
||||
type Module = typeof import("../confirm.js")
|
||||
|
||||
type Info = GetInfo<{
|
||||
file: "routes/confirm.tsx",
|
||||
module: Module
|
||||
}>
|
||||
|
||||
type Matches = [{
|
||||
id: "root";
|
||||
module: typeof import("../../root.js");
|
||||
}, {
|
||||
id: "routes/confirm";
|
||||
module: typeof import("../confirm.js");
|
||||
}];
|
||||
|
||||
type Annotations = GetAnnotations<Info & { module: Module, matches: Matches }, false>;
|
||||
|
||||
export namespace Route {
|
||||
// links
|
||||
export type LinkDescriptors = Annotations["LinkDescriptors"];
|
||||
export type LinksFunction = Annotations["LinksFunction"];
|
||||
|
||||
// meta
|
||||
export type MetaArgs = Annotations["MetaArgs"];
|
||||
export type MetaDescriptors = Annotations["MetaDescriptors"];
|
||||
export type MetaFunction = Annotations["MetaFunction"];
|
||||
|
||||
// headers
|
||||
export type HeadersArgs = Annotations["HeadersArgs"];
|
||||
export type HeadersFunction = Annotations["HeadersFunction"];
|
||||
|
||||
// middleware
|
||||
export type MiddlewareFunction = Annotations["MiddlewareFunction"];
|
||||
|
||||
// clientMiddleware
|
||||
export type ClientMiddlewareFunction = Annotations["ClientMiddlewareFunction"];
|
||||
|
||||
// loader
|
||||
export type LoaderArgs = Annotations["LoaderArgs"];
|
||||
|
||||
// clientLoader
|
||||
export type ClientLoaderArgs = Annotations["ClientLoaderArgs"];
|
||||
|
||||
// action
|
||||
export type ActionArgs = Annotations["ActionArgs"];
|
||||
|
||||
// clientAction
|
||||
export type ClientActionArgs = Annotations["ClientActionArgs"];
|
||||
|
||||
// HydrateFallback
|
||||
export type HydrateFallbackProps = Annotations["HydrateFallbackProps"];
|
||||
|
||||
// Component
|
||||
export type ComponentProps = Annotations["ComponentProps"];
|
||||
|
||||
// ErrorBoundary
|
||||
export type ErrorBoundaryProps = Annotations["ErrorBoundaryProps"];
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
// Generated by React Router
|
||||
|
||||
import type { GetInfo, GetAnnotations } from "react-router/internal";
|
||||
|
||||
type Module = typeof import("../dashboard.js")
|
||||
|
||||
type Info = GetInfo<{
|
||||
file: "routes/dashboard.tsx",
|
||||
module: Module
|
||||
}>
|
||||
|
||||
type Matches = [{
|
||||
id: "root";
|
||||
module: typeof import("../../root.js");
|
||||
}, {
|
||||
id: "routes/dashboard";
|
||||
module: typeof import("../dashboard.js");
|
||||
}];
|
||||
|
||||
type Annotations = GetAnnotations<Info & { module: Module, matches: Matches }, false>;
|
||||
|
||||
export namespace Route {
|
||||
// links
|
||||
export type LinkDescriptors = Annotations["LinkDescriptors"];
|
||||
export type LinksFunction = Annotations["LinksFunction"];
|
||||
|
||||
// meta
|
||||
export type MetaArgs = Annotations["MetaArgs"];
|
||||
export type MetaDescriptors = Annotations["MetaDescriptors"];
|
||||
export type MetaFunction = Annotations["MetaFunction"];
|
||||
|
||||
// headers
|
||||
export type HeadersArgs = Annotations["HeadersArgs"];
|
||||
export type HeadersFunction = Annotations["HeadersFunction"];
|
||||
|
||||
// middleware
|
||||
export type MiddlewareFunction = Annotations["MiddlewareFunction"];
|
||||
|
||||
// clientMiddleware
|
||||
export type ClientMiddlewareFunction = Annotations["ClientMiddlewareFunction"];
|
||||
|
||||
// loader
|
||||
export type LoaderArgs = Annotations["LoaderArgs"];
|
||||
|
||||
// clientLoader
|
||||
export type ClientLoaderArgs = Annotations["ClientLoaderArgs"];
|
||||
|
||||
// action
|
||||
export type ActionArgs = Annotations["ActionArgs"];
|
||||
|
||||
// clientAction
|
||||
export type ClientActionArgs = Annotations["ClientActionArgs"];
|
||||
|
||||
// HydrateFallback
|
||||
export type HydrateFallbackProps = Annotations["HydrateFallbackProps"];
|
||||
|
||||
// Component
|
||||
export type ComponentProps = Annotations["ComponentProps"];
|
||||
|
||||
// ErrorBoundary
|
||||
export type ErrorBoundaryProps = Annotations["ErrorBoundaryProps"];
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
// Generated by React Router
|
||||
|
||||
import type { GetInfo, GetAnnotations } from "react-router/internal";
|
||||
|
||||
type Module = typeof import("../home.js")
|
||||
|
||||
type Info = GetInfo<{
|
||||
file: "routes/home.tsx",
|
||||
module: Module
|
||||
}>
|
||||
|
||||
type Matches = [{
|
||||
id: "root";
|
||||
module: typeof import("../../root.js");
|
||||
}, {
|
||||
id: "routes/home";
|
||||
module: typeof import("../home.js");
|
||||
}];
|
||||
|
||||
type Annotations = GetAnnotations<Info & { module: Module, matches: Matches }, false>;
|
||||
|
||||
export namespace Route {
|
||||
// links
|
||||
export type LinkDescriptors = Annotations["LinkDescriptors"];
|
||||
export type LinksFunction = Annotations["LinksFunction"];
|
||||
|
||||
// meta
|
||||
export type MetaArgs = Annotations["MetaArgs"];
|
||||
export type MetaDescriptors = Annotations["MetaDescriptors"];
|
||||
export type MetaFunction = Annotations["MetaFunction"];
|
||||
|
||||
// headers
|
||||
export type HeadersArgs = Annotations["HeadersArgs"];
|
||||
export type HeadersFunction = Annotations["HeadersFunction"];
|
||||
|
||||
// middleware
|
||||
export type MiddlewareFunction = Annotations["MiddlewareFunction"];
|
||||
|
||||
// clientMiddleware
|
||||
export type ClientMiddlewareFunction = Annotations["ClientMiddlewareFunction"];
|
||||
|
||||
// loader
|
||||
export type LoaderArgs = Annotations["LoaderArgs"];
|
||||
|
||||
// clientLoader
|
||||
export type ClientLoaderArgs = Annotations["ClientLoaderArgs"];
|
||||
|
||||
// action
|
||||
export type ActionArgs = Annotations["ActionArgs"];
|
||||
|
||||
// clientAction
|
||||
export type ClientActionArgs = Annotations["ClientActionArgs"];
|
||||
|
||||
// HydrateFallback
|
||||
export type HydrateFallbackProps = Annotations["HydrateFallbackProps"];
|
||||
|
||||
// Component
|
||||
export type ComponentProps = Annotations["ComponentProps"];
|
||||
|
||||
// ErrorBoundary
|
||||
export type ErrorBoundaryProps = Annotations["ErrorBoundaryProps"];
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
// Generated by React Router
|
||||
|
||||
import type { GetInfo, GetAnnotations } from "react-router/internal";
|
||||
|
||||
type Module = typeof import("../login.js")
|
||||
|
||||
type Info = GetInfo<{
|
||||
file: "routes/login.tsx",
|
||||
module: Module
|
||||
}>
|
||||
|
||||
type Matches = [{
|
||||
id: "root";
|
||||
module: typeof import("../../root.js");
|
||||
}, {
|
||||
id: "routes/login";
|
||||
module: typeof import("../login.js");
|
||||
}];
|
||||
|
||||
type Annotations = GetAnnotations<Info & { module: Module, matches: Matches }, false>;
|
||||
|
||||
export namespace Route {
|
||||
// links
|
||||
export type LinkDescriptors = Annotations["LinkDescriptors"];
|
||||
export type LinksFunction = Annotations["LinksFunction"];
|
||||
|
||||
// meta
|
||||
export type MetaArgs = Annotations["MetaArgs"];
|
||||
export type MetaDescriptors = Annotations["MetaDescriptors"];
|
||||
export type MetaFunction = Annotations["MetaFunction"];
|
||||
|
||||
// headers
|
||||
export type HeadersArgs = Annotations["HeadersArgs"];
|
||||
export type HeadersFunction = Annotations["HeadersFunction"];
|
||||
|
||||
// middleware
|
||||
export type MiddlewareFunction = Annotations["MiddlewareFunction"];
|
||||
|
||||
// clientMiddleware
|
||||
export type ClientMiddlewareFunction = Annotations["ClientMiddlewareFunction"];
|
||||
|
||||
// loader
|
||||
export type LoaderArgs = Annotations["LoaderArgs"];
|
||||
|
||||
// clientLoader
|
||||
export type ClientLoaderArgs = Annotations["ClientLoaderArgs"];
|
||||
|
||||
// action
|
||||
export type ActionArgs = Annotations["ActionArgs"];
|
||||
|
||||
// clientAction
|
||||
export type ClientActionArgs = Annotations["ClientActionArgs"];
|
||||
|
||||
// HydrateFallback
|
||||
export type HydrateFallbackProps = Annotations["HydrateFallbackProps"];
|
||||
|
||||
// Component
|
||||
export type ComponentProps = Annotations["ComponentProps"];
|
||||
|
||||
// ErrorBoundary
|
||||
export type ErrorBoundaryProps = Annotations["ErrorBoundaryProps"];
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
// Generated by React Router
|
||||
|
||||
import type { GetInfo, GetAnnotations } from "react-router/internal";
|
||||
|
||||
type Module = typeof import("../logout.js")
|
||||
|
||||
type Info = GetInfo<{
|
||||
file: "routes/logout.tsx",
|
||||
module: Module
|
||||
}>
|
||||
|
||||
type Matches = [{
|
||||
id: "root";
|
||||
module: typeof import("../../root.js");
|
||||
}, {
|
||||
id: "routes/logout";
|
||||
module: typeof import("../logout.js");
|
||||
}];
|
||||
|
||||
type Annotations = GetAnnotations<Info & { module: Module, matches: Matches }, false>;
|
||||
|
||||
export namespace Route {
|
||||
// links
|
||||
export type LinkDescriptors = Annotations["LinkDescriptors"];
|
||||
export type LinksFunction = Annotations["LinksFunction"];
|
||||
|
||||
// meta
|
||||
export type MetaArgs = Annotations["MetaArgs"];
|
||||
export type MetaDescriptors = Annotations["MetaDescriptors"];
|
||||
export type MetaFunction = Annotations["MetaFunction"];
|
||||
|
||||
// headers
|
||||
export type HeadersArgs = Annotations["HeadersArgs"];
|
||||
export type HeadersFunction = Annotations["HeadersFunction"];
|
||||
|
||||
// middleware
|
||||
export type MiddlewareFunction = Annotations["MiddlewareFunction"];
|
||||
|
||||
// clientMiddleware
|
||||
export type ClientMiddlewareFunction = Annotations["ClientMiddlewareFunction"];
|
||||
|
||||
// loader
|
||||
export type LoaderArgs = Annotations["LoaderArgs"];
|
||||
|
||||
// clientLoader
|
||||
export type ClientLoaderArgs = Annotations["ClientLoaderArgs"];
|
||||
|
||||
// action
|
||||
export type ActionArgs = Annotations["ActionArgs"];
|
||||
|
||||
// clientAction
|
||||
export type ClientActionArgs = Annotations["ClientActionArgs"];
|
||||
|
||||
// HydrateFallback
|
||||
export type HydrateFallbackProps = Annotations["HydrateFallbackProps"];
|
||||
|
||||
// Component
|
||||
export type ComponentProps = Annotations["ComponentProps"];
|
||||
|
||||
// ErrorBoundary
|
||||
export type ErrorBoundaryProps = Annotations["ErrorBoundaryProps"];
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
// Generated by React Router
|
||||
|
||||
import type { GetInfo, GetAnnotations } from "react-router/internal";
|
||||
|
||||
type Module = typeof import("../register.js")
|
||||
|
||||
type Info = GetInfo<{
|
||||
file: "routes/register.tsx",
|
||||
module: Module
|
||||
}>
|
||||
|
||||
type Matches = [{
|
||||
id: "root";
|
||||
module: typeof import("../../root.js");
|
||||
}, {
|
||||
id: "routes/register";
|
||||
module: typeof import("../register.js");
|
||||
}];
|
||||
|
||||
type Annotations = GetAnnotations<Info & { module: Module, matches: Matches }, false>;
|
||||
|
||||
export namespace Route {
|
||||
// links
|
||||
export type LinkDescriptors = Annotations["LinkDescriptors"];
|
||||
export type LinksFunction = Annotations["LinksFunction"];
|
||||
|
||||
// meta
|
||||
export type MetaArgs = Annotations["MetaArgs"];
|
||||
export type MetaDescriptors = Annotations["MetaDescriptors"];
|
||||
export type MetaFunction = Annotations["MetaFunction"];
|
||||
|
||||
// headers
|
||||
export type HeadersArgs = Annotations["HeadersArgs"];
|
||||
export type HeadersFunction = Annotations["HeadersFunction"];
|
||||
|
||||
// middleware
|
||||
export type MiddlewareFunction = Annotations["MiddlewareFunction"];
|
||||
|
||||
// clientMiddleware
|
||||
export type ClientMiddlewareFunction = Annotations["ClientMiddlewareFunction"];
|
||||
|
||||
// loader
|
||||
export type LoaderArgs = Annotations["LoaderArgs"];
|
||||
|
||||
// clientLoader
|
||||
export type ClientLoaderArgs = Annotations["ClientLoaderArgs"];
|
||||
|
||||
// action
|
||||
export type ActionArgs = Annotations["ActionArgs"];
|
||||
|
||||
// clientAction
|
||||
export type ClientActionArgs = Annotations["ClientActionArgs"];
|
||||
|
||||
// HydrateFallback
|
||||
export type HydrateFallbackProps = Annotations["HydrateFallbackProps"];
|
||||
|
||||
// Component
|
||||
export type ComponentProps = Annotations["ComponentProps"];
|
||||
|
||||
// ErrorBoundary
|
||||
export type ErrorBoundaryProps = Annotations["ErrorBoundaryProps"];
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
// Generated by React Router
|
||||
|
||||
import type { GetInfo, GetAnnotations } from "react-router/internal";
|
||||
|
||||
type Module = typeof import("../theme.js")
|
||||
|
||||
type Info = GetInfo<{
|
||||
file: "routes/theme.tsx",
|
||||
module: Module
|
||||
}>
|
||||
|
||||
type Matches = [{
|
||||
id: "root";
|
||||
module: typeof import("../../root.js");
|
||||
}, {
|
||||
id: "routes/theme";
|
||||
module: typeof import("../theme.js");
|
||||
}];
|
||||
|
||||
type Annotations = GetAnnotations<Info & { module: Module, matches: Matches }, false>;
|
||||
|
||||
export namespace Route {
|
||||
// links
|
||||
export type LinkDescriptors = Annotations["LinkDescriptors"];
|
||||
export type LinksFunction = Annotations["LinksFunction"];
|
||||
|
||||
// meta
|
||||
export type MetaArgs = Annotations["MetaArgs"];
|
||||
export type MetaDescriptors = Annotations["MetaDescriptors"];
|
||||
export type MetaFunction = Annotations["MetaFunction"];
|
||||
|
||||
// headers
|
||||
export type HeadersArgs = Annotations["HeadersArgs"];
|
||||
export type HeadersFunction = Annotations["HeadersFunction"];
|
||||
|
||||
// middleware
|
||||
export type MiddlewareFunction = Annotations["MiddlewareFunction"];
|
||||
|
||||
// clientMiddleware
|
||||
export type ClientMiddlewareFunction = Annotations["ClientMiddlewareFunction"];
|
||||
|
||||
// loader
|
||||
export type LoaderArgs = Annotations["LoaderArgs"];
|
||||
|
||||
// clientLoader
|
||||
export type ClientLoaderArgs = Annotations["ClientLoaderArgs"];
|
||||
|
||||
// action
|
||||
export type ActionArgs = Annotations["ActionArgs"];
|
||||
|
||||
// clientAction
|
||||
export type ClientActionArgs = Annotations["ClientActionArgs"];
|
||||
|
||||
// HydrateFallback
|
||||
export type HydrateFallbackProps = Annotations["HydrateFallbackProps"];
|
||||
|
||||
// Component
|
||||
export type ComponentProps = Annotations["ComponentProps"];
|
||||
|
||||
// ErrorBoundary
|
||||
export type ErrorBoundaryProps = Annotations["ErrorBoundaryProps"];
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
import type { StorybookConfig } from '@storybook/react-vite';
|
||||
|
||||
const config: StorybookConfig = {
|
||||
stories: [
|
||||
'../stories/Configure.mdx',
|
||||
'../stories/SubmitButton.stories.tsx',
|
||||
'../stories/FormField.stories.tsx',
|
||||
'../stories/Navbar.stories.tsx',
|
||||
'../stories/Toast.stories.tsx',
|
||||
'../stories/Themes.stories.tsx',
|
||||
],
|
||||
addons: [
|
||||
'@chromatic-com/storybook',
|
||||
'@storybook/addon-vitest',
|
||||
'@storybook/addon-a11y',
|
||||
'@storybook/addon-docs',
|
||||
'@storybook/addon-onboarding',
|
||||
],
|
||||
framework: '@storybook/react-vite',
|
||||
async viteFinal(config) {
|
||||
config.plugins = (config.plugins ?? []).filter((plugin) => {
|
||||
if (!plugin) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const pluginName = typeof plugin === 'object' && 'name' in plugin ? plugin.name : '';
|
||||
return !pluginName.startsWith('react-router');
|
||||
});
|
||||
|
||||
config.build ??= {};
|
||||
config.build.rollupOptions ??= {};
|
||||
|
||||
const previousOnWarn = config.build.rollupOptions.onwarn;
|
||||
config.build.rollupOptions.onwarn = (warning, warn) => {
|
||||
if (warning.code === 'MODULE_LEVEL_DIRECTIVE') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof previousOnWarn === 'function') {
|
||||
previousOnWarn(warning, warn);
|
||||
return;
|
||||
}
|
||||
|
||||
warn(warning);
|
||||
};
|
||||
|
||||
return config;
|
||||
},
|
||||
};
|
||||
export default config;
|
||||
@@ -1,6 +0,0 @@
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin="anonymous" />
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,100..900;1,9..40,100..900&family=Volkhov:ital,wght@0,400;0,700;1,400;1,700&display=swap"
|
||||
/>
|
||||
@@ -1,63 +0,0 @@
|
||||
import type { Preview } from '@storybook/react-vite';
|
||||
import { createElement } from 'react';
|
||||
import { MemoryRouter } from 'react-router';
|
||||
import '../app/app.css';
|
||||
import { biergartenThemes, defaultThemeName, isBiergartenTheme } from '../app/lib/themes';
|
||||
|
||||
const preview: Preview = {
|
||||
globalTypes: {
|
||||
theme: {
|
||||
description: 'Active Biergarten theme',
|
||||
toolbar: {
|
||||
title: 'Theme',
|
||||
icon: 'paintbrush',
|
||||
dynamicTitle: true,
|
||||
items: biergartenThemes.map((theme) => ({
|
||||
value: theme.value,
|
||||
title: theme.label,
|
||||
})),
|
||||
},
|
||||
},
|
||||
},
|
||||
initialGlobals: {
|
||||
theme: defaultThemeName,
|
||||
},
|
||||
decorators: [
|
||||
(Story, context) => {
|
||||
const theme = isBiergartenTheme(String(context.globals.theme))
|
||||
? context.globals.theme
|
||||
: defaultThemeName;
|
||||
|
||||
return createElement(
|
||||
MemoryRouter,
|
||||
undefined,
|
||||
createElement(
|
||||
'div',
|
||||
{
|
||||
'data-theme': theme,
|
||||
className: 'bg-base-200 p-6 text-base-content',
|
||||
},
|
||||
createElement('div', { className: 'mx-auto max-w-7xl' }, createElement(Story)),
|
||||
),
|
||||
);
|
||||
},
|
||||
],
|
||||
parameters: {
|
||||
controls: {
|
||||
matchers: {
|
||||
color: /(background|color)$/i,
|
||||
date: /Date$/i,
|
||||
},
|
||||
},
|
||||
layout: 'padded',
|
||||
|
||||
a11y: {
|
||||
// 'todo' - show a11y violations in the test UI only
|
||||
// 'error' - fail CI on a11y violations
|
||||
// 'off' - skip a11y checks entirely
|
||||
test: 'todo',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default preview;
|
||||
@@ -1,7 +0,0 @@
|
||||
import * as a11yAddonAnnotations from '@storybook/addon-a11y/preview';
|
||||
import { setProjectAnnotations } from '@storybook/react-vite';
|
||||
import * as projectAnnotations from './preview';
|
||||
|
||||
// This is an important step to apply the right configuration when testing your stories.
|
||||
// More info at: https://storybook.js.org/docs/api/portable-stories/portable-stories-vitest#setprojectannotations
|
||||
setProjectAnnotations([a11yAddonAnnotations, projectAnnotations]);
|
||||
@@ -1,251 +0,0 @@
|
||||
@import 'tailwindcss';
|
||||
@plugin "daisyui" {
|
||||
themes: biergarten-lager, biergarten-stout, biergarten-cassis, biergarten-weizen;
|
||||
}
|
||||
|
||||
@theme {
|
||||
--font-sans:
|
||||
'DM Sans', ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji',
|
||||
'Segoe UI Symbol', 'Noto Color Emoji';
|
||||
--font-serif: 'Volkhov', ui-serif, Georgia, serif;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6,
|
||||
.card-title {
|
||||
font-family: var(--font-serif);
|
||||
}
|
||||
|
||||
/* ─────────────────────────────────────────
|
||||
BIERGARTEN LAGER
|
||||
Light. Warm parchment base, mellow amber
|
||||
primary, softened mahogany secondary.
|
||||
───────────────────────────────────────── */
|
||||
@plugin "daisyui/theme" {
|
||||
name: 'biergarten-lager';
|
||||
default: true;
|
||||
prefersdark: false;
|
||||
color-scheme: 'light';
|
||||
|
||||
--color-base-100: oklch(96% 0.012 82); /* warm parchment */
|
||||
--color-base-200: oklch(92% 0.018 80); /* brushed paper */
|
||||
--color-base-300: oklch(87% 0.025 78); /* tinted linen */
|
||||
--color-base-content: oklch(28% 0.025 58); /* dark brown ink — 15.6:1 on base-100 */
|
||||
|
||||
--color-primary: oklch(65% 0.085 62); /* mellow amber */
|
||||
--color-primary-content: oklch(97% 0.02 62); /* warm near-white — 7.2:1 on primary */
|
||||
|
||||
--color-secondary: oklch(42% 0.05 42); /* softened mahogany */
|
||||
--color-secondary-content: oklch(96% 0.01 76); /* off-white — 14.2:1 on secondary */
|
||||
|
||||
--color-accent: oklch(93% 0.015 90); /* frothy cream */
|
||||
--color-accent-content: oklch(28% 0.025 58); /* dark brown — 12.8:1 on accent */
|
||||
|
||||
--color-neutral: oklch(28% 0.02 46); /* warm roast dark */
|
||||
--color-neutral-content: oklch(92% 0.012 80); /* pale parchment — 12.0:1 on neutral */
|
||||
|
||||
--color-info: oklch(46% 0.065 145); /* muted hop green */
|
||||
--color-info-content: oklch(97% 0.008 145); /* near-white — 14.2:1 on info */
|
||||
|
||||
--color-success: oklch(70% 0.06 122); /* soft barley gold */
|
||||
--color-success-content: oklch(97% 0.02 122); /* warm near-white — 5.7:1 on success */
|
||||
|
||||
--color-warning: oklch(72% 0.09 56); /* toned amber */
|
||||
--color-warning-content: oklch(97% 0.02 56); /* warm near-white — 4.7:1 on warning */
|
||||
|
||||
--color-error: oklch(54% 0.09 22); /* restrained cherry */
|
||||
--color-error-content: oklch(97% 0.006 15); /* near-white — 11.1:1 on error */
|
||||
|
||||
--color-surface: oklch(88% 0.02 82); /* mid parchment, elevated cards */
|
||||
--color-surface-content: oklch(28% 0.025 58); /* dark brown — 9.2:1 on surface */
|
||||
|
||||
--color-muted: oklch(42% 0.055 62); /* amber-brown — 14.2:1 on base-100, 8.3:1 on surface */
|
||||
--color-highlight: oklch(78% 0.055 65); /* warm amber, hover and active states */
|
||||
--color-highlight-content: oklch(22% 0.025 55); /* dark brown — 4.9:1 on highlight */
|
||||
|
||||
--radius-selector: 0.375rem;
|
||||
--radius-field: 0.5rem;
|
||||
--radius-box: 0.875rem;
|
||||
--size-selector: 0.25rem;
|
||||
--size-field: 0.25rem;
|
||||
--border: 1px;
|
||||
--depth: 1;
|
||||
--noise: 1;
|
||||
}
|
||||
|
||||
/* ─────────────────────────────────────────
|
||||
BIERGARTEN STOUT
|
||||
Dark. Charred barrel base, golden amber
|
||||
primary, deep mahogany secondary.
|
||||
───────────────────────────────────────── */
|
||||
@plugin "daisyui/theme" {
|
||||
name: 'biergarten-stout';
|
||||
default: false;
|
||||
prefersdark: true;
|
||||
color-scheme: 'dark';
|
||||
|
||||
--color-base-100: oklch(14% 0.006 45); /* charred barrel black */
|
||||
--color-base-200: oklch(18% 0.008 43); /* roasted malt dark */
|
||||
--color-base-300: oklch(23% 0.01 42); /* deep brown */
|
||||
--color-base-content: oklch(88% 0.008 75); /* warm off-white — 9.4:1 on base-100 */
|
||||
|
||||
--color-primary: oklch(68% 0.055 60); /* golden amber */
|
||||
--color-primary-content: oklch(92% 0.012 50); /* warm off-white — 4.6:1 on primary */
|
||||
|
||||
--color-secondary: oklch(48% 0.035 40); /* deep mahogany ale */
|
||||
--color-secondary-content: oklch(97% 0.005 75); /* near-white — 13.9:1 on secondary */
|
||||
|
||||
--color-accent: oklch(82% 0.01 88); /* frothy cream head */
|
||||
--color-accent-content: oklch(18% 0.012 55); /* near-black — 6.2:1 on accent */
|
||||
|
||||
--color-neutral: oklch(20% 0.008 45); /* near-black with warmth */
|
||||
--color-neutral-content: oklch(88% 0.007 78); /* warm off-white — 9.3:1 on neutral */
|
||||
|
||||
--color-info: oklch(60% 0.04 145); /* cool hop green */
|
||||
--color-info-content: oklch(86% 0.006 145); /* pale green-white — 4.6:1 on info */
|
||||
|
||||
--color-success: oklch(66% 0.038 120); /* fresh barley */
|
||||
--color-success-content: oklch(90% 0.012 120); /* pale barley-white — 4.6:1 on success */
|
||||
|
||||
--color-warning: oklch(70% 0.055 55); /* amber harvest */
|
||||
--color-warning-content: oklch(94% 0.012 55); /* warm near-white — 4.7:1 on warning */
|
||||
|
||||
--color-error: oklch(50% 0.06 20); /* deep cherry kriek */
|
||||
--color-error-content: oklch(97% 0.004 15); /* near-white — 13.1:1 on error */
|
||||
|
||||
--color-surface: oklch(26% 0.012 45); /* elevated dark panel */
|
||||
--color-surface-content: oklch(88% 0.008 75); /* warm off-white — 9.2:1 on surface */
|
||||
|
||||
--color-muted: oklch(78% 0.018 72); /* warm grey — 4.7:1 on base-100, 4.6:1 on surface */
|
||||
--color-highlight: oklch(32% 0.025 48); /* warm dark brown, hover and active states */
|
||||
--color-highlight-content: oklch(88% 0.008 75); /* warm off-white — 9.0:1 on highlight */
|
||||
|
||||
--radius-selector: 0.375rem;
|
||||
--radius-field: 0.5rem;
|
||||
--radius-box: 0.875rem;
|
||||
--size-selector: 0.25rem;
|
||||
--size-field: 0.25rem;
|
||||
--border: 1px;
|
||||
--depth: 1;
|
||||
--noise: 1;
|
||||
}
|
||||
|
||||
/* ─────────────────────────────────────────
|
||||
BIERGARTEN CASSIS
|
||||
Dark. Blackberry base, cassis berry
|
||||
primary, sour cherry secondary.
|
||||
───────────────────────────────────────── */
|
||||
@plugin "daisyui/theme" {
|
||||
name: 'biergarten-cassis';
|
||||
default: false;
|
||||
prefersdark: false;
|
||||
color-scheme: 'dark';
|
||||
|
||||
--color-base-100: oklch(13% 0.01 295); /* blackberry-stained near-black */
|
||||
--color-base-200: oklch(17% 0.013 292); /* deep purple-black */
|
||||
--color-base-300: oklch(22% 0.016 290); /* dark grape */
|
||||
--color-base-content: oklch(90% 0.014 300); /* pale lavender-white — 10.7:1 on base-100 */
|
||||
|
||||
--color-primary: oklch(72% 0.075 295); /* cassis berry purple */
|
||||
--color-primary-content: oklch(95% 0.01 295); /* pale lavender — 4.5:1 on primary */
|
||||
|
||||
--color-secondary: oklch(68% 0.06 10); /* sour cherry rose */
|
||||
--color-secondary-content: oklch(92% 0.006 10); /* warm near-white — 4.6:1 on secondary */
|
||||
|
||||
--color-accent: oklch(75% 0.045 130); /* tart lime zest */
|
||||
--color-accent-content: oklch(98.5% 0.01 130); /* near-white — 4.8:1 on accent */
|
||||
|
||||
--color-neutral: oklch(18% 0.016 290); /* deep blackened grape */
|
||||
--color-neutral-content: oklch(88% 0.01 295); /* pale lavender — 9.3:1 on neutral */
|
||||
|
||||
--color-info: oklch(62% 0.04 250); /* muted indigo */
|
||||
--color-info-content: oklch(88% 0.008 250); /* pale indigo-white — 4.8:1 on info */
|
||||
|
||||
--color-success: oklch(65% 0.04 145); /* elderberry green */
|
||||
--color-success-content: oklch(90% 0.008 145); /* pale green-white — 4.7:1 on success */
|
||||
|
||||
--color-warning: oklch(70% 0.05 65); /* sour apricot */
|
||||
--color-warning-content: oklch(97% 0.03 65); /* near-white — 5.6:1 on warning */
|
||||
|
||||
--color-error: oklch(50% 0.055 22); /* kriek red */
|
||||
--color-error-content: oklch(97% 0.006 22); /* near-white — 13.2:1 on error */
|
||||
|
||||
--color-surface: oklch(27% 0.022 292); /* lifted purple-black panel */
|
||||
--color-surface-content: oklch(90% 0.014 300); /* pale lavender-white — 10.4:1 on surface */
|
||||
|
||||
--color-muted: oklch(
|
||||
77.6% 0.022 300
|
||||
); /* desaturated lavender — 4.6:1 on base-100, 4.5:1 on surface */
|
||||
--color-highlight: oklch(35% 0.04 295); /* cassis-tinted hover and active state */
|
||||
--color-highlight-content: oklch(90% 0.014 300); /* pale lavender-white — 10.1:1 on highlight */
|
||||
|
||||
--radius-selector: 0.5rem;
|
||||
--radius-field: 0.5rem;
|
||||
--radius-box: 1rem;
|
||||
--size-selector: 0.25rem;
|
||||
--size-field: 0.25rem;
|
||||
--border: 1px;
|
||||
--depth: 1;
|
||||
--noise: 1;
|
||||
}
|
||||
|
||||
/* ─────────────────────────────────────────
|
||||
BIERGARTEN WEIZEN
|
||||
Light. Near-white barley-green base,
|
||||
fresh-cut barley primary, sage secondary.
|
||||
───────────────────────────────────────── */
|
||||
@plugin "daisyui/theme" {
|
||||
name: 'biergarten-weizen';
|
||||
default: false;
|
||||
prefersdark: false;
|
||||
color-scheme: 'light';
|
||||
|
||||
--color-base-100: oklch(99% 0.007 112); /* near-white with faint barley-green tint */
|
||||
--color-base-200: oklch(96% 0.012 114); /* pale barley wash */
|
||||
--color-base-300: oklch(92% 0.019 116); /* light straw */
|
||||
--color-base-content: oklch(20% 0.022 122); /* deep green-black — 19.5:1 on base-100 */
|
||||
|
||||
--color-primary: oklch(52% 0.085 118); /* fresh-cut barley green */
|
||||
--color-primary-content: oklch(97% 0.005 118); /* near-white — 12.5:1 on primary */
|
||||
|
||||
--color-secondary: oklch(44% 0.055 128); /* muted sage stem */
|
||||
--color-secondary-content: oklch(97% 0.005 128); /* near-white — 14.8:1 on secondary */
|
||||
|
||||
--color-accent: oklch(93% 0.03 148); /* pale morning dew */
|
||||
--color-accent-content: oklch(22% 0.022 148); /* deep green — 13.4:1 on accent */
|
||||
|
||||
--color-neutral: oklch(76% 0.028 118); /* dried straw, surface differentiation */
|
||||
--color-neutral-content: oklch(98.9% 0.005 118); /* near-white — 4.6:1 on neutral */
|
||||
|
||||
--color-info: oklch(38% 0.065 232); /* clear summer sky */
|
||||
--color-info-content: oklch(98% 0.005 232); /* near-white — 16.8:1 on info */
|
||||
|
||||
--color-success: oklch(38% 0.085 145); /* young shoot green */
|
||||
--color-success-content: oklch(98% 0.005 145); /* near-white — 16.8:1 on success */
|
||||
|
||||
--color-warning: oklch(68% 0.1 76); /* ripening grain amber */
|
||||
--color-warning-content: oklch(92.5% 0.005 72); /* warm near-white — 4.5:1 on warning */
|
||||
|
||||
--color-error: oklch(52% 0.1 18); /* dusty rose red */
|
||||
--color-error-content: oklch(98% 0.005 15); /* near-white — 12.5:1 on error */
|
||||
|
||||
--color-surface: oklch(94% 0.012 112); /* soft barley-wash panel */
|
||||
--color-surface-content: oklch(20% 0.022 122); /* deep green-black — 14.0:1 on surface */
|
||||
|
||||
--color-muted: oklch(38% 0.055 120); /* sage green — 18.1:1 on base-100, 13.0:1 on surface */
|
||||
--color-highlight: oklch(85% 0.04 118); /* green-tinted hover and active state */
|
||||
--color-highlight-content: oklch(20% 0.022 122); /* deep green-black — 7.8:1 on highlight */
|
||||
|
||||
--radius-selector: 2rem;
|
||||
--radius-field: 2rem;
|
||||
--radius-box: 1rem;
|
||||
--size-selector: 0.25rem;
|
||||
--size-field: 0.25rem;
|
||||
--border: 1px;
|
||||
--depth: 0;
|
||||
--noise: 0;
|
||||
}
|
||||
@@ -1,144 +0,0 @@
|
||||
import {
|
||||
Disclosure,
|
||||
DisclosureButton,
|
||||
DisclosurePanel,
|
||||
Menu,
|
||||
MenuButton,
|
||||
MenuItem,
|
||||
MenuItems,
|
||||
} from '@headlessui/react';
|
||||
import { Link } from 'react-router';
|
||||
|
||||
interface NavbarProps {
|
||||
auth: {
|
||||
username: string;
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
userAccountId: string;
|
||||
} | null;
|
||||
}
|
||||
|
||||
export default function Navbar({ auth }: NavbarProps) {
|
||||
const navLinks = [
|
||||
{ to: '/theme', label: 'Theme' },
|
||||
{ to: '/beers', label: 'Beers' },
|
||||
{ to: '/breweries', label: 'Breweries' },
|
||||
{ to: '/beer-styles', label: 'Beer Styles' },
|
||||
];
|
||||
|
||||
return (
|
||||
<Disclosure
|
||||
as="nav"
|
||||
className="sticky top-0 z-50 border-b border-base-300 bg-base-100 shadow-md"
|
||||
>
|
||||
{({ open }) => (
|
||||
<>
|
||||
<div className="navbar mx-auto max-w-7xl px-2 sm:px-4">
|
||||
<div className="navbar-start gap-2">
|
||||
<DisclosureButton
|
||||
className="btn btn-ghost btn-square lg:hidden"
|
||||
aria-label="Toggle navigation"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
className="h-5 w-5 stroke-current"
|
||||
>
|
||||
{open ? (
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2"
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
) : (
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2"
|
||||
d="M4 6h16M4 12h16M4 18h16"
|
||||
/>
|
||||
)}
|
||||
</svg>
|
||||
</DisclosureButton>
|
||||
|
||||
<Link to="/" className="text-xl font-bold">
|
||||
🍺 The Biergarten App
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="navbar-center hidden lg:flex gap-2">
|
||||
{navLinks.map((link) => (
|
||||
<Link key={link.to} to={link.to} className="btn btn-ghost btn-sm">
|
||||
{link.label}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="navbar-end gap-2">
|
||||
{!auth && (
|
||||
<Link to="/register" className="btn btn-ghost btn-sm hidden sm:inline-flex">
|
||||
Register User
|
||||
</Link>
|
||||
)}
|
||||
|
||||
{auth ? (
|
||||
<>
|
||||
<Link to="/dashboard" className="btn btn-primary btn-sm">
|
||||
Dashboard
|
||||
</Link>
|
||||
|
||||
<Menu as="div" className="relative">
|
||||
<MenuButton className="btn btn-ghost btn-sm">
|
||||
{auth.username}
|
||||
</MenuButton>
|
||||
<MenuItems className="menu absolute right-0 z-60 mt-2 w-52 rounded-box border border-base-300 bg-base-100 p-2 shadow-xl focus:outline-none">
|
||||
<MenuItem>
|
||||
{({ focus }) => (
|
||||
<Link to="/dashboard" className={focus ? 'active' : ''}>
|
||||
Dashboard
|
||||
</Link>
|
||||
)}
|
||||
</MenuItem>
|
||||
<MenuItem>
|
||||
{({ focus }) => (
|
||||
<Link to="/logout" className={focus ? 'active' : ''}>
|
||||
Logout
|
||||
</Link>
|
||||
)}
|
||||
</MenuItem>
|
||||
</MenuItems>
|
||||
</Menu>
|
||||
</>
|
||||
) : (
|
||||
<Link to="/login" className="btn btn-primary btn-sm">
|
||||
Login
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DisclosurePanel className="border-t border-base-300 bg-base-100 px-4 py-3 lg:hidden">
|
||||
<div className="flex flex-col gap-2">
|
||||
{navLinks.map((link) => (
|
||||
<Link
|
||||
key={link.to}
|
||||
to={link.to}
|
||||
className="btn btn-ghost btn-sm justify-start"
|
||||
>
|
||||
{link.label}
|
||||
</Link>
|
||||
))}
|
||||
{!auth && (
|
||||
<Link to="/register" className="btn btn-ghost btn-sm justify-start">
|
||||
Register User
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</DisclosurePanel>
|
||||
</>
|
||||
)}
|
||||
</Disclosure>
|
||||
);
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
import { Description, Field, Label } from '@headlessui/react';
|
||||
|
||||
type FormFieldProps = React.InputHTMLAttributes<HTMLInputElement> & {
|
||||
label: string;
|
||||
error?: string;
|
||||
hint?: string;
|
||||
labelClassName?: string;
|
||||
inputClassName?: string;
|
||||
hintClassName?: string;
|
||||
};
|
||||
|
||||
export default function FormField({
|
||||
label,
|
||||
error,
|
||||
hint,
|
||||
className,
|
||||
labelClassName,
|
||||
inputClassName,
|
||||
hintClassName,
|
||||
...inputProps
|
||||
}: FormFieldProps) {
|
||||
return (
|
||||
<Field className={className ?? 'space-y-1'}>
|
||||
<Label htmlFor={inputProps.id} className={labelClassName ?? 'label font-medium'}>
|
||||
{label}
|
||||
</Label>
|
||||
|
||||
<input
|
||||
{...inputProps}
|
||||
className={inputClassName ?? `input w-full ${error ? 'input-error' : ''}`}
|
||||
/>
|
||||
|
||||
{error ? (
|
||||
<Description className={hintClassName ?? 'label text-error'}>{error}</Description>
|
||||
) : hint ? (
|
||||
<Description className={hintClassName ?? 'label'}>{hint}</Description>
|
||||
) : null}
|
||||
</Field>
|
||||
);
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
import { Button } from '@headlessui/react';
|
||||
|
||||
interface SubmitButtonProps {
|
||||
isSubmitting: boolean;
|
||||
idleText: string;
|
||||
submittingText: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function SubmitButton({
|
||||
isSubmitting,
|
||||
idleText,
|
||||
submittingText,
|
||||
className,
|
||||
}: SubmitButtonProps) {
|
||||
return (
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
className={className ?? 'btn btn-primary w-full mt-2'}
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<span className="loading loading-spinner loading-sm" /> {submittingText}
|
||||
</>
|
||||
) : (
|
||||
idleText
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
import { Toaster } from 'react-hot-toast';
|
||||
|
||||
export default function ToastProvider() {
|
||||
return (
|
||||
<Toaster
|
||||
position="top-right"
|
||||
toastOptions={{
|
||||
duration: 3500,
|
||||
className: 'rounded-box border border-base-300 bg-base-100 text-base-content shadow-lg',
|
||||
success: {
|
||||
iconTheme: {
|
||||
primary: 'var(--color-success)',
|
||||
secondary: 'var(--color-success-content)',
|
||||
},
|
||||
},
|
||||
error: {
|
||||
iconTheme: {
|
||||
primary: 'var(--color-error)',
|
||||
secondary: 'var(--color-error-content)',
|
||||
},
|
||||
},
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
export const showSuccessToast = (message: string) => toast.success(message);
|
||||
export const showErrorToast = (message: string) => toast.error(message);
|
||||
export const showInfoToast = (message: string) => toast(message);
|
||||
export const dismissToasts = () => toast.dismiss();
|
||||
@@ -1,162 +0,0 @@
|
||||
import { createCookieSessionStorage, redirect } from 'react-router';
|
||||
|
||||
const API_BASE_URL = process.env.API_BASE_URL || 'http://localhost:8080';
|
||||
|
||||
export interface AuthTokens {
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
userAccountId: string;
|
||||
username: string;
|
||||
}
|
||||
|
||||
interface ApiResponse<T> {
|
||||
message: string;
|
||||
payload: T;
|
||||
}
|
||||
|
||||
interface LoginPayload {
|
||||
userAccountId: string;
|
||||
username: string;
|
||||
refreshToken: string;
|
||||
accessToken: string;
|
||||
}
|
||||
|
||||
interface RegistrationPayload extends LoginPayload {
|
||||
confirmationEmailSent: boolean;
|
||||
}
|
||||
|
||||
const sessionStorage = createCookieSessionStorage({
|
||||
cookie: {
|
||||
name: '__session',
|
||||
httpOnly: true,
|
||||
maxAge: 60 * 60 * 24 * 21, // 21 days (matches refresh token)
|
||||
path: '/',
|
||||
sameSite: 'lax',
|
||||
secrets: [process.env.SESSION_SECRET || 'dev-secret-change-me'],
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
},
|
||||
});
|
||||
|
||||
export async function getSession(request: Request) {
|
||||
return sessionStorage.getSession(request.headers.get('Cookie'));
|
||||
}
|
||||
|
||||
export async function commitSession(session: Awaited<ReturnType<typeof getSession>>) {
|
||||
return sessionStorage.commitSession(session);
|
||||
}
|
||||
|
||||
export async function destroySession(session: Awaited<ReturnType<typeof getSession>>) {
|
||||
return sessionStorage.destroySession(session);
|
||||
}
|
||||
|
||||
export async function requireAuth(request: Request): Promise<AuthTokens> {
|
||||
const session = await getSession(request);
|
||||
const accessToken = session.get('accessToken');
|
||||
const refreshToken = session.get('refreshToken');
|
||||
|
||||
if (!accessToken || !refreshToken) {
|
||||
throw redirect('/login');
|
||||
}
|
||||
|
||||
return {
|
||||
accessToken,
|
||||
refreshToken,
|
||||
userAccountId: session.get('userAccountId'),
|
||||
username: session.get('username'),
|
||||
};
|
||||
}
|
||||
|
||||
export async function getOptionalAuth(request: Request): Promise<AuthTokens | null> {
|
||||
const session = await getSession(request);
|
||||
const accessToken = session.get('accessToken');
|
||||
|
||||
if (!accessToken) return null;
|
||||
|
||||
return {
|
||||
accessToken,
|
||||
refreshToken: session.get('refreshToken'),
|
||||
userAccountId: session.get('userAccountId'),
|
||||
username: session.get('username'),
|
||||
};
|
||||
}
|
||||
|
||||
export async function login(username: string, password: string) {
|
||||
const res = await fetch(`${API_BASE_URL}/api/auth/login`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username, password }),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
throw new Error(text || `Login failed (${res.status})`);
|
||||
}
|
||||
|
||||
const data: ApiResponse<LoginPayload> = await res.json();
|
||||
return data.payload;
|
||||
}
|
||||
|
||||
export async function register(body: {
|
||||
username: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
email: string;
|
||||
dateOfBirth: string;
|
||||
password: string;
|
||||
}) {
|
||||
const res = await fetch(`${API_BASE_URL}/api/auth/register`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
throw new Error(text || `Registration failed (${res.status})`);
|
||||
}
|
||||
|
||||
const data: ApiResponse<RegistrationPayload> = await res.json();
|
||||
return data.payload;
|
||||
}
|
||||
|
||||
export async function refreshTokens(refreshToken: string) {
|
||||
const res = await fetch(`${API_BASE_URL}/api/auth/refresh`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ refreshToken }),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error('Token refresh failed');
|
||||
}
|
||||
|
||||
const data: ApiResponse<LoginPayload> = await res.json();
|
||||
return data.payload;
|
||||
}
|
||||
|
||||
export async function confirmEmail(token: string, accessToken: string) {
|
||||
const res = await fetch(`${API_BASE_URL}/api/auth/confirm?token=${encodeURIComponent(token)}`, {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${accessToken}` },
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
throw new Error(text || `Confirmation failed (${res.status})`);
|
||||
}
|
||||
|
||||
const data: ApiResponse<{ userAccountId: string; confirmedDate: string }> = await res.json();
|
||||
return data.payload;
|
||||
}
|
||||
|
||||
export async function createAuthSession(payload: LoginPayload, redirectTo: string) {
|
||||
const session = await sessionStorage.getSession();
|
||||
session.set('accessToken', payload.accessToken);
|
||||
session.set('refreshToken', payload.refreshToken);
|
||||
session.set('userAccountId', payload.userAccountId);
|
||||
session.set('username', payload.username);
|
||||
|
||||
return redirect(redirectTo, {
|
||||
headers: { 'Set-Cookie': await commitSession(session) },
|
||||
});
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const loginSchema = z.object({
|
||||
username: z.string().min(1, 'Username is required'),
|
||||
password: z.string().min(1, 'Password is required'),
|
||||
});
|
||||
|
||||
export type LoginSchema = z.infer<typeof loginSchema>;
|
||||
|
||||
export const registerSchema = z
|
||||
.object({
|
||||
username: z
|
||||
.string()
|
||||
.min(3, 'Username must be at least 3 characters')
|
||||
.max(20, 'Username must be at most 20 characters'),
|
||||
firstName: z.string().min(1, 'First name is required'),
|
||||
lastName: z.string().min(1, 'Last name is required'),
|
||||
email: z.string().email('Invalid email address'),
|
||||
dateOfBirth: z.string().min(1, 'Date of birth is required'),
|
||||
password: z
|
||||
.string()
|
||||
.min(8, 'Password must be at least 8 characters')
|
||||
.regex(/[A-Z]/, 'Password must contain an uppercase letter')
|
||||
.regex(/[a-z]/, 'Password must contain a lowercase letter')
|
||||
.regex(/[0-9]/, 'Password must contain a number'),
|
||||
confirmPassword: z.string().min(1, 'Please confirm your password'),
|
||||
})
|
||||
.refine((data) => data.password === data.confirmPassword, {
|
||||
message: 'Passwords must match',
|
||||
path: ['confirmPassword'],
|
||||
});
|
||||
|
||||
export type RegisterSchema = z.infer<typeof registerSchema>;
|
||||
@@ -1,41 +0,0 @@
|
||||
export type ThemeName =
|
||||
| 'biergarten-lager'
|
||||
| 'biergarten-stout'
|
||||
| 'biergarten-cassis'
|
||||
| 'biergarten-weizen';
|
||||
|
||||
export interface ThemeOption {
|
||||
value: ThemeName;
|
||||
label: string;
|
||||
vibe: string;
|
||||
}
|
||||
|
||||
export const defaultThemeName: ThemeName = 'biergarten-lager';
|
||||
export const themeStorageKey = 'biergarten-theme';
|
||||
|
||||
export const biergartenThemes: ThemeOption[] = [
|
||||
{
|
||||
value: 'biergarten-lager',
|
||||
label: 'Biergarten Lager',
|
||||
vibe: 'Muted parchment, mellow amber, daytime beer garden',
|
||||
},
|
||||
{
|
||||
value: 'biergarten-stout',
|
||||
label: 'Biergarten Stout',
|
||||
vibe: 'Charred barrel, deep roast, cozy evening cellar',
|
||||
},
|
||||
{
|
||||
value: 'biergarten-cassis',
|
||||
label: 'Biergarten Cassis',
|
||||
vibe: 'Blackberry barrel, sour berry dark, vivid night market',
|
||||
},
|
||||
{
|
||||
value: 'biergarten-weizen',
|
||||
label: 'Biergarten Weizen',
|
||||
vibe: 'Ultra-light young barley, green undertone, bright spring afternoon',
|
||||
},
|
||||
];
|
||||
|
||||
export function isBiergartenTheme(value: string | null | undefined): value is ThemeName {
|
||||
return biergartenThemes.some((theme) => theme.value === value);
|
||||
}
|
||||
@@ -1,90 +0,0 @@
|
||||
import {
|
||||
isRouteErrorResponse,
|
||||
Links,
|
||||
Meta,
|
||||
Outlet,
|
||||
Scripts,
|
||||
ScrollRestoration,
|
||||
} from 'react-router';
|
||||
|
||||
import type { Route } from './+types/root';
|
||||
import './app.css';
|
||||
import Navbar from './components/Navbar';
|
||||
import ToastProvider from './components/toast/ToastProvider';
|
||||
import { getOptionalAuth } from './lib/auth.server';
|
||||
|
||||
export const links: Route.LinksFunction = () => [
|
||||
{ rel: 'preconnect', href: 'https://fonts.googleapis.com' },
|
||||
{
|
||||
rel: 'preconnect',
|
||||
href: 'https://fonts.gstatic.com',
|
||||
crossOrigin: 'anonymous',
|
||||
},
|
||||
{
|
||||
rel: 'stylesheet',
|
||||
href: 'https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,100..900;1,9..40,100..900&family=Volkhov:ital,wght@0,400;0,700;1,400;1,700&display=swap',
|
||||
},
|
||||
];
|
||||
|
||||
export const loader = async ({ request }: Route.LoaderArgs) => {
|
||||
const auth = await getOptionalAuth(request);
|
||||
return { auth };
|
||||
};
|
||||
|
||||
export function Layout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charSet="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<Meta />
|
||||
<Links />
|
||||
</head>
|
||||
<body>
|
||||
{children}
|
||||
<ScrollRestoration />
|
||||
<Scripts />
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
|
||||
export default function App({ loaderData }: Route.ComponentProps) {
|
||||
const { auth } = loaderData;
|
||||
return (
|
||||
<>
|
||||
<Navbar auth={auth} />
|
||||
<ToastProvider />
|
||||
<Outlet />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
|
||||
let message = 'Oops!';
|
||||
let details = 'An unexpected error occurred.';
|
||||
let stack: string | undefined;
|
||||
|
||||
if (isRouteErrorResponse(error)) {
|
||||
message = error.status === 404 ? '404' : 'Error';
|
||||
details =
|
||||
error.status === 404
|
||||
? 'The requested page could not be found.'
|
||||
: error.statusText || details;
|
||||
} else if (import.meta.env.DEV && error && error instanceof Error) {
|
||||
details = error.message;
|
||||
stack = error.stack;
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="pt-16 p-4 container mx-auto">
|
||||
<h1>{message}</h1>
|
||||
<p>{details}</p>
|
||||
{stack && (
|
||||
<pre className="w-full p-4 overflow-x-auto">
|
||||
<code>{stack}</code>
|
||||
</pre>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
import { type RouteConfig, index, route } from '@react-router/dev/routes';
|
||||
|
||||
export default [
|
||||
index('routes/home.tsx'),
|
||||
route('theme', 'routes/theme.tsx'),
|
||||
route('login', 'routes/login.tsx'),
|
||||
route('register', 'routes/register.tsx'),
|
||||
route('logout', 'routes/logout.tsx'),
|
||||
route('dashboard', 'routes/dashboard.tsx'),
|
||||
route('confirm', 'routes/confirm.tsx'),
|
||||
route('beers', 'routes/beers.tsx'),
|
||||
route('breweries', 'routes/breweries.tsx'),
|
||||
route('beer-styles', 'routes/beer-styles.tsx'),
|
||||
] satisfies RouteConfig;
|
||||
@@ -1,16 +0,0 @@
|
||||
import type { Route } from './+types/beer-styles';
|
||||
|
||||
export function meta({}: Route.MetaArgs) {
|
||||
return [{ title: 'Beer Styles | The Biergarten App' }];
|
||||
}
|
||||
|
||||
export default function BeerStyles() {
|
||||
return (
|
||||
<div className="min-h-screen bg-base-200">
|
||||
<div className="container mx-auto p-4">
|
||||
<h1 className="text-4xl font-bold mb-4">Beer Styles</h1>
|
||||
<p className="text-base-content/70">Learn about different beer styles.</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
import type { Route } from './+types/beers';
|
||||
|
||||
export function meta({}: Route.MetaArgs) {
|
||||
return [{ title: 'Beers | The Biergarten App' }];
|
||||
}
|
||||
|
||||
export default function Beers() {
|
||||
return (
|
||||
<div className="min-h-screen bg-base-200">
|
||||
<div className="container mx-auto p-4">
|
||||
<h1 className="text-4xl font-bold mb-4">Beers</h1>
|
||||
<p className="text-base-content/70">Explore our collection of beers.</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
import type { Route } from './+types/breweries';
|
||||
|
||||
export function meta({}: Route.MetaArgs) {
|
||||
return [{ title: 'Breweries | The Biergarten App' }];
|
||||
}
|
||||
|
||||
export default function Breweries() {
|
||||
return (
|
||||
<div className="min-h-screen bg-base-200">
|
||||
<div className="container mx-auto p-4">
|
||||
<h1 className="text-4xl font-bold mb-4">Breweries</h1>
|
||||
<p className="text-base-content/70">Discover our partner breweries.</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,91 +0,0 @@
|
||||
import { useEffect } from 'react';
|
||||
import { Link } from 'react-router';
|
||||
import { showErrorToast, showSuccessToast } from '../components/toast/toast';
|
||||
import { confirmEmail, requireAuth } from '../lib/auth.server';
|
||||
import type { Route } from './+types/confirm';
|
||||
|
||||
export function meta({}: Route.MetaArgs) {
|
||||
return [{ title: 'Confirm Email | The Biergarten App' }];
|
||||
}
|
||||
|
||||
export async function loader({ request }: Route.LoaderArgs) {
|
||||
const auth = await requireAuth(request);
|
||||
const url = new URL(request.url);
|
||||
const token = url.searchParams.get('token');
|
||||
|
||||
if (!token) {
|
||||
return { success: false as const, error: 'Missing confirmation token.' };
|
||||
}
|
||||
|
||||
try {
|
||||
const payload = await confirmEmail(token, auth.accessToken);
|
||||
return {
|
||||
success: true as const,
|
||||
confirmedDate: payload.confirmedDate,
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
success: false as const,
|
||||
error: err instanceof Error ? err.message : 'Confirmation failed.',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export default function Confirm({ loaderData }: Route.ComponentProps) {
|
||||
useEffect(() => {
|
||||
if (loaderData.success) {
|
||||
showSuccessToast('Email confirmed successfully.');
|
||||
return;
|
||||
}
|
||||
|
||||
showErrorToast(loaderData.error);
|
||||
}, [loaderData]);
|
||||
|
||||
return (
|
||||
<div className="hero min-h-screen bg-base-200">
|
||||
<div className="card w-full max-w-md bg-base-100 shadow-xl">
|
||||
<div className="card-body items-center text-center gap-4">
|
||||
{loaderData.success ? (
|
||||
<>
|
||||
<div className="text-success text-6xl">✓</div>
|
||||
<h1 className="card-title text-2xl">Email Confirmed!</h1>
|
||||
<p className="text-base-content/70">
|
||||
Your email address has been successfully verified.
|
||||
</p>
|
||||
<div className="bg-base-200 rounded-box w-full p-3 text-sm text-left">
|
||||
<span className="text-base-content/50 text-xs uppercase tracking-widest font-semibold">
|
||||
Confirmed at
|
||||
</span>
|
||||
<p className="font-mono mt-1">
|
||||
{new Date(loaderData.confirmedDate).toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
<div className="card-actions w-full pt-2">
|
||||
<Link to="/dashboard" className="btn btn-primary w-full">
|
||||
Go to Dashboard
|
||||
</Link>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="text-error text-6xl">✕</div>
|
||||
<h1 className="card-title text-2xl">Confirmation Failed</h1>
|
||||
<div role="alert" className="alert alert-error alert-soft w-full">
|
||||
<span>{loaderData.error}</span>
|
||||
</div>
|
||||
<p className="text-base-content/70 text-sm">
|
||||
The confirmation link may have expired (valid for 30 minutes) or already
|
||||
been used.
|
||||
</p>
|
||||
<div className="card-actions w-full pt-2 flex-col gap-2">
|
||||
<Link to="/dashboard" className="btn btn-primary w-full">
|
||||
Back to Dashboard
|
||||
</Link>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,105 +0,0 @@
|
||||
import { requireAuth } from '../lib/auth.server';
|
||||
import type { Route } from './+types/dashboard';
|
||||
|
||||
export function meta({}: Route.MetaArgs) {
|
||||
return [{ title: 'Dashboard | The Biergarten App' }];
|
||||
}
|
||||
|
||||
export async function loader({ request }: Route.LoaderArgs) {
|
||||
const auth = await requireAuth(request);
|
||||
return {
|
||||
username: auth.username,
|
||||
userAccountId: auth.userAccountId,
|
||||
};
|
||||
}
|
||||
|
||||
export default function Dashboard({ loaderData }: Route.ComponentProps) {
|
||||
const { username, userAccountId } = loaderData;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-base-200">
|
||||
<div className="mx-auto max-w-4xl px-6 py-10 space-y-6">
|
||||
<div className="card bg-base-100 shadow">
|
||||
<div className="card-body">
|
||||
<h2 className="card-title text-2xl">Welcome, {username}!</h2>
|
||||
<p className="text-base-content/70">
|
||||
You are successfully authenticated. This is a protected page that requires a
|
||||
valid session.
|
||||
</p>
|
||||
|
||||
<div className="bg-base-200 rounded-box p-4 mt-2">
|
||||
<p className="text-xs font-semibold uppercase tracking-widest text-base-content/50 mb-3">
|
||||
Session Info
|
||||
</p>
|
||||
<div className="stats stats-vertical w-full">
|
||||
<div className="stat py-2">
|
||||
<div className="stat-title">Username</div>
|
||||
<div className="stat-value text-lg font-mono">{username}</div>
|
||||
</div>
|
||||
<div className="stat py-2">
|
||||
<div className="stat-title">User ID</div>
|
||||
<div className="stat-desc font-mono text-xs mt-1">{userAccountId}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card bg-base-100 shadow">
|
||||
<div className="card-body">
|
||||
<h2 className="card-title">Auth Flow Demo</h2>
|
||||
<p className="text-sm text-base-content/70">
|
||||
This demo showcases the following authentication features:
|
||||
</p>
|
||||
<ul className="list">
|
||||
<li className="list-row">
|
||||
<div>
|
||||
<p className="font-semibold">Login</p>
|
||||
<p className="text-sm text-base-content/60">
|
||||
POST to <code className="kbd kbd-sm">/api/auth/login</code> with
|
||||
username & password
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
<li className="list-row">
|
||||
<div>
|
||||
<p className="font-semibold">Register</p>
|
||||
<p className="text-sm text-base-content/60">
|
||||
POST to <code className="kbd kbd-sm">/api/auth/register</code> with
|
||||
full user details
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
<li className="list-row">
|
||||
<div>
|
||||
<p className="font-semibold">Session</p>
|
||||
<p className="text-sm text-base-content/60">
|
||||
JWT access & refresh tokens stored in an HTTP-only cookie
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
<li className="list-row">
|
||||
<div>
|
||||
<p className="font-semibold">Protected Routes</p>
|
||||
<p className="text-sm text-base-content/60">
|
||||
This dashboard requires authentication via{' '}
|
||||
<code className="kbd kbd-sm">requireAuth()</code>
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
<li className="list-row">
|
||||
<div>
|
||||
<p className="font-semibold">Token Refresh</p>
|
||||
<p className="text-sm text-base-content/60">
|
||||
POST to <code className="kbd kbd-sm">/api/auth/refresh</code> with
|
||||
refresh token
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
import { Link } from 'react-router';
|
||||
import { getOptionalAuth } from '../lib/auth.server';
|
||||
import type { Route } from './+types/home';
|
||||
|
||||
export function meta({}: Route.MetaArgs) {
|
||||
return [
|
||||
{ title: 'The Biergarten App' },
|
||||
{ name: 'description', content: 'Welcome to The Biergarten App' },
|
||||
];
|
||||
}
|
||||
|
||||
export async function loader({ request }: Route.LoaderArgs) {
|
||||
const auth = await getOptionalAuth(request);
|
||||
return { username: auth?.username ?? null };
|
||||
}
|
||||
|
||||
export default function Home({ loaderData }: Route.ComponentProps) {
|
||||
const { username } = loaderData;
|
||||
|
||||
return (
|
||||
<div className="hero min-h-screen bg-base-200">
|
||||
<div className="hero-content text-center">
|
||||
<div className="max-w-md space-y-6">
|
||||
<h1 className="text-5xl font-bold">🍺 The Biergarten App</h1>
|
||||
<p className="text-lg text-base-content/70">Authentication Demo</p>
|
||||
|
||||
{username ? (
|
||||
<>
|
||||
<p className="text-base-content/80">
|
||||
Welcome back, <span className="font-semibold text-primary">{username}</span>
|
||||
!
|
||||
</p>
|
||||
<div className="flex gap-3 justify-center">
|
||||
<Link to="/dashboard" className="btn btn-primary">
|
||||
Dashboard
|
||||
</Link>
|
||||
<Link to="/logout" className="btn btn-ghost">
|
||||
Logout
|
||||
</Link>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex gap-3 justify-center">
|
||||
<Link to="/login" className="btn btn-primary">
|
||||
Login
|
||||
</Link>
|
||||
<Link to="/register" className="btn btn-outline">
|
||||
Register
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,128 +0,0 @@
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { HomeSimpleDoor, LogIn, UserPlus } from 'iconoir-react';
|
||||
import { useEffect } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { Link, redirect, useNavigation, useSubmit } from 'react-router';
|
||||
import FormField from '../components/forms/FormField';
|
||||
import SubmitButton from '../components/forms/SubmitButton';
|
||||
import { showErrorToast } from '../components/toast/toast';
|
||||
import { createAuthSession, getOptionalAuth, login } from '../lib/auth.server';
|
||||
import { loginSchema, type LoginSchema } from '../lib/schemas';
|
||||
import type { Route } from './+types/login';
|
||||
|
||||
export function meta({}: Route.MetaArgs) {
|
||||
return [{ title: 'Login | The Biergarten App' }];
|
||||
}
|
||||
|
||||
export async function loader({ request }: Route.LoaderArgs) {
|
||||
const auth = await getOptionalAuth(request);
|
||||
if (auth) throw redirect('/dashboard');
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function action({ request }: Route.ActionArgs) {
|
||||
const formData = await request.formData();
|
||||
const result = loginSchema.safeParse({
|
||||
username: formData.get('username'),
|
||||
password: formData.get('password'),
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
return { error: result.error.issues[0].message };
|
||||
}
|
||||
|
||||
try {
|
||||
const payload = await login(result.data.username, result.data.password);
|
||||
return createAuthSession(payload, '/dashboard');
|
||||
} catch (err) {
|
||||
return { error: err instanceof Error ? err.message : 'Login failed.' };
|
||||
}
|
||||
}
|
||||
|
||||
export default function Login({ actionData }: Route.ComponentProps) {
|
||||
const navigation = useNavigation();
|
||||
const submit = useSubmit();
|
||||
const isSubmitting = navigation.state === 'submitting';
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { errors },
|
||||
} = useForm<LoginSchema>({ resolver: zodResolver(loginSchema) });
|
||||
|
||||
const onSubmit = handleSubmit((data) => {
|
||||
submit(data, { method: 'post' });
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (actionData?.error) {
|
||||
showErrorToast(actionData.error);
|
||||
}
|
||||
}, [actionData?.error]);
|
||||
|
||||
return (
|
||||
<div className="hero min-h-screen bg-base-200">
|
||||
<div className="card w-full max-w-md bg-base-100 shadow-xl">
|
||||
<div className="card-body gap-4">
|
||||
<div className="text-center">
|
||||
<h1 className="card-title text-3xl justify-center gap-2">
|
||||
<LogIn className="size-7" aria-hidden="true" />
|
||||
Login
|
||||
</h1>
|
||||
<p className="text-base-content/70">Sign in to your Biergarten account</p>
|
||||
</div>
|
||||
|
||||
{actionData?.error && (
|
||||
<div role="alert" className="alert alert-error alert-soft">
|
||||
<span>{actionData.error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={onSubmit} className="space-y-3">
|
||||
<FormField
|
||||
id="username"
|
||||
type="text"
|
||||
autoComplete="username"
|
||||
placeholder="your_username"
|
||||
label="Username"
|
||||
error={errors.username?.message}
|
||||
{...register('username')}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
id="password"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
placeholder="••••••••"
|
||||
label="Password"
|
||||
error={errors.password?.message}
|
||||
{...register('password')}
|
||||
/>
|
||||
|
||||
<SubmitButton
|
||||
isSubmitting={isSubmitting}
|
||||
idleText="Sign In"
|
||||
submittingText="Signing in..."
|
||||
/>
|
||||
</form>
|
||||
|
||||
<div className="divider text-xs">New here?</div>
|
||||
|
||||
<div className="text-center space-y-2">
|
||||
<Link to="/register" className="btn btn-outline btn-sm w-full gap-2">
|
||||
<UserPlus className="size-4" aria-hidden="true" />
|
||||
Create an account
|
||||
</Link>
|
||||
<Link
|
||||
to="/"
|
||||
className="link link-hover text-sm text-base-content/60 inline-flex items-center gap-1"
|
||||
>
|
||||
<HomeSimpleDoor className="size-4" aria-hidden="true" />
|
||||
Back to home
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
import { redirect } from 'react-router';
|
||||
import { destroySession, getSession } from '../lib/auth.server';
|
||||
import type { Route } from './+types/logout';
|
||||
|
||||
export async function loader({ request }: Route.LoaderArgs) {
|
||||
const session = await getSession(request);
|
||||
return redirect('/', {
|
||||
headers: { 'Set-Cookie': await destroySession(session) },
|
||||
});
|
||||
}
|
||||
@@ -1,189 +0,0 @@
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useEffect } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { Link, redirect, useNavigation, useSubmit } from 'react-router';
|
||||
import FormField from '../components/forms/FormField';
|
||||
import SubmitButton from '../components/forms/SubmitButton';
|
||||
import { showErrorToast } from '../components/toast/toast';
|
||||
import { createAuthSession, getOptionalAuth, register } from '../lib/auth.server';
|
||||
import { registerSchema, type RegisterSchema } from '../lib/schemas';
|
||||
import type { Route } from './+types/register';
|
||||
|
||||
export function meta({}: Route.MetaArgs) {
|
||||
return [{ title: 'Register | The Biergarten App' }];
|
||||
}
|
||||
|
||||
export async function loader({ request }: Route.LoaderArgs) {
|
||||
const auth = await getOptionalAuth(request);
|
||||
if (auth) throw redirect('/dashboard');
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function action({ request }: Route.ActionArgs) {
|
||||
const formData = await request.formData();
|
||||
const result = registerSchema.safeParse({
|
||||
username: formData.get('username'),
|
||||
firstName: formData.get('firstName'),
|
||||
lastName: formData.get('lastName'),
|
||||
email: formData.get('email'),
|
||||
dateOfBirth: formData.get('dateOfBirth'),
|
||||
password: formData.get('password'),
|
||||
confirmPassword: formData.get('confirmPassword'),
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
const fieldErrors = result.error.flatten().fieldErrors;
|
||||
return { error: null, fieldErrors };
|
||||
}
|
||||
|
||||
try {
|
||||
const body = {
|
||||
username: result.data.username,
|
||||
firstName: result.data.firstName,
|
||||
lastName: result.data.lastName,
|
||||
email: result.data.email,
|
||||
dateOfBirth: result.data.dateOfBirth,
|
||||
password: result.data.password,
|
||||
};
|
||||
const payload = await register(body);
|
||||
return createAuthSession(payload, '/dashboard');
|
||||
} catch (err) {
|
||||
return {
|
||||
error: err instanceof Error ? err.message : 'Registration failed.',
|
||||
fieldErrors: null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export default function Register({ actionData }: Route.ComponentProps) {
|
||||
const navigation = useNavigation();
|
||||
const submit = useSubmit();
|
||||
const isSubmitting = navigation.state === 'submitting';
|
||||
|
||||
const {
|
||||
register: field,
|
||||
handleSubmit,
|
||||
formState: { errors },
|
||||
} = useForm<RegisterSchema>({ resolver: zodResolver(registerSchema) });
|
||||
|
||||
const onSubmit = handleSubmit((data) => {
|
||||
submit(data, { method: 'post' });
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (actionData?.error) {
|
||||
showErrorToast(actionData.error);
|
||||
}
|
||||
}, [actionData?.error]);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-base-200 flex items-center justify-center p-4">
|
||||
<div className="card w-full max-w-lg bg-base-100 shadow-xl">
|
||||
<div className="card-body gap-4">
|
||||
<div className="text-center">
|
||||
<h1 className="card-title text-3xl justify-center">Register</h1>
|
||||
<p className="text-base-content/70">Create your Biergarten account</p>
|
||||
</div>
|
||||
|
||||
{actionData?.error && (
|
||||
<div role="alert" className="alert alert-error alert-soft">
|
||||
<span>{actionData.error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={onSubmit} className="space-y-3">
|
||||
<FormField
|
||||
id="username"
|
||||
type="text"
|
||||
autoComplete="username"
|
||||
placeholder="your_username"
|
||||
label="Username"
|
||||
hint="3-64 characters, alphanumeric and . _ -"
|
||||
error={errors.username?.message}
|
||||
{...field('username')}
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<FormField
|
||||
id="firstName"
|
||||
type="text"
|
||||
autoComplete="given-name"
|
||||
placeholder="Jane"
|
||||
label="First Name"
|
||||
error={errors.firstName?.message}
|
||||
{...field('firstName')}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
id="lastName"
|
||||
type="text"
|
||||
autoComplete="family-name"
|
||||
placeholder="Doe"
|
||||
label="Last Name"
|
||||
error={errors.lastName?.message}
|
||||
{...field('lastName')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
id="email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
placeholder="jane@example.com"
|
||||
label="Email"
|
||||
error={errors.email?.message}
|
||||
{...field('email')}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
id="dateOfBirth"
|
||||
type="date"
|
||||
label="Date of Birth"
|
||||
hint="Must be 19 years or older"
|
||||
error={errors.dateOfBirth?.message}
|
||||
{...field('dateOfBirth')}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
id="password"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
placeholder="••••••••"
|
||||
label="Password"
|
||||
hint="8+ chars: uppercase, lowercase, digit, special character"
|
||||
error={errors.password?.message}
|
||||
{...field('password')}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
id="confirmPassword"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
placeholder="••••••••"
|
||||
label="Confirm Password"
|
||||
error={errors.confirmPassword?.message}
|
||||
{...field('confirmPassword')}
|
||||
/>
|
||||
|
||||
<SubmitButton
|
||||
isSubmitting={isSubmitting}
|
||||
idleText="Create Account"
|
||||
submittingText="Creating account..."
|
||||
/>
|
||||
</form>
|
||||
|
||||
<div className="divider text-xs">Already have an account?</div>
|
||||
|
||||
<div className="text-center space-y-2">
|
||||
<Link to="/login" className="btn btn-outline btn-sm w-full">
|
||||
Sign in
|
||||
</Link>
|
||||
<Link to="/" className="link link-hover text-sm text-base-content/60">
|
||||
← Back to home
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,169 +0,0 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import {
|
||||
biergartenThemes,
|
||||
defaultThemeName,
|
||||
isBiergartenTheme,
|
||||
themeStorageKey,
|
||||
type ThemeName,
|
||||
} from '../lib/themes';
|
||||
import type { Route } from './+types/theme';
|
||||
|
||||
export function meta({}: Route.MetaArgs) {
|
||||
return [
|
||||
{ title: 'Theme | The Biergarten App' },
|
||||
{
|
||||
name: 'description',
|
||||
content: 'Theme guide and switcher for The Biergarten App',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function applyTheme(theme: ThemeName) {
|
||||
document.documentElement.setAttribute('data-theme', theme);
|
||||
localStorage.setItem(themeStorageKey, theme);
|
||||
}
|
||||
|
||||
export default function ThemePage() {
|
||||
const [selectedTheme, setSelectedTheme] = useState<ThemeName>(() => {
|
||||
if (typeof window === 'undefined') {
|
||||
return defaultThemeName;
|
||||
}
|
||||
|
||||
const savedTheme = localStorage.getItem(themeStorageKey);
|
||||
return isBiergartenTheme(savedTheme) ? savedTheme : defaultThemeName;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
applyTheme(selectedTheme);
|
||||
}, [selectedTheme]);
|
||||
|
||||
const activeTheme =
|
||||
biergartenThemes.find((theme) => theme.value === selectedTheme) ?? biergartenThemes[0];
|
||||
|
||||
return (
|
||||
<main className="min-h-screen bg-base-200 px-4 py-8 sm:px-6 lg:px-8">
|
||||
<div className="mx-auto flex w-full max-w-6xl flex-col gap-6">
|
||||
<section className="card border border-base-300 bg-base-100 shadow-xl">
|
||||
<div className="card-body gap-4">
|
||||
<h1 className="card-title text-3xl sm:text-4xl">Theme Guide</h1>
|
||||
<p className="text-base-content/70">
|
||||
Four themes, four moods — from the sun-bleached clarity of a Weizen afternoon
|
||||
to the deep berry dark of a Cassis barrel. Every theme shares the same semantic
|
||||
token structure so components stay consistent while the atmosphere shifts
|
||||
completely.
|
||||
</p>
|
||||
<div className="alert alert-info alert-soft">
|
||||
<span>
|
||||
Active theme: <strong>{activeTheme.label}</strong> — {activeTheme.vibe}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="card border border-base-300 bg-base-100 shadow-xl">
|
||||
<div className="card-body gap-4">
|
||||
<h2 className="card-title text-2xl">Theme switcher</h2>
|
||||
<p className="text-base-content/70">Pick a theme and preview it immediately.</p>
|
||||
|
||||
<div
|
||||
className="join join-vertical sm:join-horizontal"
|
||||
role="radiogroup"
|
||||
aria-label="Theme selector"
|
||||
>
|
||||
{biergartenThemes.map((theme) => {
|
||||
const checked = selectedTheme === theme.value;
|
||||
|
||||
return (
|
||||
<label
|
||||
key={theme.value}
|
||||
className={`btn join-item ${checked ? 'btn-primary' : 'btn-outline'}`}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="theme"
|
||||
value={theme.value}
|
||||
className="sr-only"
|
||||
checked={checked}
|
||||
onChange={() => {
|
||||
setSelectedTheme(theme.value);
|
||||
applyTheme(theme.value);
|
||||
}}
|
||||
/>
|
||||
{theme.label}
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
<div className="card border border-base-300 bg-base-100 shadow-lg">
|
||||
<div className="card-body">
|
||||
<h3 className="card-title">Brand colors</h3>
|
||||
<div className="grid grid-cols-2 gap-2 text-sm font-medium">
|
||||
<div className="rounded-box bg-primary p-3 text-primary-content">
|
||||
Primary
|
||||
</div>
|
||||
<div className="rounded-box bg-secondary p-3 text-secondary-content">
|
||||
Secondary
|
||||
</div>
|
||||
<div className="rounded-box bg-accent p-3 text-accent-content">Accent</div>
|
||||
<div className="rounded-box bg-neutral p-3 text-neutral-content">
|
||||
Neutral
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card border border-base-300 bg-base-100 shadow-lg">
|
||||
<div className="card-body">
|
||||
<h3 className="card-title">Status colors</h3>
|
||||
<div className="space-y-2 text-sm font-medium">
|
||||
<div className="rounded-box bg-info p-3 text-info-content">Info</div>
|
||||
<div className="rounded-box bg-success p-3 text-success-content">
|
||||
Success
|
||||
</div>
|
||||
<div className="rounded-box bg-warning p-3 text-warning-content">
|
||||
Warning
|
||||
</div>
|
||||
<div className="rounded-box bg-error p-3 text-error-content">Error</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card border border-base-300 bg-base-100 shadow-lg md:col-span-2 xl:col-span-1">
|
||||
<div className="card-body">
|
||||
<h3 className="card-title">Core style outline</h3>
|
||||
<ul className="list list-disc space-y-2 pl-5 text-base-content/80">
|
||||
<li>Warm serif headings paired with clear sans-serif body text</li>
|
||||
<li>Rounded, tactile surfaces with subtle depth and grain</li>
|
||||
<li>Semantic token usage to keep contrast consistent in both themes</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="card border border-base-300 bg-base-100 shadow-xl">
|
||||
<div className="card-body gap-4">
|
||||
<h2 className="card-title text-2xl">Component preview</h2>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<button className="btn btn-primary">Primary action</button>
|
||||
<button className="btn btn-secondary">Secondary action</button>
|
||||
<button className="btn btn-accent">Accent action</button>
|
||||
<button className="btn btn-ghost">Ghost action</button>
|
||||
</div>
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<div role="alert" className="alert alert-success alert-soft">
|
||||
<span>Theme tokens are applied consistently.</span>
|
||||
</div>
|
||||
<div role="alert" className="alert alert-warning alert-soft">
|
||||
<span>Use semantic colors over hard-coded color values.</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
// For more info, see https://github.com/storybookjs/eslint-plugin-storybook#configuration-flat-config-format
|
||||
import storybook from 'eslint-plugin-storybook';
|
||||
|
||||
import js from '@eslint/js';
|
||||
import prettierConfig from 'eslint-config-prettier';
|
||||
import reactHooks from 'eslint-plugin-react-hooks';
|
||||
import globals from 'globals';
|
||||
import tseslint from 'typescript-eslint';
|
||||
|
||||
export default tseslint.config(
|
||||
{
|
||||
ignores: ['build/**', 'node_modules/**', '.react-router/**', 'coverage/**'],
|
||||
},
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
js.configs.recommended,
|
||||
...tseslint.configs.recommended,
|
||||
...tseslint.configs.stylistic,
|
||||
],
|
||||
languageOptions: {
|
||||
ecmaVersion: 'latest',
|
||||
sourceType: 'module',
|
||||
globals: {
|
||||
...globals.browser,
|
||||
...globals.node,
|
||||
},
|
||||
parserOptions: {
|
||||
ecmaFeatures: {
|
||||
jsx: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
'react-hooks': reactHooks,
|
||||
},
|
||||
rules: {
|
||||
...reactHooks.configs.recommended.rules,
|
||||
'no-empty-pattern': 'off',
|
||||
'@typescript-eslint/no-unused-vars': [
|
||||
'warn',
|
||||
{
|
||||
argsIgnorePattern: '^_',
|
||||
varsIgnorePattern: '^_',
|
||||
caughtErrorsIgnorePattern: '^_',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
prettierConfig,
|
||||
storybook.configs['flat/recommended'],
|
||||
);
|
||||
13928
src/Website/package-lock.json
generated
@@ -1,68 +1,98 @@
|
||||
{
|
||||
"name": "biergarten-website",
|
||||
"type": "module",
|
||||
"version": "0.0.0",
|
||||
"scripts": {
|
||||
"dev": "react-router dev",
|
||||
"build": "react-router build",
|
||||
"start": "NODE_ENV=production node ./build/server/index.js",
|
||||
"lint": "eslint .",
|
||||
"lint:fix": "eslint . --fix",
|
||||
"format": "prettier . --write",
|
||||
"format:check": "prettier . --check",
|
||||
"typegen": "react-router typegen",
|
||||
"typecheck": "npm run typegen && tsc -p tsconfig.json",
|
||||
"storybook": "storybook dev -p 6006",
|
||||
"build-storybook": "storybook build",
|
||||
"test:storybook": "vitest run --project storybook",
|
||||
"test:storybook:playwright": "playwright test -c playwright.storybook.config.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@headlessui/react": "^2.2.9",
|
||||
"@hookform/resolvers": "^5.2.2",
|
||||
"@react-router/dev": "^7.13.1",
|
||||
"@react-router/express": "^7.13.1",
|
||||
"@react-router/node": "^7.13.1",
|
||||
"iconoir-react": "^7.11.0",
|
||||
"isbot": "^5.1.36",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
"react-hook-form": "^7.71.2",
|
||||
"react-hot-toast": "^2.6.0",
|
||||
"react-router": "^7.13.1",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@chromatic-com/storybook": "^5.0.1",
|
||||
"@eslint/js": "^9.0.0",
|
||||
"@playwright/test": "^1.58.2",
|
||||
"@storybook/addon-a11y": "^10.2.19",
|
||||
"@storybook/addon-docs": "^10.2.19",
|
||||
"@storybook/addon-onboarding": "^10.2.19",
|
||||
"@storybook/addon-vitest": "^10.2.19",
|
||||
"@storybook/react-vite": "^10.2.19",
|
||||
"@tailwindcss/postcss": "^4.2.1",
|
||||
"@types/express": "^5.0.6",
|
||||
"@types/node": "^25.5.0",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitest/browser-playwright": "^4.1.0",
|
||||
"@vitest/coverage-v8": "^4.1.0",
|
||||
"autoprefixer": "^10.4.27",
|
||||
"daisyui": "^5.5.19",
|
||||
"eslint": "^9.0.0",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint-plugin-storybook": "^10.2.19",
|
||||
"globals": "^17.4.0",
|
||||
"playwright": "^1.58.2",
|
||||
"postcss": "^8.5.8",
|
||||
"prettier": "^3.8.1",
|
||||
"storybook": "^10.2.19",
|
||||
"tailwindcss": "^4.2.1",
|
||||
"typescript": "^5.9.3",
|
||||
"typescript-eslint": "^8.57.0",
|
||||
"vite": "^7.0.0",
|
||||
"vitest": "^4.1.0"
|
||||
}
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
import { defineConfig } from '@playwright/test';
|
||||
|
||||
const port = process.env.STORYBOOK_PORT ?? '6006';
|
||||
const baseURL = process.env.STORYBOOK_URL ?? `http://127.0.0.1:${port}`;
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './tests/playwright',
|
||||
timeout: 30_000,
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
use: {
|
||||
baseURL,
|
||||
trace: 'on-first-retry',
|
||||
},
|
||||
webServer: {
|
||||
command: `npm run storybook -- --ci --port ${port}`,
|
||||
url: baseURL,
|
||||
reuseExistingServer: true,
|
||||
timeout: 120_000,
|
||||
},
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
'@tailwindcss/postcss': {},
|
||||
},
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
|
||||
|
Before Width: | Height: | Size: 203 KiB After Width: | Height: | Size: 203 KiB |
|
Before Width: | Height: | Size: 7.3 KiB After Width: | Height: | Size: 7.3 KiB |
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 6.5 KiB After Width: | Height: | Size: 6.5 KiB |
|
Before Width: | Height: | Size: 515 B After Width: | Height: | Size: 515 B |
|
Before Width: | Height: | Size: 961 B After Width: | Height: | Size: 961 B |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 15 KiB |
@@ -1,5 +0,0 @@
|
||||
import type { Config } from '@react-router/dev/config';
|
||||
|
||||
export default {
|
||||
ssr: true,
|
||||
} satisfies Config;
|
||||
|
Before Width: | Height: | Size: 256 KiB After Width: | Height: | Size: 256 KiB |