Compare commits

..

2 Commits
0605 ... main

Author SHA1 Message Date
mohamad
193be18726 Add initial HTML, CSS, and JavaScript files for form management interface
- Created global.css for styling with a Scandinavian industrial palette.
- Added index.ejs as the main entry point for the application, featuring a form creation section and a list of existing forms.
- Implemented main.js for dropdown and modal functionalities.
- Introduced submissions.ejs to display submissions for each form with pagination and action buttons.
- Ensured accessibility features such as skip links and ARIA attributes for better user experience.
2025-05-16 02:11:02 +02:00
mohamad
2ac4fda944 Refactor project structure to transition from Rust to Node.js, removing Rust-specific files and adding Node.js dependencies. Introduced Docker support with a new Dockerfile and docker-compose.yml. Added server.js for Express application setup, along with necessary middleware and routes. Updated .gitignore to exclude environment files and SQLite database. Removed legacy files including Cargo.toml, Cargo.lock, and design.html. 2025-05-16 02:10:41 +02:00
61 changed files with 2079 additions and 14561 deletions

View File

@ -1,77 +0,0 @@
---
description:
globs:
alwaysApply: false
---
Objective: Deliver the minimum set of features a user would expect from a basic form backend service.
use notes.md to track progress!
Task 2.1: User Dashboard & Form Management UI (Replacing current "admin")
* Mindset Shift: This is no longer your admin panel. It's the user's control center.
* Subtask 2.1.1: Design User Dashboard Layout:
* [ ] Wireframe basic layout: List forms, create form, account settings (placeholder).
* [ ] Decide on frontend tech (EJS is fine for MVP if you make it dynamic with client-side JS, or consider a simple SPA framework if comfortable).
* Subtask 2.1.2: "My Forms" View:
* [ ] Fetch and display forms owned by the logged-in user.
* [ ] Show key info: name, submission count, endpoint URL, created date.
* [ ] Links to: view submissions, edit settings, delete.
* Subtask 2.1.3: "Create New Form" Functionality (for logged-in user):
* [ ] UI and backend logic. Associates form with req.user.id.
* Subtask 2.1.4: "View Form Submissions" (Scoped & Paginated):
* [ ] UI and backend for a user to view submissions for their specific form.
* [ ] Pagination is critical here (as you have).
* Subtask 2.1.5: Form Settings UI (Basic):
* [ ] Allow users to update form name.
* [ ] Placeholder for future settings (thank you URL, notifications).
* Subtask 2.1.6: Delete Form/Submission (with soft delete/archival consideration):
* [ ] You have is_archived. Solidify this. Users should be able to archive/unarchive.
* [ ] True delete should be a confirmed, rare operation.
Task 2.2: Per-Form Configuration by User
* Mindset Shift: Empower users to customize their form behavior.
* Subtask 2.2.1: Database Schema Updates for forms Table:
* [ ] Review existing fields (thank_you_url, thank_you_message, ntfy_enabled, allowed_domains). These are good.
* [ ] Add email_notifications_enabled (boolean).
* [ ] Add notification_email_address (string, defaults to user's email, but allow override).
* Subtask 2.2.2: UI for Form Settings Page:
* [ ] Create a dedicated page/modal for each form's settings.
* [ ] Allow users to edit: Name, Thank You URL, Thank You Message, Allowed Domains, Email Notification toggle, Notification Email Address.
* Subtask 2.2.3: Backend to Save and Apply Settings:
* [ ] API endpoints to update these settings for a specific form (owned by user).
* [ ] Logic in /submit/:formUuid to use these form-specific settings.
Task 2.3: Email Notifications for Submissions (Core Feature)
* Mindset Shift: Ntfy is cool for you. Users expect email.
* Subtask 2.3.1: Integrate Transactional Email Service:
* [ ] Sign up for SendGrid, Mailgun, AWS SES (free tiers available).
* [ ] Install their SDK. Store API key securely (env vars).
* Subtask 2.3.2: Email Sending Logic:
* [ ] Create a service/function sendSubmissionNotification(form, submissionData).
* [ ] If email_notifications_enabled for the form, send an email to notification_email_address.
* Subtask 2.3.3: Basic Email Template:
* [ ] Simple, clear email: "New Submission for [Form Name]", list submitted data.
* Subtask 2.3.4: Error Handling for Email Sending:
* [ ] Log errors if email fails to send; don't let it break the submission flow.
Task 2.4: Enhanced Spam Protection (Beyond Basic Honeypot)
* Mindset Shift: Your honeypot is step 1. Real services need more.
* Subtask 2.4.1: Integrate CAPTCHA (e.g., Google reCAPTCHA):
* [ ] Sign up for reCAPTCHA (v2 "I'm not a robot" or v3 invisible). Get site/secret keys.
* [ ] Frontend: Add reCAPTCHA widget/JS to user's HTML form example.
* [ ] Backend: /submit/:formUuid endpoint must verify reCAPTCHA token with Google.
* Subtask 2.4.2: User Configuration for Spam Protection:
* [ ] Allow users to enable/disable reCAPTCHA for their forms (and input their own site key if they want, or use a global one you provide).
* Subtask 2.4.3: (Future Consideration) Akismet / Content Analysis.
Task 2.5: Basic API for Users to Access Their Data
* Mindset Shift: Power users and integrations need an API.
* Subtask 2.5.1: API Key Generation & Management:
* [ ] Allow users to generate/revoke API keys from their dashboard.
* [ ] Store hashed API keys in DB, associated with user.
* Subtask 2.5.2: Secure API Endpoints:
* [ ] Create new API routes (e.g., /api/v1/forms, /api/v1/forms/:uuid/submissions).
* [ ] Authenticate using API keys (e.g., Bearer token).
* Subtask 2.5.3: Basic API Documentation:
* [ ] Simple Markdown file explaining authentication and available endpoints.

38
.env
View File

@ -1,34 +1,4 @@
PORT=3000 INITIAL_ADMIN_USERNAME=admin
JWT_SECRET=dognidnrfognpobibsnccofr INITIAL_ADMIN_PASSWORD=admin
ALLOWED_ORIGIN=http://127.0.0.1:5500,http://localhost:5500
ADMIN_USER=youradminuser DATABASE_URL=form_data.db
ADMIN_PASSWORD=yoursecurepassword
# Ntfy Configuration
NTFY_TOPIC_URL=https://ntfggy.sh/your-secret-form-alerts # IMPORTANT: Change this!
NTFY_ENABLED=true # set to false to disable ntfy
RECAPTCHA_V2_SITE_KEY=your_actual_site_key
RECAPTCHA_V2_SECRET_KEY=your_actual_secret_key
RESEND_API_KEY=xxx
EMAIL_FROM_ADDRESS=xxx
recaptcha_enabled = TRUE
DATABASE_URL=postgresql://formies_owner:npg_VtO2HSgGnI9J@ep-royal-scene-a2961c60-pooler.eu-central-1.aws.neon.tech/formies?sslmode=require
# Redis
REDIS_HOST=your_production_redis_host_or_service_name # e.g., the service name in docker-compose.prod.yml like 'redis'
REDIS_PORT=6379 # Or your production Redis port if different
REDIS_PASSWORD=your_production_redis_password # Ensure this is set for production
# Application specific
NODE_ENV=production
PORT=3000 # Or your desired production port
# Security - VERY IMPORTANT: Use strong, unique secrets for production
SESSION_SECRET=generate_a_very_strong_random_string_for_session_secret
JWT_SECRET=generate_a_very_strong_random_string_for_jwt_secret

View File

@ -1,32 +0,0 @@
# .env.test
NODE_ENV=test
PORT=3001 # Different port for test server
DB_HOST=localhost
DB_USER=your_test_pg_user
DB_PASSWORD=your_test_pg_password
DB_NAME=formies_test_db # CRITICAL: MUST BE A TEST DATABASE
DB_PORT=5432
JWT_SECRET=a_different_test_secret_key_that_is_very_long_and_secure
JWT_ISSUER=formies-test
JWT_AUDIENCE=formies-users-test
JWT_ACCESS_EXPIRY=5s
JWT_REFRESH_EXPIRY=10s
SESSION_SECRET=another_test_session_secret
APP_URL=http://localhost:3001
# Mocked or test service creds
RESEND_API_KEY=test_resend_key # For email service mocking
EMAIL_FROM_ADDRESS=test@formies.local
NTFY_ENABLED=false
RECAPTCHA_V2_SITE_KEY=6LeIxAcTAAAAAJcZVRqyHh71UMIEGNQ_MM_sF2s_ # Google's test site key
RECAPTCHA_V2_SECRET_KEY=6LeIxAcTAAAAAGG-vFI1TnRWxMZNFuojJ4WifJWe # Google's test secret key
REDIS_HOST=localhost
REDIS_PORT=6379 # Assuming test Redis runs on default port
REDIS_PASSWORD=

3
.gitignore vendored
View File

@ -1,3 +1,4 @@
.env *.env
package-lock.json package-lock.json
node_modules node_modules
database.sqlite

View File

@ -1,98 +0,0 @@
# Formies API Documentation (v1)
This document provides instructions on how to use the Formies API to access your forms and submission data programmatically.
## Authentication
All API requests must be authenticated using an API Key.
1. **Generate an API Key**: You can generate and manage your API keys from your user dashboard under the "API Keys" section.
2. **Pass the API Key**: The API key must be included in the `Authorization` header of your HTTP requests, using the `Bearer` scheme.
Example:
```
Authorization: Bearer YOUR_FULL_API_KEY_HERE
```
Replace `YOUR_FULL_API_KEY_HERE` with the actual API key you generated (e.g., `fsk_xxxxxxxxxxxx_yyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy`).
If authentication fails (e.g., missing key, invalid key, expired key), the API will respond with a `401 Unauthorized` or `403 Forbidden` status code and a JSON error message.
## Endpoints
All API endpoints are prefixed with `/api/v1`.
### 1. List Your Forms
- **Endpoint**: `GET /api/v1/forms`
- **Method**: `GET`
- **Authentication**: Required (Bearer Token)
- **Description**: Retrieves a list of all forms owned by the authenticated user.
- **Successful Response (200 OK)**:
```json
{
"success": true,
"forms": [
{
"uuid": "form-uuid-123",
"name": "My Contact Form",
"created_at": "2023-10-26T10:00:00.000Z",
"is_archived": false,
"submission_count": 150
}
// ... other forms
]
}
```
- **Error Responses**:
- `401 Unauthorized`: Authentication failed.
- `500 Internal Server Error`: If there was an issue fetching the forms.
### 2. List Submissions for a Form
- **Endpoint**: `GET /api/v1/forms/:formUuid/submissions`
- **Method**: `GET`
- **Authentication**: Required (Bearer Token)
- **Path Parameters**:
- `formUuid` (string, required): The UUID of the form for which to retrieve submissions.
- **Query Parameters (for pagination)**:
- `page` (integer, optional, default: `1`): The page number of submissions to retrieve.
- `limit` (integer, optional, default: `25`): The number of submissions to retrieve per page.
- **Description**: Retrieves a paginated list of submissions for a specific form owned by the authenticated user.
- **Successful Response (200 OK)**:
```json
{
"success": true,
"formName": "My Contact Form",
"formUuid": "form-uuid-123",
"pagination": {
"currentPage": 1,
"totalPages": 3,
"totalSubmissions": 65,
"limit": 25,
"perPage": 25,
"count": 25
},
"submissions": [
{
"id": 1,
"data": { "email": "test@example.com", "message": "Hello!" },
"ip_address": "123.123.123.123",
"submitted_at": "2023-10-27T14:30:00.000Z"
}
// ... other submissions for the current page
]
}
```
- **Error Responses**:
- `401 Unauthorized`: Authentication failed.
- `403 Forbidden`: If the authenticated user does not own the specified form.
- `404 Not Found`: If the specified `formUuid` does not exist.
- `500 Internal Server Error`: If there was an issue fetching the submissions.
## General Notes
- All API responses are in JSON format.
- Successful responses will generally include a `success: true` field.
- Error responses will include `success: false` and an `error` field (string or object) with details.

View File

@ -1,432 +0,0 @@
# Authentication System Setup Guide
## Overview
This guide will help you set up the robust user authentication and authorization system for your Formies SaaS application. The system includes:
- **JWT-based authentication** with access and refresh tokens
- **Email verification** with automated emails
- **Password reset** functionality
- **Role-based authorization** (user, admin, super_admin)
- **Account security** features (failed login tracking, account locking)
- **Rate limiting** to prevent abuse
- **Session management** with token blacklisting
## Required Dependencies
The following packages have been added to your `package.json`:
```json
{
"bcryptjs": "^2.4.3",
"express-rate-limit": "^7.1.5",
"express-session": "^1.17.3",
"express-validator": "^7.0.1",
"jsonwebtoken": "^9.0.2",
"nodemailer": "^6.9.8",
"passport": "^0.7.0",
"passport-jwt": "^4.0.1",
"passport-local": "^1.0.0"
}
```
## Environment Variables
Create a `.env` file with the following variables:
```env
# Database Configuration
DB_HOST=localhost
DB_USER=your_db_user
DB_PASSWORD=your_db_password
DB_NAME=forms_db
# JWT Configuration (REQUIRED)
JWT_SECRET=your-super-secret-jwt-key-at-least-32-characters-long
JWT_ISSUER=formies
JWT_AUDIENCE=formies-users
JWT_ACCESS_EXPIRY=15m
JWT_REFRESH_EXPIRY=7d
# Session Configuration
SESSION_SECRET=your-session-secret-key-change-this-in-production
# Application Configuration
APP_URL=http://localhost:3000
NODE_ENV=development
PORT=3000
# SMTP Email Configuration (Optional but recommended)
SMTP_HOST=smtp.gmail.com
SMTP_PORT=587
SMTP_SECURE=false
SMTP_USER=your-email@gmail.com
SMTP_PASS=your-app-password
SMTP_FROM_EMAIL=noreply@yourdomain.com
# Notification Configuration
NTFY_ENABLED=true
NTFY_TOPIC_URL=https://ntfy.sh/your-topic
```
## Database Setup
1. **Install dependencies:**
```bash
npm install
```
2. **Update your database** by running the updated `init.sql`:
This script will create all necessary tables, including the `users` table with a default `super_admin` account (`admin@formies.local`).
The initial password for this `super_admin` is NOT set in the `init.sql` script. The `must_change_password` flag will be set to `TRUE`.
```bash
# If using Docker
docker-compose down
docker-compose up -d
# Or manually run the SQL file in your MySQL database
mysql -u your_user -p your_database < init.sql
```
If the login is for the `super_admin` (`admin@formies.local`) and it's their first login (`must_change_password` is `TRUE` on the user object returned from the `/login` attempt, even if successful), the API might return a successful login response but the client should check for this flag. Alternatively, the `/login` endpoint itself has been modified to return a `403 Forbidden` response with `code: "MUST_CHANGE_PASSWORD"` directly if this condition is met. The client application should handle this response and prompt the user to use the `/force-change-password` endpoint.
## API Endpoints
### Authentication Endpoints
All authentication endpoints are prefixed with `/api/auth`:
#### Registration
```http
POST /api/auth/register
Content-Type: application/json
{
"email": "user@example.com",
"password": "SecurePass123!",
"first_name": "John",
"last_name": "Doe"
}
```
#### Login
```http
POST /api/auth/login
Content-Type: application/json
{
"email": "user@example.com",
"password": "SecurePass123!"
}
Response:
{
"success": true,
"message": "Login successful",
"data": {
"user": { ... },
"accessToken": "eyJ...",
"refreshToken": "eyJ...",
"accessTokenExpiresAt": "2024-01-01T00:00:00.000Z",
"refreshTokenExpiresAt": "2024-01-07T00:00:00.000Z",
"tokenType": "Bearer"
}
}
```
**Super Admin First Login:**
If the login attempt is for the `super_admin` (`admin@formies.local`) and the `must_change_password` flag is `TRUE` for this user, the `/api/auth/login` endpoint will return a `403 Forbidden` response with the following structure:
```json
{
"success": false,
"message": "Password change required.",
"code": "MUST_CHANGE_PASSWORD",
"data": {
"user": {
"id": "user_id",
"uuid": "user_uuid",
"email": "admin@formies.local",
"role": "super_admin"
}
}
}
```
The client application should detect this `code: "MUST_CHANGE_PASSWORD"` and guide the user to set a new password using the endpoint below. The `accessToken` and `refreshToken` will NOT be issued in this case. The client will need to make a subsequent call to `/api/auth/force-change-password` using a temporary mechanism if required, or by having the user log in, get the 403, then use a password change form that calls the next endpoint. For the current implementation, the super_admin will receive a standard JWT upon providing correct credentials (even if `must_change_password` is true), and this token should be used for the `/force-change-password` call.
#### Force Password Change
This endpoint is used when a user, particularly the initial `super_admin`, needs to set their password for the first time or has been flagged for a mandatory password update.
```http
POST /api/auth/force-change-password
Authorization: Bearer your-access-token-from-login-attempt
Content-Type: application/json
{
"newPassword": "ANewStrongPassword123!"
}
Response (on success):
{
"success": true,
"message": "Password changed successfully. Please log in again with your new password."
}
```
After a successful password change using this endpoint:
- The user's password is updated.
- The `must_change_password` flag is set to `FALSE`.
- All other active sessions for this user are invalidated for security.
- The user will need to log in again with their new password to obtain new session tokens.
#### Token Refresh
```http
POST /api/auth/refresh
Content-Type: application/json
{
"refreshToken": "eyJ..."
}
```
#### Logout
```http
POST /api/auth/logout
Authorization: Bearer your-access-token
```
#### Email Verification
```http
GET /api/auth/verify-email?token=verification_token
```
#### Profile Management
```http
GET /api/auth/profile
Authorization: Bearer your-access-token
PUT /api/auth/profile
Authorization: Bearer your-access-token
Content-Type: application/json
{
"first_name": "John",
"last_name": "Doe",
"email": "newemail@example.com"
}
```
## Security Features
### Password Requirements
- Minimum 8 characters
- At least one lowercase letter
- At least one uppercase letter
- At least one number
- At least one special character (@$!%\*?&)
### Account Security
- Failed login attempts are tracked
- Account locks after 5 failed attempts for 30 minutes
- Email verification required for new accounts
- JWT tokens are tracked and can be revoked
### Rate Limiting
- **Login attempts:** 5 per 15 minutes per IP/email
- **Registration:** 3 per hour per IP
- **Password reset:** 3 per hour per IP/email
## Using the Authentication System
### Frontend Integration
1. **Store tokens securely:**
```javascript
// Store in secure httpOnly cookies or localStorage (less secure)
localStorage.setItem("accessToken", response.data.accessToken);
localStorage.setItem("refreshToken", response.data.refreshToken);
```
2. **Include token in requests:**
```javascript
fetch("/api/protected-endpoint", {
headers: {
Authorization: `Bearer ${accessToken}`,
"Content-Type": "application/json",
},
});
```
3. **Handle token refresh:**
```javascript
async function refreshToken() {
const refreshToken = localStorage.getItem("refreshToken");
const response = await fetch("/api/auth/refresh", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ refreshToken }),
});
if (response.ok) {
const data = await response.json();
localStorage.setItem("accessToken", data.data.accessToken);
return data.data.accessToken;
} else {
// Redirect to login
localStorage.removeItem("accessToken");
localStorage.removeItem("refreshToken");
window.location.href = "/login";
}
}
```
### Backend Integration
1. **Protect routes with authentication:**
```javascript
const {
requireAuth,
requireAdmin,
} = require("./src/middleware/authMiddleware");
// Require authentication
router.get("/protected", requireAuth, (req, res) => {
res.json({ user: req.user });
});
// Require admin role
router.get("/admin-only", requireAdmin, (req, res) => {
res.json({ message: "Admin access granted" });
});
```
2. **Check resource ownership:**
```javascript
const {
requireOwnershipOrAdmin,
} = require("./src/middleware/authMiddleware");
router.get(
"/forms/:id",
requireOwnershipOrAdmin(async (req) => {
const form = await Form.findById(req.params.id);
return form.user_id;
}),
(req, res) => {
// User can only access their own forms or admin can access all
}
);
```
## Migration from Basic Auth
The system maintains backward compatibility with your existing basic auth. To fully migrate:
1. **Update admin routes** to use the new authentication system
2. **Create admin users** in the database with appropriate roles
3. **Remove basic auth middleware** once migration is complete
## Default Admin Account
A default super admin account is created automatically:
- **Email:** admin@formies.local
- **Password:** admin123 (change immediately!)
## Email Configuration
For email verification and password reset to work, configure SMTP settings:
### Gmail Setup
1. Enable 2-factor authentication
2. Generate an app password
3. Use the app password in `SMTP_PASS`
### Other Providers
- **Outlook:** smtp-mail.outlook.com:587
- **SendGrid:** smtp.sendgrid.net:587
- **Mailgun:** smtp.mailgun.org:587
## Production Considerations
1. **Use strong secrets:** Generate random JWT_SECRET and SESSION_SECRET
2. **Enable HTTPS:** Set `NODE_ENV=production` and use SSL certificates
3. **Use Redis for sessions:** Replace memory sessions with Redis
4. **Monitor rate limits:** Adjust rate limiting based on usage patterns
5. **Backup token sessions:** Consider database-backed session storage
## Troubleshooting
### Common Issues
1. **JWT_SECRET not set:**
```
WARNING: JWT_SECRET not set. Authentication will not work properly.
```
Solution: Add JWT_SECRET to your .env file
2. **Email service not working:**
```
Email service not configured. Set SMTP environment variables.
```
Solution: Configure SMTP settings in .env file
3. **Database connection errors:**
- Verify database credentials
- Ensure database exists
- Check if init.sql has been run
4. **Token validation errors:**
- Check if JWT_SECRET matches between requests
- Verify token hasn't expired
- Ensure token is properly formatted in Authorization header
## Testing the System
Use these curl commands to test the authentication endpoints:
```bash
# Register a new user
curl -X POST http://localhost:3000/api/auth/register \
-H "Content-Type: application/json" \
-d '{"email":"test@example.com","password":"TestPass123!","first_name":"Test","last_name":"User"}'
# Login
curl -X POST http://localhost:3000/api/auth/login \
-H "Content-Type: application/json" \
-d '{"email":"test@example.com","password":"TestPass123!"}'
# Access protected endpoint
curl -X GET http://localhost:3000/api/auth/profile \
-H "Authorization: Bearer YOUR_ACCESS_TOKEN"
```
This authentication system provides enterprise-grade security for your SaaS application while maintaining flexibility and ease of use.

View File

@ -1,23 +1,31 @@
FROM node:18.19-alpine AS builder FROM node:24-alpine AS builder
WORKDIR /usr/src/app WORKDIR /usr/src/app
# Install build dependencies for sqlite3
RUN apk add --no-cache python3 make g++ sqlite-dev
COPY package*.json ./ COPY package*.json ./
RUN npm ci RUN npm ci
COPY . . COPY . .
FROM node:18.19-alpine FROM node:24-alpine
WORKDIR /usr/src/app WORKDIR /usr/src/app
# Install runtime dependencies for sqlite3
RUN apk add --no-cache sqlite-libs python3 make g++ sqlite-dev
# Create a non-root user # Create a non-root user
RUN addgroup -S appgroup && adduser -S appuser -G appgroup RUN addgroup -S appgroup && adduser -S appuser -G appgroup
COPY --from=builder /usr/src/app/node_modules ./node_modules
COPY --from=builder /usr/src/app/package*.json ./ COPY --from=builder /usr/src/app/package*.json ./
COPY --from=builder /usr/src/app/ ./ COPY --from=builder /usr/src/app/ ./
# Rebuild sqlite3 for the target architecture
RUN npm rebuild sqlite3
# Set ownership to non-root user # Set ownership to non-root user
RUN chown -R appuser:appgroup /usr/src/app RUN chown -R appuser:appgroup /usr/src/app

View File

@ -1,164 +0,0 @@
# Rate Limiting Documentation
## Overview
This application now implements a scalable Redis-backed rate limiting system to protect against abuse and ensure fair usage of the form submission endpoints.
## Rate Limiting Strategy
The `/submit/:formUuid` endpoint is protected by three layers of rate limiting:
### 1. Strict Rate Limiter (First Layer)
- **Window**: 1 hour
- **Limit**: 50 requests per IP address across all forms
- **Purpose**: Prevents aggressive abuse from single IP addresses
- **Key**: `strict_ip:{ip_address}`
### 2. General Submission Rate Limiter (Second Layer)
- **Window**: 15 minutes
- **Limit**: 10 requests per IP address for any form submissions
- **Purpose**: Prevents rapid-fire submissions from legitimate users
- **Key**: `submit_ip:{ip_address}`
### 3. Form-Specific Rate Limiter (Third Layer)
- **Window**: 5 minutes
- **Limit**: 3 requests per IP address per specific form
- **Purpose**: Prevents spam on individual forms
- **Key**: `submit_form:{formUuid}:{ip_address}`
## Infrastructure
### Redis Configuration
#### Development Environment
- **Service**: `redis:7-alpine`
- **Port**: `6379`
- **Data Persistence**: Yes (Redis AOF)
- **Volume**: `redis_data:/data`
#### Production Environment
- **Service**: `redis:7-alpine`
- **Port**: `6380` (external, to avoid conflicts)
- **Data Persistence**: Yes (Redis AOF)
- **Volume**: `redis_data:/data`
- **Password Protection**: Configurable via `REDIS_PASSWORD`
- **Health Checks**: Enabled
### Environment Variables
```env
# Redis Configuration
REDIS_HOST=redis # Redis hostname (default: redis in Docker, localhost otherwise)
REDIS_PORT=6379 # Redis port (default: 6379)
REDIS_PASSWORD= # Optional Redis password (production recommended)
```
## Fallback Mechanism
If Redis is unavailable, the system automatically falls back to an in-memory rate limiter:
- **Graceful Degradation**: Application continues to function without Redis
- **Automatic Detection**: Detects Redis availability and switches accordingly
- **Logging**: Warns when falling back to memory store
- **Same Limits**: Maintains the same rate limiting rules
## Rate Limit Headers
When rate limits are applied, the following headers are returned:
- `RateLimit-Limit`: Maximum number of requests allowed
- `RateLimit-Remaining`: Number of requests remaining in window
- `RateLimit-Reset`: Time when the rate limit window resets
## Error Responses
When rate limits are exceeded, the API returns:
```json
{
"error": "Too many requests from this IP address. Please try again later."
}
```
The specific error message varies by rate limiter:
- **Strict**: "Too many requests from this IP address. Please try again later."
- **General**: "Too many form submissions from this IP address. Please try again later."
- **Form-Specific**: "Too many submissions for this form from your IP address. Please try again later."
## Deployment
### Starting Services
#### Development
```bash
docker-compose up -d
```
#### Production
```bash
docker-compose -f docker-compose.prod.yml up -d
```
### Monitoring Redis
Check Redis connection:
```bash
docker exec -it formies-redis-1 redis-cli ping
```
View rate limiting keys:
```bash
docker exec -it formies-redis-1 redis-cli --scan --pattern "submit_*"
```
## Security Considerations
1. **Redis Security**: In production, always use password authentication
2. **Network Security**: Redis should not be exposed to public networks
3. **Data Persistence**: Redis data is persisted to handle container restarts
4. **Graceful Shutdown**: Application properly closes Redis connections on exit
## Performance
- **Scalability**: Redis-backed rate limiting scales across multiple application instances
- **Efficiency**: O(1) operations for rate limit checks
- **Memory Usage**: Efficient key expiration prevents memory leaks
- **High Availability**: Can be configured with Redis clustering for production
## Troubleshooting
### Common Issues
1. **Redis Connection Failed**
- Check if Redis container is running
- Verify environment variables
- Check Docker network connectivity
2. **Rate Limiting Not Working**
- Verify Redis connection in application logs
- Check if fallback to memory store is occurring
- Ensure proper IP address detection
3. **Performance Issues**
- Monitor Redis memory usage
- Check for connection pooling configuration
- Verify network latency between app and Redis
### Logs to Monitor
- Redis connection status
- Rate limiter fallback warnings
- Rate limit exceeded events
- Redis error messages

View File

@ -1,197 +0,0 @@
// __tests__/integration/auth.test.js
const request = require("supertest");
const app = require("../../../server"); // Adjust path to your Express app
const { pool, clearAllTables } = require("../../setup/testDbUtils"); // Adjust path
const User = require("../../../src/models/User"); // Adjust path
describe("Auth API Endpoints", () => {
let server;
beforeAll(() => {
// If your app directly listens, you might not need this.
// If app is just exported, supertest handles starting/stopping.
// server = app.listen(process.env.PORT || 3001); // Use test port
});
afterAll(async () => {
// if (server) server.close();
// await pool.end(); // Already handled by global teardown
});
beforeEach(async () => {
await clearAllTables();
});
describe("POST /api/auth/register", () => {
it("should register a new user successfully", async () => {
const res = await request(app).post("/api/auth/register").send({
email: "newuser@example.com",
password: "Password123!",
first_name: "New",
last_name: "User",
});
expect(res.statusCode).toEqual(201);
expect(res.body.success).toBe(true);
expect(res.body.data.user.email).toBe("newuser@example.com");
const dbUser = await User.findByEmail("newuser@example.com");
expect(dbUser).toBeDefined();
});
it("should return 409 if email already exists", async () => {
await User.create({
email: "existing@example.com",
password: "Password123!",
});
const res = await request(app)
.post("/api/auth/register")
.send({ email: "existing@example.com", password: "Password123!" });
expect(res.statusCode).toEqual(409);
expect(res.body.message).toContain("already exists");
});
// ... more registration tests (validation, etc.)
});
describe("POST /api/auth/login", () => {
let testUser;
beforeEach(async () => {
testUser = await User.create({
email: "login@example.com",
password: "Password123!",
is_verified: 1, // Mark as verified for login
});
});
it("should login an existing verified user and return tokens", async () => {
const res = await request(app)
.post("/api/auth/login")
.send({ email: "login@example.com", password: "Password123!" });
expect(res.statusCode).toEqual(200);
expect(res.body.success).toBe(true);
expect(res.body.data.accessToken).toBeDefined();
expect(res.body.data.refreshToken).toBeDefined();
expect(res.body.data.user.email).toBe("login@example.com");
});
it("should return 401 for invalid credentials", async () => {
const res = await request(app)
.post("/api/auth/login")
.send({ email: "login@example.com", password: "WrongPassword!" });
expect(res.statusCode).toEqual(401);
});
// ... more login tests (unverified, locked, must_change_password for super_admin)
// Example for super_admin must_change_password
it("should return 403 with MUST_CHANGE_PASSWORD for super_admin first login", async () => {
// Ensure the default super_admin exists with must_change_password = TRUE
// and password_hash = 'NEEDS_TO_BE_SET_ON_FIRST_LOGIN'
// This requires the special handling in LocalStrategy as discussed.
// For this test, you might need to manually insert/update the super_admin in testDb.
await pool.query(
`INSERT INTO users (email, password_hash, role, is_verified, is_active, must_change_password, uuid)
VALUES ($1, $2, 'super_admin', TRUE, TRUE, TRUE, $3)
ON CONFLICT (email) DO UPDATE SET password_hash = $2, must_change_password = TRUE`,
[
"admin@formies.local",
"NEEDS_TO_BE_SET_ON_FIRST_LOGIN",
require("uuid").v4(),
]
);
// This also assumes your special login logic for this specific hash exists
const res = await request(app)
.post("/api/auth/login")
.send({ email: "admin@formies.local", password: "anypassword" }); // Password might be ignored by special logic
if (
res.statusCode === 200 &&
res.body?.data?.user?.must_change_password
) {
// This means your special login logic works by issuing a token even if bcrypt would fail,
// and your /login route has a check for user.must_change_password AFTER successful auth by passport.
// The client would then be responsible for triggering the force-change-password flow.
// This is one way to handle it.
console.warn(
"Super admin login with must_change_password=true returned 200, client must handle redirection to force password change."
);
} else {
// The ideal case from previous discussion:
// expect(res.statusCode).toEqual(403);
// expect(res.body.success).toBe(false);
// expect(res.body.code).toBe('MUST_CHANGE_PASSWORD');
// expect(res.body.data.user.email).toBe('admin@formies.local');
// For now, let's check for either the 403, or the 200 with the flag, as implementation details may vary slightly.
expect([200, 403]).toContain(res.statusCode);
if (res.statusCode === 200)
expect(res.body.data.user.must_change_password).toBe(1); // or true
if (res.statusCode === 403)
expect(res.body.code).toBe("MUST_CHANGE_PASSWORD");
}
});
});
describe("POST /api/auth/force-change-password", () => {
let superAdminToken;
beforeEach(async () => {
// Simulate super admin login that requires password change
await pool.query(
`INSERT INTO users (id, email, password_hash, role, is_verified, is_active, must_change_password, uuid)
VALUES (999, $1, $2, 'super_admin', TRUE, TRUE, TRUE, $3)
ON CONFLICT (email) DO UPDATE SET password_hash = $2, must_change_password = TRUE`,
[
"admin@formies.local",
"NEEDS_TO_BE_SET_ON_FIRST_LOGIN",
require("uuid").v4(),
]
);
// This part is tricky: how do you get a token if login itself is blocked?
// Option 1: Special login route for first-time setup (not implemented).
// Option 2: Modify LocalStrategy to issue a temporary token for this specific case.
// Option 3: Assume `must_change_password` doesn't block login fully but returns a flag,
// and a normal token is issued, which is then used here.
// Let's assume Option 3 for this test, where login provides a token.
const loginRes = await request(app)
.post("/api/auth/login")
.send({ email: "admin@formies.local", password: "anypassword" }); // Password will be bypassed by special logic
if (loginRes.body.data && loginRes.body.data.accessToken) {
superAdminToken = loginRes.body.data.accessToken;
} else {
// If login directly returns 403 for MUST_CHANGE_PASSWORD, then this test needs rethinking.
// It implies the client makes this call *without* a token initially, which is unusual for a POST.
// Or, the client gets some other form of temporary credential.
// For now, this test assumes a token is acquired.
console.warn(
"Could not get token for superAdmin requiring password change. /force-change-password test may be invalid."
);
}
});
it("should allow super_admin to change password if must_change_password is true", async () => {
if (!superAdminToken) {
console.warn("Skipping force-change-password test: no superAdminToken");
return; // or expect(superAdminToken).toBeDefined(); to fail if setup is wrong
}
const res = await request(app)
.post("/api/auth/force-change-password")
.set("Authorization", `Bearer ${superAdminToken}`)
.send({ newPassword: "NewSecurePassword123!" });
expect(res.statusCode).toEqual(200);
expect(res.body.success).toBe(true);
expect(res.body.message).toContain("Password changed successfully");
const dbUser = await User.findByEmail("admin@formies.local");
expect(dbUser.must_change_password).toBe(0); // Or FALSE
const isMatch = await require("bcryptjs").compare(
"NewSecurePassword123!",
dbUser.password_hash
);
expect(isMatch).toBe(true);
});
});
// ... tests for /refresh, /logout, /verify-email, /forgot-password, /reset-password, /profile etc.
});

View File

@ -1,58 +0,0 @@
// __tests__/integration/dashboard.test.js
// ... imports ...
describe("GET /dashboard (My Forms)", () => {
let userToken;
let userId;
beforeEach(async () => {
// Create user and login to get token
const user = await User.create({
email: "dash@example.com",
password: "Password123!",
is_verified: 1,
});
userId = user.id;
const loginRes = await request(app)
.post("/api/auth/login")
.send({ email: "dash@example.com", password: "Password123!" });
userToken = loginRes.body.data.accessToken;
// Create some forms for this user
await pool.query(
"INSERT INTO forms (uuid, user_id, name) VALUES ($1, $2, $3), ($4, $2, $5)",
[
require("uuid").v4(),
userId,
"My First Form",
require("uuid").v4(),
"My Second Form",
]
);
// Create a form for another user
const otherUser = await User.create({
email: "other@example.com",
password: "Password123!",
});
await pool.query(
"INSERT INTO forms (uuid, user_id, name) VALUES ($1, $2, $3)",
[require("uuid").v4(), otherUser.id, "Other Users Form"]
);
});
it("should list forms owned by the authenticated user", async () => {
const res = await request(app)
.get("/dashboard")
.set("Authorization", `Bearer ${userToken}`); // Or handle session if dashboard uses sessions
// If dashboard uses sessions, you need to manage login via supertest's agent:
// const agent = request.agent(app);
// await agent.post('/api/auth/login').send({ email: 'dash@example.com', password: 'Password123!' });
// const res = await agent.get('/dashboard');
expect(res.statusCode).toEqual(200);
// For EJS, you'd check for HTML content:
expect(res.text).toContain("My First Form");
expect(res.text).toContain("My Second Form");
expect(res.text).not.toContain("Other Users Form");
});
// ... more dashboard tests for create, settings, submissions view, API keys...
});

View File

@ -1,34 +0,0 @@
// __tests__/setup/jest.setup.js
const {
initializeTestDB,
clearAllTables,
disconnectTestDB,
} = require("./testDbUtils");
// Optional: Runs once before all test suites
beforeAll(async () => {
console.log("Global setup: Initializing test database...");
await initializeTestDB(); // Ensure clean slate for the entire test run
});
// Runs before each test file (or each test if inside describe block)
// For a truly clean slate for each test file or even each test:
beforeEach(async () => {
// console.log('Resetting tables before test...');
// Depending on your needs, you might re-initialize or just clear tables
await clearAllTables(); // This is faster than full re-init if schema doesn't change
});
// Optional: Runs once after all test suites
afterAll(async () => {
console.log("Global teardown: Disconnecting test database pool...");
await disconnectTestDB();
// You might also need to close your main app's DB pool if it's shared or server is kept running
// And close Redis connections if your tests directly interact with them
const { closeRedis } = require("../../src/config/redis"); // Adjust path
await closeRedis();
// If your server is started for integration tests, ensure it's closed.
// This is often handled by supertest if 'app' is imported and not globally started.
// Or if you start server in globalSetup, close it in globalTeardown.
});

View File

@ -1,99 +0,0 @@
// __tests__/setup/testDbUtils.js
const fs = require("fs");
const path = require("path");
const { Pool } = require("pg"); // Use pg directly for setup
// Load .env.test variables
require("dotenv").config({ path: path.resolve(__dirname, "../../.env.test") });
const poolConfig = {
user: process.env.DB_USER,
host: process.env.DB_HOST,
database: process.env.DB_NAME,
password: process.env.DB_PASSWORD,
port: parseInt(process.env.DB_PORT || "5432", 10),
};
const pool = new Pool(poolConfig);
const initSql = fs.readFileSync(
path.resolve(__dirname, "../../init.sql"),
"utf8"
);
async function initializeTestDB() {
const client = await pool.connect();
try {
// Drop all tables (order matters due to FK constraints)
// This is a simple way for tests; migrations are better for complex apps.
await client.query("DROP TABLE IF EXISTS user_sessions CASCADE;");
await client.query("DROP TABLE IF EXISTS api_keys CASCADE;");
await client.query("DROP TABLE IF EXISTS submissions CASCADE;");
await client.query("DROP TABLE IF EXISTS forms CASCADE;");
await client.query("DROP TABLE IF EXISTS users CASCADE;");
await client.query("DROP TABLE IF EXISTS rate_limits CASCADE;"); // If you used this table
// Potentially drop extensions or other objects if init.sql creates them and they persist
// Re-run init.sql
// Note: node-postgres pool.query might not execute multi-statement SQL directly from a file easily.
// It's often better to split init.sql or execute statements one by one.
// For simplicity here, assuming init.sql can be run or you adjust this.
// A common approach is to split init.sql by ';' (excluding those in strings/comments)
const statements = initSql
.split(";\n")
.map((s) => s.trim())
.filter((s) => s.length > 0);
for (const statement of statements) {
if (statement.toUpperCase().startsWith("CREATE TRIGGER")) {
// pg doesn't like CREATE TRIGGER in multi-statement query via client.query
// Skip or handle differently if complex. For now, we assume init.sql is mostly CREATE TABLE / INSERT
// Or, ensure your init.sql puts CREATE EXTENSION at the very top if needed.
// console.warn("Skipping TRIGGER creation in test setup, ensure DB compatibility or handle manually.");
} else {
await client.query(statement);
}
}
console.log("Test database initialized/reset.");
} catch (err) {
console.error("Error initializing test database:", err);
throw err;
} finally {
client.release();
}
}
async function clearTable(tableName) {
const client = await pool.connect();
try {
await client.query(`DELETE FROM "${tableName}";`); // Or TRUNCATE if preferred and allowed
} finally {
client.release();
}
}
async function clearAllTables() {
const client = await pool.connect();
try {
await client.query("DELETE FROM user_sessions;");
await client.query("DELETE FROM api_keys;");
await client.query("DELETE FROM submissions;");
await client.query("DELETE FROM forms;");
await client.query("DELETE FROM users;");
await client.query("DELETE FROM rate_limits;");
} finally {
client.release();
}
}
async function disconnectTestDB() {
await pool.end();
console.log("Test database pool disconnected.");
}
module.exports = {
pool, // Export the pool for direct use in tests if needed
initializeTestDB,
clearTable,
clearAllTables,
disconnectTestDB,
};

View File

@ -1,154 +0,0 @@
// __tests__/unit/models/User.db.test.js
const User = require("../../../src/models/User"); // Adjust path
const { pool, clearAllTables } = require("../../setup/testDbUtils"); // Adjust path
describe("User Model (PostgreSQL)", () => {
beforeEach(async () => {
await clearAllTables(); // Ensure clean state for each test
});
describe("create", () => {
it("should create a new user with hashed password and verification token", async () => {
const userData = {
email: "test@example.com",
password: "Password123!",
first_name: "Test",
last_name: "User",
};
const user = await User.create(userData);
expect(user.id).toBeDefined();
expect(user.uuid).toBeDefined();
expect(user.email).toBe(userData.email);
expect(user.password_hash).not.toBe(userData.password); // Should be hashed
expect(user.verification_token).toBeDefined();
expect(user.is_verified).toBe(0); // Default for SQLite, ensure it's FALSE for PG
const dbUser = await pool.query("SELECT * FROM users WHERE id = $1", [
user.id,
]);
expect(dbUser.rows[0].email).toBe(userData.email);
expect(dbUser.rows[0].password_hash).not.toBe(userData.password);
});
it("should throw an error if email already exists", async () => {
const userData = {
email: "duplicate@example.com",
password: "Password123!",
};
await User.create(userData);
await expect(User.create(userData)).rejects.toThrow(
"Email already exists"
);
});
});
describe("findByEmail", () => {
it("should find an active user by email", async () => {
const createdUser = await User.create({
email: "findme@example.com",
password: "Password123!",
});
const foundUser = await User.findByEmail("findme@example.com");
expect(foundUser).toBeDefined();
expect(foundUser.id).toBe(createdUser.id);
});
it("should return null if user not found or inactive", async () => {
expect(await User.findByEmail("dontexist@example.com")).toBeNull();
// Add test for inactive user if you implement that logic
});
});
describe("findById", () => {
it("should find an active user by ID", async () => {
const createdUser = await User.create({
email: "findbyid@example.com",
password: "Password123!",
});
const foundUser = await User.findById(createdUser.id);
expect(foundUser).toBeDefined();
expect(foundUser.email).toBe(createdUser.email);
});
});
describe("verifyEmail", () => {
it("should verify a user and nullify the token", async () => {
const user = await User.create({
email: "verify@example.com",
password: "Pass!",
});
const verificationToken = user.verification_token;
const verified = await User.verifyEmail(verificationToken);
expect(verified).toBe(true);
const dbUser = await User.findById(user.id);
expect(dbUser.is_verified).toBe(1); // Or TRUE depending on PG boolean handling
expect(dbUser.verification_token).toBeNull();
});
});
describe("setPasswordResetToken and findByPasswordResetToken", () => {
it("should set and find a valid password reset token", async () => {
const user = await User.create({
email: "reset@example.com",
password: "password",
});
const { token } = await User.setPasswordResetToken(user.email);
expect(token).toBeDefined();
const foundUser = await User.findByPasswordResetToken(token);
expect(foundUser).toBeDefined();
expect(foundUser.id).toBe(user.id);
});
it("should not find an expired password reset token", async () => {
const user = await User.create({
email: "resetexpired@example.com",
password: "password",
});
const { token } = await User.setPasswordResetToken(user.email);
// Manually expire the token in DB for testing
await pool.query(
"UPDATE users SET password_reset_expires = NOW() - INTERVAL '2 hour' WHERE id = $1",
[user.id]
);
const foundUser = await User.findByPasswordResetToken(token);
expect(foundUser).toBeNull();
});
});
// ... more tests for other User model methods (updatePassword, login attempts, etc.) ...
// Example: updatePasswordAndClearChangeFlag
describe("updatePasswordAndClearChangeFlag", () => {
it("should update password and set must_change_password to false", async () => {
const user = await User.create({
email: "changeme@example.com",
password: "oldpassword",
});
// Manually set must_change_password to true for test
await pool.query(
"UPDATE users SET must_change_password = TRUE WHERE id = $1",
[user.id]
);
const newPassword = "NewStrongPassword123!";
const updated = await User.updatePasswordAndClearChangeFlag(
user.id,
newPassword
);
expect(updated).toBe(true);
const dbUser = await User.findById(user.id);
const isMatch = await require("bcryptjs").compare(
newPassword,
dbUser.password_hash
);
expect(isMatch).toBe(true);
expect(dbUser.must_change_password).toBe(0); // Or FALSE
});
});
});

View File

@ -1,133 +0,0 @@
// __tests__/unit/services/emailService.test.js
const emailServiceModule = require("../../../src/services/emailService"); // Adjust path
const { Resend } = require("resend");
const logger = require("../../../config/logger"); // Adjust path
jest.mock("resend"); // Mock the Resend constructor and its methods
jest.mock("../../../config/logger"); // Mock logger to spy on it
describe("Email Service (Resend)", () => {
const mockSend = jest.fn();
const originalResendApiKey = process.env.RESEND_API_KEY;
const originalEmailFrom = process.env.EMAIL_FROM_ADDRESS;
beforeEach(() => {
mockSend.mockClear();
Resend.mockClear();
Resend.mockImplementation(() => ({
emails: { send: mockSend },
}));
// Ensure env vars are set for these tests
process.env.RESEND_API_KEY = "test-resend-api-key";
process.env.EMAIL_FROM_ADDRESS = "sender@example.com";
});
afterAll(() => {
process.env.RESEND_API_KEY = originalResendApiKey;
process.env.EMAIL_FROM_ADDRESS = originalEmailFrom;
});
describe("sendSubmissionNotification", () => {
const form = {
name: "Test Form",
email_notifications_enabled: true,
notification_email_address: "custom@example.com",
};
const submissionData = { name: "John Doe", message: "Hello" };
const userOwnerEmail = "owner@example.com";
it("should send email if notifications enabled and custom address provided", async () => {
mockSend.mockResolvedValue({ data: { id: "email_id_123" }, error: null });
await emailServiceModule.sendSubmissionNotification(
form,
submissionData,
userOwnerEmail
);
expect(Resend).toHaveBeenCalledWith("test-resend-api-key");
expect(mockSend).toHaveBeenCalledWith({
from: "sender@example.com",
to: "custom@example.com",
subject: "New Submission for Form: Test Form",
html: expect.stringContaining("<strong>Test Form</strong>"),
});
expect(logger.info).toHaveBeenCalledWith(
expect.stringContaining("Submission email sent successfully")
);
});
it("should use owner email if custom address not provided", async () => {
const formNoCustomEmail = { ...form, notification_email_address: null };
mockSend.mockResolvedValue({ data: { id: "email_id_123" }, error: null });
await emailServiceModule.sendSubmissionNotification(
formNoCustomEmail,
submissionData,
userOwnerEmail
);
expect(mockSend).toHaveBeenCalledWith(
expect.objectContaining({
to: "owner@example.com",
})
);
});
it("should not send email if notifications are disabled", async () => {
const disabledForm = { ...form, email_notifications_enabled: false };
await emailServiceModule.sendSubmissionNotification(
disabledForm,
submissionData,
userOwnerEmail
);
expect(mockSend).not.toHaveBeenCalled();
expect(logger.info).toHaveBeenCalledWith(
expect.stringContaining("Email notifications are disabled")
);
});
it("should log error if Resend fails", async () => {
const resendError = new Error("Resend API Error");
mockSend.mockResolvedValue({ data: null, error: resendError }); // Resend SDK might return error in object
// OR mockSend.mockRejectedValue(resendError); if it throws
await emailServiceModule.sendSubmissionNotification(
form,
submissionData,
userOwnerEmail
);
expect(logger.error).toHaveBeenCalledWith(
"Error sending submission email via Resend:",
resendError
);
});
it("should not send if RESEND_API_KEY is missing", async () => {
delete process.env.RESEND_API_KEY; // Temporarily remove
// Re-require or re-instantiate the service if it checks env vars at import time
// For this structure, the check is at the top of the file, so it might already be 'null'
// A better approach would be for the service to have an isConfigured() method.
// Forcing a re-import for the test is tricky without specific Jest features for module reloading.
// Let's assume the check inside sendSubmissionNotification handles the 'resend' object being null.
// To test this properly, we might need to re-import the module after changing env var
jest.resetModules(); // Clears module cache
process.env.RESEND_API_KEY = undefined;
const freshEmailService = require("../../../src/services/emailService");
await freshEmailService.sendSubmissionNotification(
form,
submissionData,
userOwnerEmail
);
expect(mockSend).not.toHaveBeenCalled();
expect(logger.warn).toHaveBeenCalledWith(
expect.stringContaining("Resend SDK not initialized")
);
process.env.RESEND_API_KEY = "test-resend-api-key"; // Restore
jest.resetModules(); // Clean up
});
});
// You would add similar tests for sendVerificationEmail, etc. from the old Nodemailer-based service
// if you intend to keep that functionality (currently it's commented out or separate)
});

View File

@ -1,126 +0,0 @@
// __tests__/unit/services/jwtService.test.js
const jwtService = require("../../../src/services/jwtService"); // Adjust path
const User = require("../../../src/models/User"); // Adjust path
const jwt = require("jsonwebtoken");
jest.mock("../../../src/models/User"); // Mock the User model
describe("JWT Service", () => {
const mockUser = { id: 1, email: "test@example.com", role: "user" };
const originalJwtSecret = process.env.JWT_SECRET;
beforeAll(() => {
process.env.JWT_SECRET = "test-secret-for-jwt-service"; // Use a fixed secret for tests
});
afterAll(() => {
process.env.JWT_SECRET = originalJwtSecret; // Restore original
});
beforeEach(() => {
User.saveSession.mockClear();
User.isTokenBlacklisted.mockClear();
User.revokeSession.mockClear();
});
describe("generateAccessToken", () => {
it("should generate a valid access token and save session", async () => {
User.saveSession.mockResolvedValue(true);
const { token, expiresAt, jti } =
jwtService.generateAccessToken(mockUser);
expect(token).toBeDefined();
expect(jti).toBeDefined();
const decoded = jwt.verify(token, process.env.JWT_SECRET);
expect(decoded.sub).toBe(mockUser.id);
expect(decoded.type).toBe("access");
expect(decoded.jti).toBe(jti);
expect(User.saveSession).toHaveBeenCalledWith(
mockUser.id,
jti,
expiresAt,
undefined,
undefined
);
});
});
describe("generateRefreshToken", () => {
it("should generate a valid refresh token and save session", async () => {
User.saveSession.mockResolvedValue(true);
const { token } = jwtService.generateRefreshToken(mockUser);
const decoded = jwt.verify(token, process.env.JWT_SECRET);
expect(decoded.sub).toBe(mockUser.id);
expect(decoded.type).toBe("refresh");
});
});
describe("verifyToken", () => {
it("should verify a valid token", () => {
const { token } = jwtService.generateAccessToken(mockUser);
const decoded = jwtService.verifyToken(token, "access");
expect(decoded.sub).toBe(mockUser.id);
});
it("should throw error for an expired token", () => {
// Generate token with 0s expiry (sign options need to be passed to jwt.sign)
const expiredToken = jwt.sign(
{ sub: mockUser.id, type: "access" },
process.env.JWT_SECRET,
{ expiresIn: "0s" }
);
// Wait a bit for it to actually expire
return new Promise((resolve) => {
setTimeout(() => {
expect(() => jwtService.verifyToken(expiredToken, "access")).toThrow(
"Token has expired"
);
resolve();
}, 10);
});
});
it("should throw error for an invalid token type", () => {
const { token } = jwtService.generateAccessToken(mockUser); // This is an 'access' token
expect(() => jwtService.verifyToken(token, "refresh")).toThrow(
"Invalid token type. Expected refresh"
);
});
});
describe("refreshAccessToken", () => {
it("should refresh access token with a valid refresh token", async () => {
const { token: rToken, jti: refreshJti } =
jwtService.generateRefreshToken(mockUser);
User.isTokenBlacklisted.mockResolvedValue(false); // Not blacklisted
User.findById.mockResolvedValue(mockUser); // User exists
User.saveSession.mockResolvedValue(true); // For the new access token
const { accessToken } = await jwtService.refreshAccessToken(rToken);
expect(accessToken).toBeDefined();
const decodedAccess = jwt.verify(accessToken, process.env.JWT_SECRET);
expect(decodedAccess.type).toBe("access");
expect(User.isTokenBlacklisted).toHaveBeenCalledWith(refreshJti);
});
it("should throw if refresh token is blacklisted", async () => {
const { token: rToken, jti: refreshJti } =
jwtService.generateRefreshToken(mockUser);
User.isTokenBlacklisted.mockResolvedValue(true); // Blacklisted
await expect(jwtService.refreshAccessToken(rToken)).rejects.toThrow(
"Refresh token has been revoked"
);
expect(User.isTokenBlacklisted).toHaveBeenCalledWith(refreshJti);
});
});
describe("revokeToken", () => {
it("should call User.revokeSession with JTI", async () => {
const { token, jti } = jwtService.generateAccessToken(mockUser);
User.revokeSession.mockResolvedValue(true);
await jwtService.revokeToken(token);
expect(User.revokeSession).toHaveBeenCalledWith(jti);
});
});
// ... more tests ...
});

View File

@ -1,33 +0,0 @@
// __tests__/unit/utils/apiKeyHelper.test.js
const {
generateApiKeyParts,
hashApiKeySecret,
compareApiKeySecret,
API_KEY_IDENTIFIER_PREFIX,
} = require("../../../src/utils/apiKeyHelper"); // Adjust path
describe("API Key Helper", () => {
describe("generateApiKeyParts", () => {
it("should generate an API key with correct prefix, identifier, and secret", () => {
const { fullApiKey, identifier, secret } = generateApiKeyParts();
expect(identifier).toMatch(
new RegExp(`^${API_KEY_IDENTIFIER_PREFIX}_[a-f0-9]{12}$`)
);
expect(secret).toMatch(/^[a-f0-9]{64}$/); // 32 bytes = 64 hex chars
expect(fullApiKey).toBe(`${identifier}_${secret}`);
});
});
describe("hashApiKeySecret and compareApiKeySecret", () => {
it("should correctly hash and compare a secret", async () => {
const secret = "mySuperSecretApiKeyPart";
const hashedSecret = await hashApiKeySecret(secret);
expect(hashedSecret).not.toBe(secret);
expect(await compareApiKeySecret(secret, hashedSecret)).toBe(true);
expect(await compareApiKeySecret("wrongSecret", hashedSecret)).toBe(
false
);
});
});
});

View File

@ -1,82 +0,0 @@
// __tests__/unit/utils/recaptchaHelper.test.js
const { verifyRecaptchaV2 } = require("../../../src/utils/recaptchaHelper"); // Adjust path
// Mock global fetch
global.fetch = jest.fn();
describe("reCAPTCHA Helper", () => {
const RECAPTCHA_V2_SECRET_KEY_ORIG = process.env.RECAPTCHA_V2_SECRET_KEY;
beforeEach(() => {
fetch.mockClear();
// Ensure a secret key is set for these tests
process.env.RECAPTCHA_V2_SECRET_KEY = "test-secret-key";
});
afterAll(() => {
process.env.RECAPTCHA_V2_SECRET_KEY = RECAPTCHA_V2_SECRET_KEY_ORIG; // Restore original
});
it("should return true for a successful verification", async () => {
fetch.mockResolvedValueOnce({
json: async () => ({ success: true }),
});
const result = await verifyRecaptchaV2("valid-token", "127.0.0.1");
expect(result).toBe(true);
expect(fetch).toHaveBeenCalledTimes(1);
expect(fetch).toHaveBeenCalledWith(
expect.stringContaining("response=valid-token"),
{ method: "POST" }
);
});
it("should return false for a failed verification", async () => {
fetch.mockResolvedValueOnce({
json: async () => ({
success: false,
"error-codes": ["invalid-input-response"],
}),
});
const result = await verifyRecaptchaV2("invalid-token");
expect(result).toBe(false);
});
it("should return false if reCAPTCHA secret key is not set", async () => {
delete process.env.RECAPTCHA_V2_SECRET_KEY; // Temporarily remove for this test
const consoleWarnSpy = jest
.spyOn(console, "warn")
.mockImplementation(() => {});
const result = await verifyRecaptchaV2("any-token");
expect(result).toBe(false);
expect(consoleWarnSpy).toHaveBeenCalledWith(
expect.stringContaining("RECAPTCHA_V2_SECRET_KEY is not set")
);
process.env.RECAPTCHA_V2_SECRET_KEY = "test-secret-key"; // Restore for other tests
consoleWarnSpy.mockRestore();
});
it("should return false if no token is provided", async () => {
const consoleWarnSpy = jest
.spyOn(console, "warn")
.mockImplementation(() => {});
const result = await verifyRecaptchaV2("");
expect(result).toBe(false);
expect(consoleWarnSpy).toHaveBeenCalledWith(
"No reCAPTCHA token provided by client."
);
consoleWarnSpy.mockRestore();
});
it("should return false if fetch throws an error", async () => {
fetch.mockRejectedValueOnce(new Error("Network error"));
const consoleErrorSpy = jest
.spyOn(console, "error")
.mockImplementation(() => {});
const result = await verifyRecaptchaV2("any-token");
expect(result).toBe(false);
expect(consoleErrorSpy).toHaveBeenCalledWith(
"Error during reCAPTCHA verification request:",
expect.any(Error)
);
consoleErrorSpy.mockRestore();
});
});

View File

@ -1,156 +0,0 @@
{"level":"info","message":"Server running on http://localhost:3000","service":"user-service"}
{"level":"info","message":"Admin access: User: youradminuser, Pass: (hidden)","service":"user-service"}
{"level":"info","message":"Ntfy notifications enabled for topic: https://ntfy.sh/your-secret-form-alerts","service":"user-service"}
{"level":"info","message":"Server running on http://localhost:3000","service":"user-service"}
{"level":"info","message":"Admin access: User: youradminuser, Pass: (hidden)","service":"user-service"}
{"level":"info","message":"Ntfy notifications enabled for topic: https://ntfggy.sh/your-secret-form-alerts","service":"user-service"}
{"level":"warn","message":"404 - Endpoint not found: / - Method: GET - IP: ::1","service":"user-service"}
{"level":"warn","message":"404 - Endpoint not found: /favicon.ico - Method: GET - IP: ::1","service":"user-service"}
{"level":"info","message":"Server running on http://localhost:3000","service":"user-service"}
{"level":"info","message":"Admin access: User: youradminuser, Pass: (hidden)","service":"user-service"}
{"level":"info","message":"Ntfy notifications enabled for topic: https://ntfggy.sh/your-secret-form-alerts","service":"user-service"}
{"level":"info","message":"Server running on http://localhost:3000","service":"user-service"}
{"level":"info","message":"Admin access: User: youradminuser, Pass: (hidden)","service":"user-service"}
{"level":"info","message":"Ntfy notifications enabled for topic: https://ntfggy.sh/your-secret-form-alerts","service":"user-service"}
{"level":"info","message":"Server running on http://localhost:3000","service":"user-service"}
{"level":"info","message":"Admin access: User: youradminuser, Pass: (hidden)","service":"user-service"}
{"level":"info","message":"Ntfy notifications enabled for topic: https://ntfggy.sh/your-secret-form-alerts","service":"user-service"}
{"level":"info","message":"Server running on http://localhost:3000","service":"user-service"}
{"level":"info","message":"Admin access: User: youradminuser, Pass: (hidden)","service":"user-service"}
{"level":"info","message":"Ntfy notifications enabled for topic: https://ntfggy.sh/your-secret-form-alerts","service":"user-service"}
{"level":"info","message":"Server running on http://localhost:3000","service":"user-service"}
{"level":"info","message":"Admin access: User: youradminuser, Pass: (hidden)","service":"user-service"}
{"level":"info","message":"Ntfy notifications enabled for topic: https://ntfggy.sh/your-secret-form-alerts","service":"user-service"}
{"level":"info","message":"Server running on http://localhost:3000","service":"user-service"}
{"level":"info","message":"Admin access: User: youradminuser, Pass: (hidden)","service":"user-service"}
{"level":"info","message":"Ntfy notifications enabled for topic: https://ntfggy.sh/your-secret-form-alerts","service":"user-service"}
{"level":"info","message":"Database file found.","service":"user-service"}
{"level":"info","message":"Server running on http://localhost:3000","service":"user-service"}
{"level":"info","message":"Ntfy notifications enabled for topic: https://ntfggy.sh/your-secret-form-alerts","service":"user-service"}
{"level":"warn","message":"404 - Endpoint not found: / - Method: GET - IP: ::1","service":"user-service"}
{"level":"warn","message":"404 - Endpoint not found: /.well-known/appspecific/com.chrome.devtools.json - Method: GET - IP: ::1","service":"user-service"}
{"level":"warn","message":"404 - Endpoint not found: /.well-known/appspecific/com.chrome.devtools.json - Method: GET - IP: ::1","service":"user-service"}
{"level":"info","message":"Database file found.","service":"user-service"}
{"level":"info","message":"Server running on http://localhost:3000","service":"user-service"}
{"level":"info","message":"Ntfy notifications enabled for topic: https://ntfggy.sh/your-secret-form-alerts","service":"user-service"}
{"level":"warn","message":"404 - Endpoint not found: /.well-known/appspecific/com.chrome.devtools.json - Method: GET - IP: ::1","service":"user-service"}
{"level":"warn","message":"404 - Endpoint not found: /login - Method: GET - IP: ::1","service":"user-service"}
{"level":"warn","message":"404 - Endpoint not found: /.well-known/appspecific/com.chrome.devtools.json - Method: GET - IP: ::1","service":"user-service"}
{"level":"warn","message":"404 - Endpoint not found: /dashboard.html - Method: GET - IP: ::1","service":"user-service"}
{"level":"warn","message":"404 - Endpoint not found: /.well-known/appspecific/com.chrome.devtools.json - Method: GET - IP: ::1","service":"user-service"}
{"level":"warn","message":"404 - Endpoint not found: /.well-known/appspecific/com.chrome.devtools.json - Method: GET - IP: ::1","service":"user-service"}
{"level":"info","message":"Database file found.","service":"user-service"}
{"level":"info","message":"Server running on http://localhost:3000","service":"user-service"}
{"level":"info","message":"Ntfy notifications enabled for topic: https://ntfggy.sh/your-secret-form-alerts","service":"user-service"}
{"level":"info","message":"Database file found.","service":"user-service"}
{"level":"info","message":"Server running on http://localhost:3000","service":"user-service"}
{"level":"info","message":"Ntfy notifications enabled for topic: https://ntfggy.sh/your-secret-form-alerts","service":"user-service"}
{"level":"info","message":"Database file found.","service":"user-service"}
{"level":"info","message":"Server running on http://localhost:3000","service":"user-service"}
{"level":"info","message":"Ntfy notifications enabled for topic: https://ntfggy.sh/your-secret-form-alerts","service":"user-service"}
{"level":"info","message":"Database file found.","service":"user-service"}
{"level":"info","message":"Server running on http://localhost:3000","service":"user-service"}
{"level":"info","message":"Ntfy notifications enabled for topic: https://ntfggy.sh/your-secret-form-alerts","service":"user-service"}
{"level":"info","message":"Database file found.","service":"user-service"}
{"level":"info","message":"Server running on http://localhost:3000","service":"user-service"}
{"level":"info","message":"Ntfy notifications enabled for topic: https://ntfggy.sh/your-secret-form-alerts","service":"user-service"}
{"level":"info","message":"Database file found.","service":"user-service"}
{"level":"info","message":"Server running on http://localhost:3000","service":"user-service"}
{"level":"info","message":"Ntfy notifications enabled for topic: https://ntfggy.sh/your-secret-form-alerts","service":"user-service"}
{"level":"info","message":"Database file found.","service":"user-service"}
{"level":"info","message":"Server running on http://localhost:3000","service":"user-service"}
{"level":"info","message":"Ntfy notifications enabled for topic: https://ntfggy.sh/your-secret-form-alerts","service":"user-service"}
{"level":"info","message":"Database file found.","service":"user-service"}
{"level":"info","message":"Server running on http://localhost:3000","service":"user-service"}
{"level":"info","message":"Ntfy notifications enabled for topic: https://ntfggy.sh/your-secret-form-alerts","service":"user-service"}
{"level":"info","message":"Database file found.","service":"user-service"}
{"level":"info","message":"Server running on http://localhost:3000","service":"user-service"}
{"level":"info","message":"Ntfy notifications enabled for topic: https://ntfggy.sh/your-secret-form-alerts","service":"user-service"}
{"level":"info","message":"Received SIGINT, shutting down gracefully...","service":"user-service"}
{"level":"error","message":"Failed to connect to PostgreSQL database:","service":"user-service"}
{"level":"error","message":"Failed to connect to PostgreSQL database:","service":"user-service"}
{"code":"XX000","length":73,"level":"error","message":"Error checking for users table: connection is insecure (try using `sslmode=require`)","name":"error","service":"user-service","severity":"ERROR","stack":"error: connection is insecure (try using `sslmode=require`)\n at C:\\Users\\Mohamad.Elsena\\Desktop\\dev\\mooo\\mo\\formies\\node_modules\\pg-pool\\index.js:45:11\n at process.processTicksAndRejections (node:internal/process/task_queues:95:5)\n at async initializeDatabase (C:\\Users\\Mohamad.Elsena\\Desktop\\dev\\mooo\\mo\\formies\\server.js:26:3)\n at async initializeApp (C:\\Users\\Mohamad.Elsena\\Desktop\\dev\\mooo\\mo\\formies\\server.js:65:3)"}
{"level":"error","message":"Failed to connect to PostgreSQL database:","service":"user-service"}
{"level":"info","message":"New client connected to the PostgreSQL database","service":"user-service"}
{"level":"info","message":"Successfully connected to PostgreSQL database via pool.","service":"user-service"}
{"level":"info","message":"PostgreSQL current time: Wed May 28 2025 11:50:05 GMT+0200 (Central European Summer Time)","service":"user-service"}
{"level":"info","message":"New client connected to the PostgreSQL database","service":"user-service"}
{"level":"info","message":"Users table not found, attempting to initialize database...","service":"user-service"}
{"level":"info","message":"Database initialized successfully from init.sql.","service":"user-service"}
{"level":"info","message":"Server running on http://localhost:3000","service":"user-service"}
{"level":"info","message":"Ntfy notifications enabled for topic: https://ntfggy.sh/your-secret-form-alerts","service":"user-service"}
{"level":"info","message":"New client connected to the PostgreSQL database","service":"user-service"}
{"level":"info","message":"Successfully connected to PostgreSQL database via pool.","service":"user-service"}
{"level":"info","message":"New client connected to the PostgreSQL database","service":"user-service"}
{"level":"info","message":"Database tables appear to exist. Skipping initialization.","service":"user-service"}
{"level":"info","message":"Server running on http://localhost:3000","service":"user-service"}
{"level":"info","message":"Ntfy notifications enabled for topic: https://ntfggy.sh/your-secret-form-alerts","service":"user-service"}
{"level":"info","message":"PostgreSQL current time: Wed May 28 2025 11:51:38 GMT+0200 (Central European Summer Time)","service":"user-service"}
{"level":"info","message":"New client connected to the PostgreSQL database","service":"user-service"}
{"level":"info","message":"Successfully connected to PostgreSQL database via pool.","service":"user-service"}
{"level":"info","message":"New client connected to the PostgreSQL database","service":"user-service"}
{"level":"info","message":"PostgreSQL current time: Wed May 28 2025 12:31:18 GMT+0200 (Central European Summer Time)","service":"user-service"}
{"level":"info","message":"Database tables appear to exist. Skipping initialization.","service":"user-service"}
{"level":"warn","message":"Failed to initialize RedisStore, falling back to MemoryStore for sessions. Redis client not available","service":"user-service","stack":"Error: Redis client not available\n at getRedisClient (C:\\Users\\Mohamad.Elsena\\Desktop\\dev\\mooo\\mo\\formies\\src\\config\\redis.js:82:9)\n at initializeApp (C:\\Users\\Mohamad.Elsena\\Desktop\\dev\\mooo\\mo\\formies\\server.js:99:24)\n at process.processTicksAndRejections (node:internal/process/task_queues:95:5)"}
{"level":"info","message":"Server running on http://localhost:3000","service":"user-service"}
{"level":"info","message":"Ntfy notifications enabled for topic: https://ntfggy.sh/your-secret-form-alerts","service":"user-service"}
{"level":"info","message":"Received SIGINT, shutting down gracefully...","service":"user-service"}
{"level":"info","message":"New client connected to the PostgreSQL database","service":"user-service"}
{"level":"info","message":"Successfully connected to PostgreSQL database via pool.","service":"user-service"}
{"level":"info","message":"PostgreSQL current time: Wed May 28 2025 12:43:16 GMT+0200 (Central European Summer Time)","service":"user-service"}
{"level":"info","message":"New client connected to the PostgreSQL database","service":"user-service"}
{"level":"info","message":"Database tables appear to exist. Skipping initialization.","service":"user-service"}
{"level":"warn","message":"Failed to initialize RedisStore, falling back to MemoryStore for sessions. Redis client not available","service":"user-service","stack":"Error: Redis client not available\n at getRedisClient (C:\\Users\\Mohamad.Elsena\\Desktop\\dev\\mooo\\mo\\formies\\src\\config\\redis.js:82:9)\n at initializeApp (C:\\Users\\Mohamad.Elsena\\Desktop\\dev\\mooo\\mo\\formies\\server.js:99:24)\n at process.processTicksAndRejections (node:internal/process/task_queues:95:5)"}
{"level":"info","message":"Server running on http://localhost:3000","service":"user-service"}
{"level":"info","message":"Ntfy notifications enabled for topic: https://ntfggy.sh/your-secret-form-alerts","service":"user-service"}
{"level":"info","message":"New client connected to the PostgreSQL database","service":"user-service"}
{"level":"info","message":"Successfully connected to PostgreSQL database via pool.","service":"user-service"}
{"level":"info","message":"PostgreSQL current time: Wed May 28 2025 12:43:40 GMT+0200 (Central European Summer Time)","service":"user-service"}
{"level":"info","message":"New client connected to the PostgreSQL database","service":"user-service"}
{"level":"info","message":"Database tables appear to exist. Skipping initialization.","service":"user-service"}
{"level":"info","message":"Server running on http://localhost:3000","service":"user-service"}
{"level":"info","message":"Ntfy notifications enabled for topic: https://ntfggy.sh/your-secret-form-alerts","service":"user-service"}
{"level":"info","message":"New client connected to the PostgreSQL database","service":"user-service"}
{"level":"info","message":"Successfully connected to PostgreSQL database via pool.","service":"user-service"}
{"level":"info","message":"New client connected to the PostgreSQL database","service":"user-service"}
{"level":"info","message":"PostgreSQL current time: Wed May 28 2025 12:43:59 GMT+0200 (Central European Summer Time)","service":"user-service"}
{"level":"info","message":"Database tables appear to exist. Skipping initialization.","service":"user-service"}
{"level":"info","message":"Server running on http://localhost:3000","service":"user-service"}
{"level":"info","message":"Ntfy notifications enabled for topic: https://ntfggy.sh/your-secret-form-alerts","service":"user-service"}
{"level":"warn","message":"404 - Endpoint not found: /.well-known/appspecific/com.chrome.devtools.json - Method: GET - IP: ::1","service":"user-service"}
{"level":"warn","message":"404 - Endpoint not found: /api/auth - Method: GET - IP: ::1","service":"user-service"}
{"level":"warn","message":"404 - Endpoint not found: /.well-known/appspecific/com.chrome.devtools.json - Method: GET - IP: ::1","service":"user-service"}
{"level":"warn","message":"404 - Endpoint not found: /.well-known/appspecific/com.chrome.devtools.json - Method: GET - IP: ::1","service":"user-service"}
{"level":"warn","message":"404 - Endpoint not found: /.well-known/appspecific/com.chrome.devtools.json - Method: GET - IP: ::1","service":"user-service"}
{"level":"warn","message":"404 - Endpoint not found: /.well-known/appspecific/com.chrome.devtools.json - Method: GET - IP: ::1","service":"user-service"}
{"level":"warn","message":"404 - Endpoint not found: /.well-known/appspecific/com.chrome.devtools.json - Method: GET - IP: ::1","service":"user-service"}
{"level":"warn","message":"404 - Endpoint not found: /.well-known/appspecific/com.chrome.devtools.json - Method: GET - IP: ::1","service":"user-service"}
{"level":"warn","message":"404 - Endpoint not found: /.well-known/appspecific/com.chrome.devtools.json - Method: GET - IP: ::1","service":"user-service"}
{"level":"warn","message":"404 - Endpoint not found: /.well-known/appspecific/com.chrome.devtools.json - Method: GET - IP: ::1","service":"user-service"}
{"level":"warn","message":"404 - Endpoint not found: /.well-known/appspecific/com.chrome.devtools.json - Method: GET - IP: ::1","service":"user-service"}
{"level":"warn","message":"404 - Endpoint not found: /.well-known/appspecific/com.chrome.devtools.json - Method: GET - IP: ::1","service":"user-service"}
{"level":"warn","message":"404 - Endpoint not found: /favicon.ico - Method: GET - IP: ::1","service":"user-service"}
{"level":"info","message":"New client connected to the PostgreSQL database","service":"user-service"}
{"level":"info","message":"Cleaned up 0 expired user sessions.","service":"user-service"}
{"level":"info","message":"New client connected to the PostgreSQL database","service":"user-service"}
{"level":"info","message":"Cleaned up 0 expired user sessions.","service":"user-service"}
{"level":"info","message":"New client connected to the PostgreSQL database","service":"user-service"}
{"level":"info","message":"Cleaned up 0 expired user sessions.","service":"user-service"}
{"level":"info","message":"New client connected to the PostgreSQL database","service":"user-service"}
{"level":"info","message":"Successfully connected to PostgreSQL database via pool.","service":"user-service"}
{"level":"info","message":"New client connected to the PostgreSQL database","service":"user-service"}
{"level":"info","message":"PostgreSQL current time: Wed May 28 2025 16:05:43 GMT+0200 (Central European Summer Time)","service":"user-service"}
{"level":"info","message":"Database tables appear to exist. Skipping initialization.","service":"user-service"}
{"level":"info","message":"Server running on http://localhost:3000","service":"user-service"}
{"level":"info","message":"Ntfy notifications enabled for topic: https://ntfggy.sh/your-secret-form-alerts","service":"user-service"}
{"level":"warn","message":"404 - Endpoint not found: /register - Method: GET - IP: ::1","service":"user-service"}
{"level":"info","message":"New client connected to the PostgreSQL database","service":"user-service"}
{"level":"info","message":"Successfully connected to PostgreSQL database via pool.","service":"user-service"}
{"level":"info","message":"New client connected to the PostgreSQL database","service":"user-service"}
{"level":"info","message":"PostgreSQL current time: Wed May 28 2025 16:10:57 GMT+0200 (Central European Summer Time)","service":"user-service"}
{"level":"info","message":"Database tables appear to exist. Skipping initialization.","service":"user-service"}
{"level":"info","message":"Server running on http://localhost:3000","service":"user-service"}
{"level":"info","message":"Ntfy notifications enabled for topic: https://ntfggy.sh/your-secret-form-alerts","service":"user-service"}
{"level":"warn","message":"404 - Endpoint not found: /.well-known/appspecific/com.chrome.devtools.json - Method: GET - IP: ::1","service":"user-service"}
{"level":"warn","message":"404 - Endpoint not found: /.well-known/appspecific/com.chrome.devtools.json - Method: GET - IP: ::1","service":"user-service"}
{"level":"warn","message":"404 - Endpoint not found: /.well-known/appspecific/com.chrome.devtools.json - Method: GET - IP: ::1","service":"user-service"}

View File

@ -1,29 +0,0 @@
const winston = require("winston");
const logger = winston.createLogger({
level: "info",
format: winston.format.json(),
defaultMeta: { service: "user-service" },
transports: [
//
// - Write all logs with importance level of `error` or less to `error.log`
// - Write all logs with importance level of `info` or less to `combined.log`
//
new winston.transports.File({ filename: "error.log", level: "error" }),
new winston.transports.File({ filename: "combined.log" }),
],
});
//
// If we're not in production then log to the `console` with the format:
// `${info.level}: ${info.message} JSON.stringify({ ...rest }) `
//
if (process.env.NODE_ENV !== "production") {
logger.add(
new winston.transports.Console({
format: winston.format.simple(),
})
);
}
module.exports = logger;

View File

@ -1,58 +0,0 @@
version: "3.8"
services:
app:
build: .
ports:
- "3000:3000" # Expose app on host port 3000
depends_on:
db:
condition: service_healthy # Wait for DB to be healthy
redis:
condition: service_started # Wait for Redis to start
environment:
- DB_HOST=${DB_HOST}
- DB_USER=${DB_USER}
- DB_PASSWORD=${DB_PASSWORD}
- DB_NAME=${DB_NAME}
- PORT=${PORT}
- REDIS_HOST=${REDIS_HOST:-redis}
- REDIS_PORT=${REDIS_PORT:-6379}
- REDIS_PASSWORD=${REDIS_PASSWORD:-}
restart: unless-stopped
db:
image: postgres:15-alpine
ports:
- "5432:5432" # Standard PostgreSQL port
environment:
POSTGRES_USER: ${DB_USER}
POSTGRES_PASSWORD: ${DB_PASSWORD}
POSTGRES_DB: ${DB_NAME}
volumes:
- pg_data:/var/lib/postgresql/data # Persist database data
- ./init.sql:/docker-entrypoint-initdb.d/init.sql # Run init script on startup
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${DB_USER} -d ${DB_NAME} -h localhost"]
interval: 10s
timeout: 5s
retries: 5
restart: unless-stopped
redis:
image: redis:7-alpine
ports:
- "6380:6379" # Expose Redis on host port 6380 (to avoid conflict if you have local Redis on 6379)
command: redis-server --appendonly yes --requirepass ${REDIS_PASSWORD:-}
volumes:
- redis_data:/data # Persist Redis data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 5s
retries: 5
restart: unless-stopped
volumes:
pg_data:
redis_data:

View File

@ -1,44 +1,13 @@
version: "3.8"
services: services:
app: app:
build: . image: whtvrboo/formies:1.02
container_name: formies
ports: ports:
- "3000:3000" - "3000:3000"
environment: environment:
- NODE_ENV=development - NODE_ENV=development
- DB_HOST=postgres - NTFY_TOPIC_URL=https://ntfy.vinylnostalgia.com/form-alerts
- DB_USER=${DB_USER} - NTFY_ENABLED=true
- DB_PASSWORD=${DB_PASSWORD} - PORT=3000
- DB_NAME=${DB_NAME}
- REDIS_HOST=redis
- REDIS_PORT=6379
volumes: volumes:
- .:/usr/src/app - ./database.sqlite:/usr/src/app/database.sqlite
- /usr/src/app/node_modules
depends_on:
- postgres
- redis
postgres:
image: postgres:15-alpine
ports:
- "5432:5432"
environment:
- POSTGRES_USER=${DB_USER}
- POSTGRES_PASSWORD=${DB_PASSWORD}
- POSTGRES_DB=${DB_NAME}
volumes:
- pg_data:/var/lib/postgresql/data
redis:
image: redis:7-alpine
ports:
- "6379:6379"
command: redis-server --appendonly yes
volumes:
- redis_data:/data
volumes:
pg_data:
redis_data:

View File

@ -1,19 +0,0 @@
DATABASE_URL=your_neon_development_connection_string_with_sslmode_require # e.g., postgresql://user:password@host:port/dbname?sslmode=require
# DB_HOST=localhost
# DB_PORT=5432
# DB_USER=your_postgres_user
# DB_PASSWORD=your_postgres_password
# DB_NAME=your_postgres_database_name
# Redis - if you keep using it
REDIS_HOST=localhost
REDIS_PORT=6379
# REDIS_PASSWORD=your_redis_password # Uncomment if your Redis has a password
# Application specific
NODE_ENV=development
PORT=3000
# Example for JWT secrets, session secrets, etc.
# SESSION_SECRET=your_strong_session_secret
# JWT_SECRET=your_strong_jwt_secret

View File

@ -1,24 +0,0 @@
DATABASE_URL=your_neon_production_connection_string_with_sslmode_require # e.g., postgresql://user:password@host:port/dbname?sslmode=require
# DB_HOST=your_production_db_host_or_service_name # e.g., the service name in docker-compose.prod.yml like 'db'
# DB_PORT=5432
# DB_USER=your_production_postgres_user
# DB_PASSWORD=your_production_postgres_password
# DB_NAME=your_production_postgres_database_name
# Redis
REDIS_HOST=your_production_redis_host_or_service_name # e.g., the service name in docker-compose.prod.yml like 'redis'
REDIS_PORT=6379 # Or your production Redis port if different
REDIS_PASSWORD=your_production_redis_password # Ensure this is set for production
# Application specific
NODE_ENV=production
PORT=3000 # Or your desired production port
# Security - VERY IMPORTANT: Use strong, unique secrets for production
SESSION_SECRET=generate_a_very_strong_random_string_for_session_secret
JWT_SECRET=generate_a_very_strong_random_string_for_jwt_secret
# Other production settings
# For example, if you have specific logging levels or API keys for production
# LOG_LEVEL=warn
# THIRD_PARTY_API_KEY=your_production_api_key

View File

@ -1,4 +0,0 @@
{"level":"error","message":"Failed to connect to PostgreSQL database:","service":"user-service"}
{"level":"error","message":"Failed to connect to PostgreSQL database:","service":"user-service"}
{"code":"XX000","length":73,"level":"error","message":"Error checking for users table: connection is insecure (try using `sslmode=require`)","name":"error","service":"user-service","severity":"ERROR","stack":"error: connection is insecure (try using `sslmode=require`)\n at C:\\Users\\Mohamad.Elsena\\Desktop\\dev\\mooo\\mo\\formies\\node_modules\\pg-pool\\index.js:45:11\n at process.processTicksAndRejections (node:internal/process/task_queues:95:5)\n at async initializeDatabase (C:\\Users\\Mohamad.Elsena\\Desktop\\dev\\mooo\\mo\\formies\\server.js:26:3)\n at async initializeApp (C:\\Users\\Mohamad.Elsena\\Desktop\\dev\\mooo\\mo\\formies\\server.js:65:3)"}
{"level":"error","message":"Failed to connect to PostgreSQL database:","service":"user-service"}

View File

147
init.sql
View File

@ -1,147 +0,0 @@
-- init.sql for PostgreSQL
-- Attempt to create the database if it doesn't exist.
-- Note: CREATE DATABASE IF NOT EXISTS is not standard SQL for all clients.
-- This might need to be handled outside the script or by connecting to a default db like 'postgres' first.
-- For docker-entrypoint-initdb.d, this script is typically run after the DB specified by POSTGRES_DB is created.
-- Enable pgcrypto extension for gen_random_uuid() if not already enabled
CREATE EXTENSION IF NOT EXISTS "pgcrypto";
-- Users table for authentication and authorization
CREATE TABLE IF NOT EXISTS users (
id SERIAL PRIMARY KEY,
uuid UUID NOT NULL UNIQUE DEFAULT gen_random_uuid(),
email VARCHAR(255) NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
first_name VARCHAR(255) DEFAULT NULL,
last_name VARCHAR(255) DEFAULT NULL,
role VARCHAR(50) DEFAULT 'user' CHECK(role IN ('user', 'admin', 'super_admin')),
is_verified BOOLEAN DEFAULT FALSE,
is_active BOOLEAN DEFAULT TRUE,
verification_token TEXT DEFAULT NULL,
password_reset_token TEXT DEFAULT NULL,
password_reset_expires TIMESTAMPTZ NULL DEFAULT NULL,
last_login TIMESTAMPTZ NULL DEFAULT NULL,
failed_login_attempts INTEGER DEFAULT 0,
account_locked_until TIMESTAMPTZ NULL DEFAULT NULL,
must_change_password BOOLEAN DEFAULT FALSE,
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
-- Removed redundant UNIQUE constraints as they are already on id, uuid, email
);
CREATE INDEX IF NOT EXISTS idx_email ON users (email);
CREATE INDEX IF NOT EXISTS idx_verification_token ON users (verification_token);
CREATE INDEX IF NOT EXISTS idx_password_reset_token ON users (password_reset_token);
CREATE INDEX IF NOT EXISTS idx_uuid_users ON users (uuid);
-- User sessions table for JWT blacklisting and session management
CREATE TABLE IF NOT EXISTS user_sessions (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL,
token_jti TEXT NOT NULL UNIQUE,
expires_at TIMESTAMPTZ NOT NULL,
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
user_agent TEXT DEFAULT NULL,
ip_address VARCHAR(255) DEFAULT NULL,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_token_jti ON user_sessions (token_jti);
CREATE INDEX IF NOT EXISTS idx_user_id_sessions ON user_sessions (user_id);
CREATE INDEX IF NOT EXISTS idx_expires_at_sessions ON user_sessions (expires_at);
-- Forms table
CREATE TABLE IF NOT EXISTS forms (
id SERIAL PRIMARY KEY,
uuid UUID NOT NULL UNIQUE DEFAULT gen_random_uuid(),
user_id INTEGER NOT NULL,
name VARCHAR(255) DEFAULT 'My Form',
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
thank_you_url TEXT DEFAULT NULL,
thank_you_message TEXT DEFAULT NULL,
ntfy_enabled BOOLEAN DEFAULT TRUE,
is_archived BOOLEAN DEFAULT FALSE,
allowed_domains TEXT DEFAULT NULL, -- Consider array of VARCHARs or separate table for multi-domain
email_notifications_enabled BOOLEAN NOT NULL DEFAULT FALSE,
notification_email_address VARCHAR(255) DEFAULT NULL,
recaptcha_enabled BOOLEAN NOT NULL DEFAULT FALSE,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_user_id_forms ON forms (user_id);
CREATE INDEX IF NOT EXISTS idx_uuid_forms ON forms (uuid);
-- Submissions table
CREATE TABLE IF NOT EXISTS submissions (
id SERIAL PRIMARY KEY,
form_uuid UUID NOT NULL,
user_id INTEGER NOT NULL, -- Assuming submissions are tied to a user account that owns the form
data JSONB NOT NULL, -- Storing JSON as JSONB
ip_address VARCHAR(255) NULL,
submitted_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (form_uuid) REFERENCES forms(uuid) ON DELETE CASCADE,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE -- Or remove if submissions are anonymous to users table
);
CREATE INDEX IF NOT EXISTS idx_form_uuid_submissions ON submissions (form_uuid);
CREATE INDEX IF NOT EXISTS idx_user_id_submissions ON submissions (user_id);
CREATE INDEX IF NOT EXISTS idx_submitted_at_submissions ON submissions (submitted_at);
-- Rate limiting table
CREATE TABLE IF NOT EXISTS rate_limits (
id SERIAL PRIMARY KEY,
identifier TEXT NOT NULL,
action TEXT NOT NULL,
count INTEGER DEFAULT 1,
window_start TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
expires_at TIMESTAMPTZ NOT NULL,
UNIQUE (identifier, action)
);
CREATE INDEX IF NOT EXISTS idx_identifier_action_rate_limits ON rate_limits (identifier, action);
CREATE INDEX IF NOT EXISTS idx_expires_at_rate_limits ON rate_limits (expires_at);
-- API Keys table
CREATE TABLE IF NOT EXISTS api_keys (
id SERIAL PRIMARY KEY,
uuid UUID NOT NULL UNIQUE DEFAULT gen_random_uuid(),
user_id INTEGER NOT NULL,
key_name VARCHAR(255) DEFAULT NULL,
api_key_identifier TEXT NOT NULL UNIQUE,
hashed_api_key_secret TEXT NOT NULL,
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
last_used_at TIMESTAMPTZ NULL DEFAULT NULL,
expires_at TIMESTAMPTZ NULL DEFAULT NULL,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_user_id_api_keys ON api_keys (user_id);
CREATE INDEX IF NOT EXISTS idx_api_key_identifier_api_keys ON api_keys (api_key_identifier);
-- Function and Trigger to update 'updated_at' timestamp
CREATE OR REPLACE FUNCTION trigger_set_timestamp()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
-- Trigger for users table
CREATE TRIGGER set_timestamp_users
BEFORE UPDATE ON users
FOR EACH ROW
EXECUTE PROCEDURE trigger_set_timestamp();
-- Trigger for forms table
CREATE TRIGGER set_timestamp_forms
BEFORE UPDATE ON forms
FOR EACH ROW
EXECUTE PROCEDURE trigger_set_timestamp();
-- Create default super admin user
-- Using ON CONFLICT to prevent error if user already exists.
-- UUID is now generated by default by the database.
INSERT INTO users (email, password_hash, first_name, last_name, role, is_verified, is_active, must_change_password)
VALUES ('admin@formies.local', 'NEEDS_TO_BE_SET_ON_FIRST_LOGIN', 'Admin', 'User', 'super_admin', TRUE, TRUE, TRUE)
ON CONFLICT (email) DO NOTHING;
-- Note: PRAGMA foreign_keys = ON; is not needed in PostgreSQL. FKs are enforced by default if defined.
-- Note: Backticks for table/column names are generally not needed unless using reserved words or special chars.
-- Standard SQL double quotes can be used if necessary, but unquoted is often preferred.

View File

@ -1,28 +0,0 @@
// jest.config.js
module.exports = {
testEnvironment: "node",
verbose: true,
coveragePathIgnorePatterns: [
"/node_modules/",
"/__tests__/setup/",
"/src/config/", // DB, Passport, Redis configs
"/config/", // Logger config
],
clearMocks: true,
coverageDirectory: "coverage",
collectCoverage: true,
collectCoverageFrom: [
"src/**/*.js",
"!server.js",
"!src/app.js", // If you create an app.js
"!src/config/database.js", // Usually not directly tested
"!src/config/passport.js", // Tested via auth integration tests
"!src/config/redis.js", // Tested via rate limiter integration tests
"!src/services/notification.js", // External, consider mocking if tested
],
setupFilesAfterEnv: ["./__tests__/setup/jest.setup.js"],
// Stop tests after first failure if desired for faster feedback during dev
// bail: 1,
// Force exit after tests are complete if you have open handles (use with caution)
// forceExit: true, // Usually indicates something isn't being torn down correctly
};

View File

@ -1,31 +0,0 @@
const logger = require("../config/logger");
const errorHandler = (err, req, res, next) => {
logger.error(err.message, {
stack: err.stack,
path: req.path,
method: req.method,
});
// If the error is a known type, customize the response
// Otherwise, send a generic server error
if (err.isOperational) {
// You can add an 'isOperational' property to your custom errors
res.status(err.statusCode || 500).json({
error: {
message: err.message,
code: err.errorCode || "INTERNAL_SERVER_ERROR",
},
});
} else {
// For unexpected errors, don't leak details to the client
res.status(500).json({
error: {
message: "An unexpected error occurred.",
code: "INTERNAL_SERVER_ERROR",
},
});
}
};
module.exports = errorHandler;

340
notes.md
View File

@ -1,340 +0,0 @@
## Task 2.1: User Dashboard & Form Management UI (Replacing current "admin")
- Mindset Shift: This is no longer an admin panel. It's the user's control center.
### Subtask 2.1.1: Design User Dashboard Layout
- **Wireframe basic layout:**
- **Navigation Bar:**
- Logo/App Name (e.g., "Formies")
- My Forms (Active Link)
- Create New Form
- Account Settings (e.g., "Hi, [User Name]" dropdown with "Settings", "Logout")
- **Main Content Area (for "My Forms" view):**
- Header: "My Forms"
- Button: "+ Create New Form"
- Forms List Table:
- Columns: Form Name, Submissions (count), Endpoint URL, Created Date, Actions
- Actions per row: View Submissions, Settings, Archive/Delete
- Pagination for the forms list if it becomes long.
- **Main Content Area (for "Create New Form" view - initial thought, might be a separate page/modal):**
- Header: "Create New Form"
- Form fields: Form Name
- Button: "Create Form"
- **Main Content Area (for "Account Settings" - placeholder for now):**
- Header: "Account Settings"
- Placeholder content.
- **Frontend Tech Decision:**
- EJS for templating, made dynamic with client-side JavaScript. This aligns with the existing structure and MVP scope. We will enhance EJS views to be more interactive.
[X] Wireframe basic layout: List forms, create form, account settings (placeholder). - _Textual wireframe defined above_
[X] Decide on frontend tech (EJS is fine for MVP if you make it dynamic with client-side JS, or consider a simple SPA framework if comfortable). - _Decision made: EJS with client-side JS_
- Created `views/dashboard.ejs` as the main layout.
- Created `views/partials/_forms_table.ejs` for displaying the list of forms.
### Subtask 2.1.2: "My Forms" View:
- Objective: Fetch and display forms owned by the logged-in user.
- Show key info: name, submission count, endpoint URL, created date, status (Active/Archived).
- Links/Actions: View Submissions, Settings, Archive/Unarchive, Delete.
- Frontend: `views/dashboard.ejs` with `view = 'my_forms'` and `views/partials/_forms_table.ejs` will handle this.
- Backend:
- Need a new route, e.g., `GET /dashboard`, protected by authentication (e.g., `requireAuth` from `authMiddleware.js`).
- This route handler will:
- Fetch forms for `req.user.id` from the database.
- Query should include `name`, `uuid`, `created_at`, `is_archived`, and `submission_count`.
- Render `views/dashboard.ejs` passing the forms data, `user` object, `appUrl`, and `view = 'my_forms'`.
- Implemented in `src/routes/dashboard.js` via GET `/`.
[X] Fetch and display forms owned by the logged-in user.
[X] Show key info: name, submission count, endpoint URL, created date.
[X] Links to: view submissions, edit settings, delete. (Links are present in `_forms_table.ejs`, functionality for all to be built out in subsequent tasks)
### Subtask 2.1.3: "Create New Form" Functionality (for logged-in user):
- UI: `dashboard.ejs` (with `view = 'create_form'`) provides the form input.
- Route `GET /dashboard/create-form` in `src/routes/dashboard.js` renders this view.
- Backend: `POST /dashboard/forms/create` route in `src/routes/dashboard.js` handles form submission.
- Associates form with `req.user.id`.
- Redirects to `/dashboard` on success.
- Handles errors and re-renders create form view with an error message.
[X] UI and backend logic. Associates form with req.user.id.
### Subtask 2.1.4: "View Form Submissions" (Scoped & Paginated):
- Objective: Allow users to view submissions for their specific forms, with pagination.
- UI:
- `views/partials/_submissions_view.ejs` created to display submissions list and pagination.
- `views/dashboard.ejs` updated to include this partial when `view = 'form_submissions'`.
- Backend:
- Route: `GET /dashboard/submissions/:formUuid` added to `src/routes/dashboard.js`.
- Verifies that `req.user.id` owns the `formUuid`.
- Fetches paginated submissions for the given `formUuid`.
- Renders `dashboard.ejs` with `view = 'form_submissions'`, passing submissions data, form details, and pagination info.
- Error handling improved to render user-friendly messages within the dashboard view.
[X] UI and backend for a user to view submissions for their specific form.
[X] Pagination is critical here (as you have).
### Subtask 2.1.5: Form Settings UI (Basic):
- Objective: Allow users to update basic form settings, starting with the form name.
- UI:
- A new view/section in `dashboard.ejs` (e.g., when `view = 'form_settings'`).
- This view will display a form with an input for the form name.
- It will also be a placeholder for future settings (thank you URL, notifications).
- Backend:
- Route: `GET /dashboard/forms/:formUuid/settings` to display the settings page.
- Implemented in `src/routes/dashboard.js`.
- Verifies form ownership by `req.user.id`.
- Fetches current form details (name).
- Renders the `form_settings` view in `dashboard.ejs`.
- Route: `POST /dashboard/forms/:formUuid/settings/update-name` to handle the update.
- Implemented in `src/routes/dashboard.js`.
- Verifies form ownership.
- Updates the form name in the database.
- Redirects back to form settings page with a success/error message via query parameters.
[X] Allow users to update form name.
[X] Placeholder for future settings (thank you URL, notifications) - (Placeholders added in EJS).
### Subtask 2.1.6: Delete Form/Submission (with soft delete/archival consideration):
- Objective: Implement form archival (soft delete) and permanent deletion for users.
- Users should be able to archive/unarchive their forms.
- True delete should be a confirmed, rare operation.
- The `is_archived` field in the `forms` table will be used.
- Submissions deletion is already partially handled in `_submissions_view.ejs` via a POST to `/dashboard/submissions/delete/:submissionId`. We need to implement this backend route.
- **Form Archival/Unarchival:**
- UI: Buttons for "Archive" / "Unarchive" are already in `views/partials/_forms_table.ejs`.
- Archive action: `POST /dashboard/forms/archive/:formUuid`
- Unarchive action: `POST /dashboard/forms/unarchive/:formUuid`
- Backend:
- Create these two POST routes in `src/routes/dashboard.js`.
- Must verify form ownership by `req.user.id`.
- Fetch current form details (name).
- Render the settings view.
- Route: `POST /dashboard/forms/:formUuid/settings` (or `/dashboard/forms/:formUuid/update-name`) to handle the update.
- Must verify form ownership.
- Update the form name in the database.
- Redirect back to form settings page or main dashboard with a success message.
* **Submission Deletion (User-scoped):**
- UI: "Delete" button per submission in `views/partials/_submissions_view.ejs` (with `confirm()` dialog).
- Action: `POST /dashboard/submissions/delete/:submissionId`
- Backend (in `src/routes/dashboard.js`):
- Implemented `POST /dashboard/submissions/delete/:submissionId`:
- Verifies the `req.user.id` owns the form to which the submission belongs.
- Deletes the specific submission.
- Redirects back to the form's submissions view (`/dashboard/submissions/:formUuid`) with message.
[X] You have is_archived. Solidify this. Users should be able to archive/unarchive.
[X] True delete should be a confirmed, rare operation.
[X] Implement user-scoped submission deletion.
## Task 2.2: Per-Form Configuration by User
- Mindset Shift: Empower users to customize their form behavior.
### Subtask 2.2.1: Database Schema Updates for forms Table:
- Objective: Add new fields to the `forms` table to support per-form email notification settings.
- Review existing fields (`thank_you_url`, `thank_you_message`, `ntfy_enabled`, `allowed_domains`) - these are good as per plan.
- **New fields to add:**
- `email_notifications_enabled` (BOOLEAN, DEFAULT FALSE, NOT NULL)
- `notification_email_address` (VARCHAR(255), NULL) - This will store an override email address. If NULL, the user's primary email will be used.
[X] Review existing fields (thank_you_url, thank_you_message, ntfy_enabled, allowed_domains). These are good.
[X] Add email_notifications_enabled (boolean). (Added to `init.sql`)
[X] Add notification_email_address (string, defaults to user's email, but allow override). (Added to `init.sql`)
### Subtask 2.2.2: UI for Form Settings Page:
- Objective: Enhance the form settings page to allow users to configure these new email notification options.
- The existing form settings page is `dashboard.ejs` with `view = 'form_settings'` (created in Subtask 2.1.5).
- **UI Elements to add to this page:**
- **Email Notifications Section:**
- Checkbox/Toggle: "Enable Email Notifications for new submissions"
- Controls `email_notifications_enabled`.
- Input Field (text, email type): "Notification Email Address"
- Controls `notification_email_address`.
- Should be pre-filled with the user's primary email if `notification_email_address` is NULL/empty in the DB.
- Label should indicate that if left blank, notifications will go to the account email.
- The `GET /dashboard/forms/:formUuid/settings` route will need to fetch these new fields.
- The form on this page will need to be updated to submit these new fields. The POST route will likely be `/dashboard/forms/:formUuid/settings/update-notifications` or similar, or a general update to the existing `/dashboard/forms/:formUuid/settings/update-name` to become a general settings update route.
[X] Create a dedicated page/modal for each form's settings. (Using existing settings section in `dashboard.ejs`)
[X] Allow users to edit: Name, Email Notification toggle, Notification Email Address. (Thank You URL, Thank You Message, Allowed Domains are placeholders for now as per 2.1.5).
_ UI elements added to `dashboard.ejs` in the `form_settings` view.
_ `GET /dashboard/forms/:formUuid/settings` in `src/routes/dashboard.js` updated to fetch and pass these settings. \* `POST /dashboard/forms/:formUuid/settings/update-notifications` in `src/routes/dashboard.js` created to save these settings.
### Subtask 2.2.3: Backend to Save and Apply Settings:
- Objective: Ensure the backend API endpoints correctly save and the submission logic uses these settings.
- API endpoints to update settings for a specific form (owned by user):
- `POST .../update-name` (Done in 2.1.5)
- `POST .../update-notifications` (Done in 2.2.2)
- Future: endpoints for Thank You URL, Message, Allowed Domains.
- Logic in `/submit/:formUuid` to use these form-specific settings:
- When a form is submitted to `/submit/:formUuid`:
- Fetch the form's settings from the DB, including `email_notifications_enabled` and `notification_email_address`.
- This logic is now implemented in `src/routes/public.js` as part of Task 2.3.2 integration.
[X] API endpoints to update these settings for a specific form (owned by user). (Name and Email Notification settings covered so far)
[X] Logic in /submit/:formUuid to use these form-specific settings. (Addressed as part of 2.3.2)
## Task 2.3: Email Notifications for Submissions (Core Feature)
- Mindset Shift: Ntfy is cool for you. Users expect email.
### Subtask 2.3.1: Integrate Transactional Email Service:
- Objective: Set up a third-party email service to send submission notifications.
- **Action for you (USER):**
- Choose a transactional email service (e.g., SendGrid, Mailgun, AWS SES). Many offer free tiers.
- Sign up for the service and obtain an API Key.
- Securely store this API Key as an environment variable in your `.env` file.
- For example, if you choose SendGrid, you might use `SENDGRID_API_KEY=your_actual_api_key`.
- Also, note the sender email address you configure with the service (e.g., `EMAIL_FROM_ADDRESS=notifications@yourdomain.com`).
- Once you have these, let me know which service you've chosen so I can help with installing the correct SDK and writing the integration code.
- User selected: Resend
- API Key ENV Var: `RESEND_API_KEY`
- From Email ENV Var: `EMAIL_FROM_ADDRESS`
[X] Sign up for SendGrid, Mailgun, AWS SES (free tiers available). (User selected Resend)
[X] Install their SDK. (npm install resend done)
[X] Store API key securely (env vars). (User confirmed `RESEND_API_KEY` and `EMAIL_FROM_ADDRESS` are set up)
### Subtask 2.3.2: Email Sending Logic:
- Objective: Create a reusable service/function to handle the sending of submission notification emails.
- This service will use the Resend SDK and the configured API key.
- **Create a new service file:** `src/services/emailService.js`
- It should export a function, e.g., `sendSubmissionNotification(form, submissionData, userEmail)`.
- `form`: An object containing form details (`name`, `email_notifications_enabled`, `notification_email_address`).
- `submissionData`: The actual data submitted to the form.
- `userEmail`: The email of the user who owns the form (to be used if `form.notification_email_address` is not set).
- Inside the function:
- Check if `form.email_notifications_enabled` is true.
- Determine the recipient: `form.notification_email_address` or `userEmail`.
- Construct the email subject and body (using a basic template for now - Subtask 2.3.3).
- Use the Resend SDK to send the email.
- Include error handling (Subtask 2.3.4).
[X] Create a service/function sendSubmissionNotification(form, submissionData, userEmail) - (`src/services/emailService.js` created with this function).
[X] If email_notifications_enabled for the form, send an email to notification_email_address (or user's email). - (Logic implemented in `emailService.js` and integrated into `/submit/:formUuid` route in `src/routes/public.js`).
### Subtask 2.3.3: Basic Email Template:
- Objective: Define a simple, clear email template for notifications.
- The current `createEmailHtmlBody` function in `src/services/emailService.js` provides a very basic HTML template:
- Subject: "New Submission for [Form Name]"
- Body: Lists submitted data (key-value pairs).
- This fulfills the MVP requirement.
[X] Simple, clear email: "New Submission for [Form Name]", list submitted data. (Implemented in `emailService.js`)
### Subtask 2.3.4: Error Handling for Email Sending:
- Objective: Ensure email sending failures don't break the submission flow and are logged.
- In `src/services/emailService.js`, within `sendSubmissionNotification`:
- Errors from `resend.emails.send()` are caught and logged.
- The function does not throw an error that would halt the caller, allowing the submission to be considered successful even if the email fails.
- In `src/routes/public.js` (`/submit/:formUuid` route):
- The call to `sendSubmissionNotification` is followed by `.catch()` to log any unexpected errors from the email sending promise itself, ensuring the main response to the user is not blocked.
[X] Log errors if email fails to send; don't let it break the submission flow. (Implemented in `emailService.js` and `public.js` route)
## Task 2.4: Enhanced Spam Protection (Beyond Basic Honeypot)
- Mindset Shift: Your honeypot is step 1. Real services need more.
### Subtask 2.4.1: Integrate CAPTCHA (e.g., Google reCAPTCHA):
- Objective: Add server-side CAPTCHA validation to the form submission process.
- We'll use Google reCAPTCHA v2 ("I'm not a robot" checkbox) for this MVP.
- **Action for you (USER):**
- Go to the [Google reCAPTCHA admin console](https://www.google.com/recaptcha/admin/create).
- Register your site: Choose reCAPTCHA v2, then "I'm not a robot" Checkbox.
- Add your domain(s) (e.g., `localhost` for development, and your production domain).
- Accept the terms of service.
- You will receive a **Site Key** and a **Secret Key**.
- Store these securely in your `.env` file:
- `RECAPTCHA_V2_SITE_KEY=your_site_key`
- `RECAPTCHA_V2_SECRET_KEY=your_secret_key`
- Let me know once you have these keys set up in your `.env` file.
- **Frontend Changes (Illustrative - User will implement on their actual forms):**
- User needs to include the reCAPTCHA API script in their HTML form page: `<script src="https://www.google.com/recaptcha/api.js" async defer></script>`
- User needs to add the reCAPTCHA widget div where the checkbox should appear: `<div class="g-recaptcha" data-sitekey="YOUR_RECAPTCHA_V2_SITE_KEY"></div>` (replacing with the actual site key, possibly passed from server or configured client-side if site key is public).
- **Backend Changes (`/submit/:formUuid` route in `src/routes/public.js`):**
- When a submission is received, it should include a `g-recaptcha-response` field from the reCAPTCHA widget.
- Create a new middleware or a helper function `verifyRecaptcha(recaptchaResponse, clientIp)`.
- This function will make a POST request to Google's verification URL: `https://www.google.com/recaptcha/api/siteverify`.
- Parameters: `secret` (your `RECAPTCHA_V2_SECRET_KEY`), `response` (the `g-recaptcha-response` value), `remoteip` (optional, user's IP).
- The response from Google will be JSON indicating success or failure.
- In the `/submit` route, call this verification function. If verification fails, reject the submission with an appropriate error.
[X] Sign up for reCAPTCHA (v2 "I'm not a robot" or v3 invisible). Get site/secret keys. (User action) - _User confirmed keys are in .env_
[ ] Frontend: Add reCAPTCHA widget/JS to user's HTML form example. (User responsibility for their forms)
[X] Backend: /submit/:formUuid endpoint must verify reCAPTCHA token with Google. (_Already implemented in `src/routes/public.js` using `src/utils/recaptchaHelper.js`_)
### Subtask 2.4.2: User Configuration for Spam Protection:
- [x] Database Schema: Add `recaptcha_enabled` (BOOLEAN, DEFAULT FALSE) to `forms` table. (_Done in `init.sql`_)
- [x] UI: Added reCAPTCHA toggle to Form Settings page (`dashboard.ejs`) and consolidated settings form to POST to `/dashboard/forms/:formUuid/settings/update`. (_Done_)
- [x] Backend:
- [x] `GET /dashboard/forms/:formUuid/settings` fetches and passes `recaptcha_enabled`. (_Done_)
- [x] Consolidated `POST /dashboard/forms/:formUuid/settings/update` saves `recaptcha_enabled` and other settings (formName, emailNotificationsEnabled, notificationEmailAddress). (_Done_)
- [x] `/submit/:formUuid` in `public.js` now checks form's `recaptcha_enabled` flag: if true, token is required & verified; if false, check is skipped. (_Done_)
- [x] Allow users to enable/disable reCAPTCHA for their forms (and input their own site key if they want, or use a global one you provide). - _Implemented using global keys for MVP._
- Subtask 2.4.3: (Future Consideration) Akismet / Content Analysis.
## Task 2.5: Basic API for Users to Access Their Data
- Mindset Shift: Power users and integrations need an API.
### Subtask 2.5.1: API Key Generation & Management:
- Objective: Allow users to generate/revoke API keys from their dashboard.
- **Action for you (USER):**
- Choose a RESTful API framework (e.g., Express, Fastify).
- Implement the API endpoints to allow users to access their data.
- Ensure the API is secure and uses authentication.
- Let me know once you have the API implemented and tested.
[X] Database Schema: Create `api_keys` table (user*id, key_name, api_key_identifier, hashed_api_key_secret, etc.). (\_Done in `init.sql` with refined structure*)
[X] Helper Utilities: Created `src/utils/apiKeyHelper.js` with `generateApiKeyParts`, `hashApiKeySecret`, `compareApiKeySecret`. (_Done_)
[X] Backend Routes: Added `GET /dashboard/api-keys` (list), `POST /dashboard/api-keys/generate` (create), `POST /dashboard/api-keys/:apiKeyUuid/revoke` (delete) to `src/routes/dashboard.js`. (_Done_)
[X] UI in Dashboard: Added "API Keys" section to `dashboard.ejs` for generating, listing (name, identifier, created/last*used), and revoking keys. Displays newly generated key once via session. (\_Done*)
[X] Allow users to generate/revoke API keys from their dashboard. (_Done_)
[X] Store hashed API keys in DB, associated with user. (_Done via backend routes and helpers_)
### Subtask 2.5.2: Secure API Endpoints:
- Objective: Ensure the API is secure and uses authentication.
- **Action for you (USER):**
- Choose a RESTful API framework (e.g., Express, Fastify).
- Implement the API endpoints to allow users to access their data.
- Ensure the API is secure and uses authentication.
- Let me know once you have the API implemented and tested.
[X] Created `src/middleware/apiAuthMiddleware.js` for Bearer token authentication (checks signature, expiry, active user, updates last*used). (\_Done*)
[X] Created `src/routes/api_v1.js` and mounted it at `/api/v1` in `server.js`. (_Done_)
[X] Added `GET /api/v1/forms` (list user's forms) and `GET /api/v1/forms/:formUuid/submissions` (list form submissions, paginated), both protected by the API auth middleware. (_Done_)
[X] Create new API routes (e.g., /api/v1/forms, /api/v1/forms/:uuid/submissions). (_Covered by above point_)
[X] Authenticate using API keys (e.g., Bearer token). (_Done_)
### Subtask 2.5.3: Basic API Documentation:
- Objective: Provide basic documentation for the API.
- **Action for you (USER):**
- Choose a documentation format (e.g., Swagger, Postman, Markdown).
- Implement the documentation for the API endpoints.
- Let me know once you have the API documentation implemented.
[ ] Simple Markdown file explaining authentication and available endpoints.

View File

@ -1,12 +1,10 @@
{ {
"name": "formies", "name": "formies",
"version": "1.0.0", "version": "1.0.0",
"main": "server.js", "type": "module",
"main": "index.js",
"scripts": { "scripts": {
"test": "NODE_ENV=test jest --runInBand --detectOpenHandles --forceExit", "test": "echo \"Error: no test specified\" && exit 1"
"test:watch": "NODE_ENV=test jest --watch",
"start": "node server.js",
"dev": "nodemon server.js"
}, },
"keywords": [], "keywords": [],
"author": "", "author": "",
@ -14,31 +12,14 @@
"description": "", "description": "",
"dependencies": { "dependencies": {
"basic-auth": "^2.0.1", "basic-auth": "^2.0.1",
"bcryptjs": "^2.4.3", "cors": "^2.8.5",
"connect-redis": "^8.1.0",
"dotenv": "^16.5.0", "dotenv": "^16.5.0",
"ejs": "^3.1.10", "ejs": "^3.1.10",
"express": "^5.1.0", "express": "^5.1.0",
"express-rate-limit": "^7.1.5",
"express-session": "^1.17.3",
"express-validator": "^7.0.1",
"helmet": "^8.1.0", "helmet": "^8.1.0",
"jsonwebtoken": "^9.0.2", "node-fetch": "^3.3.2",
"nodemailer": "^6.9.8", "sqlite": "^5.1.1",
"passport": "^0.7.0",
"passport-jwt": "^4.0.1",
"passport-local": "^1.0.0",
"pg": "^8.16.0",
"rate-limit-redis": "^4.2.0",
"redis": "^4.7.0",
"resend": "^4.5.1",
"sqlite3": "^5.1.7", "sqlite3": "^5.1.7",
"uuid": "^11.1.0", "uuid": "^11.1.0"
"winston": "^3.17.0"
},
"devDependencies": {
"jest": "^29.7.0",
"nodemon": "^3.0.2",
"supertest": "^7.0.0"
} }
} }

File diff suppressed because it is too large Load Diff

215
server.js
View File

@ -1,196 +1,53 @@
require("dotenv").config(); import dotenv from "dotenv";
const express = require("express"); import express from "express";
const path = require("path"); import helmet from "helmet";
const fs = require("fs"); // Added for fs operations import cors from "cors";
const pool = require("./src/config/database"); // Changed to pg pool import adminRoutes from "./src/routes/admin.js";
const helmet = require("helmet"); import publicRoutes from "./src/routes/public.js";
const session = require("express-session");
const passport = require("./src/config/passport");
const logger = require("./config/logger"); // Corrected logger path back to original
const errorHandler = require("./middleware/errorHandler");
const { connectRedis, closeRedis } = require("./src/config/redis");
// Import routes dotenv.config();
const publicRoutes = require("./src/routes/public");
const authRoutes = require("./src/routes/auth");
const dashboardRoutes = require("./src/routes/dashboard");
const apiV1Routes = require("./src/routes/api_v1");
const app = express(); const app = express();
const PORT = process.env.PORT || 3000; const PORT = process.env.PORT || 3000;
// Function to initialize the database with PostgreSQL // CORS configuration
async function initializeDatabase() { app.use(cors({
try { origin: ['https://mohamad.dev', 'https://www.mohamad.dev'],
// Check if a key table exists (e.g., users) to see if DB is initialized methods: ['GET', 'POST'],
await pool.query("SELECT 1 FROM users LIMIT 1"); credentials: true
logger.info("Database tables appear to exist. Skipping initialization."); }));
} catch (tableCheckError) {
// Specific error code for undefined_table in PostgreSQL is '42P01'
if (tableCheckError.code === "42P01") {
logger.info(
"Users table not found, attempting to initialize database..."
);
try {
const initSql = fs.readFileSync(
path.resolve(__dirname, "init.sql"),
"utf8"
);
// Execute the entire init.sql script.
// pg library can usually handle multi-statement queries if separated by semicolons.
await pool.query(initSql);
logger.info("Database initialized successfully from init.sql.");
} catch (initError) {
logger.error("Failed to initialize database with init.sql:", initError);
process.exit(1); // Exit if DB initialization fails
}
} else {
// Another error occurred during the table check
logger.error("Error checking for users table:", tableCheckError);
process.exit(1); // Exit on other DB errors during startup
}
}
}
// Initialize Redis connection and Database
async function initializeApp() {
// Initialize Redis first, but don't block on failure
connectRedis().catch(() => {
logger.warn(
"Redis connection failed, continuing with in-memory rate limiting"
);
});
try {
await initializeDatabase(); // Initialize PostgreSQL database
} catch (error) {
logger.error("Failed to initialize database:", error);
process.exit(1); // Exit if DB initialization fails
}
// Middleware // Middleware
app.use( app.use(helmet({
helmet({ crossOriginResourcePolicy: { policy: "cross-origin" }
contentSecurityPolicy: { }));
directives: { app.use(express.json());
defaultSrc: ["'self'"], app.use(express.urlencoded({ extended: true }));
styleSrc: ["'self'", "'unsafe-inline'"],
scriptSrc: ["'self'"],
imgSrc: ["'self'", "data:", "https:"],
},
},
})
);
app.use(express.json({ limit: "10mb" }));
app.use(express.urlencoded({ extended: true, limit: "10mb" }));
// Session configuration (for development only, use Redis in production)
app.use(
session({
secret:
process.env.SESSION_SECRET || "fallback-secret-change-in-production",
resave: false,
saveUninitialized: false,
cookie: {
secure: process.env.NODE_ENV === "production",
httpOnly: true,
maxAge: 24 * 60 * 60 * 1000, // 24 hours
},
})
);
// Initialize Passport
app.use(passport.initialize());
app.use(passport.session());
// Set view engine
app.set("view engine", "ejs"); app.set("view engine", "ejs");
app.use(express.static('views', {
setHeaders: (res, path) => {
if (path.endsWith('.js')) {
res.setHeader('Content-Type', 'application/javascript');
}
}
}));
// API Routes // Routes
app.use("/api/auth", authRoutes); app.use("/admin", adminRoutes);
// API V1 Routes
app.use("/api/v1", apiV1Routes);
// User Dashboard Routes
app.use("/dashboard", dashboardRoutes);
// Existing routes (maintaining backward compatibility)
app.use("/", publicRoutes); app.use("/", publicRoutes);
// Health check endpoint
app.get("/health", (req, res) => {
res.json({
status: "healthy",
timestamp: new Date().toISOString(),
version: "1.0.0",
});
});
// Global error handler - should be the last middleware
app.use(errorHandler);
// 404 handler
app.use((req, res) => {
logger.warn(
`404 - Endpoint not found: ${req.originalUrl} - Method: ${req.method} - IP: ${req.ip}`
);
res.status(404).json({
error: {
message: "Endpoint not found",
code: "NOT_FOUND",
},
});
});
// Start server // Start server
app.listen(PORT, () => { app.listen(80, () => {
logger.info(`Server running on http://localhost:${PORT}`); console.log(`Server running on http://0.0.0.0:${PORT}`);
if (!process.env.ADMIN_USER || !process.env.ADMIN_PASSWORD) {
// Environment checks console.warn("WARNING: Admin routes are UNPROTECTED. Set ADMIN_USER and ADMIN_PASSWORD in .env");
if (!process.env.JWT_SECRET) { }
logger.warn( if (process.env.ADMIN_USER && process.env.ADMIN_PASSWORD) {
"WARNING: JWT_SECRET not set. Authentication will not work properly." console.log(`Admin access: User: ${process.env.ADMIN_USER}, Pass: (hidden)`);
);
} }
if (process.env.NTFY_ENABLED === "true" && process.env.NTFY_TOPIC_URL) { if (process.env.NTFY_ENABLED === "true" && process.env.NTFY_TOPIC_URL) {
logger.info( console.log(`Ntfy notifications enabled for topic: ${process.env.NTFY_TOPIC_URL}`);
`Ntfy notifications enabled for topic: ${process.env.NTFY_TOPIC_URL}`
);
} else { } else {
logger.info("Ntfy notifications disabled or topic not configured."); console.log("Ntfy notifications disabled or topic not configured.");
} }
// Start cleanup of expired sessions every hour
setInterval(
() => {
const jwtService = require("./src/services/jwtService");
jwtService.cleanupExpiredSessions();
},
60 * 60 * 1000
);
}); });
// Graceful shutdown
process.on("SIGINT", async () => {
logger.info("Received SIGINT, shutting down gracefully...");
await closeRedis();
process.exit(0);
});
process.on("SIGTERM", async () => {
logger.info("Received SIGTERM, shutting down gracefully...");
await closeRedis();
process.exit(0);
});
}
// Initialize the application
initializeApp().catch((error) => {
logger.error("Failed to initialize application:", error);
process.exit(1);
});
module.exports = app;

View File

@ -1,53 +1,34 @@
const { Pool } = require("pg"); import sqlite3 from 'sqlite3';
const logger = require("../../config/logger"); // Corrected logger path import { open } from 'sqlite';
// Load environment variables // Create a database connection
// require('dotenv').config(); // Call this at the very start of your app, e.g. in server.js const db = await open({
filename: './database.sqlite',
const pool = new Pool({ driver: sqlite3.Database
connectionString: process.env.DATABASE_URL,
ssl: {
rejectUnauthorized: false, // Necessary for some cloud providers, including Neon
},
// user: process.env.DB_USER,
// host: process.env.DB_HOST,
// database: process.env.DB_NAME,
// password: process.env.DB_PASSWORD,
// port: process.env.DB_PORT || 5432, // Default PostgreSQL port
// Optional: Add more pool configuration options if needed
// max: 20, // Max number of clients in the pool
// idleTimeoutMillis: 30000, // How long a client is allowed to remain idle before being closed
// connectionTimeoutMillis: 2000, // How long to wait for a connection from the pool
}); });
pool.on("connect", (client) => { // Initialize tables if they don't exist
logger.info("New client connected to the PostgreSQL database"); await db.exec(`
// You can set session-level parameters here if needed, e.g.: CREATE TABLE IF NOT EXISTS forms (
// client.query('SET TIMEZONE="UTC";'); id INTEGER PRIMARY KEY AUTOINCREMENT,
}); uuid TEXT NOT NULL UNIQUE,
name TEXT DEFAULT 'My Form',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
thank_you_url TEXT,
thank_you_message TEXT,
ntfy_enabled INTEGER DEFAULT 1,
is_archived INTEGER DEFAULT 0,
allowed_domains TEXT
);
pool.on("error", (err, client) => { CREATE TABLE IF NOT EXISTS submissions (
logger.error("Unexpected error on idle PostgreSQL client", { id INTEGER PRIMARY KEY AUTOINCREMENT,
error: err.message, form_uuid TEXT NOT NULL,
clientInfo: client ? `Client connected for ${client.processID}` : "N/A", data TEXT NOT NULL,
}); ip_address TEXT,
// process.exit(-1); // Consider if you want to exit on idle client errors submitted_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
}); FOREIGN KEY (form_uuid) REFERENCES forms(uuid) ON DELETE CASCADE
);
`);
// Test the connection (optional, but good for startup diagnostics) export default db;
async function testConnection() {
try {
const client = await pool.connect();
logger.info("Successfully connected to PostgreSQL database via pool.");
const res = await client.query("SELECT NOW()");
logger.info(`PostgreSQL current time: ${res.rows[0].now}`);
client.release();
} catch (err) {
logger.error("Failed to connect to PostgreSQL database:", err.stack);
// process.exit(1); // Exit if DB connection is critical for startup
}
}
testConnection();
module.exports = pool; // Export the pool

View File

@ -1,170 +0,0 @@
const passport = require("passport");
const LocalStrategy = require("passport-local").Strategy;
const JwtStrategy = require("passport-jwt").Strategy;
const ExtractJwt = require("passport-jwt").ExtractJwt;
const bcrypt = require("bcryptjs");
const User = require("../models/User");
// Local Strategy for email/password authentication
passport.use(
new LocalStrategy(
{
usernameField: "email",
passwordField: "password",
},
async (email, password, done) => {
try {
// Find user by email
const user = await User.findByEmail(email);
if (!user) {
return done(null, false, { message: "Invalid email or password" });
}
// Check if account is locked
if (
user.account_locked_until &&
new Date() < user.account_locked_until
) {
return done(null, false, {
message:
"Account temporarily locked due to multiple failed login attempts",
});
}
// Check if account is active
if (!user.is_active) {
return done(null, false, { message: "Account has been deactivated" });
}
// Check if email is verified (for non-admin users)
if (!user.is_verified && user.role !== "super_admin") {
return done(null, false, {
message: "Please verify your email address before logging in",
});
}
// Verify password
const isValidPassword = await bcrypt.compare(
password,
user.password_hash
);
if (!isValidPassword) {
// Increment failed login attempts
await User.incrementFailedLoginAttempts(user.id);
return done(null, false, { message: "Invalid email or password" });
}
// Reset failed login attempts and update last login
await User.resetFailedLoginAttempts(user.id);
await User.updateLastLogin(user.id);
// Remove sensitive information before returning user
const userSafe = {
id: user.id,
uuid: user.uuid,
email: user.email,
first_name: user.first_name,
last_name: user.last_name,
role: user.role,
is_verified: user.is_verified,
is_active: user.is_active,
created_at: user.created_at,
last_login: user.last_login,
must_change_password: user.must_change_password,
};
return done(null, userSafe);
} catch (error) {
return done(error);
}
}
)
);
// JWT Strategy for token-based authentication
passport.use(
new JwtStrategy(
{
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
secretOrKey: process.env.JWT_SECRET || "trhrtjtzmkjt56fgdfg3tcvv",
issuer: process.env.JWT_ISSUER || "formies",
audience: process.env.JWT_AUDIENCE || "formies-users",
},
async (payload, done) => {
try {
// Check if token is blacklisted
const isBlacklisted = await User.isTokenBlacklisted(payload.jti);
if (isBlacklisted) {
return done(null, false, { message: "Token has been revoked" });
}
// Find user by ID
const user = await User.findById(payload.sub);
if (!user) {
return done(null, false, { message: "User not found" });
}
// Check if account is active
if (!user.is_active) {
return done(null, false, { message: "Account has been deactivated" });
}
// Remove sensitive information before returning user
const userSafe = {
id: user.id,
uuid: user.uuid,
email: user.email,
first_name: user.first_name,
last_name: user.last_name,
role: user.role,
is_verified: user.is_verified,
is_active: user.is_active,
created_at: user.created_at,
last_login: user.last_login,
must_change_password: user.must_change_password,
};
return done(null, userSafe);
} catch (error) {
return done(error);
}
}
)
);
// Serialize user for session
passport.serializeUser((user, done) => {
done(null, user.id);
});
// Deserialize user from session
passport.deserializeUser(async (id, done) => {
try {
const user = await User.findById(id);
if (user) {
const userSafe = {
id: user.id,
uuid: user.uuid,
email: user.email,
first_name: user.first_name,
last_name: user.last_name,
role: user.role,
is_verified: user.is_verified,
is_active: user.is_active,
created_at: user.created_at,
last_login: user.last_login,
must_change_password: user.must_change_password,
};
done(null, userSafe);
} else {
done(null, false);
}
} catch (error) {
done(error);
}
});
module.exports = passport;

View File

@ -1,110 +0,0 @@
const { createClient } = require("redis");
let redisClient = null;
let connectionAttempted = false;
let isRedisAvailable = false;
const connectRedis = async () => {
if (redisClient) {
return redisClient;
}
// If we already tried and failed, don't keep trying
if (connectionAttempted && !isRedisAvailable) {
return null;
}
connectionAttempted = true;
const redisHost = process.env.REDIS_HOST || "localhost";
const redisPort = process.env.REDIS_PORT || 6379;
const redisPassword = process.env.REDIS_PASSWORD || "";
const config = {
socket: {
host: redisHost,
port: redisPort,
connectTimeout: 1000, // Reduced timeout to 1 second
lazyConnect: true,
},
// Disable automatic reconnection to prevent spam
retry_unfulfilled_commands: false,
enable_offline_queue: false,
};
// Add password if provided
if (redisPassword) {
config.password = redisPassword;
}
redisClient = createClient(config);
// Only log the first error, not subsequent ones
let errorLogged = false;
redisClient.on("error", (err) => {
if (!errorLogged) {
console.warn("Redis connection failed:", err.code || err.message);
console.warn("Falling back to in-memory rate limiting");
errorLogged = true;
}
isRedisAvailable = false;
});
redisClient.on("connect", () => {
console.log("Connected to Redis");
isRedisAvailable = true;
});
redisClient.on("disconnect", () => {
if (isRedisAvailable) {
console.log("Disconnected from Redis");
}
isRedisAvailable = false;
});
try {
await redisClient.connect();
console.log("Redis client connected successfully");
isRedisAvailable = true;
} catch (error) {
console.warn("Failed to connect to Redis:", error.code || error.message);
console.warn("Continuing with in-memory rate limiting");
isRedisAvailable = false;
redisClient = null;
return null;
}
return redisClient;
};
const getRedisClient = () => {
if (!redisClient || !isRedisAvailable) {
throw new Error("Redis client not available");
}
return redisClient;
};
const closeRedis = async () => {
if (redisClient && isRedisAvailable) {
try {
await redisClient.quit();
console.log("Redis connection closed");
} catch (error) {
// Ignore errors during shutdown
}
}
redisClient = null;
isRedisAvailable = false;
connectionAttempted = false;
};
const isRedisConnected = () => {
return isRedisAvailable && redisClient && redisClient.isOpen;
};
module.exports = {
connectRedis,
getRedisClient,
closeRedis,
isRedisConnected,
};

View File

@ -1,101 +0,0 @@
const pool = require("../config/database");
const { compareApiKeySecret } = require("../utils/apiKeyHelper");
async function apiAuthMiddleware(req, res, next) {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith("Bearer ")) {
return res
.status(401)
.json({
error:
"Unauthorized: Missing or malformed API key. Expected Bearer token.",
});
}
const fullApiKey = authHeader.substring(7); // Remove "Bearer "
const parts = fullApiKey.split("_");
// Expects key format: prefix_identifierRandomPart_secretPart
// So, identifier is parts[0] + '_' + parts[1]
// And secret is parts[2]
if (parts.length < 3) {
// Basic check for fsk_random_secret format
return res
.status(401)
.json({ error: "Unauthorized: Invalid API key format." });
}
// Reconstruct identifier: e.g., parts[0] = 'fsk', parts[1] = 'randompart' -> 'fsk_randompart'
const apiKeyIdentifier = `${parts[0]}_${parts[1]}`;
const providedSecret = parts.slice(2).join("_"); // secret part could contain underscores if generated differently, though unlikely with current helper
if (!apiKeyIdentifier || !providedSecret) {
return res
.status(401)
.json({ error: "Unauthorized: Invalid API key structure." });
}
try {
const [apiKeyRecords] = await pool.query(
"SELECT ak.id, ak.user_id, ak.hashed_api_key_secret, ak.expires_at, u.is_active as user_is_active, u.role as user_role FROM api_keys ak JOIN users u ON ak.user_id = u.id WHERE ak.api_key_identifier = ?",
[apiKeyIdentifier]
);
if (apiKeyRecords.length === 0) {
return res.status(401).json({ error: "Unauthorized: Invalid API key." });
}
const apiKeyRecord = apiKeyRecords[0];
if (!apiKeyRecord.user_is_active) {
return res
.status(403)
.json({ error: "Forbidden: User account is inactive." });
}
// Check for expiration (if implemented and expires_at is not null)
if (
apiKeyRecord.expires_at &&
new Date(apiKeyRecord.expires_at) < new Date()
) {
return res.status(403).json({ error: "Forbidden: API key has expired." });
}
const isValid = await compareApiKeySecret(
providedSecret,
apiKeyRecord.hashed_api_key_secret
);
if (!isValid) {
return res.status(401).json({ error: "Unauthorized: Invalid API key." });
}
// Attach user information and API key ID to request for use in controllers/routes
req.user = {
id: apiKeyRecord.user_id,
role: apiKeyRecord.user_role, // Add other relevant user fields if needed
// Potentially add more fields from the user table if fetched in the JOIN
};
req.apiKeyId = apiKeyRecord.id;
// Update last_used_at (fire and forget, no need to await or block)
pool
.query(
"UPDATE api_keys SET last_used_at = CURRENT_TIMESTAMP WHERE id = ?",
[apiKeyRecord.id]
)
.catch((err) =>
console.error("Failed to update API key last_used_at:", err)
);
next();
} catch (error) {
console.error("API Authentication error:", error);
return res
.status(500)
.json({ error: "Internal Server Error during API authentication." });
}
}
module.exports = apiAuthMiddleware;

View File

@ -1,263 +0,0 @@
const passport = require("../config/passport");
const jwtService = require("../services/jwtService");
const rateLimit = require("express-rate-limit");
// JWT Authentication middleware
const authenticateJWT = (req, res, next) => {
passport.authenticate("jwt", { session: false }, (err, user, info) => {
if (err) {
return res.status(500).json({
success: false,
message: "Authentication error",
error: err.message,
});
}
if (!user) {
return res.status(401).json({
success: false,
message: info?.message || "Authentication required",
});
}
req.user = user;
next();
})(req, res, next);
};
// Optional JWT Authentication (doesn't fail if no token)
const authenticateJWTOptional = (req, res, next) => {
const authHeader = req.headers.authorization;
const token = jwtService.extractTokenFromHeader(authHeader);
if (!token) {
return next(); // No token provided, continue without user
}
passport.authenticate("jwt", { session: false }, (err, user, info) => {
if (!err && user) {
req.user = user;
}
// Continue regardless of authentication result
next();
})(req, res, next);
};
// Role-based authorization middleware
const requireRole = (roles) => {
if (typeof roles === "string") {
roles = [roles];
}
return (req, res, next) => {
if (!req.user) {
return res.status(401).json({
success: false,
message: "Authentication required",
});
}
if (!roles.includes(req.user.role)) {
return res.status(403).json({
success: false,
message: "Insufficient permissions",
});
}
next();
};
};
// Check if user is admin or super admin
const requireAdmin = requireRole(["admin", "super_admin"]);
// Check if user is super admin
const requireSuperAdmin = requireRole(["super_admin"]);
// Check if user owns the resource or is admin
const requireOwnershipOrAdmin = (getResourceUserId) => {
return async (req, res, next) => {
try {
if (!req.user) {
return res.status(401).json({
success: false,
message: "Authentication required",
});
}
// Super admins can access everything
if (req.user.role === "super_admin") {
return next();
}
// Get the user ID that owns the resource
const resourceUserId = await getResourceUserId(req);
// Check if user owns the resource or is admin
if (
req.user.id === resourceUserId ||
["admin", "super_admin"].includes(req.user.role)
) {
return next();
}
return res.status(403).json({
success: false,
message: "Access denied. You can only access your own resources.",
});
} catch (error) {
return res.status(500).json({
success: false,
message: "Authorization error",
error: error.message,
});
}
};
};
// Check if account is verified
const requireVerifiedAccount = (req, res, next) => {
if (!req.user) {
return res.status(401).json({
success: false,
message: "Authentication required",
});
}
// Super admins don't need verification
if (req.user.role === "super_admin") {
return next();
}
if (!req.user.is_verified) {
return res.status(403).json({
success: false,
message: "Please verify your email address to access this resource",
requiresVerification: true,
});
}
next();
};
// Rate limiting middleware for authentication endpoints
const authRateLimit = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 5, // Limit each IP to 5 requests per windowMs
message: {
success: false,
message: "Too many authentication attempts, please try again later",
},
standardHeaders: true,
legacyHeaders: false,
keyGenerator: (req) => {
// Use IP and email if available for more granular rate limiting
return req.ip + (req.body?.email || "");
},
});
// Rate limiting for password reset
const passwordResetRateLimit = rateLimit({
windowMs: 60 * 60 * 1000, // 1 hour
max: 3, // Limit each IP to 3 password reset attempts per hour
message: {
success: false,
message: "Too many password reset attempts, please try again later",
},
standardHeaders: true,
legacyHeaders: false,
keyGenerator: (req) => {
return req.ip + (req.body?.email || "");
},
});
// Rate limiting for registration
const registrationRateLimit = rateLimit({
windowMs: 60 * 60 * 1000, // 1 hour
max: 3, // Limit each IP to 3 registrations per hour
message: {
success: false,
message: "Too many registration attempts, please try again later",
},
standardHeaders: true,
legacyHeaders: false,
keyGenerator: (req) => {
return req.ip;
},
});
// Middleware to check if user is active
const requireActiveAccount = (req, res, next) => {
if (!req.user) {
return res.status(401).json({
success: false,
message: "Authentication required",
});
}
if (!req.user.is_active) {
return res.status(403).json({
success: false,
message: "Your account has been deactivated. Please contact support.",
});
}
next();
};
// Combine common authentication checks
const requireAuth = [authenticateJWT, requireActiveAccount];
const requireVerifiedAuth = [
authenticateJWT,
requireActiveAccount,
requireVerifiedAccount,
];
// Legacy basic auth middleware (for backward compatibility during transition)
const basicAuth = require("basic-auth");
const httpAuthMiddleware = (req, res, next) => {
if (!process.env.ADMIN_USER || !process.env.ADMIN_PASSWORD) {
console.warn(
"ADMIN_USER or ADMIN_PASSWORD not set. Admin routes are unprotected."
);
return next();
}
const user = basicAuth(req);
if (
!user ||
user.name !== process.env.ADMIN_USER ||
user.pass !== process.env.ADMIN_PASSWORD
) {
res.set("WWW-Authenticate", 'Basic realm="Admin Area"');
return res.status(401).send("Authentication required.");
}
return next();
};
module.exports = {
// JWT Authentication
authenticateJWT,
authenticateJWTOptional,
// Authorization
requireRole,
requireAdmin,
requireSuperAdmin,
requireOwnershipOrAdmin,
requireVerifiedAccount,
requireActiveAccount,
// Combined middleware
requireAuth,
requireVerifiedAuth,
// Rate limiting
authRateLimit,
passwordResetRateLimit,
registrationRateLimit,
// Legacy (for backward compatibility)
httpAuthMiddleware,
};

View File

@ -1,21 +1,22 @@
import dbPromise from "../config/database.js";
const domainChecker = async (req, res, next) => { const domainChecker = async (req, res, next) => {
const formUuid = req.params.formUuid; const formUuid = req.params.formUuid;
const referer = req.headers.referer || req.headers.origin; const referer = req.headers.referer || req.headers.origin;
try { try {
const [rows] = await req.db.query( const db = await dbPromise;
const form = await db.get(
"SELECT allowed_domains FROM forms WHERE uuid = ?", "SELECT allowed_domains FROM forms WHERE uuid = ?",
[formUuid] [formUuid]
); );
if (rows.length === 0) { if (!form) {
return res.status(404).json({ error: "Form not found" }); return res.status(404).json({ error: "Form not found" });
} }
const form = rows[0]; // If no domains are specified, allow all
if (!form.allowed_domains) {
// If no domains are specified or it's empty/null, allow all
if (!form.allowed_domains || form.allowed_domains.trim() === "") {
return next(); return next();
} }
@ -45,4 +46,4 @@ const domainChecker = async (req, res, next) => {
} }
}; };
module.exports = domainChecker; export default domainChecker;

View File

@ -0,0 +1,44 @@
const ipRateLimitStore = new Map();
// Clean up old entries every 5 minutes
setInterval(() => {
const now = Date.now();
for (const [key, value] of ipRateLimitStore.entries()) {
if (now - value.timestamp > 60000) {
// 1 minute
ipRateLimitStore.delete(key);
}
}
}, 5 * 60 * 1000);
const rateLimiter = (req, res, next) => {
const formUuid = req.params.formUuid;
const ip = req.ip;
const key = `${formUuid}_${ip}`;
const now = Date.now();
const windowMs = 60000; // 1 minute
const maxRequests = 5; // 5 requests per minute
const current = ipRateLimitStore.get(key) || { count: 0, timestamp: now };
// Reset if window has passed
if (now - current.timestamp > windowMs) {
current.count = 0;
current.timestamp = now;
}
// Check if limit exceeded
if (current.count >= maxRequests) {
return res.status(429).json({
error: "Too many requests. Please try again later.",
});
}
// Increment counter
current.count++;
ipRateLimitStore.set(key, current);
next();
};
export default rateLimiter;

View File

@ -1,146 +0,0 @@
const rateLimit = require("express-rate-limit");
const RedisStore = require("rate-limit-redis").default;
const { getRedisClient, isRedisConnected } = require("../config/redis");
// Track if we've already logged the fallback warning
let fallbackWarningLogged = false;
// Simple in-memory store as fallback when Redis is not available
class MemoryStore {
constructor() {
this.hits = new Map();
this.resetTime = new Map();
// Clean up old entries periodically to prevent memory leaks
this.cleanupInterval = setInterval(
() => {
const now = Date.now();
for (const [key, resetTime] of this.resetTime.entries()) {
if (now > resetTime) {
this.hits.delete(key);
this.resetTime.delete(key);
}
}
},
5 * 60 * 1000
); // Clean up every 5 minutes
}
async increment(key, windowMs) {
const now = Date.now();
const resetTime = this.resetTime.get(key);
if (!resetTime || now > resetTime) {
this.hits.set(key, 1);
this.resetTime.set(key, now + windowMs);
return { totalHits: 1, timeToExpire: windowMs };
}
const hits = (this.hits.get(key) || 0) + 1;
this.hits.set(key, hits);
return { totalHits: hits, timeToExpire: resetTime - now };
}
async decrement(key) {
const hits = this.hits.get(key) || 0;
if (hits > 0) {
this.hits.set(key, hits - 1);
}
}
async resetKey(key) {
this.hits.delete(key);
this.resetTime.delete(key);
}
}
// Create store based on Redis availability
const createStore = () => {
try {
if (isRedisConnected()) {
const redisClient = getRedisClient();
return new RedisStore({
sendCommand: (...args) => redisClient.sendCommand(args),
});
} else {
throw new Error("Redis not connected");
}
} catch (error) {
// Only log the warning once to avoid spam
if (!fallbackWarningLogged) {
console.warn("Rate limiting: Using in-memory store (Redis unavailable)");
fallbackWarningLogged = true;
}
return new MemoryStore();
}
};
// Create rate limiter for form submissions
const createSubmissionRateLimiter = () => {
return rateLimit({
store: createStore(),
windowMs: 15 * 60 * 1000, // 15 minutes
max: 10, // Limit each IP to 10 requests per windowMs for any form
message: {
error:
"Too many form submissions from this IP address. Please try again later.",
},
standardHeaders: true, // Return rate limit info in the `RateLimit-*` headers
legacyHeaders: false, // Disable the `X-RateLimit-*` headers
keyGenerator: (req) => {
// Generate unique key per IP
return `submit_ip:${req.ip}`;
},
skip: (req) => {
// Skip rate limiting for specific conditions if needed
return false;
},
});
};
// Create more restrictive rate limiter for specific form+IP combinations
const createFormSpecificRateLimiter = () => {
return rateLimit({
store: createStore(),
windowMs: 5 * 60 * 1000, // 5 minutes
max: 3, // Limit each IP to 3 requests per 5 minutes per specific form
message: {
error:
"Too many submissions for this form from your IP address. Please try again later.",
},
standardHeaders: true,
legacyHeaders: false,
keyGenerator: (req) => {
// Generate unique key per form+IP combination
const formUuid = req.params.formUuid;
return `submit_form:${formUuid}:${req.ip}`;
},
skip: (req) => {
// Skip rate limiting for specific conditions if needed
return false;
},
});
};
// Create a more aggressive rate limiter for potential abuse
const createStrictRateLimiter = () => {
return rateLimit({
store: createStore(),
windowMs: 60 * 60 * 1000, // 1 hour
max: 50, // Limit each IP to 50 requests per hour across all forms
message: {
error: "Too many requests from this IP address. Please try again later.",
},
standardHeaders: true,
legacyHeaders: false,
keyGenerator: (req) => {
return `strict_ip:${req.ip}`;
},
});
};
module.exports = {
createSubmissionRateLimiter,
createFormSpecificRateLimiter,
createStrictRateLimiter,
};

View File

@ -1,115 +0,0 @@
const { body, param, query, validationResult } = require("express-validator");
// Validation error handler
const handleValidationErrors = (req, res, next) => {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({
success: false,
message: "Validation failed",
errors: errors.array().map((error) => ({
field: error.path,
message: error.msg,
value: error.value,
})),
});
}
next();
};
// Password validation
const passwordValidation = body("password")
.isLength({ min: 8 })
.withMessage("Password must be at least 8 characters long")
.matches(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]/)
.withMessage(
"Password must contain at least one lowercase letter, one uppercase letter, one number, and one special character"
);
// Email validation
const emailValidation = body("email")
.isEmail()
.withMessage("Please provide a valid email address")
.normalizeEmail()
.isLength({ max: 255 })
.withMessage("Email address is too long");
// Registration validation
const validateRegistration = [
emailValidation,
passwordValidation,
body("first_name")
.optional()
.trim()
.isLength({ min: 1, max: 100 })
.withMessage("First name must be between 1 and 100 characters"),
body("last_name")
.optional()
.trim()
.isLength({ min: 1, max: 100 })
.withMessage("Last name must be between 1 and 100 characters"),
handleValidationErrors,
];
// Login validation
const validateLogin = [
body("email")
.isEmail()
.withMessage("Please provide a valid email address")
.normalizeEmail(),
body("password").notEmpty().withMessage("Password is required"),
handleValidationErrors,
];
// Forgot password validation
const validateForgotPassword = [emailValidation, handleValidationErrors];
// Reset password validation
const validateResetPassword = [
body("token")
.notEmpty()
.withMessage("Reset token is required")
.isLength({ min: 64, max: 64 })
.withMessage("Invalid reset token format"),
passwordValidation,
body("confirmPassword").custom((value, { req }) => {
if (value !== req.body.password) {
throw new Error("Password confirmation does not match password");
}
return true;
}),
handleValidationErrors,
];
// Profile update validation
const validateProfileUpdate = [
body("first_name")
.optional()
.trim()
.isLength({ min: 1, max: 100 })
.withMessage("First name must be between 1 and 100 characters"),
body("last_name")
.optional()
.trim()
.isLength({ min: 1, max: 100 })
.withMessage("Last name must be between 1 and 100 characters"),
body("email")
.optional()
.isEmail()
.withMessage("Please provide a valid email address")
.normalizeEmail()
.isLength({ max: 255 })
.withMessage("Email address is too long"),
handleValidationErrors,
];
module.exports = {
validateRegistration,
validateLogin,
validateForgotPassword,
validateResetPassword,
validateProfileUpdate,
handleValidationErrors,
passwordValidation,
emailValidation,
};

View File

@ -1,434 +0,0 @@
const bcrypt = require("bcryptjs");
const crypto = require("crypto");
// const { v4: uuidv4 } = require("uuid"); // UUIDs will be generated by PostgreSQL
const pool = require("../config/database"); // db is now the pg Pool
const logger = require("../../config/logger"); // Corrected logger path
class User {
// No need for _run, _get, _all as pool.query returns a promise with a consistent result object.
// Create a new user
static async create(userData) {
const {
email,
password,
first_name,
last_name,
role = "user",
is_verified = false, // PostgreSQL uses true/false for BOOLEAN
} = userData;
const saltRounds = 12;
const password_hash = await bcrypt.hash(password, saltRounds);
const verification_token = crypto.randomBytes(32).toString("hex");
// UUID is generated by DB default (gen_random_uuid())
const query = `
INSERT INTO users (email, password_hash, first_name, last_name, role, is_verified, verification_token, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, NOW(), NOW())
RETURNING id, uuid, email, first_name, last_name, role, is_verified, verification_token;
`;
const values = [
email,
password_hash,
first_name,
last_name,
role,
is_verified,
verification_token,
];
try {
const result = await pool.query(query, values);
return result.rows[0]; // Returns the newly created user data including id and uuid
} catch (error) {
// PostgreSQL error codes: https://www.postgresql.org/docs/current/errcodes-appendix.html
if (error.code === "23505") {
// unique_violation
if (error.constraint === "users_email_key") {
// Or whatever your unique constraint name for email is
throw new Error("Email already exists");
}
// Potentially other unique constraints like users_uuid_key if not handled by default generation
}
logger.error("Error creating user:", error);
throw error;
}
}
// Find user by email
static async findByEmail(email) {
const query = "SELECT * FROM users WHERE email = $1 AND is_active = TRUE";
const { rows } = await pool.query(query, [email]);
return rows[0];
}
// Find user by ID
static async findById(id) {
const query = "SELECT * FROM users WHERE id = $1 AND is_active = TRUE";
const { rows } = await pool.query(query, [id]);
return rows[0];
}
// Find user by UUID
static async findByUuid(uuid) {
const query = "SELECT * FROM users WHERE uuid = $1 AND is_active = TRUE";
const { rows } = await pool.query(query, [uuid]);
return rows[0];
}
// Find user by verification token
static async findByVerificationToken(token) {
const query = "SELECT * FROM users WHERE verification_token = $1";
const { rows } = await pool.query(query, [token]);
return rows[0];
}
// Find user by password reset token
static async findByPasswordResetToken(token) {
const query = `
SELECT * FROM users
WHERE password_reset_token = $1
AND password_reset_expires > NOW()
AND is_active = TRUE
`;
const { rows } = await pool.query(query, [token]);
return rows[0];
}
// Verify email
static async verifyEmail(token) {
const query = `
UPDATE users
SET is_verified = TRUE, verification_token = NULL -- updated_at is handled by trigger
WHERE verification_token = $1
RETURNING id;
`;
const result = await pool.query(query, [token]);
return result.rowCount > 0;
}
// Update password
static async updatePassword(id, newPassword) {
const saltRounds = 12;
const password_hash = await bcrypt.hash(newPassword, saltRounds);
const query = `
UPDATE users
SET password_hash = $1, password_reset_token = NULL, password_reset_expires = NULL -- updated_at handled by trigger
WHERE id = $2
`;
const result = await pool.query(query, [password_hash, id]);
return result.rowCount > 0;
}
// Update password and clear must_change_password flag
static async updatePasswordAndClearChangeFlag(id, newPassword) {
const saltRounds = 12;
const password_hash = await bcrypt.hash(newPassword, saltRounds);
const query = `
UPDATE users
SET password_hash = $1,
must_change_password = FALSE,
password_reset_token = NULL,
password_reset_expires = NULL -- updated_at handled by trigger
WHERE id = $2
`;
const result = await pool.query(query, [password_hash, id]);
return result.rowCount > 0;
}
// Set password reset token
static async setPasswordResetToken(email) {
const token = crypto.randomBytes(32).toString("hex");
// PostgreSQL TIMESTAMPTZ handles timezone conversion, interval syntax is cleaner
const expires = new Date(Date.now() + 3600000); // Still use JS Date for interval calculation
const query = `
UPDATE users
SET password_reset_token = $1, password_reset_expires = $2 -- updated_at handled by trigger
WHERE email = $3 AND is_active = TRUE
RETURNING id;
`;
const result = await pool.query(query, [token, expires, email]);
if (result.rowCount > 0) {
return { token, expires };
}
return null;
}
// Increment failed login attempts
static async incrementFailedLoginAttempts(id) {
const query = `
UPDATE users
SET failed_login_attempts = failed_login_attempts + 1,
account_locked_until = CASE
WHEN failed_login_attempts >= 4 THEN NOW() + interval '30 minutes'
ELSE account_locked_until
END -- updated_at handled by trigger
WHERE id = $1
`;
await pool.query(query, [id]);
}
// Reset failed login attempts
static async resetFailedLoginAttempts(id) {
const query = `
UPDATE users
SET failed_login_attempts = 0, account_locked_until = NULL -- updated_at handled by trigger
WHERE id = $1
`;
await pool.query(query, [id]);
}
// Update last login
static async updateLastLogin(id) {
const query = "UPDATE users SET last_login = NOW() WHERE id = $1"; // updated_at handled by trigger
await pool.query(query, [id]);
}
// Deactivate user account
static async deactivateUser(id) {
const query = "UPDATE users SET is_active = FALSE WHERE id = $1"; // updated_at handled by trigger
const result = await pool.query(query, [id]);
return result.rowCount > 0;
}
// Activate user account
static async activateUser(id) {
const query = "UPDATE users SET is_active = TRUE WHERE id = $1"; // updated_at handled by trigger
const result = await pool.query(query, [id]);
return result.rowCount > 0;
}
// Update user profile
static async updateProfile(id, updates) {
const allowedFields = ["first_name", "last_name", "email"];
const fieldsToUpdate = [];
const values = [];
let paramIndex = 1;
for (const [key, value] of Object.entries(updates)) {
if (allowedFields.includes(key) && value !== undefined) {
// Use double quotes for field names if they might be reserved words, though not strictly necessary here
fieldsToUpdate.push(`\"${key}\" = $${paramIndex++}`);
values.push(value);
}
}
if (fieldsToUpdate.length === 0) {
return false; // No valid fields to update
}
values.push(id); // Add id as the last parameter for the WHERE clause
const query = `
UPDATE users
SET ${fieldsToUpdate.join(", ")}
WHERE id = $${paramIndex}
RETURNING *;
`;
// updated_at is handled by the trigger
try {
const result = await pool.query(query, values);
return result.rows[0]; // Return the updated user object
} catch (error) {
if (error.code === "23505" && error.constraint === "users_email_key") {
throw new Error("Email already exists");
}
logger.error("Error updating user profile:", error);
throw error;
}
}
// Get all users (with pagination and optional filters)
static async findAll(page = 1, limit = 20, filters = {}) {
let query =
"SELECT id, uuid, email, first_name, last_name, role, is_verified, is_active, last_login, created_at, updated_at FROM users";
const countQuery = "SELECT COUNT(*) FROM users";
const queryParams = [];
const filterClauses = [];
let paramIndex = 1;
if (filters.role) {
filterClauses.push(`role = $${paramIndex++}`);
queryParams.push(filters.role);
}
if (filters.is_active !== undefined) {
filterClauses.push(`is_active = $${paramIndex++}`);
queryParams.push(filters.is_active);
}
// Add more filters as needed
if (filterClauses.length > 0) {
query += " WHERE " + filterClauses.join(" AND ");
// Note: countQuery would also need the WHERE clause. This can get complex.
// For simplicity, the count query here doesn't include filters. Consider a more robust way if filters are common.
}
query += ` ORDER BY created_at DESC LIMIT $${paramIndex++} OFFSET $${paramIndex++}`;
const offset = (page - 1) * limit;
queryParams.push(limit, offset);
const { rows } = await pool.query(query, queryParams);
// For total count, you might need a separate query without limit/offset but with filters
// const totalResult = await pool.query(countQuery); // Potentially with filter conditions
// const total = parseInt(totalResult.rows[0].count, 10);
// For now, returning rows without total count for simplicity to match old behavior more closely
return rows;
}
// --- User Session Management (Example methods, adjust as needed) ---
static async saveSession(
userId,
tokenJti,
expiresAt,
userAgent = null,
ipAddress = null
) {
// expiresAt should be a Date object or a string PostgreSQL can parse
const query = `
INSERT INTO user_sessions (user_id, token_jti, expires_at, user_agent, ip_address, created_at)
VALUES ($1, $2, $3, $4, $5, NOW())
RETURNING id;
`;
const values = [userId, tokenJti, expiresAt, userAgent, ipAddress];
const result = await pool.query(query, values);
return result.rows[0].id;
}
static async isTokenBlacklisted(tokenJti) {
const query =
"SELECT 1 FROM user_sessions WHERE token_jti = $1 AND expires_at > NOW()";
const { rows } = await pool.query(query, [tokenJti]);
return rows.length > 0;
}
static async revokeSession(tokenJti) {
// Or, update expires_at to NOW() if you prefer not to delete
const query = "DELETE FROM user_sessions WHERE token_jti = $1";
const result = await pool.query(query, [tokenJti]);
return result.rowCount > 0;
}
static async revokeAllUserSessions(userId) {
const query = "DELETE FROM user_sessions WHERE user_id = $1";
const result = await pool.query(query, [userId]);
return result.rowCount > 0;
}
static async revokeAllUserSessionsExcept(userId, exceptJti) {
const query =
"DELETE FROM user_sessions WHERE user_id = $1 AND token_jti != $2";
const result = await pool.query(query, [userId, exceptJti]);
return result.rowCount > 0;
}
static async getUserActiveSessions(userId) {
const query =
"SELECT id, token_jti, user_agent, ip_address, created_at, expires_at FROM user_sessions WHERE user_id = $1 AND expires_at > NOW() ORDER BY created_at DESC";
const { rows } = await pool.query(query, [userId]);
return rows;
}
static async getSessionByJti(jti) {
const query = "SELECT * FROM user_sessions WHERE token_jti = $1";
const { rows } = await pool.query(query, [jti]);
return rows[0];
}
static async cleanupExpiredSessions() {
const query = "DELETE FROM user_sessions WHERE expires_at <= NOW()";
const result = await pool.query(query);
logger.info(`Cleaned up ${result.rowCount} expired user sessions.`);
return result.rowCount;
}
// --- API Key Management (Example methods, needs hashing for api_key_secret) ---
static async createApiKey(userId, keyName, daysUntilExpiry = null) {
const apiKeyIdentifier = crypto.randomBytes(16).toString("hex"); // Public part
const apiKeySecret = crypto.randomBytes(32).toString("hex"); // Secret part, show ONCE to user
// IMPORTANT: You MUST hash the apiKeySecret before storing it.
// Use a strong, one-way hashing algorithm like bcrypt or scrypt.
// This example will store it directly for simplicity, but DO NOT do this in production.
const saltRounds = 12; // Or appropriate for your chosen hashing algorithm
const hashedApiKeySecret = await bcrypt.hash(apiKeySecret, saltRounds);
let expiresAt = null;
if (daysUntilExpiry) {
expiresAt = new Date();
expiresAt.setDate(expiresAt.getDate() + daysUntilExpiry);
}
const query = `
INSERT INTO api_keys (user_id, key_name, api_key_identifier, hashed_api_key_secret, expires_at, created_at)
VALUES ($1, $2, $3, $4, $5, NOW())
RETURNING id, uuid, api_key_identifier, created_at, expires_at;
`;
const values = [
userId,
keyName,
apiKeyIdentifier,
hashedApiKeySecret,
expiresAt,
];
try {
const result = await pool.query(query, values);
return { ...result.rows[0], apiKeySecret }; // Return the raw secret ONCE for the user to copy
} catch (error) {
if (error.code === "23505") {
// unique_violation
// Handle if api_key_identifier somehow collides, though highly unlikely
logger.error("API Key identifier collision:", error);
}
logger.error("Error creating API key:", error);
throw error;
}
}
static async findApiKeyByIdentifier(identifier) {
const query = "SELECT * FROM api_keys WHERE api_key_identifier = $1";
const { rows } = await pool.query(query, [identifier]);
return rows[0]; // This will include the hashed_api_key_secret
}
// Call this after a key is used successfully
static async updateApiKeyLastUsed(apiKeyId) {
const query = "UPDATE api_keys SET last_used_at = NOW() WHERE id = $1";
await pool.query(query, [apiKeyId]);
}
static async getUserApiKeys(userId) {
// Do NOT return hashed_api_key_secret to the user, only metadata
const query =
"SELECT id, uuid, user_id, key_name, api_key_identifier, created_at, last_used_at, expires_at FROM api_keys WHERE user_id = $1 ORDER BY created_at DESC";
const { rows } = await pool.query(query, [userId]);
return rows;
}
static async revokeApiKey(apiKeyId, userId) {
// Ensure the user owns this API key before revoking
const query = "DELETE FROM api_keys WHERE id = $1 AND user_id = $2";
const result = await pool.query(query, [apiKeyId, userId]);
return result.rowCount > 0;
}
// Placeholder for user stats - adjust query as needed for form/submission counts
static async getUserStats(userId) {
// This is a simplified example. You'd need to join with forms and submissions tables.
const query = `
SELECT
(SELECT COUNT(*) FROM forms WHERE user_id = $1) as form_count,
(SELECT COUNT(*) FROM submissions WHERE user_id = $1) as submission_count
-- Add more stats as needed
`;
// This query assumes user_id is directly on submissions. Adjust if form_uuid is the link.
const { rows } = await pool.query(query, [userId]);
return rows[0] || { form_count: 0, submission_count: 0 };
}
}
module.exports = User;

405
src/routes/admin.js Normal file
View File

@ -0,0 +1,405 @@
import express from "express";
import { v4 as uuidv4 } from "uuid";
import dbPromise from "../config/database.js";
import { sendNtfyNotification } from "../services/notification.js";
const router = express.Router();
router.get("/", async (req, res) => {
try {
const db = await dbPromise;
const forms = await db.all(`
SELECT f.uuid, f.name, f.created_at, f.is_archived,
(SELECT COUNT(*) FROM submissions WHERE form_uuid = f.uuid) as submission_count
FROM forms f ORDER BY created_at DESC
`);
res.render("index", {
forms,
appUrl: `${req.protocol}://${req.get("host")}`,
});
} catch (error) {
console.error("Error fetching forms:", error);
res.status(500).send("Error fetching forms");
}
});
router.post("/create-form", async (req, res) => {
const formName = req.body.formName || "Untitled Form";
const newUuid = uuidv4();
try {
const db = await dbPromise;
await db.run("INSERT INTO forms (uuid, name) VALUES (?, ?)", [newUuid, formName]);
console.log(`Form created: ${formName} with UUID: ${newUuid}`);
await sendNtfyNotification(
"New Form Created",
`Form "${formName}" (UUID: ${newUuid}) was created.`,
"high"
);
res.redirect("/admin");
} catch (error) {
console.error("Error creating form:", error);
res.status(500).send("Error creating form");
}
});
router.get("/submissions/:formUuid", async (req, res) => {
const { formUuid } = req.params;
const page = parseInt(req.query.page) || 1;
const limit = parseInt(req.query.limit) || 10;
const offset = (page - 1) * limit;
try {
const db = await dbPromise;
const formDetails = await db.get("SELECT name FROM forms WHERE uuid = ?", [formUuid]);
if (!formDetails) {
return res.status(404).send("Form not found.");
}
// Get total count for pagination
const countResult = await db.get(
"SELECT COUNT(*) as total FROM submissions WHERE form_uuid = ?",
[formUuid]
);
const totalSubmissions = countResult.total;
const totalPages = Math.ceil(totalSubmissions / limit);
const submissions = await db.all(
"SELECT id, data, ip_address, submitted_at FROM submissions WHERE form_uuid = ? ORDER BY submitted_at DESC LIMIT ? OFFSET ?",
[formUuid, limit, offset]
);
res.render("submissions", {
submissions,
formUuid,
formName: formDetails.name,
appUrl: `${req.protocol}://${req.get("host")}`,
pagination: {
currentPage: page,
totalPages,
totalSubmissions,
limit,
},
});
} catch (error) {
console.error("Error fetching submissions:", error);
res.status(500).send("Error fetching submissions");
}
});
router.get("/submissions/:formUuid/export", async (req, res) => {
const { formUuid } = req.params;
try {
const db = await dbPromise;
const formDetails = await db.get("SELECT name FROM forms WHERE uuid = ?", [formUuid]);
if (!formDetails) {
return res.status(404).send("Form not found.");
}
const submissions = await db.all(
"SELECT data, ip_address, submitted_at FROM submissions WHERE form_uuid = ? ORDER BY submitted_at DESC",
[formUuid]
);
// Create CSV content
const headers = ["Submitted At", "IP Address"];
const rows = submissions.map((submission) => {
const data = JSON.parse(submission.data);
// Add all form fields as headers
Object.keys(data).forEach((key) => {
if (!headers.includes(key)) {
headers.push(key);
}
});
return {
submitted_at: new Date(submission.submitted_at).toISOString(),
ip_address: submission.ip_address,
...data,
};
});
// Generate CSV content
let csvContent = headers.join(",") + "\n";
rows.forEach((row) => {
const values = headers.map((header) => {
const value = row[header] || "";
// Escape commas and quotes in values
return `"${String(value).replace(/"/g, '""')}"`;
});
csvContent += values.join(",") + "\n";
});
// Set response headers for CSV download
res.setHeader("Content-Type", "text/csv");
res.setHeader(
"Content-Disposition",
`attachment; filename="${formDetails.name}-submissions.csv"`
);
res.send(csvContent);
} catch (error) {
console.error("Error exporting submissions:", error);
res.status(500).send("Error exporting submissions");
}
});
router.post("/delete-form/:formUuid", async (req, res) => {
const { formUuid } = req.params;
try {
const db = await dbPromise;
const formResult = await db.all("SELECT name FROM forms WHERE uuid = ?", [formUuid]);
const formName =
formResult.length > 0 ? formResult[0].name : `Form ${formUuid}`;
await db.run("DELETE FROM forms WHERE uuid = ?", [formUuid]);
console.log(`Form ${formUuid} deleted.`);
await sendNtfyNotification(
"Form Deleted",
`Form "${formName}" (UUID: ${formUuid}) was deleted.`,
"default"
);
res.redirect("/admin");
} catch (error) {
console.error("Error deleting form:", error);
res.status(500).send("Error deleting form.");
}
});
router.post("/delete-submission/:submissionId", async (req, res) => {
const { submissionId } = req.params;
let formUuidForRedirect = "unknown-form";
try {
const db = await dbPromise;
const submissionResult = await db.all("SELECT form_uuid FROM submissions WHERE id = ?", [submissionId]);
if (submissionResult.length > 0) {
formUuidForRedirect = submissionResult[0].form_uuid;
}
await db.run("DELETE FROM submissions WHERE id = ?", [submissionId]);
console.log(`Submission ${submissionId} deleted.`);
await sendNtfyNotification(
"Submission Deleted",
`Submission ID ${submissionId} (for form ${formUuidForRedirect}) was deleted.`,
"low"
);
if (formUuidForRedirect !== "unknown-form") {
res.redirect(`/admin/submissions/${formUuidForRedirect}`);
} else {
res.redirect("/admin");
}
} catch (error) {
console.error("Error deleting submission:", error);
res.status(500).send("Error deleting submission.");
if (formUuidForRedirect !== "unknown-form") {
res.redirect(`/admin/submissions/${formUuidForRedirect}`);
} else {
res.redirect("/admin");
}
}
});
router.post("/update-form-name/:formUuid", async (req, res) => {
const { formUuid } = req.params;
const { newName } = req.body;
if (!newName || newName.trim() === "") {
return res.status(400).send("New form name is required.");
}
try {
const db = await dbPromise;
const formResult = await db.all("SELECT name FROM forms WHERE uuid = ?", [formUuid]);
if (formResult.length === 0) {
return res.status(404).send("Form not found.");
}
const oldName = formResult[0].name;
await db.run("UPDATE forms SET name = ? WHERE uuid = ?", [
newName,
formUuid,
]);
console.log(
`Form name updated from '${oldName}' to '${newName}' for UUID: ${formUuid}`
);
await sendNtfyNotification(
"Form Name Updated",
`Form name changed from '${oldName}' to '${newName}' for UUID: ${formUuid}.`,
"default"
);
res.redirect("/admin");
} catch (error) {
console.error("Error updating form name:", error);
res.status(500).send("Error updating form name.");
}
});
router.post("/update-thank-you-url/:formUuid", async (req, res) => {
const { formUuid } = req.params;
const { thankYouUrl } = req.body;
try {
const db = await dbPromise;
await db.run("UPDATE forms SET thank_you_url = ? WHERE uuid = ?", [thankYouUrl, formUuid]);
console.log(`Thank You URL updated for form UUID: ${formUuid}`);
res.redirect("/admin");
} catch (error) {
console.error("Error updating Thank You URL:", error);
res.status(500).send("Error updating Thank You URL.");
}
});
router.post("/update-ntfy-enabled/:formUuid", async (req, res) => {
const { formUuid } = req.params;
const ntfyEnabled = req.body.ntfyEnabled === "true";
try {
const db = await dbPromise;
await db.run("UPDATE forms SET ntfy_enabled = ? WHERE uuid = ?", [
ntfyEnabled,
formUuid,
]);
console.log(`Ntfy notifications updated for form UUID: ${formUuid}`);
res.redirect("/admin");
} catch (error) {
console.error("Error updating Ntfy notifications setting:", error);
res.status(500).send("Error updating Ntfy notifications setting.");
}
});
router.post("/clear-submissions/:formUuid", async (req, res) => {
const { formUuid } = req.params;
try {
// First get form name for notification before clearing
const db = await dbPromise;
const formResult = await db.all("SELECT name FROM forms WHERE uuid = ?", [formUuid]);
const formName =
formResult.length > 0 ? formResult[0].name : `Form ${formUuid}`;
// Delete all submissions for this form
await db.run("DELETE FROM submissions WHERE form_uuid = ?", [formUuid]);
console.log(`All submissions cleared for form ${formUuid}`);
await sendNtfyNotification(
"Submissions Cleared",
`All submissions for form "${formName}" (UUID: ${formUuid}) were cleared.`,
"default"
);
res.redirect(`/admin/submissions/${formUuid}`);
} catch (error) {
console.error("Error clearing submissions:", error);
res.status(500).send("Error clearing submissions.");
}
});
router.post("/update-thank-you-message/:formUuid", async (req, res) => {
const { formUuid } = req.params;
const { thankYouMessage } = req.body;
try {
const db = await dbPromise;
await db.run("UPDATE forms SET thank_you_message = ? WHERE uuid = ?", [thankYouMessage, formUuid]);
console.log(`Thank you message updated for form ${formUuid}`);
res.redirect("/admin");
} catch (error) {
console.error("Error updating thank you message:", error);
res.status(500).send("Error updating thank you message.");
}
});
router.post("/archive-form/:formUuid", async (req, res) => {
const { formUuid } = req.params;
const { archive } = req.body;
try {
const db = await dbPromise;
const formResult = await db.all("SELECT name FROM forms WHERE uuid = ?", [formUuid]);
if (formResult.length === 0) {
return res.status(404).send("Form not found.");
}
await db.run("UPDATE forms SET is_archived = ? WHERE uuid = ?", [
archive === "true",
formUuid,
]);
const action = archive === "true" ? "archived" : "unarchived";
await sendNtfyNotification(
`Form ${action}`,
`Form "${formResult[0].name}" (UUID: ${formUuid}) has been ${action}.`,
"default"
);
res.redirect("/admin");
} catch (error) {
console.error("Error updating form archive status:", error);
res.status(500).send("Error updating form archive status");
}
});
router.post("/update-allowed-domains/:formUuid", async (req, res) => {
const { formUuid } = req.params;
const { allowedDomains } = req.body;
try {
const db = await dbPromise;
const formResult = await db.all("SELECT name FROM forms WHERE uuid = ?", [formUuid]);
if (formResult.length === 0) {
return res.status(404).send("Form not found.");
}
await db.run("UPDATE forms SET allowed_domains = ? WHERE uuid = ?", [
allowedDomains,
formUuid,
]);
await sendNtfyNotification(
"Form Allowed Domains Updated",
`Form "${formResult[0].name}" (UUID: ${formUuid}) allowed domains have been updated.`,
"default"
);
res.redirect("/admin");
} catch (error) {
console.error("Error updating allowed domains:", error);
res.status(500).send("Error updating allowed domains");
}
});
router.post("/test-notification/:formUuid", async (req, res) => {
const { formUuid } = req.params;
try {
const db = await dbPromise;
const formResult = await db.all("SELECT name, ntfy_enabled FROM forms WHERE uuid = ?", [formUuid]);
if (formResult.length === 0) {
return res.status(404).send("Form not found.");
}
if (!formResult[0].ntfy_enabled) {
return res
.status(400)
.send("Ntfy notifications are not enabled for this form.");
}
await sendNtfyNotification(
"Test Notification",
`This is a test notification for form "${formResult[0].name}" (UUID: ${formUuid}).`,
"default",
"test"
);
res.json({
success: true,
message: "Test notification sent successfully.",
});
} catch (error) {
console.error("Error sending test notification:", error);
res.status(500).send("Error sending test notification");
}
});
export default router;

View File

@ -1,98 +0,0 @@
const express = require("express");
const pool = require("../config/database");
const apiAuthMiddleware = require("../middleware/apiAuthMiddleware");
const logger = require("../../config/logger");
const router = express.Router();
// All routes in this file will be protected by API key authentication
router.use(apiAuthMiddleware);
// GET /api/v1/forms - List forms for the authenticated user
router.get("/forms", async (req, res) => {
try {
const { rows: forms } = await pool.query(
`SELECT uuid, name, created_at, is_archived,
(SELECT COUNT(*) FROM submissions WHERE form_uuid = f.uuid) as submission_count
FROM forms f
WHERE f.user_id = $1
ORDER BY f.created_at DESC`,
[req.user.id] // req.user.id is attached by apiAuthMiddleware
);
res.json({ success: true, forms });
} catch (error) {
logger.error("API Error fetching forms for user:", {
userId: req.user.id,
error,
});
res.status(500).json({ success: false, error: "Failed to fetch forms." });
}
});
// GET /api/v1/forms/:formUuid/submissions - List submissions for a specific form
router.get("/forms/:formUuid/submissions", async (req, res) => {
const { formUuid } = req.params;
const page = parseInt(req.query.page) || 1;
const limit = parseInt(req.query.limit) || 25; // Default 25 submissions per page for API
const offset = (page - 1) * limit;
try {
const { rows: formDetailsRows } = await pool.query(
"SELECT user_id, name FROM forms WHERE uuid = $1",
[formUuid]
);
if (formDetailsRows.length === 0) {
return res.status(404).json({ success: false, error: "Form not found." });
}
const formDetails = formDetailsRows[0];
if (formDetails.user_id !== req.user.id) {
logger.warn(
`API Access Denied: User ${req.user.id} attempted to access form ${formUuid} owned by ${formDetails.user_id}`
);
return res.status(403).json({
success: false,
error: "Access denied. You do not own this form.",
});
}
const { rows: countResultRows } = await pool.query(
"SELECT COUNT(*) as total FROM submissions WHERE form_uuid = $1",
[formUuid]
);
const totalSubmissions = parseInt(countResultRows[0].total, 10);
const totalPages = Math.ceil(totalSubmissions / limit);
const { rows: submissions } = await pool.query(
"SELECT id, data, ip_address, submitted_at FROM submissions WHERE form_uuid = $1 ORDER BY submitted_at DESC LIMIT $2 OFFSET $3",
[formUuid, limit, offset]
);
res.json({
success: true,
formName: formDetails.name,
formUuid,
pagination: {
currentPage: page,
totalPages: totalPages,
totalSubmissions: totalSubmissions,
limit: limit,
perPage: limit, // Alias for limit
count: submissions.length,
},
submissions,
});
} catch (error) {
logger.error("API Error fetching submissions for form:", {
formUuid,
userId: req.user.id,
error,
});
res
.status(500)
.json({ success: false, error: "Failed to fetch submissions." });
}
});
module.exports = router;

View File

@ -1,765 +0,0 @@
const express = require("express");
const passport = require("../config/passport");
const User = require("../models/User");
const jwtService = require("../services/jwtService");
const emailService = require("../services/emailService");
const { body } = require("express-validator");
const {
validateRegistration,
validateLogin,
validateForgotPassword,
validateResetPassword,
validateProfileUpdate,
handleValidationErrors,
} = require("../middleware/validation");
const {
authRateLimit,
passwordResetRateLimit,
registrationRateLimit,
requireAuth,
requireVerifiedAuth,
} = require("../middleware/authMiddleware");
const router = express.Router();
// Register new user
router.post(
"/register",
registrationRateLimit,
validateRegistration,
async (req, res) => {
try {
const { email, password, first_name, last_name } = req.body;
// Check if user already exists
const existingUser = await User.findByEmail(email);
if (existingUser) {
return res.status(409).json({
success: false,
message: "An account with this email address already exists",
});
}
// Create new user
const newUser = await User.create({
email,
password,
first_name,
last_name,
role: "user",
is_verified: false,
});
// Send verification email
if (emailService.isAvailable()) {
await emailService.sendVerificationEmail(
newUser.email,
newUser.first_name,
newUser.verification_token
);
}
res.status(201).json({
success: true,
message:
"Account created successfully. Please check your email to verify your account.",
data: {
user: {
id: newUser.id,
uuid: newUser.uuid,
email: newUser.email,
first_name: newUser.first_name,
last_name: newUser.last_name,
is_verified: newUser.is_verified,
},
},
});
} catch (error) {
console.error("Registration error:", error);
res.status(500).json({
success: false,
message: error.message || "Registration failed",
});
}
}
);
// Login user
router.post("/login", authRateLimit, validateLogin, (req, res, next) => {
passport.authenticate(
"local",
{ session: false },
async (err, user, info) => {
try {
if (err) {
return res.status(500).json({
success: false,
message: "Authentication error",
error: err.message,
});
}
if (!user) {
return res.status(401).json({
success: false,
message: info.message || "Invalid credentials",
});
}
// Check if password change is required
if (user.must_change_password) {
// Generate a temporary token that's only valid for password change
// This step depends on how you want to handle the forced change flow.
// For now, we'll just send a specific response.
// A more robust solution might involve a temporary, restricted token.
return res.status(403).json({
// 403 Forbidden, but with a specific reason
success: false,
message: "Password change required.",
code: "MUST_CHANGE_PASSWORD",
data: {
user: {
// Send minimal user info
id: user.id,
uuid: user.uuid,
email: user.email,
role: user.role,
},
},
});
}
// Generate JWT tokens
const sessionInfo = {
userAgent: req.get("User-Agent"),
ipAddress: req.ip,
};
const tokens = jwtService.generateTokenPair(user, sessionInfo);
res.json({
success: true,
message: "Login successful",
data: {
user: {
id: user.id,
uuid: user.uuid,
email: user.email,
first_name: user.first_name,
last_name: user.last_name,
role: user.role,
is_verified: user.is_verified,
last_login: user.last_login,
},
...tokens,
},
});
} catch (error) {
console.error("Login error:", error);
res.status(500).json({
success: false,
message: "Login failed",
});
}
}
)(req, res, next);
});
// Refresh access token
router.post("/refresh", async (req, res) => {
try {
const { refreshToken } = req.body;
if (!refreshToken) {
return res.status(400).json({
success: false,
message: "Refresh token is required",
});
}
const sessionInfo = {
userAgent: req.get("User-Agent"),
ipAddress: req.ip,
};
const result = await jwtService.refreshAccessToken(
refreshToken,
sessionInfo
);
res.json({
success: true,
message: "Token refreshed successfully",
data: result,
});
} catch (error) {
console.error("Token refresh error:", error);
res.status(401).json({
success: false,
message: error.message || "Token refresh failed",
});
}
});
// Logout user
router.post("/logout", requireAuth, async (req, res) => {
try {
const authHeader = req.headers.authorization;
const token = jwtService.extractTokenFromHeader(authHeader);
if (token) {
await jwtService.revokeToken(token);
}
res.json({
success: true,
message: "Logged out successfully",
});
} catch (error) {
console.error("Logout error:", error);
res.status(500).json({
success: false,
message: "Logout failed",
});
}
});
// Logout from all devices
router.post("/logout-all", requireAuth, async (req, res) => {
try {
const revokedCount = await jwtService.revokeAllUserTokens(req.user.id);
res.json({
success: true,
message: `Logged out from ${revokedCount} devices successfully`,
});
} catch (error) {
console.error("Logout all error:", error);
res.status(500).json({
success: false,
message: "Logout from all devices failed",
});
}
});
// Verify email
router.get("/verify-email", async (req, res) => {
try {
const { token } = req.query;
if (!token) {
return res.status(400).json({
success: false,
message: "Verification token is required",
});
}
const user = await User.findByVerificationToken(token);
if (!user) {
return res.status(400).json({
success: false,
message: "Invalid or expired verification token",
});
}
if (user.is_verified) {
return res.status(400).json({
success: false,
message: "Email is already verified",
});
}
const verified = await User.verifyEmail(token);
if (!verified) {
return res.status(400).json({
success: false,
message: "Email verification failed",
});
}
// Send welcome email
if (emailService.isAvailable()) {
await emailService.sendWelcomeEmail(user.email, user.first_name);
}
res.json({
success: true,
message: "Email verified successfully! You can now access all features.",
});
} catch (error) {
console.error("Email verification error:", error);
res.status(500).json({
success: false,
message: "Email verification failed",
});
}
});
// Resend verification email
router.post("/resend-verification", authRateLimit, async (req, res) => {
try {
const { email } = req.body;
if (!email) {
return res.status(400).json({
success: false,
message: "Email is required",
});
}
const user = await User.findByEmail(email);
if (!user) {
// Don't reveal if email exists or not
return res.json({
success: true,
message:
"If an account with this email exists and is not verified, a verification email has been sent.",
});
}
if (user.is_verified) {
return res.status(400).json({
success: false,
message: "Email is already verified",
});
}
// Send verification email
if (emailService.isAvailable() && user.verification_token) {
await emailService.sendVerificationEmail(
user.email,
user.first_name,
user.verification_token
);
}
res.json({
success: true,
message:
"If an account with this email exists and is not verified, a verification email has been sent.",
});
} catch (error) {
console.error("Resend verification error:", error);
res.status(500).json({
success: false,
message: "Failed to resend verification email",
});
}
});
// Forgot password - Request password reset
router.post(
"/forgot-password",
passwordResetRateLimit,
validateForgotPassword,
async (req, res) => {
try {
const { email } = req.body;
// Don't reveal if email exists or not for security
const user = await User.findByEmail(email);
if (user) {
// Generate password reset token
const resetData = await User.setPasswordResetToken(email);
if (resetData && emailService.isAvailable()) {
await emailService.sendPasswordResetEmail(
user.email,
user.first_name,
resetData.token
);
}
}
// Always return success to prevent email enumeration
res.json({
success: true,
message:
"If an account with this email exists, a password reset email has been sent.",
});
} catch (error) {
console.error("Forgot password error:", error);
res.status(500).json({
success: false,
message: "Failed to process password reset request",
});
}
}
);
// Reset password - Change password using reset token
router.post(
"/reset-password",
passwordResetRateLimit,
validateResetPassword,
async (req, res) => {
try {
const { token, password } = req.body;
// Find user by reset token
const user = await User.findByPasswordResetToken(token);
if (!user) {
return res.status(400).json({
success: false,
message: "Invalid or expired reset token",
});
}
// Update password
const updated = await User.updatePassword(user.id, password);
if (!updated) {
return res.status(500).json({
success: false,
message: "Failed to update password",
});
}
// Send password changed notification
if (emailService.isAvailable()) {
await emailService.sendPasswordChangedEmail(
user.email,
user.first_name
);
}
// Revoke all existing sessions for security
await jwtService.revokeAllUserTokens(user.id);
res.json({
success: true,
message:
"Password has been reset successfully. Please log in with your new password.",
});
} catch (error) {
console.error("Reset password error:", error);
res.status(500).json({
success: false,
message: "Failed to reset password",
});
}
}
);
// Get current user profile
router.get("/profile", requireAuth, async (req, res) => {
try {
const stats = await User.getUserStats(req.user.id);
res.json({
success: true,
data: {
user: {
...req.user,
stats,
},
},
});
} catch (error) {
console.error("Profile fetch error:", error);
res.status(500).json({
success: false,
message: "Failed to fetch profile",
});
}
});
// Update user profile
router.put("/profile", requireAuth, validateProfileUpdate, async (req, res) => {
try {
const { first_name, last_name, email } = req.body;
const updates = {};
if (first_name !== undefined) updates.first_name = first_name;
if (last_name !== undefined) updates.last_name = last_name;
if (email !== undefined && email !== req.user.email) {
updates.email = email;
// If email is being changed, user needs to verify the new email
// For now, we'll just update it directly
}
if (Object.keys(updates).length === 0) {
return res.status(400).json({
success: false,
message: "No valid fields to update",
});
}
const updated = await User.updateProfile(req.user.id, updates);
if (!updated) {
return res.status(400).json({
success: false,
message: "Profile update failed",
});
}
// Get updated user data
const updatedUser = await User.findById(req.user.id);
res.json({
success: true,
message: "Profile updated successfully",
data: {
user: {
id: updatedUser.id,
uuid: updatedUser.uuid,
email: updatedUser.email,
first_name: updatedUser.first_name,
last_name: updatedUser.last_name,
role: updatedUser.role,
is_verified: updatedUser.is_verified,
is_active: updatedUser.is_active,
},
},
});
} catch (error) {
console.error("Profile update error:", error);
res.status(500).json({
success: false,
message: error.message || "Profile update failed",
});
}
});
// Get user's active sessions
router.get("/sessions", requireAuth, async (req, res) => {
try {
const sessions = await User.getUserActiveSessions(req.user.id);
res.json({
success: true,
data: {
sessions,
},
});
} catch (error) {
console.error("Get sessions error:", error);
res.status(500).json({
success: false,
message: "Failed to fetch sessions",
});
}
});
// Revoke a specific session
router.delete("/sessions/:jti", requireAuth, async (req, res) => {
try {
const { jti } = req.params;
// Verify the session belongs to the user
const session = await User.getSessionByJti(jti);
if (!session || session.user_id !== req.user.id) {
return res.status(404).json({
success: false,
message: "Session not found",
});
}
const revoked = await User.revokeSession(jti);
if (!revoked) {
return res.status(500).json({
success: false,
message: "Failed to revoke session",
});
}
res.json({
success: true,
message: "Session revoked successfully",
});
} catch (error) {
console.error("Revoke session error:", error);
res.status(500).json({
success: false,
message: "Failed to revoke session",
});
}
});
// Get current session information
router.get("/current-session", requireAuth, async (req, res) => {
try {
const authHeader = req.headers.authorization;
const token = jwtService.extractTokenFromHeader(authHeader);
if (!token) {
return res.status(401).json({
success: false,
message: "No token provided",
});
}
const session = await jwtService.getCurrentSession(token);
res.json({
success: true,
data: {
session,
},
});
} catch (error) {
console.error("Get current session error:", error);
res.status(500).json({
success: false,
message: "Failed to get current session information",
});
}
});
// Change password for logged-in users
router.put(
"/change-password",
requireAuth,
[
body("currentPassword")
.notEmpty()
.withMessage("Current password is required"),
body("newPassword")
.isLength({ min: 8 })
.withMessage("New password must be at least 8 characters long")
.matches(
/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]/
)
.withMessage(
"New password must contain at least one lowercase letter, one uppercase letter, one number, and one special character"
),
body("confirmNewPassword").custom((value, { req }) => {
if (value !== req.body.newPassword) {
throw new Error("Password confirmation does not match new password");
}
return true;
}),
handleValidationErrors,
],
async (req, res) => {
try {
const { currentPassword, newPassword } = req.body;
const bcrypt = require("bcryptjs");
// Get user with password hash
const user = await User.findById(req.user.id);
if (!user) {
return res.status(404).json({
success: false,
message: "User not found",
});
}
// Verify current password
const isCurrentPasswordValid = await bcrypt.compare(
currentPassword,
user.password_hash
);
if (!isCurrentPasswordValid) {
return res.status(400).json({
success: false,
message: "Current password is incorrect",
});
}
// Update password
const updated = await User.updatePassword(user.id, newPassword);
if (!updated) {
return res.status(500).json({
success: false,
message: "Failed to update password",
});
}
// Send password changed notification
if (emailService.isAvailable()) {
await emailService.sendPasswordChangedEmail(
user.email,
user.first_name
);
}
// Revoke all other sessions (keep current session)
const authHeader = req.headers.authorization;
const currentToken = jwtService.extractTokenFromHeader(authHeader);
const decoded = jwtService.verifyToken(currentToken);
// Revoke all sessions except current one
await jwtService.revokeAllUserTokensExcept(user.id, decoded.jti);
res.json({
success: true,
message: "Password changed successfully",
});
} catch (error) {
console.error("Change password error:", error);
res.status(500).json({
success: false,
message: "Failed to change password",
});
}
}
);
// Force password change if must_change_password is true
router.post(
"/force-change-password",
requireAuth, // Ensures user is logged in (even if with must_change_password = true)
[
body("newPassword")
.isLength({ min: 8 })
.withMessage("Password must be at least 8 characters long"),
],
handleValidationErrors,
async (req, res) => {
try {
const { newPassword } = req.body;
const userId = req.user.id;
// Double check if user still needs to change password
// (req.user might be from a valid token but DB state could have changed)
const currentUser = await User.findById(userId);
if (!currentUser || !currentUser.must_change_password) {
return res.status(400).json({
success: false,
message: "Password change not required or user not found.",
});
}
// Update password and clear the flag
const updated = await User.updatePasswordAndClearChangeFlag(
userId,
newPassword
);
if (!updated) {
return res.status(500).json({
success: false,
message: "Failed to update password.",
});
}
// Log out all other sessions for this user for security
const authHeader = req.headers.authorization;
const currentToken = jwtService.extractTokenFromHeader(authHeader);
const decoded = jwtService.verifyToken(currentToken); // Make sure verifyToken doesn't throw on expired/invalid for this flow if needed or handle it
if (decoded && decoded.jti) {
// Ensure there is a jti in the current token
await jwtService.revokeAllUserTokensExcept(userId, decoded.jti);
} else {
// Fallback if current token has no jti, revoke all including current. User will need to log in again.
await jwtService.revokeAllUserTokens(userId);
}
res.json({
success: true,
message:
"Password changed successfully. Please log in again with your new password.",
});
} catch (error) {
console.error("Force change password error:", error);
res.status(500).json({
success: false,
message: "Failed to change password",
});
}
}
);
module.exports = router;

View File

@ -1,671 +0,0 @@
const express = require("express");
const pool = require("../config/database"); // pg Pool
const { requireAuth } = require("../middleware/authMiddleware");
const { v4: uuidv4 } = require("uuid"); // Retained for now, though new form UUIDs could be DB generated
const { sendNtfyNotification } = require("../services/notification");
const User = require("../models/User"); // For API Key management
const logger = require("../../config/logger"); // Corrected logger path
const router = express.Router();
// All dashboard routes require authentication
router.use(requireAuth);
// GET /dashboard - Main dashboard view (My Forms)
router.get("/", async (req, res) => {
try {
const { rows: forms } = await pool.query(
`SELECT f.uuid, f.name, f.created_at, f.is_archived,
(SELECT COUNT(*) FROM submissions WHERE form_uuid = f.uuid) as submission_count
FROM forms f
WHERE f.user_id = $1
ORDER BY f.created_at DESC`,
[req.user.id]
);
res.render("dashboard", {
user: req.user,
forms: forms,
appUrl: `${req.protocol}://${req.get("host")}`,
view: "my_forms",
pageTitle: "My Forms",
});
} catch (error) {
logger.error("Error fetching user forms:", error);
res.render("dashboard", {
user: req.user,
forms: [],
appUrl: `${req.protocol}://${req.get("host")}`,
view: "my_forms",
pageTitle: "My Forms",
error: "Could not load your forms at this time.",
});
}
});
// GET /dashboard/create-form - Display page to create a new form
router.get("/create-form", (req, res) => {
res.render("dashboard", {
user: req.user,
appUrl: `${req.protocol}://${req.get("host")}`,
view: "create_form",
pageTitle: "Create New Form",
});
});
// POST /dashboard/forms/create - Handle new form creation
router.post("/forms/create", async (req, res) => {
const formName = req.body.formName || "Untitled Form";
// const newUuid = uuidv4(); // UUID will be generated by DB if schema is set up for it
try {
// Assuming forms.uuid has DEFAULT gen_random_uuid()
const {
rows: [newForm],
} = await pool.query(
"INSERT INTO forms (name, user_id) VALUES ($1, $2) RETURNING uuid, name",
[formName, req.user.id]
);
logger.info(
`Form created: ${newForm.name} with UUID: ${newForm.uuid} for user: ${req.user.id}`
);
if (process.env.NTFY_ENABLED === "true" && process.env.NTFY_TOPIC_URL) {
try {
await sendNtfyNotification(
"New Form Created (User)",
`Form \"${newForm.name}\" (UUID: ${newForm.uuid}) was created by user ${req.user.email}.`,
"high"
);
} catch (ntfyError) {
logger.error(
"Failed to send ntfy notification for new form creation:",
ntfyError
);
}
}
res.redirect("/dashboard");
} catch (error) {
logger.error("Error creating form for user:", error);
res.render("dashboard", {
user: req.user,
appUrl: `${req.protocol}://${req.get("host")}`,
view: "create_form",
pageTitle: "Create New Form",
error: "Failed to create form. Please try again.",
formNameValue: formName,
});
}
});
// GET /dashboard/submissions/:formUuid - View submissions for a specific form
router.get("/submissions/:formUuid", async (req, res) => {
const { formUuid } = req.params;
const page = parseInt(req.query.page) || 1;
const limit = parseInt(req.query.limit) || 10;
const offset = (page - 1) * limit;
try {
const { rows: formDetailsRows } = await pool.query(
"SELECT name, user_id FROM forms WHERE uuid = $1",
[formUuid]
);
if (formDetailsRows.length === 0) {
return res.render("dashboard", {
user: req.user,
view: "my_forms",
pageTitle: "Form Not Found",
error: "The form you are looking for does not exist.",
appUrl: `${req.protocol}://${req.get("host")}`,
forms: [],
});
}
const formDetails = formDetailsRows[0];
if (formDetails.user_id !== req.user.id) {
return res.render("dashboard", {
user: req.user,
view: "my_forms",
pageTitle: "Access Denied",
error: "You do not have permission to view submissions for this form.",
appUrl: `${req.protocol}://${req.get("host")}`,
forms: [],
});
}
const formName = formDetails.name;
const { rows: countResultRows } = await pool.query(
"SELECT COUNT(*) as total FROM submissions WHERE form_uuid = $1",
[formUuid]
);
const totalSubmissions = parseInt(countResultRows[0].total, 10);
const totalPages = Math.ceil(totalSubmissions / limit);
const { rows: submissions } = await pool.query(
"SELECT id, data, ip_address, submitted_at FROM submissions WHERE form_uuid = $1 ORDER BY submitted_at DESC LIMIT $2 OFFSET $3",
[formUuid, limit, offset]
);
res.render("dashboard", {
user: req.user,
view: "form_submissions",
pageTitle: `Submissions for ${formName}`,
submissions: submissions,
formUuid: formUuid,
formName: formName,
appUrl: `${req.protocol}://${req.get("host")}`,
pagination: {
currentPage: page,
totalPages: totalPages,
totalSubmissions: totalSubmissions,
limit: limit,
},
});
} catch (error) {
logger.error(
"Error fetching submissions for form:",
formUuid,
"user:",
req.user.id,
error
);
res.render("dashboard", {
user: req.user,
view: "form_submissions",
pageTitle: "Error Loading Submissions",
error:
"Could not load submissions for this form. Please try again later.",
formUuid: formUuid,
formName: "Error",
submissions: [],
appUrl: `${req.protocol}://${req.get("host")}`,
pagination: {
currentPage: 1,
totalPages: 1,
totalSubmissions: 0,
limit: limit,
},
});
}
});
// GET /dashboard/submissions/:formUuid/export - Export submissions to CSV
router.get("/submissions/:formUuid/export", async (req, res) => {
const { formUuid } = req.params;
try {
const { rows: formDetailsRows } = await pool.query(
"SELECT name, user_id FROM forms WHERE uuid = $1",
[formUuid]
);
if (formDetailsRows.length === 0) {
return res.status(404).send("Form not found.");
}
const formDetails = formDetailsRows[0];
if (formDetails.user_id !== req.user.id) {
return res.status(403).send("Access denied. You do not own this form.");
}
const formName = formDetails.name;
const { rows: submissions } = await pool.query(
"SELECT data, ip_address, submitted_at FROM submissions WHERE form_uuid = $1 ORDER BY submitted_at DESC",
[formUuid]
);
// Create CSV content
let headers = ["Submitted At", "IP Address"]; // Initialize with default headers
const dataRows = submissions.map((submission) => {
// Ensure submission.data is parsed if it's a JSON string, or used directly if already an object
let data = {};
if (typeof submission.data === "string") {
try {
data = JSON.parse(submission.data);
} catch (e) {
logger.warn(
`Failed to parse submission data for form ${formUuid}, submission ID ${submission.id}: ${submission.data}`
);
// Potentially include raw data or an error placeholder
data = { error_parsing_data: submission.data };
}
} else if (
typeof submission.data === "object" &&
submission.data !== null
) {
data = submission.data;
} else {
logger.warn(
`Unexpected submission data format for form ${formUuid}, submission ID ${submission.id}:`,
submission.data
);
data = { unexpected_data_format: String(submission.data) };
}
// Dynamically add keys from parsed data to headers, ensuring no duplicates
Object.keys(data).forEach((key) => {
if (!headers.includes(key)) {
headers.push(key);
}
});
return {
"Submitted At": new Date(submission.submitted_at).toISOString(),
"IP Address": submission.ip_address,
...data,
};
});
let csvContent = headers.join(",") + "\n";
dataRows.forEach((row) => {
const values = headers.map((header) => {
const value =
row[header] === null || row[header] === undefined ? "" : row[header];
return `"${String(value).replace(/"/g, '""')}"`;
});
csvContent += values.join(",") + "\n";
});
res.header("Content-Type", "text/csv");
res.attachment(
`submissions-${formName.replace(/\s+/g, "_")}-${formUuid}.csv`
);
res.send(csvContent);
} catch (error) {
logger.error("Error exporting submissions:", error);
res.status(500).send("Error exporting submissions.");
}
});
// GET /dashboard/forms/:formUuid/settings - Display form settings page
router.get("/forms/:formUuid/settings", async (req, res) => {
const { formUuid } = req.params;
try {
const { rows: formRows } = await pool.query(
"SELECT * FROM forms WHERE uuid = $1 AND user_id = $2",
[formUuid, req.user.id]
);
if (formRows.length === 0) {
return res.status(404).render("dashboard", {
user: req.user,
view: "my_forms",
pageTitle: "Not Found",
error: "Form not found or you do not have permission to access it.",
appUrl: `${req.protocol}://${req.get("host")}`,
forms: [],
});
}
res.render("dashboard", {
user: req.user,
form: formRows[0],
appUrl: `${req.protocol}://${req.get("host")}`,
view: "form_settings",
pageTitle: `Settings for ${formRows[0].name}`,
});
} catch (error) {
logger.error("Error fetching form settings:", error);
res.status(500).render("dashboard", {
user: req.user,
view: "my_forms",
pageTitle: "Error",
error: "Error fetching form settings.",
appUrl: `${req.protocol}://${req.get("host")}`,
forms: [],
});
}
});
// POST /dashboard/forms/:formUuid/settings - Update form settings
router.post("/forms/:formUuid/settings", async (req, res) => {
const { formUuid } = req.params;
const {
formName,
thankYouUrl,
thankYouMessage,
ntfyEnabled,
allowedDomains,
emailNotificationsEnabled,
notificationEmailAddress,
recaptchaEnabled,
} = req.body;
try {
// Verify user owns the form first
const { rows: formCheckRows } = await pool.query(
"SELECT user_id FROM forms WHERE uuid = $1",
[formUuid]
);
if (
formCheckRows.length === 0 ||
formCheckRows[0].user_id !== req.user.id
) {
return res.status(403).send("Access denied or form not found.");
}
// Construct the update query dynamically to only update provided fields
const updates = [];
const values = [];
let paramIndex = 1;
if (formName !== undefined) {
updates.push(`name = $${paramIndex++}`);
values.push(formName);
}
if (thankYouUrl !== undefined) {
updates.push(`thank_you_url = $${paramIndex++}`);
values.push(thankYouUrl || null);
}
if (thankYouMessage !== undefined) {
updates.push(`thank_you_message = $${paramIndex++}`);
values.push(thankYouMessage || null);
}
if (ntfyEnabled !== undefined) {
updates.push(`ntfy_enabled = $${paramIndex++}`);
values.push(ntfyEnabled === "on" || ntfyEnabled === true);
}
if (allowedDomains !== undefined) {
updates.push(`allowed_domains = $${paramIndex++}`);
values.push(allowedDomains || null);
}
if (emailNotificationsEnabled !== undefined) {
updates.push(`email_notifications_enabled = $${paramIndex++}`);
values.push(
emailNotificationsEnabled === "on" || emailNotificationsEnabled === true
);
}
if (notificationEmailAddress !== undefined) {
updates.push(`notification_email_address = $${paramIndex++}`);
values.push(notificationEmailAddress || null);
}
if (recaptchaEnabled !== undefined) {
updates.push(`recaptcha_enabled = $${paramIndex++}`);
values.push(recaptchaEnabled === "on" || recaptchaEnabled === true);
}
if (updates.length === 0) {
return res.redirect(
`/dashboard/forms/${formUuid}/settings?success=No changes detected`
);
}
values.push(formUuid);
values.push(req.user.id);
const query = `UPDATE forms SET ${updates.join(", ")}, updated_at = NOW() WHERE uuid = $${paramIndex++} AND user_id = $${paramIndex++}`;
const { rowCount } = await pool.query(query, values);
if (rowCount > 0) {
res.redirect(
`/dashboard/forms/${formUuid}/settings?success=Form settings updated successfully.`
);
} else {
// This case should ideally not happen if the form ownership check passed
res.redirect(
`/dashboard/forms/${formUuid}/settings?error=Failed to update settings or no changes made.`
);
}
} catch (error) {
logger.error("Error updating form settings:", error);
res.redirect(
`/dashboard/forms/${formUuid}/settings?error=Error updating form settings.`
);
}
});
// POST /dashboard/forms/:formUuid/archive - Archive a form
router.post("/forms/:formUuid/archive", async (req, res) => {
const { formUuid } = req.params;
try {
const { rowCount } = await pool.query(
"UPDATE forms SET is_archived = TRUE, updated_at = NOW() WHERE uuid = $1 AND user_id = $2",
[formUuid, req.user.id]
);
if (rowCount > 0) {
res.redirect("/dashboard?archived=true");
} else {
res.status(404).send("Form not found or not owned by user.");
}
} catch (error) {
logger.error("Error archiving form:", error);
res.status(500).send("Error archiving form.");
}
});
// POST /dashboard/forms/:formUuid/unarchive - Unarchive a form
router.post("/forms/:formUuid/unarchive", async (req, res) => {
const { formUuid } = req.params;
try {
const { rowCount } = await pool.query(
"UPDATE forms SET is_archived = FALSE, updated_at = NOW() WHERE uuid = $1 AND user_id = $2",
[formUuid, req.user.id]
);
if (rowCount > 0) {
res.redirect("/dashboard?unarchived=true");
} else {
res.status(404).send("Form not found or not owned by user.");
}
} catch (error) {
logger.error("Error unarchiving form:", error);
res.status(500).send("Error unarchiving form.");
}
});
// POST /dashboard/forms/:formUuid/delete - Delete a form
router.post("/forms/:formUuid/delete", async (req, res) => {
const { formUuid } = req.params;
try {
// Add additional checks or soft delete if needed
const { rowCount } = await pool.query(
"DELETE FROM forms WHERE uuid = $1 AND user_id = $2",
[formUuid, req.user.id]
);
if (rowCount > 0) {
res.redirect("/dashboard?deleted=true");
} else {
res.status(404).send("Form not found or not owned by user.");
}
} catch (error) {
logger.error("Error deleting form:", error);
res.status(500).send("Error deleting form.");
}
});
// GET /dashboard/profile - Display user profile page
router.get("/profile", async (req, res) => {
try {
// Fetch the full user object for the profile page, could use User model
const userProfile = await User.findById(req.user.id);
if (!userProfile) {
logger.warn(
`User not found in DB for ID: ${req.user.id} during profile view`
);
return res.status(404).send("User profile not found.");
}
res.render("dashboard", {
user: userProfile, // Pass the full userProfile object
appUrl: `${req.protocol}://${req.get("host")}`,
view: "profile_settings",
pageTitle: "My Profile",
});
} catch (error) {
logger.error("Error fetching user profile:", error);
res.status(500).render("dashboard", {
user: req.user, // Fallback to req.user if profile fetch fails
view: "profile_settings",
pageTitle: "My Profile",
error: "Could not load your profile information.",
appUrl: `${req.protocol}://${req.get("host")}`,
});
}
});
// POST /dashboard/profile - Update user profile
router.post("/profile", async (req, res) => {
const { firstName, lastName, email } = req.body;
try {
const updatedUser = await User.updateProfile(req.user.id, {
first_name: firstName,
last_name: lastName,
email: email,
});
if (updatedUser) {
// Update the session user object if email changes, etc.
// This is important because req.user is populated from the session at login.
// If email is part of the identifier or used for display, it needs to be fresh.
req.login(updatedUser, (err) => {
// req.login is from Passport to update session user
if (err) {
logger.error("Error updating session after profile update:", err);
return res.redirect("/dashboard/profile?error=Session update failed");
}
return res.redirect(
"/dashboard/profile?success=Profile updated successfully"
);
});
} else {
res.redirect(
"/dashboard/profile?error=Failed to update profile or no changes made"
);
}
} catch (error) {
logger.error("Error updating profile:", error);
let errorMessage = "Error updating profile.";
if (error.message === "Email already exists") {
errorMessage =
"That email address is already in use. Please choose another.";
}
res.redirect(
`/dashboard/profile?error=${encodeURIComponent(errorMessage)}`
);
}
});
// POST /dashboard/profile/change-password - Change user password
router.post("/profile/change-password", async (req, res) => {
const { currentPassword, newPassword, confirmPassword } = req.body;
if (newPassword !== confirmPassword) {
return res.redirect(
"/dashboard/profile?passError=New passwords do not match."
);
}
if (!newPassword || newPassword.length < 8) {
// Basic validation
return res.redirect(
"/dashboard/profile?passError=New password must be at least 8 characters long."
);
}
try {
const user = await User.findById(req.user.id);
if (!user) {
return res.status(400).send("User not found.");
}
const isMatch = await bcrypt.compare(currentPassword, user.password_hash);
if (!isMatch) {
return res.redirect(
"/dashboard/profile?passError=Incorrect current password."
);
}
const success = await User.updatePassword(req.user.id, newPassword);
if (success) {
// Optionally, log out other sessions for security
// await User.revokeAllUserSessionsExcept(req.user.id, req.session.jwtJti); // Assuming jwtJti is stored in session
res.redirect(
"/dashboard/profile?passSuccess=Password changed successfully."
);
} else {
res.redirect("/dashboard/profile?passError=Failed to change password.");
}
} catch (error) {
logger.error("Error changing password:", error);
res.redirect("/dashboard/profile?passError=Error changing password.");
}
});
// API Keys Section
// GET /dashboard/api-keys - Display API keys page
router.get("/api-keys", async (req, res) => {
try {
const apiKeys = await User.getUserApiKeys(req.user.id);
res.render("dashboard", {
user: req.user,
appUrl: `${req.protocol}://${req.get("host")}`,
view: "api_keys",
pageTitle: "API Keys",
apiKeys: apiKeys,
newApiKey: req.query.newApiKey, // For showing the new key once
newApiKeyName: req.query.newApiKeyName,
});
} catch (error) {
logger.error("Error fetching API keys:", error);
res.render("dashboard", {
user: req.user,
appUrl: `${req.protocol}://${req.get("host")}`,
view: "api_keys",
pageTitle: "API Keys",
error: "Could not load your API keys.",
apiKeys: [],
});
}
});
// POST /dashboard/api-keys/create - Create a new API key
router.post("/api-keys/create", async (req, res) => {
const { keyName } = req.body;
if (!keyName || keyName.trim() === "") {
return res.redirect(
"/dashboard/api-keys?keyError=API Key name cannot be empty."
);
}
try {
// User.createApiKey should handle hashing and return the raw secret ONCE
const { apiKeySecret, ...newKeyDetails } = await User.createApiKey(
req.user.id,
keyName.trim()
);
// Pass the raw secret key to the view via query param for the user to copy ONCE.
// This is a common pattern but ensure it's clear this is the only time it's shown.
res.redirect(
`/dashboard/api-keys?success=API Key created successfully.&newApiKey=${encodeURIComponent(apiKeySecret)}&newApiKeyName=${encodeURIComponent(newKeyDetails.key_name)}`
);
} catch (error) {
logger.error("Error creating API key:", error);
res.redirect("/dashboard/api-keys?keyError=Error creating API key.");
}
});
// POST /dashboard/api-keys/:apiKeyId/delete - Delete an API key
router.post("/api-keys/:apiKeyId/delete", async (req, res) => {
const { apiKeyId } = req.params;
try {
const success = await User.revokeApiKey(apiKeyId, req.user.id);
if (success) {
res.redirect("/dashboard/api-keys?deleted=API Key deleted successfully.");
} else {
res.redirect(
"/dashboard/api-keys?keyError=Failed to delete API Key or key not found."
);
}
} catch (error) {
logger.error("Error deleting API key:", error);
res.redirect("/dashboard/api-keys?keyError=Error deleting API key.");
}
});
// GET /dashboard/settings - Main settings page (could link to profile, api keys, etc.)
router.get("/settings", (req, res) => {
res.render("dashboard", {
user: req.user,
appUrl: `${req.protocol}://${req.get("host")}`,
view: "general_settings", // A new EJS view or section for general settings
pageTitle: "Settings",
});
});
module.exports = router;

View File

@ -1,61 +1,25 @@
const express = require("express"); import express from "express";
const pool = require("../config/database"); // pg Pool import dbPromise from "../config/database.js";
const { sendNtfyNotification } = require("../services/notification"); import { sendNtfyNotification } from "../services/notification.js";
const { sendSubmissionNotification } = require("../services/emailService"); import rateLimiter from "../middleware/rateLimiter.js";
const { verifyRecaptchaV2 } = require("../utils/recaptchaHelper"); import domainChecker from "../middleware/domainChecker.js";
const {
createSubmissionRateLimiter,
createFormSpecificRateLimiter,
createStrictRateLimiter,
} = require("../middleware/redisRateLimiter");
const domainChecker = require("../middleware/domainChecker");
const logger = require("../../config/logger"); // Corrected logger path
const router = express.Router(); const router = express.Router();
// Initialize rate limiters router.get("/", (req, res) => res.redirect("/admin"));
const submissionRateLimit = createSubmissionRateLimiter();
const formSpecificRateLimit = createFormSpecificRateLimiter();
const strictRateLimit = createStrictRateLimiter();
router.get("/health", (req, res) => res.status(200).json({ status: "ok" })); router.get("/health", (req, res) => res.status(200).json({ status: "ok" }));
// Render login page
router.get("/login", (req, res) => {
res.render("login", {
error: req.query.error,
success: req.query.success,
email: req.query.email,
});
});
// Render registration page
router.get("/register", (req, res) => {
res.render("register", {
error: req.query.error,
success: req.query.success,
email: req.query.email,
first_name: req.query.first_name,
last_name: req.query.last_name,
});
});
router.post( router.post(
"/submit/:formUuid", "/submit/:formUuid",
strictRateLimit, rateLimiter,
submissionRateLimit,
formSpecificRateLimit,
domainChecker, domainChecker,
async (req, res) => { async (req, res) => {
const { formUuid } = req.params; const { formUuid } = req.params;
const submissionData = { ...req.body }; const submissionData = { ...req.body };
const ipAddress = req.ip; const ipAddress = req.ip;
const recaptchaToken = submissionData["g-recaptcha-response"];
delete submissionData["g-recaptcha-response"];
if (submissionData.honeypot_field && submissionData.honeypot_field !== "") { if (submissionData.honeypot_field && submissionData.honeypot_field !== "") {
logger.info( console.log(
`Honeypot triggered for ${formUuid} by IP ${ipAddress}. Ignoring submission.` `Honeypot triggered for ${formUuid} by IP ${ipAddress}. Ignoring submission.`
); );
if (submissionData._thankyou) { if (submissionData._thankyou) {
@ -65,164 +29,57 @@ router.post(
"<h1>Thank You!</h1><p>Your submission has been received.</p>" "<h1>Thank You!</h1><p>Your submission has been received.</p>"
); );
} }
delete submissionData.honeypot_field;
let formSettings; let formNameForNotification = `Form ${formUuid}`;
try { try {
const { rows: forms } = await pool.query( const db = await dbPromise;
"SELECT id, user_id, name, thank_you_url, thank_you_message, ntfy_enabled, is_archived, email_notifications_enabled, notification_email_address, recaptcha_enabled FROM forms WHERE uuid = $1", const form = await db.get(
"SELECT id, name, thank_you_url, thank_you_message, ntfy_enabled, is_archived FROM forms WHERE uuid = ?",
[formUuid] [formUuid]
); );
if (forms.length === 0) { if (!form) {
logger.warn(
`Submission attempt to non-existent form UUID: ${formUuid} from IP: ${ipAddress}`
);
return res.status(404).send("Form endpoint not found."); return res.status(404).send("Form endpoint not found.");
} }
formSettings = forms[0];
if (formSettings.is_archived) { if (form.is_archived) {
logger.warn(
`Submission attempt to archived form UUID: ${formUuid} from IP: ${ipAddress}`
);
return res return res
.status(410) .status(410)
.send( .send(
"This form has been archived and is no longer accepting submissions." "This form has been archived and is no longer accepting submissions."
); );
} }
} catch (dbError) {
logger.error("Error fetching form settings during submission:", {
formUuid,
error: dbError,
});
return res
.status(500)
.send("Error processing submission due to a configuration issue."); // More generic error to user
}
if (formSettings.recaptcha_enabled) { formNameForNotification = form.name || `Form ${formUuid}`;
if (!recaptchaToken) { const ntfyEnabled = form.ntfy_enabled;
logger.warn(
`reCAPTCHA enabled for form ${formUuid} but no token provided by IP ${ipAddress}.`
);
return res
.status(403)
.send(
"reCAPTCHA is required for this form. Please complete the challenge."
);
}
const isRecaptchaValid = await verifyRecaptchaV2( await db.run(
recaptchaToken, "INSERT INTO submissions (form_uuid, data, ip_address) VALUES (?, ?, ?)",
ipAddress [formUuid, JSON.stringify(submissionData), ipAddress]
);
if (!isRecaptchaValid) {
logger.warn(
`reCAPTCHA verification failed for form ${formUuid} from IP ${ipAddress}.`
);
return res
.status(403)
.send("reCAPTCHA verification failed. Please try again.");
}
}
let formNameForNotification = formSettings.name || `Form ${formUuid}`;
try {
const ntfyEnabled = formSettings.ntfy_enabled;
const formOwnerUserId = formSettings.user_id; // This should be NOT NULL based on forms schema
const formForEmail = {
name: formSettings.name,
email_notifications_enabled: formSettings.email_notifications_enabled,
notification_email_address: formSettings.notification_email_address,
};
let ownerEmail = null;
if (formOwnerUserId) {
// Should always be true if form exists
const { rows: users } = await pool.query(
"SELECT email FROM users WHERE id = $1",
[formOwnerUserId]
);
if (users.length > 0) {
ownerEmail = users[0].email;
} else {
logger.warn(
`Owner user with ID ${formOwnerUserId} not found for form ${formUuid}, though form record exists.`
);
}
}
// The user_id in submissions table is NOT NULL in PostgreSQL schema, ensure formOwnerUserId is valid.
if (!formOwnerUserId) {
logger.error(
`Critical: formOwnerUserId is null for form ${formUuid} during submission. This should not happen if form exists.`
);
// Potentially send an alert to admin here
return res
.status(500)
.send("Error processing submission due to inconsistent data.");
}
await pool.query(
"INSERT INTO submissions (form_uuid, user_id, data, ip_address) VALUES ($1, $2, $3, $4)",
[formUuid, formOwnerUserId, JSON.stringify(submissionData), ipAddress]
);
logger.info(
`Submission received for ${formUuid} (user: ${formOwnerUserId}): ${JSON.stringify(submissionData)}`
); );
console.log(`Submission received for ${formUuid}:`, submissionData);
const submissionSummary = Object.entries(submissionData) const submissionSummary = Object.entries(submissionData)
.filter(([key]) => key !== "_thankyou") // Ensure _thankyou is not in summary .filter(([key]) => key !== "_thankyou")
.map(([key, value]) => `${key}: ${value}`) .map(([key, value]) => `${key}: ${value}`)
.join(", "); .join(", ");
if (ntfyEnabled) { if (ntfyEnabled) {
sendNtfyNotification( await sendNtfyNotification(
`New Submission: ${formNameForNotification}`, `New Submission: ${formNameForNotification}`,
`Data: ${ `Data: ${submissionSummary || "No data fields"
submissionSummary || "No data fields"
}\nFrom IP: ${ipAddress}`, }\nFrom IP: ${ipAddress}`,
"high", "high",
"incoming_form" "incoming_form"
).catch((err) =>
logger.error("Failed to send NTFY notification:", err)
); // Log & continue
}
if (ownerEmail) {
sendSubmissionNotification(
formForEmail,
submissionData,
ownerEmail
).catch((err) =>
logger.error("Failed to send submission email:", {
formUuid,
recipient: ownerEmail,
error: err,
})
);
} else if (
formForEmail.email_notifications_enabled &&
!formForEmail.notification_email_address
) {
logger.warn(
`Email notification enabled for form ${formUuid} but owner email could not be determined and no custom address set.`
); );
} }
if (formSettings.thank_you_url) { if (form.thank_you_url) {
return res.redirect(formSettings.thank_you_url); return res.redirect(form.thank_you_url);
} }
if (formSettings.thank_you_message) { if (form.thank_you_message) {
const safeMessage = formSettings.thank_you_message return res.send(`<h1>Thank You!</h1><p>${form.thank_you_message}</p>`);
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
return res.send(safeMessage);
} }
if (submissionData._thankyou) { if (submissionData._thankyou) {
@ -230,30 +87,18 @@ router.post(
} }
res.send( res.send(
'<h1>Thank You!</h1><p>Your submission has been received.</p><p><a href="/">Back to form manager</a></p>' '<h1>Thank You!</h1><p>Your submission has been received.</p><p><a href="/">Back to formies</a></p>'
); );
} catch (error) { } catch (error) {
logger.error("Error processing submission (main block):", { console.error("Error processing submission:", error);
formUuid, await sendNtfyNotification(
error: error.message,
stack: error.stack,
});
// Avoid sending detailed error to client, but log it.
sendNtfyNotification(
`Submission Error: ${formNameForNotification}`, `Submission Error: ${formNameForNotification}`,
`Failed to process submission for ${formUuid} from IP ${ipAddress}. Error: ${error.message}`, `Failed to process submission for ${formUuid} from IP ${ipAddress}. Error: ${error.message}`,
"max" "max"
).catch((err) =>
logger.error("Failed to send error NTFY notification:", err)
);
res
.status(500)
.send(
"An error occurred while processing your submission. Please try again later."
); );
res.status(500).send("Error processing submission.");
} }
} }
); );
module.exports = router; export default router;

View File

@ -1,450 +0,0 @@
const nodemailer = require("nodemailer");
require("dotenv").config(); // Ensure environment variables are loaded
const { Resend } = require("resend");
const logger = require("../../config/logger"); // Adjust path as needed
const resendApiKey = process.env.RESEND_API_KEY;
const emailFromAddress = process.env.EMAIL_FROM_ADDRESS;
if (!resendApiKey) {
logger.warn(
"RESEND_API_KEY is not set. Email notifications will be disabled."
);
}
if (!emailFromAddress) {
logger.warn(
"EMAIL_FROM_ADDRESS is not set. Email notifications may not work correctly."
);
}
const resend = resendApiKey ? new Resend(resendApiKey) : null;
class EmailService {
constructor() {
this.transporter = null;
this.init();
}
async init() {
try {
// Create reusable transporter object using the default SMTP transport
this.transporter = nodemailer.createTransporter({
host: process.env.SMTP_HOST,
port: process.env.SMTP_PORT || 587,
secure: process.env.SMTP_SECURE === "true", // true for 465, false for other ports
auth: {
user: process.env.SMTP_USER,
pass: process.env.SMTP_PASS,
},
});
// Verify connection configuration
if (this.transporter && process.env.SMTP_HOST) {
await this.transporter.verify();
console.log("Email service initialized successfully");
} else {
console.warn(
"Email service not configured. Set SMTP environment variables."
);
}
} catch (error) {
console.error("Email service initialization failed:", error.message);
this.transporter = null;
}
}
// Check if email service is available
isAvailable() {
return this.transporter !== null;
}
// Send verification email
async sendVerificationEmail(to, firstName, verificationToken) {
if (!this.isAvailable()) {
console.warn("Email service not available. Verification email not sent.");
return false;
}
const verificationUrl = `${process.env.APP_URL}/auth/verify-email?token=${verificationToken}`;
const subject = "Verify Your Email Address - Formies";
const html = this.getVerificationEmailTemplate(firstName, verificationUrl);
try {
await this.transporter.sendMail({
from: `"Formies" <${
process.env.SMTP_FROM_EMAIL || process.env.SMTP_USER
}>`,
to,
subject,
html,
});
console.log(`Verification email sent to ${to}`);
return true;
} catch (error) {
console.error("Failed to send verification email:", error);
return false;
}
}
// Send password reset email
async sendPasswordResetEmail(to, firstName, resetToken) {
if (!this.isAvailable()) {
console.warn(
"Email service not available. Password reset email not sent."
);
return false;
}
const resetUrl = `${process.env.APP_URL}/auth/reset-password?token=${resetToken}`;
const subject = "Password Reset Request - Formies";
const html = this.getPasswordResetEmailTemplate(firstName, resetUrl);
try {
await this.transporter.sendMail({
from: `"Formies" <${
process.env.SMTP_FROM_EMAIL || process.env.SMTP_USER
}>`,
to,
subject,
html,
});
console.log(`Password reset email sent to ${to}`);
return true;
} catch (error) {
console.error("Failed to send password reset email:", error);
return false;
}
}
// Send welcome email
async sendWelcomeEmail(to, firstName) {
if (!this.isAvailable()) {
console.warn("Email service not available. Welcome email not sent.");
return false;
}
const subject = "Welcome to Formies!";
const html = this.getWelcomeEmailTemplate(firstName);
try {
await this.transporter.sendMail({
from: `"Formies" <${
process.env.SMTP_FROM_EMAIL || process.env.SMTP_USER
}>`,
to,
subject,
html,
});
console.log(`Welcome email sent to ${to}`);
return true;
} catch (error) {
console.error("Failed to send welcome email:", error);
return false;
}
}
// Send password changed notification
async sendPasswordChangedEmail(to, firstName) {
if (!this.isAvailable()) {
return false;
}
const subject = "Password Changed Successfully - Formies";
const html = this.getPasswordChangedEmailTemplate(firstName);
try {
await this.transporter.sendMail({
from: `"Formies" <${
process.env.SMTP_FROM_EMAIL || process.env.SMTP_USER
}>`,
to,
subject,
html,
});
return true;
} catch (error) {
console.error("Failed to send password changed email:", error);
return false;
}
}
// Email templates
getVerificationEmailTemplate(firstName, verificationUrl) {
return `
<!DOCTYPE html>
<html>
<head>
<style>
body { font-family: Arial, sans-serif; line-height: 1.6; color: #333; }
.container { max-width: 600px; margin: 0 auto; padding: 20px; }
.header { background: #007bff; color: white; padding: 20px; text-align: center; }
.content { padding: 20px; background: #f9f9f9; }
.button {
display: inline-block;
padding: 12px 30px;
background: #007bff;
color: white;
text-decoration: none;
border-radius: 5px;
margin: 20px 0;
}
.footer { padding: 20px; text-align: center; color: #666; font-size: 12px; }
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>Welcome to Formies!</h1>
</div>
<div class="content">
<h2>Hi ${firstName || "there"},</h2>
<p>Thank you for signing up for Formies! To complete your registration, please verify your email address by clicking the button below:</p>
<p style="text-align: center;">
<a href="${verificationUrl}" class="button">Verify Email Address</a>
</p>
<p>If the button doesn't work, you can copy and paste this link into your browser:</p>
<p style="word-break: break-all; background: #eee; padding: 10px;">${verificationUrl}</p>
<p><strong>This link will expire in 24 hours.</strong></p>
<p>If you didn't create an account with Formies, you can safely ignore this email.</p>
</div>
<div class="footer">
<p>© 2024 Formies. All rights reserved.</p>
</div>
</div>
</body>
</html>
`;
}
getPasswordResetEmailTemplate(firstName, resetUrl) {
return `
<!DOCTYPE html>
<html>
<head>
<style>
body { font-family: Arial, sans-serif; line-height: 1.6; color: #333; }
.container { max-width: 600px; margin: 0 auto; padding: 20px; }
.header { background: #dc3545; color: white; padding: 20px; text-align: center; }
.content { padding: 20px; background: #f9f9f9; }
.button {
display: inline-block;
padding: 12px 30px;
background: #dc3545;
color: white;
text-decoration: none;
border-radius: 5px;
margin: 20px 0;
}
.footer { padding: 20px; text-align: center; color: #666; font-size: 12px; }
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>Password Reset Request</h1>
</div>
<div class="content">
<h2>Hi ${firstName || "there"},</h2>
<p>We received a request to reset your password for your Formies account. If you made this request, click the button below to reset your password:</p>
<p style="text-align: center;">
<a href="${resetUrl}" class="button">Reset Password</a>
</p>
<p>If the button doesn't work, you can copy and paste this link into your browser:</p>
<p style="word-break: break-all; background: #eee; padding: 10px;">${resetUrl}</p>
<p><strong>This link will expire in 1 hour.</strong></p>
<p>If you didn't request a password reset, you can safely ignore this email. Your password won't be changed.</p>
</div>
<div class="footer">
<p>© 2024 Formies. All rights reserved.</p>
</div>
</div>
</body>
</html>
`;
}
getWelcomeEmailTemplate(firstName) {
return `
<!DOCTYPE html>
<html>
<head>
<style>
body { font-family: Arial, sans-serif; line-height: 1.6; color: #333; }
.container { max-width: 600px; margin: 0 auto; padding: 20px; }
.header { background: #28a745; color: white; padding: 20px; text-align: center; }
.content { padding: 20px; background: #f9f9f9; }
.button {
display: inline-block;
padding: 12px 30px;
background: #28a745;
color: white;
text-decoration: none;
border-radius: 5px;
margin: 20px 0;
}
.footer { padding: 20px; text-align: center; color: #666; font-size: 12px; }
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>Welcome to Formies!</h1>
</div>
<div class="content">
<h2>Hi ${firstName || "there"},</h2>
<p>Welcome to Formies! Your email has been verified and your account is now active.</p>
<p>You can now start creating beautiful forms and collecting submissions. Here's what you can do:</p>
<ul>
<li>Create unlimited forms</li>
<li>Customize form fields and styling</li>
<li>Receive instant notifications</li>
<li>Export your data anytime</li>
</ul>
<p style="text-align: center;">
<a href="${
process.env.APP_URL
}/dashboard" class="button">Go to Dashboard</a>
</p>
</div>
<div class="footer">
<p>© 2024 Formies. All rights reserved.</p>
</div>
</div>
</body>
</html>
`;
}
getPasswordChangedEmailTemplate(firstName) {
return `
<!DOCTYPE html>
<html>
<head>
<style>
body { font-family: Arial, sans-serif; line-height: 1.6; color: #333; }
.container { max-width: 600px; margin: 0 auto; padding: 20px; }
.header { background: #17a2b8; color: white; padding: 20px; text-align: center; }
.content { padding: 20px; background: #f9f9f9; }
.footer { padding: 20px; text-align: center; color: #666; font-size: 12px; }
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>Password Changed</h1>
</div>
<div class="content">
<h2>Hi ${firstName || "there"},</h2>
<p>This email confirms that your password has been successfully changed for your Formies account.</p>
<p><strong>If you didn't make this change, please contact our support team immediately.</strong></p>
<p>For your security, here are some tips:</p>
<ul>
<li>Use a strong, unique password</li>
<li>Don't share your password with anyone</li>
<li>Consider using a password manager</li>
</ul>
</div>
<div class="footer">
<p>© 2024 Formies. All rights reserved.</p>
</div>
</div>
</body>
</html>
`;
}
}
/**
* Generates a simple HTML body for the submission notification email.
* @param {string} formName - The name of the form.
* @param {object} submissionData - The data submitted to the form.
* @returns {string} - HTML string for the email body.
*/
function createEmailHtmlBody(formName, submissionData) {
let body = `<p>You have a new submission for your form: <strong>${formName}</strong>.</p>`;
body += "<p>Here are the details:</p><ul>";
for (const [key, value] of Object.entries(submissionData)) {
// Exclude honeypot and other internal fields if necessary
if (key.toLowerCase() !== "honeypot_field" && key !== "_thankyou") {
body += `<li><strong>${key}:</strong> ${value}</li>`;
}
}
body += "</ul><p>Thank you for using Formies!</p>";
return body;
}
/**
* Sends a submission notification email.
* @param {object} form - Form details (name, email_notifications_enabled, notification_email_address).
* @param {object} submissionData - The actual data submitted to the form.
* @param {string} userOwnerEmail - The email of the user who owns the form.
*/
async function sendSubmissionNotification(
form,
submissionData,
userOwnerEmail
) {
if (!resend) {
logger.warn(
"Resend SDK not initialized due to missing API key. Skipping email notification."
);
return;
}
if (!emailFromAddress) {
logger.warn(
"EMAIL_FROM_ADDRESS not configured. Skipping email notification."
);
return;
}
if (!form || !form.email_notifications_enabled) {
logger.info(
`Email notifications are disabled for form: ${form ? form.name : "Unknown Form"}. Skipping.`
);
return;
}
const recipientEmail = form.notification_email_address || userOwnerEmail;
if (!recipientEmail) {
logger.warn(
`No recipient email address found for form: ${form.name}. Skipping notification.`
);
return;
}
const subject = `New Submission for Form: ${form.name}`;
const htmlBody = createEmailHtmlBody(form.name, submissionData);
try {
const { data, error } = await resend.emails.send({
from: emailFromAddress,
to: recipientEmail,
subject: subject,
html: htmlBody,
});
if (error) {
logger.error("Error sending submission email via Resend:", error);
// Do not let email failure break the submission flow (as per 2.3.4)
return; // Or throw a specific error to be caught upstream if needed for more complex handling
}
logger.info(
`Submission email sent successfully to ${recipientEmail} for form ${form.name}. Message ID: ${data ? data.id : "N/A"}`
);
} catch (err) {
logger.error("Exception caught while sending submission email:", err);
// Do not let email failure break the submission flow
}
}
module.exports = {
sendSubmissionNotification,
// Potentially export createEmailHtmlBody if it needs to be used elsewhere or for testing
};

View File

@ -1,272 +0,0 @@
const jwt = require("jsonwebtoken");
const { v4: uuidv4 } = require("uuid");
const User = require("../models/User");
class JWTService {
constructor() {
this.secret = process.env.JWT_SECRET;
this.issuer = process.env.JWT_ISSUER || "formies";
this.audience = process.env.JWT_AUDIENCE || "formies-users";
this.accessTokenExpiry = process.env.JWT_ACCESS_EXPIRY || "15m";
this.refreshTokenExpiry = process.env.JWT_REFRESH_EXPIRY || "7d";
if (!this.secret) {
throw new Error("JWT_SECRET environment variable is required");
}
}
// Generate access token
generateAccessToken(user, sessionInfo = {}) {
const jti = uuidv4(); // JWT ID for token tracking
const payload = {
sub: user.id, // Subject (user ID)
email: user.email,
role: user.role,
jti: jti,
type: "access",
};
const options = {
issuer: this.issuer,
audience: this.audience,
expiresIn: this.accessTokenExpiry,
};
const token = jwt.sign(payload, this.secret, options);
const decoded = jwt.decode(token);
// Save session for token tracking
const expiresAt = new Date(decoded.exp * 1000);
User.saveSession(
user.id,
jti,
expiresAt,
sessionInfo.userAgent,
sessionInfo.ipAddress
).catch(console.error);
return {
token,
expiresAt,
jti,
};
}
// Generate refresh token
generateRefreshToken(user, sessionInfo = {}) {
const jti = uuidv4();
const payload = {
sub: user.id,
jti: jti,
type: "refresh",
};
const options = {
issuer: this.issuer,
audience: this.audience,
expiresIn: this.refreshTokenExpiry,
};
const token = jwt.sign(payload, this.secret, options);
const decoded = jwt.decode(token);
// Save session for token tracking
const expiresAt = new Date(decoded.exp * 1000);
User.saveSession(
user.id,
jti,
expiresAt,
sessionInfo.userAgent,
sessionInfo.ipAddress
).catch(console.error);
return {
token,
expiresAt,
jti,
};
}
// Generate token pair (access + refresh)
generateTokenPair(user, sessionInfo = {}) {
const accessToken = this.generateAccessToken(user, sessionInfo);
const refreshToken = this.generateRefreshToken(user, sessionInfo);
return {
accessToken: accessToken.token,
refreshToken: refreshToken.token,
accessTokenExpiresAt: accessToken.expiresAt,
refreshTokenExpiresAt: refreshToken.expiresAt,
tokenType: "Bearer",
};
}
// Verify and decode token
verifyToken(token, tokenType = "access") {
try {
const options = {
issuer: this.issuer,
audience: this.audience,
};
const decoded = jwt.verify(token, this.secret, options);
// Check token type
if (decoded.type !== tokenType) {
throw new Error(`Invalid token type. Expected ${tokenType}`);
}
return decoded;
} catch (error) {
if (error.name === "TokenExpiredError") {
throw new Error("Token has expired");
} else if (error.name === "JsonWebTokenError") {
throw new Error("Invalid token");
} else if (error.name === "NotBeforeError") {
throw new Error("Token not active yet");
}
throw error;
}
}
// Refresh access token using refresh token
async refreshAccessToken(refreshToken, sessionInfo = {}) {
try {
// Verify refresh token
const decoded = this.verifyToken(refreshToken, "refresh");
// Check if token is blacklisted
const isBlacklisted = await User.isTokenBlacklisted(decoded.jti);
if (isBlacklisted) {
throw new Error("Refresh token has been revoked");
}
// Get user
const user = await User.findById(decoded.sub);
if (!user || !user.is_active) {
throw new Error("User not found or inactive");
}
// Generate new access token
const newAccessToken = this.generateAccessToken(user, sessionInfo);
return {
accessToken: newAccessToken.token,
accessTokenExpiresAt: newAccessToken.expiresAt,
tokenType: "Bearer",
};
} catch (error) {
throw error;
}
}
// Revoke token (add to blacklist)
async revokeToken(token) {
try {
const decoded = jwt.decode(token);
if (!decoded || !decoded.jti) {
throw new Error("Invalid token format");
}
await User.revokeSession(decoded.jti);
return true;
} catch (error) {
console.error("Error revoking token:", error);
return false;
}
}
// Revoke all user tokens
async revokeAllUserTokens(userId) {
try {
const revokedCount = await User.revokeAllUserSessions(userId);
return revokedCount;
} catch (error) {
console.error("Error revoking all user tokens:", error);
return 0;
}
}
// Revoke all user tokens except one
async revokeAllUserTokensExcept(userId, exceptJti) {
try {
const revokedCount = await User.revokeAllUserSessionsExcept(
userId,
exceptJti
);
return revokedCount;
} catch (error) {
console.error("Error revoking user tokens:", error);
return 0;
}
}
// Extract token from Authorization header
extractTokenFromHeader(authHeader) {
if (!authHeader) {
return null;
}
const parts = authHeader.split(" ");
if (parts.length !== 2 || parts[0] !== "Bearer") {
return null;
}
return parts[1];
}
// Get token info without verification
getTokenInfo(token) {
try {
return jwt.decode(token);
} catch (error) {
return null;
}
}
// Check if token is expired (without verifying signature)
isTokenExpired(token) {
const decoded = this.getTokenInfo(token);
if (!decoded || !decoded.exp) {
return true;
}
return Date.now() >= decoded.exp * 1000;
}
// Cleanup expired sessions (call this periodically)
async cleanupExpiredSessions() {
try {
const cleanedCount = await User.cleanupExpiredSessions();
console.log(`Cleaned up ${cleanedCount} expired sessions`);
return cleanedCount;
} catch (error) {
console.error("Error cleaning up expired sessions:", error);
return 0;
}
}
// Get current session information
async getCurrentSession(token) {
try {
const decoded = this.verifyToken(token);
const session = await User.getSessionByJti(decoded.jti);
if (!session) {
throw new Error("Session not found");
}
return {
jti: session.token_jti,
userAgent: session.user_agent,
ipAddress: session.ip_address,
createdAt: session.created_at,
expiresAt: session.expires_at,
};
} catch (error) {
throw error;
}
}
}
module.exports = new JWTService();

View File

@ -1,4 +1,6 @@
async function sendNtfyNotification( import fetch from "node-fetch";
export async function sendNtfyNotification(
title, title,
message, message,
priority = "default", priority = "default",
@ -28,4 +30,4 @@ async function sendNtfyNotification(
} }
} }
module.exports = { sendNtfyNotification }; export default { sendNtfyNotification };

View File

@ -1,51 +0,0 @@
const crypto = require("crypto");
const bcrypt = require("bcryptjs");
const API_KEY_IDENTIFIER_PREFIX = "fsk"; // Formies Secret Key
const API_KEY_IDENTIFIER_LENGTH = 12; // Length of the random part of the identifier
const API_KEY_SECRET_LENGTH = 32; // Length of the secret part in bytes, results in 2x hex string length
/**
* Generates a new API key parts: the full key (to show to user once) and its components for storage.
* Identifier: A public, non-secret unique string for lookup (e.g., 'fsk_abcdef123').
* Secret: A cryptographically strong random string.
* Full Key: Identifier + '_' + Secret (this is what the user gets).
* @returns {{ fullApiKey: string, identifier: string, secret: string }}
*/
function generateApiKeyParts() {
const randomIdentifierPart = crypto
.randomBytes(Math.ceil(API_KEY_IDENTIFIER_LENGTH / 2))
.toString("hex")
.slice(0, API_KEY_IDENTIFIER_LENGTH);
const identifier = `${API_KEY_IDENTIFIER_PREFIX}_${randomIdentifierPart}`;
const secret = crypto.randomBytes(API_KEY_SECRET_LENGTH).toString("hex");
const fullApiKey = `${identifier}_${secret}`;
return { fullApiKey, identifier, secret };
}
/**
* Hashes an API key secret using bcrypt.
* @param {string} apiKeySecret - The secret part of the API key.
* @returns {Promise<string>} - The hashed API key secret.
*/
async function hashApiKeySecret(apiKeySecret) {
const saltRounds = 10; // Standard practice
return bcrypt.hash(apiKeySecret, saltRounds);
}
/**
* Compares a plain text API key secret with a stored hashed secret.
* @param {string} plainTextSecret - The plain text secret part provided by the user.
* @param {string} hashedSecret - The stored hashed secret from the database.
* @returns {Promise<boolean>} - True if the secrets match, false otherwise.
*/
async function compareApiKeySecret(plainTextSecret, hashedSecret) {
return bcrypt.compare(plainTextSecret, hashedSecret);
}
module.exports = {
generateApiKeyParts,
hashApiKeySecret,
compareApiKeySecret,
API_KEY_IDENTIFIER_PREFIX,
};

View File

@ -1,56 +0,0 @@
// Native fetch is available in Node.js 18+ and doesn't need to be imported
// const logger = require("../../config/logger"); // Adjust path as needed
const RECAPTCHA_V2_SECRET_KEY = process.env.RECAPTCHA_V2_SECRET_KEY;
const GOOGLE_RECAPTCHA_VERIFY_URL =
"https://www.google.com/recaptcha/api/siteverify";
/**
* Verifies a Google reCAPTCHA v2 response.
* @param {string} recaptchaToken - The g-recaptcha-response token from the client.
* @param {string} [clientIp] - Optional. The user's IP address.
* @returns {Promise<boolean>} - True if verification is successful, false otherwise.
*/
async function verifyRecaptchaV2(recaptchaToken, clientIp) {
if (!RECAPTCHA_V2_SECRET_KEY) {
console.warn(
"RECAPTCHA_V2_SECRET_KEY is not set. Skipping reCAPTCHA verification. THIS IS INSECURE FOR PRODUCTION."
);
// In a real scenario, you might want to fail open or closed based on policy
// For now, let's assume if it's not set, we can't verify, so effectively it fails if meant to be checked.
// However, the calling route will decide if reCAPTCHA is mandatory.
return false; // Or true if you want to bypass if not configured, though less secure.
}
if (!recaptchaToken) {
console.warn("No reCAPTCHA token provided by client.");
return false;
}
const verificationUrl = `${GOOGLE_RECAPTCHA_VERIFY_URL}?secret=${RECAPTCHA_V2_SECRET_KEY}&response=${recaptchaToken}`;
// Add remoteip if provided
const finalUrl = clientIp
? `${verificationUrl}&remoteip=${clientIp}`
: verificationUrl;
try {
const response = await fetch(finalUrl, { method: "POST" });
const data = await response.json();
if (data.success) {
console.info("reCAPTCHA verification successful.");
return true;
} else {
console.warn(
"reCAPTCHA verification failed.",
data["error-codes"] || "No error codes"
);
return false;
}
} catch (error) {
console.error("Error during reCAPTCHA verification request:", error);
return false;
}
}
module.exports = { verifyRecaptchaV2 };

View File

@ -1,356 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>User Dashboard - Formies</title>
<!-- Basic styling - replace with a proper CSS framework or custom styles later -->
<style>
body {
font-family: sans-serif;
margin: 0;
background-color: #f4f7f6;
}
.navbar {
background-color: #333;
color: white;
padding: 1rem;
display: flex;
justify-content: space-between;
align-items: center;
}
.navbar a {
color: white;
text-decoration: none;
margin-left: 1rem;
}
.navbar .logo {
font-size: 1.5rem;
font-weight: bold;
}
.container {
padding: 2rem;
}
.header-bar {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1.5rem;
}
.header-bar h1 {
margin: 0;
}
.btn {
background-color: #007bff;
color: white;
padding: 0.5rem 1rem;
text-decoration: none;
border-radius: 0.25rem;
border: none;
cursor: pointer;
}
.btn-secondary {
background-color: #6c757d;
}
/* Add more styles as needed */
</style>
</head>
<body>
<nav class="navbar">
<div class="logo"><a href="/dashboard">Formies</a></div>
<div>
<a href="/dashboard">My Forms</a>
<a href="/dashboard/create-form">Create New Form</a>
<a href="/dashboard/api-keys">API Keys</a>
<!-- Placeholder for Account Settings -->
<a href="#">Hi, <%= user ? user.email : 'User' %> &#9662;</a>
</div>
</nav>
<div class="container">
<!-- Main content will be injected or decided by the route -->
<% if (view === 'my_forms') { %>
<div class="header-bar">
<h1>My Forms</h1>
<a href="/dashboard/create-form" class="btn">+ Create New Form</a>
</div>
<%- include('partials/_forms_table', { forms: forms, appUrl: appUrl }) %>
<% } else if (view === 'create_form') { %>
<h1>Create New Form</h1>
<form action="/dashboard/forms/create" method="POST">
<div>
<label for="formName">Form Name:</label>
<input
type="text"
id="formName"
name="formName"
value="<%= typeof formNameValue !== 'undefined' ? formNameValue : 'Untitled Form' %>"
required />
</div>
<button type="submit" class="btn">Create Form</button>
<% if (typeof error !== 'undefined' && error) { %>
<p style="color: red; margin-top: 1rem"><%= error %></p>
<% } %>
</form>
<% } else if (view === 'form_submissions') { %> <%-
include('partials/_submissions_view', { submissions: submissions,
formUuid: formUuid, formName: formName, pagination: pagination, appUrl:
appUrl }) %> <% } else if (view === 'account_settings') { %>
<h1>Account Settings</h1>
<p>Account settings will be here.</p>
<% } else if (view === 'form_settings') { %>
<div class="header-bar">
<h1>
Settings for <span style="font-weight: normal"><%= formName %></span>
</h1>
<a href="/dashboard" class="btn btn-secondary">Back to My Forms</a>
</div>
<% if (typeof successMessage !== 'undefined' && successMessage) { %>
<div
style="
background-color: #d4edda;
color: #155724;
padding: 0.75rem 1.25rem;
margin-bottom: 1rem;
border: 1px solid #c3e6cb;
border-radius: 0.25rem;
">
<%= successMessage %>
</div>
<% } %> <% if (typeof errorMessage !== 'undefined' && errorMessage) { %>
<div
style="
background-color: #f8d7da;
color: #721c24;
padding: 0.75rem 1.25rem;
margin-bottom: 1rem;
border: 1px solid #f5c6cb;
border-radius: 0.25rem;
">
<%= errorMessage %>
</div>
<% } %>
<form
action="/dashboard/forms/<%= formUuid %>/settings/update"
method="POST"
style="
background-color: white;
padding: 1.5rem;
border-radius: 0.25rem;
border: 1px solid #ddd;
margin-bottom: 2rem;
">
<h3 style="margin-top: 0; margin-bottom: 1rem">General Settings</h3>
<div style="margin-bottom: 1rem">
<label
for="formNameInput"
style="display: block; margin-bottom: 0.5rem"
>Form Name:</label
>
<input
type="text"
id="formNameInput"
name="formName"
value="<%= currentFormName %>"
required
style="
width: 100%;
padding: 0.5rem;
border: 1px solid #ccc;
border-radius: 0.25rem;
" />
</div>
<h3 style="margin-top: 2rem; margin-bottom: 1rem">Email Notifications</h3>
<div style="margin-bottom: 1rem">
<input type="checkbox" id="emailNotificationsEnabled"
name="emailNotificationsEnabled" value="on" <%=
currentEmailNotificationsEnabled ? 'checked' : '' %>
style="margin-right: 0.5rem;">
<label for="emailNotificationsEnabled"
>Enable Email Notifications for new submissions</label
>
</div>
<div style="margin-bottom: 1rem">
<label
for="notificationEmailAddress"
style="display: block; margin-bottom: 0.5rem"
>Notification Email Address:</label
>
<input
type="email"
id="notificationEmailAddress"
name="notificationEmailAddress"
value="<%= currentNotificationEmailAddress || '' %>"
placeholder="Defaults to your account email (<%= user.email %>)"
style="
width: 100%;
padding: 0.5rem;
border: 1px solid #ccc;
border-radius: 0.25rem;
" />
<small style="display: block; margin-top: 0.25rem; color: #6c757d"
>If left blank, notifications will be sent to your account email:
<%= user.email %></small
>
</div>
<h3 style="margin-top: 2rem; margin-bottom: 1rem">Spam Protection</h3>
<div style="margin-bottom: 1rem">
<input type="checkbox" id="recaptchaEnabled"
name="recaptchaEnabled" value="on" <%=
currentRecaptchaEnabled ? 'checked' : '' %>
style="margin-right: 0.5rem;">
<label for="recaptchaEnabled"
>Enable Google reCAPTCHA v2 ("I'm not a robot")</label
>
<small style="display: block; margin-top: 0.25rem; color: #6c757d"
>Uses the globally configured site and secret keys. Ensure these are set in your server's .env file.</small
>
</div>
<h3 style="margin-top: 2rem; margin-bottom: 1rem">Thank You Page</h3>
<div style="margin-bottom: 1rem;">
<label for="thankYouUrl" style="display: block; margin-bottom: 0.5rem;">Thank You URL (Optional):</label>
<input
type="url"
id="thankYouUrl"
name="thankYouUrl"
value="<%= currentThankYouUrl || '' %>"
placeholder="e.g., https://example.com/thank-you"
style="
width: 100%;
padding: 0.5rem;
border: 1px solid #ccc;
border-radius: 0.25rem;
" />
</div>
<div style="margin-bottom: 1rem;">
<label for="thankYouMessage" style="display: block; margin-bottom: 0.5rem;">Custom Thank You Message (Optional):</label>
<textarea
id="thankYouMessage"
name="thankYouMessage"
rows="3"
placeholder="e.g., Thanks for your submission!"
style="
width: 100%;
padding: 0.5rem;
border: 1px solid #ccc;
border-radius: 0.25rem;
"><%= currentThankYouMessage || '' %></textarea>
<small style="display: block; margin-top: 0.25rem; color: #6c757d;">
If a "Thank You URL" is provided, it will be used. Otherwise, this custom message will be shown. If both are blank, a default message is used.
</small>
</div>
<h3 style="margin-top: 2rem; margin-bottom: 1rem">Allowed Domains</h3>
<div style="margin-bottom: 1rem;">
<label for="allowedDomains" style="display: block; margin-bottom: 0.5rem;">Allowed Domains:</label>
<textarea
id="allowedDomains"
name="allowedDomains"
rows="3"
placeholder="e.g., example.com, app.example.com"
style="
width: 100%;
padding: 0.5rem;
border: 1px solid #ccc;
border-radius: 0.25rem;
"><%= currentAllowedDomains || '' %></textarea>
<small style="display: block; margin-top: 0.25rem; color: #6c757d;">
Comma-separated list of domains. Leave blank to allow submissions from any domain.
</small>
</div>
<button type="submit" class="btn" style="margin-top: 1.5rem;">Save All Settings</button>
</form>
<% } else if (view === 'api_keys') { %>
<div class="header-bar">
<h1>API Keys</h1>
</div>
<% if (typeof successMessage !== 'undefined' && successMessage) { %>
<div style="background-color: #d4edda; color: #155724; padding: 0.75rem 1.25rem; margin-bottom: 1rem; border: 1px solid #c3e6cb; border-radius: 0.25rem;">
<%= successMessage %>
</div>
<% } %>
<% if (typeof errorMessage !== 'undefined' && errorMessage) { %>
<div style="background-color: #f8d7da; color: #721c24; padding: 0.75rem 1.25rem; margin-bottom: 1rem; border: 1px solid #f5c6cb; border-radius: 0.25rem;">
<%= errorMessage %>
</div>
<% } %>
<% if (typeof newlyGeneratedApiKey !== 'undefined' && newlyGeneratedApiKey) { %>
<div style="background-color: #fff3cd; color: #856404; padding: 1rem; margin-bottom: 1.5rem; border: 1px solid #ffeeba; border-radius: 0.25rem;">
<h3 style="margin-top:0;">New API Key Generated: <%= newlyGeneratedApiKeyName %></h3>
<p><strong>Important:</strong> This is the only time you will see this API key. Copy it now and store it securely.</p>
<pre style="background-color: #e9ecef; padding: 0.5rem; border-radius: 0.25rem; word-break: break-all;"><code id="newApiKey"><%= newlyGeneratedApiKey %></code></pre>
<button onclick="copyApiKeyToClipboard()" class="btn btn-secondary" style="margin-top:0.5rem;">Copy to Clipboard</button>
</div>
<% } %>
<div style="background-color: white; padding: 1.5rem; border-radius: 0.25rem; border: 1px solid #ddd; margin-bottom: 2rem;">
<h3 style="margin-top: 0; margin-bottom: 1rem">Generate New API Key</h3>
<form action="/dashboard/api-keys/generate" method="POST">
<div style="margin-bottom: 1rem;">
<label for="keyName" style="display: block; margin-bottom: 0.5rem;">Key Name (for your reference):</label>
<input type="text" id="keyName" name="keyName" required style="width: 100%; padding: 0.5rem; border: 1px solid #ccc; border-radius: 0.25rem;">
</div>
<button type="submit" class="btn">Generate Key</button>
</form>
</div>
<div style="background-color: white; padding: 1.5rem; border-radius: 0.25rem; border: 1px solid #ddd;">
<h3 style="margin-top: 0; margin-bottom: 1rem">Your API Keys</h3>
<% if (apiKeys && apiKeys.length > 0) { %>
<table style="width: 100%; border-collapse: collapse;">
<thead>
<tr style="text-align: left; border-bottom: 1px solid #dee2e6;">
<th style="padding: 0.75rem;">Name</th>
<th style="padding: 0.75rem;">Identifier (Prefix)</th>
<th style="padding: 0.75rem;">Created At</th>
<th style="padding: 0.75rem;">Last Used</th>
<th style="padding: 0.75rem;">Actions</th>
</tr>
</thead>
<tbody>
<% apiKeys.forEach(key => { %>
<tr style="border-bottom: 1px solid #f1f1f1;">
<td style="padding: 0.75rem;"><%= key.key_name %></td>
<td style="padding: 0.75rem;"><%= key.api_key_identifier %></td>
<td style="padding: 0.75rem;"><%= new Date(key.created_at).toLocaleDateString() %></td>
<td style="padding: 0.75rem;"><%= key.last_used_at ? new Date(key.last_used_at).toLocaleDateString() : 'Never' %></td>
<td style="padding: 0.75rem;">
<form action="/dashboard/api-keys/<%= key.uuid %>/revoke" method="POST" onsubmit="return confirm('Are you sure you want to revoke this API key? This cannot be undone.');" style="display: inline;">
<button type="submit" class="btn btn-secondary" style="background-color: #dc3545;">Revoke</button>
</form>
</td>
</tr>
<% }) %>
</tbody>
</table>
<% } else { %>
<p>You have not generated any API keys yet.</p>
<% } %>
</div>
<% } %>
</div>
<script>
// Client-side JS for dynamic interactions will go here
function copyApiKeyToClipboard() {
const apiKeyText = document.getElementById('newApiKey').innerText;
navigator.clipboard.writeText(apiKeyText).then(() => {
alert('API Key copied to clipboard!');
}).catch(err => {
console.error('Failed to copy API key: ', err);
alert('Failed to copy API key. Please copy it manually.');
});
}
</script>
</body>
</html>

979
views/global.css Normal file
View File

@ -0,0 +1,979 @@
:root {
/* Scandinavian Industrial Palette */
--color-bg: #f5f7fa;
/* Very light cool gray - Scandi base */
--color-surface: #ffffff;
/* White - Scandi cleanliness */
--color-primary: #34495e;
/* Dark slate blue/gray - Industrial strength */
--color-primary-rgb: 52, 73, 94;
--color-secondary: #7f8c8d;
/* Grayish cyan/slate - Industrial accent */
--color-accent: #c09574;
/* Muted tan/light wood - Scandi warmth */
--color-accent-rgb: 192, 149, 116;
--color-text: #2c3e50;
/* Dark, similar to primary for harmony */
--color-text-light: #566573;
/* Lighter gray for secondary text */
--color-border: #e1e5ea;
/* Light, cool gray for subtle borders */
--color-success: #27ae60;
/* Clear Green */
--color-success-bg: rgba(39, 174, 96, 0.1);
--color-pending: #f39c12;
/* Clear Amber/Orange */
--color-pending-bg: rgba(243, 156, 18, 0.1);
--color-archived: #95a5a6;
/* Muted Silver/Gray */
--color-archived-bg: rgba(149, 165, 166, 0.15);
--color-danger: #c0392b;
/* Clear, strong Red */
--color-danger-bg: rgba(192, 57, 43, 0.1);
--shadow-sm: 0 2px 4px rgba(0, 0, 0, 0.05);
/* Softer, more diffuse */
--shadow-md: 0 5px 15px rgba(0, 0, 0, 0.08);
/* Softer, more diffuse */
--border-radius: 4px;
/* Slightly sharper edges */
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
font-family: "Inter", "Segoe UI", Tahoma, Geneva, Verdana, sans-serif;
/* Modern sans-serif */
}
body {
background-color: var(--color-bg);
color: var(--color-text);
min-height: 100vh;
padding: 20px;
/* Removed background pattern for cleaner look */
}
main {
display: block;
}
.container {
max-width: 1200px;
width: 100%;
margin: 0 auto;
}
.skip-link {
position: absolute;
left: -9999px;
top: auto;
width: 1px;
height: 1px;
overflow: hidden;
z-index: -9999;
}
.skip-link:focus,
.skip-link:active {
display: block;
position: static;
width: auto;
height: auto;
overflow: visible;
padding: 10px 15px;
margin: 10px auto;
background-color: var(--color-primary);
color: var(--color-surface);
border-radius: var(--border-radius);
text-decoration: none;
font-size: 1rem;
z-index: 100000;
text-align: center;
}
.visually-hidden {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
overflow: hidden;
clip: rect(0, 0, 0, 0);
border: 0;
}
/* Titles */
.page-title {
font-size: 2.1rem;
/* Slightly larger */
font-weight: 700;
color: var(--color-primary);
line-height: 1.2;
margin-bottom: 32px;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.07);
/* Very subtle shadow */
}
.section-title {
font-size: 1.4rem;
/* Slightly larger */
font-weight: 600;
color: var(--color-primary);
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 24px;
}
.section-title svg {
color: var(--color-accent);
flex-shrink: 0;
}
/* Buttons */
.button {
background-color: var(--color-primary);
color: var(--color-surface);
/* Changed to var for consistency, typically white */
border: 1px solid var(--color-primary);
/* Ensure border for consistency */
padding: 10px 18px;
border-radius: var(--border-radius);
font-weight: 500;
font-size: 0.9rem;
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 8px;
transition: all 0.2s ease;
text-decoration: none;
line-height: 1.5;
user-select: none;
box-shadow: var(--shadow-sm);
}
.button svg {
flex-shrink: 0;
}
.button:hover {
background-color: #2c3e50;
/* Darker primary */
border-color: #2c3e50;
box-shadow: var(--shadow-md);
transform: translateY(-2px);
}
.button:active {
background-color: #212f3c;
/* Even darker primary */
border-color: #212f3c;
box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.2);
transform: translateY(0);
}
.button:focus-visible {
outline: 2px solid var(--color-accent);
outline-offset: 3px;
}
.button-secondary {
background-color: var(--color-surface);
color: var(--color-primary);
border: 1px solid var(--color-border);
box-shadow: none;
}
.button-secondary:hover {
background-color: var(--color-bg);
border-color: #c8ced3;
/* Darker border */
box-shadow: var(--shadow-sm);
transform: translateY(-1px);
}
.button-secondary:active {
background-color: #e0e5ea;
/* Darker bg */
box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.1);
transform: translateY(0);
}
.button-secondary:focus-visible {
outline: 2px solid var(--color-accent);
outline-offset: 3px;
}
.button.button-sm {
padding: 6px 12px;
font-size: 0.8rem;
box-shadow: none;
}
.button.button-sm:hover {
box-shadow: var(--shadow-sm);
transform: translateY(-1px);
}
.button.button-sm:active {
box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.1);
transform: translateY(0);
}
.button.button-danger {
background-color: var(--color-danger);
border-color: var(--color-danger);
color: white;
}
.button.button-danger:hover {
background-color: #a93226;
/* Darker danger */
border-color: #a93226;
box-shadow: var(--shadow-md);
transform: translateY(-2px);
}
.button.button-danger:active {
background-color: #922b21;
/* Even darker danger */
border-color: #922b21;
box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.2);
transform: translateY(0);
}
.button.button-danger:focus-visible {
outline: 2px solid var(--color-danger);
outline-offset: 3px;
}
/* Generic Card */
.simple-card {
background-color: var(--color-surface);
border-radius: var(--border-radius);
padding: 20px;
box-shadow: var(--shadow-sm);
border: 1px solid var(--color-border);
margin-bottom: 24px;
transition: box-shadow 0.2s ease, transform 0.2s ease;
}
.simple-card:hover {
box-shadow: var(--shadow-md);
transform: translateY(-2px);
/* Standardized lift */
}
.simple-card .card-title {
font-size: 1.1rem;
font-weight: 600;
color: var(--color-primary);
margin-bottom: 16px;
}
/* Form Card (for listing forms) */
.form-card {
background-color: var(--color-surface);
border-radius: var(--border-radius);
box-shadow: var(--shadow-sm);
border: 1px solid var(--color-border);
transition: all 0.2s ease;
display: flex;
flex-direction: column;
margin-bottom: 20px;
}
.form-card:hover {
box-shadow: var(--shadow-md);
/* transform: translateY(-2px); */
/* Standardized lift */
border-color: var(--color-accent);
}
.form-card-header {
padding: 16px;
background-color: var(--color-surface);
/* Clean header */
border-bottom: 1px solid var(--color-border);
display: flex;
justify-content: space-between;
align-items: center;
}
.form-title {
font-weight: 600;
font-size: 1rem;
color: var(--color-primary);
display: flex;
align-items: center;
}
.form-menu {
color: var(--color-text-light);
cursor: pointer;
padding: 6px;
border-radius: var(--border-radius);
transition: background-color 0.2s ease, color 0.2s ease;
background: none;
border: none;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.form-menu:hover {
background-color: var(--color-bg);
color: var(--color-primary);
}
.form-menu:focus-visible {
outline: 2px solid var(--color-accent);
outline-offset: 1px;
background-color: var(--color-bg);
color: var(--color-primary);
}
.form-card-content {
padding: 16px;
flex-grow: 1;
}
.form-submission-count {
font-size: 0.85rem;
color: var(--color-text-light);
margin-bottom: 8px;
}
.form-url-info {
font-size: 0.8rem;
color: var(--color-text-light);
margin-top: 12px;
display: flex;
align-items: center;
gap: 8px;
word-break: break-all;
background-color: var(--color-bg);
/* Use main BG for slight recess */
padding: 8px 12px;
border-radius: var(--border-radius);
border: 1px solid var(--color-border);
}
.form-url-info input[type="text"] {
flex-grow: 1;
font-size: 0.8rem;
padding: 0;
background-color: transparent;
cursor: text;
border: none;
color: var(--color-text);
/* Ensure text color is readable */
}
.form-url-info input[type="text"]:focus-visible {
outline: none;
box-shadow: none;
}
.form-badge {
font-size: 0.75rem;
padding: 4px 10px;
border-radius: 12px;
font-weight: 500;
line-height: 1.2;
white-space: nowrap;
margin-left: 8px;
display: inline-block;
}
.form-badge.archived {
background-color: var(--color-archived-bg);
color: var(--color-archived);
}
.form-badge.active {
background-color: var(--color-success-bg);
color: var(--color-success);
}
/* Dropdown Menu */
.dropdown {
position: relative;
display: inline-block;
}
.dropdown-menu {
display: none;
position: absolute;
right: 0;
top: 100%;
background-color: var(--color-surface);
min-width: 200px;
box-shadow: var(--shadow-md);
border: 1px solid var(--color-border);
border-radius: var(--border-radius);
z-index: 1000;
padding: 8px 0;
margin-top: 4px;
list-style: none;
opacity: 0;
transform: translateY(10px);
transition: opacity 0.2s ease, transform 0.2s ease;
}
.dropdown-menu.show {
display: block;
opacity: 1;
transform: translateY(0);
}
.dropdown-item {
display: block;
width: 100%;
padding: 8px 16px;
text-align: left;
text-decoration: none;
color: var(--color-text);
background-color: transparent;
border: none;
cursor: pointer;
font-size: 0.9rem;
white-space: nowrap;
transition: background-color 0.2s ease, color 0.2s ease;
}
.dropdown-item:hover {
background-color: var(--color-bg);
color: var(--color-primary);
}
.dropdown-item:focus-visible {
outline: 2px solid var(--color-accent);
outline-offset: -2px;
/* Inset outline */
background-color: var(--color-bg);
color: var(--color-primary);
}
.dropdown-divider {
height: 1px;
background-color: var(--color-border);
margin: 8px 0;
}
.dropdown-item.text-danger {
color: var(--color-danger);
}
.dropdown-item.text-danger:hover {
background-color: var(--color-danger-bg);
color: var(--color-danger);
/* Keep text color as danger */
}
.dropdown-item.text-danger:focus-visible {
outline: 2px solid var(--color-danger);
background-color: var(--color-danger-bg);
color: var(--color-danger);
}
/* Modals */
.modal {
display: none;
position: fixed;
z-index: 1050;
left: 0;
top: 0;
width: 100%;
height: 100%;
overflow-y: auto;
background-color: rgba(var(--color-primary-rgb), 0.6);
/* Primary color backdrop */
backdrop-filter: blur(4px);
/* Increased blur */
opacity: 0;
transition: opacity 0.3s ease;
}
.modal.show {
display: flex;
align-items: center;
justify-content: center;
opacity: 1;
}
.modal-dialog {
background-color: var(--color-surface);
padding: 24px;
border: 1px solid var(--color-border);
border-radius: var(--border-radius);
width: 95%;
max-width: 500px;
box-shadow: var(--shadow-md);
position: relative;
margin: 20px;
box-sizing: border-box;
transform: translateY(-20px);
opacity: 0;
transition: opacity 0.3s ease, transform 0.3s ease;
}
.modal.show .modal-dialog {
transform: translateY(0);
opacity: 1;
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding-bottom: 16px;
margin-bottom: 16px;
border-bottom: 1px solid var(--color-border);
}
.modal-title {
font-size: 1.3rem;
font-weight: 600;
color: var(--color-primary);
margin: 0;
}
.btn-close {
background: transparent;
border: none;
font-size: 1.8rem;
font-weight: bold;
color: var(--color-text-light);
cursor: pointer;
padding: 0.5rem;
margin: -0.5rem;
line-height: 1;
transition: color 0.2s ease;
border-radius: var(--border-radius);
}
.btn-close:hover {
color: var(--color-primary);
}
.btn-close:focus-visible {
outline: 2px solid var(--color-accent);
outline-offset: 2px;
color: var(--color-primary);
}
.modal-body {
padding-bottom: 16px;
}
.modal-footer {
display: flex;
justify-content: flex-end;
gap: 10px;
padding-top: 16px;
margin-top: 16px;
border-top: 1px solid var(--color-border);
}
/* Form Elements (inputs, labels) */
.form-label {
display: block;
margin-bottom: 8px;
font-weight: 500;
color: var(--color-text);
font-size: 0.9rem;
}
.form-group {
margin-bottom: 1rem;
}
input[type="text"],
input[type="email"],
textarea {
width: 100%;
padding: 10px 12px;
border: 1px solid var(--color-border);
border-radius: var(--border-radius);
background-color: var(--color-surface);
color: var(--color-text);
font-size: 0.9rem;
line-height: 1.5;
transition: border-color 0.2s ease, box-shadow 0.2s ease;
}
input[type="text"]:focus-visible,
textarea:focus-visible {
outline: none;
border-color: var(--color-accent);
box-shadow: 0 0 0 3px rgba(var(--color-accent-rgb), 0.25);
/* Accent glow */
}
/* Specific style for the read-only URL input inside .form-url-info */
.form-url-info input[type="text"].form-url-display {
/* Inherits styles from .form-url-info input[type="text"] */
}
.form-text {
font-size: 0.8rem;
color: var(--color-text-light);
margin-top: 4px;
display: block;
}
/* For Form Manager Specific Layouts */
.create-form-section {
background-color: var(--color-surface);
border-radius: var(--border-radius);
padding: 24px;
box-shadow: var(--shadow-sm);
border: 1px solid var(--color-border);
margin-bottom: 40px;
}
.create-form-section .section-title {
margin-bottom: 20px;
}
.create-form-section .form-group {
margin-bottom: 24px;
}
/* Copy URL Button Specifics */
.copy-button {
flex-shrink: 0;
padding: 6px 10px;
font-size: 0.8rem;
background-color: var(--color-surface);
color: var(--color-primary);
border: 1px solid var(--color-border);
box-shadow: none;
gap: 4px;
}
.copy-button:hover {
background-color: var(--color-bg);
/* Use main BG for hover */
border-color: #c8ced3;
/* Darker border */
box-shadow: var(--shadow-sm);
transform: translateY(-1px);
color: var(--color-accent);
}
.copy-button:active {
background-color: #e0e5ea;
/* Darker bg */
box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.1);
transform: translateY(0);
}
.copy-button .copy-text {
display: inline-block;
min-width: 35px;
text-align: center;
}
.copy-button.copied .copy-text {
color: var(--color-success);
font-weight: 600;
}
.copy-button.copied svg {
color: var(--color-success);
}
/* Submissions Page specific */
.dashboard-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 32px;
flex-wrap: wrap;
gap: 16px;
}
.dashboard-title {
font-size: 2.1rem;
/* Matched page-title */
font-weight: 700;
color: var(--color-primary);
line-height: 1.2;
margin: 0;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.07);
/* Matched page-title */
}
.alert-info-custom {
padding: 16px;
background-color: var(--color-surface);
border: 1px solid var(--color-border);
border-left: 4px solid var(--color-accent);
border-radius: var(--border-radius);
margin-bottom: 24px;
color: var(--color-text-light);
font-size: 0.9rem;
box-shadow: var(--shadow-sm);
}
/* Submissions Table */
.submissions-table-wrapper {
overflow-x: auto;
background-color: var(--color-surface);
border-radius: var(--border-radius);
box-shadow: var(--shadow-sm);
border: 1px solid var(--color-border);
margin-bottom: 24px;
}
.submissions-table {
width: 100%;
border-collapse: collapse;
min-width: 700px;
caption-side: bottom;
}
.submissions-table caption {
padding: 10px;
font-size: 0.85rem;
color: var(--color-text-light);
text-align: left;
}
.submissions-table th,
.submissions-table td {
padding: 14px 16px;
text-align: left;
border-bottom: 1px solid var(--color-border);
vertical-align: top;
font-size: 0.9rem;
}
.submissions-table th {
font-weight: 600;
color: var(--color-primary);
font-size: 0.85rem;
background-color: var(--color-bg);
/* Use main BG for header */
white-space: nowrap;
}
.submissions-table th:first-child {
/* No specific radius if table wrapper has it */
}
.submissions-table th:last-child {
/* No specific radius if table wrapper has it */
text-align: right;
}
.submissions-table tbody tr:last-child td {
border-bottom: none;
}
.submissions-table tbody tr:hover {
background-color: color-mix(in srgb, var(--color-bg), #000000 3%);
/* Subtle darker BG hover */
}
.submissions-table td:last-child {
text-align: right;
}
.table-actions {
display: flex;
gap: 8px;
justify-content: flex-end;
align-items: center;
}
.table-action {
color: var(--color-text-light);
cursor: pointer;
transition: color 0.2s ease, transform 0.1s ease;
display: flex;
align-items: center;
background: none;
border: none;
padding: 4px;
border-radius: var(--border-radius);
}
.table-action:hover {
color: var(--color-accent);
transform: scale(1.1);
}
.table-action:focus-visible {
outline: 2px solid var(--color-accent);
outline-offset: 2px;
color: var(--color-accent);
}
.table-action.delete:hover {
color: var(--color-danger);
}
.table-action.delete:focus-visible {
outline: 2px solid var(--color-danger);
outline-offset: 2px;
color: var(--color-danger);
}
.submission-data-item {
margin-bottom: 8px;
word-break: break-word;
padding-bottom: 4px;
border-bottom: 1px dashed var(--color-border);
}
.submission-data-item:last-child {
margin-bottom: 0;
border-bottom: none;
}
.submission-data-item strong {
color: var(--color-primary);
margin-right: 6px;
font-weight: 600;
}
/* Pagination */
.pagination {
display: flex;
justify-content: center;
align-items: center;
list-style: none;
padding: 0;
margin: 32px 0 16px 0;
gap: 8px;
flex-wrap: wrap;
}
.page-item .page-link {
display: inline-block;
padding: 8px 12px;
text-decoration: none;
color: var(--color-primary);
background-color: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--border-radius);
transition: all 0.2s ease;
font-size: 0.9rem;
min-width: 38px;
text-align: center;
}
.page-item .page-link:hover {
background-color: var(--color-bg);
border-color: var(--color-accent);
color: var(--color-accent);
}
.page-item .page-link:focus-visible {
outline: 2px solid var(--color-accent);
outline-offset: 2px;
background-color: var(--color-bg);
border-color: var(--color-accent);
color: var(--color-accent);
}
.page-item.active .page-link {
background-color: var(--color-primary);
color: var(--color-surface);
border-color: var(--color-primary);
font-weight: 600;
box-shadow: var(--shadow-sm);
}
.page-item.active .page-link:hover {
background-color: #2c3e50;
/* Darker primary */
border-color: #2c3e50;
box-shadow: var(--shadow-md);
color: var(--color-surface);
}
.page-item.disabled .page-link {
color: var(--color-text-light);
pointer-events: none;
background-color: var(--color-bg);
border-color: var(--color-border);
opacity: 0.7;
cursor: not-allowed;
box-shadow: none;
}
.page-item.disabled .page-link:focus-visible {
outline: none;
}
.pagination-info {
text-align: center;
color: var(--color-text-light);
font-size: 0.85rem;
margin-top: 0;
margin-bottom: 32px;
}
/* Utility classes */
.mb-2 {
margin-bottom: 0.5rem !important;
}
.mt-2 {
margin-top: 0.5rem !important;
}
.me-2 {
margin-right: 0.5rem !important;
}
.mb-3 {
margin-bottom: 1rem !important;
}
.mt-3 {
margin-top: 1rem !important;
}
.me-3 {
margin-right: 1rem !important;
}
.d-flex {
display: flex !important;
}
.justify-content-between {
justify-content: space-between !important;
}
.align-items-center {
align-items: center !important;
}
.align-items-start {
align-items: flex-start !important;
}

240
views/index.ejs Normal file
View File

@ -0,0 +1,240 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>formies</title>
<link rel="stylesheet" href="/global.css">
</head>
<body>
<a href="#main-content" class="skip-link">Skip to main content</a>
<div class="container">
<h1 class="page-title">formies</h1>
<main id="main-content">
<!-- Create New Form -->
<section class="create-form-section">
<h2 class="section-title">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
aria-hidden="true" focusable="false">
<line x1="12" y1="5" x2="12" y2="19"></line>
<line x1="5" y1="12" x2="19" y2="12"></line>
</svg>
Create New Form
</h2>
<form action="/admin/create-form" method="POST">
<div class="form-group">
<label for="formNameInput" class="form-label">Form Name</label>
<input type="text" id="formNameInput" name="formName" placeholder="e.g., Contact Us, Feedback"
required aria-describedby="formNameHelp" />
<small id="formNameHelp" class="form-text">A descriptive name for your new form.</small>
</div>
<button type="submit" class="button">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
aria-hidden="true" focusable="false">
<line x1="12" y1="5" x2="12" y2="19"></line>
<line x1="5" y1="12" x2="19" y2="12"></line>
</svg>
Create Form
</button>
</form>
</section>
<!-- Forms List -->
<section>
<h2 class="section-title">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
aria-hidden="true" focusable="false">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path>
<polyline points="14 2 14 8 20 8"></polyline>
<line x1="16" y1="13" x2="8" y2="13"></line>
<line x1="16" y1="17" x2="8" y2="17"></line>
<polyline points="10 9 9 9 8 9"></polyline>
</svg>
Your Forms
</h2>
<% if (forms.length===0) { %>
<p class="alert-info-custom">No forms created yet. Create your first form above!</p>
<% } else { %>
<% forms.forEach(form=> { %>
<div class="form-card">
<div class="form-card-header">
<h3 class="form-title">
<a href="/admin/submissions/<%= form.uuid %>">
<%= form.name %>
</a>
<% if (form.is_archived) { %>
<span class="form-badge archived">Archived</span>
<% } %>
</h3>
<div class="dropdown">
<button type="button" class="form-menu" data-action="toggle-dropdown"
aria-label="Actions for form <%= form.name %>" aria-expanded="false"
aria-controls="dropdownMenu<%= form.uuid %>">
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18"
viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"
focusable="false">
<circle cx="12" cy="12" r="1"></circle>
<circle cx="19" cy="12" r="1"></circle>
<circle cx="5" cy="12" r="1"></circle>
</svg>
</button>
<ul class="dropdown-menu" id="dropdownMenu<%= form.uuid %>" role="menu"
aria-labelledby="actionsButton<%= form.uuid %>">
<!-- Added aria-labelledby for context -->
<li role="none"><a role="menuitem" class="dropdown-item"
href="/admin/submissions/<%= form.uuid %>">View Submissions</a>
</li>
<li role="none"><a role="menuitem" class="dropdown-item"
href="/admin/submissions/<%= form.uuid %>/export">Export
Submissions</a>
</li>
<li role="separator" class="dropdown-divider">
<hr class="dropdown-divider" />
</li>
<li role="none"><button role="menuitem" type="button" class="dropdown-item"
data-action="show-modal"
data-modal="renameModal<%= form.uuid %>">Rename Form</button>
</li>
<li role="none"><button role="menuitem" type="button" class="dropdown-item"
data-action="show-modal"
data-modal="domainsModal<%= form.uuid %>">Set Allowed
Domains</button>
</li>
<li role="none"><button role="menuitem" type="button" class="dropdown-item"
data-action="test-notification" data-form-id="<%= form.uuid %>">Test
Notification</button></li>
<li role="separator" class="dropdown-divider">
<hr class="dropdown-divider" />
</li>
<li role="none">
<form action="/admin/archive-form/<%= form.uuid %>" method="POST"
style="display: block;">
<input type="hidden" name="archive"
value="<%= form.is_archived ? 'false' : 'true' %>" />
<button type="submit" class="dropdown-item">
<%= form.is_archived ? 'Unarchive Form' : 'Archive Form' %>
</button>
</form>
</li>
<li role="none">
<form action="/admin/delete-form/<%= form.uuid %>" method="POST"
style="display: block;">
<button type="submit" class="dropdown-item text-danger"
onclick="return confirm('Are you sure you want to delete this form? This action cannot be undone.')"
aria-describedby="deleteWarning<%= form.uuid %>">Delete
Form</button>
</form>
<span id="deleteWarning<%= form.uuid %>"
class="visually-hidden">Warning:
Deleting this form is permanent and cannot be undone.</span>
</li>
</ul>
</div>
</div>
<div class="form-card-content">
<p class="form-submission-count">
<%= form.submission_count %> submission<%= form.submission_count !==1 ? 's' : ''
%>
</p>
<div class="form-url-info">
<label for="formUrl<%= form.uuid %>" class="form-label visually-hidden">Form URL
for <%= form.name %></label>
<input type="text" id="formUrl<%= form.uuid %>" readonly
value="<%= appUrl %>/submit/<%= form.uuid %>" class="form-url-display"
aria-label="Form URL for <%= form.name %> (Read-only)">
<button type="button" class="button button-sm button-secondary copy-button"
data-copy-target="#formUrl<%= form.uuid %>" title="Copy URL to clipboard">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14"
viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"
focusable="false">
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1">
</path>
</svg>
</button>
</div>
</div>
</div>
<!-- Rename Modal -->
<div class="modal" id="renameModal<%= form.uuid %>" role="dialog" aria-modal="true"
aria-hidden="true" aria-labelledby="renameModalTitle<%= form.uuid %>">
<div class="modal-dialog">
<div class="modal-header">
<h5 class="modal-title" id="renameModalTitle<%= form.uuid %>">Rename Form</h5>
<button type="button" class="btn-close" data-action="hide-modal"
data-modal="renameModal<%= form.uuid %>"
aria-label="Close rename form modal">×</button>
</div>
<form action="/admin/update-form-name/<%= form.uuid %>" method="POST">
<div class="modal-body">
<div class="form-group">
<label for="newName<%= form.uuid %>" class="form-label">New Name</label>
<input type="text" id="newName<%= form.uuid %>" name="newName"
value="<%= form.name %>" required />
</div>
</div>
<div class="modal-footer">
<button type="button" class="button button-secondary"
data-action="hide-modal"
data-modal="renameModal<%= form.uuid %>">Cancel</button>
<button type="submit" class="button">Save Changes</button>
</div>
</form>
</div>
</div>
<!-- Allowed Domains Modal -->
<div class="modal" id="domainsModal<%= form.uuid %>" role="dialog" aria-modal="true"
aria-hidden="true" aria-labelledby="domainsModalTitle<%= form.uuid %>">
<div class="modal-dialog">
<div class="modal-header">
<h5 class="modal-title" id="domainsModalTitle<%= form.uuid %>">Set Allowed
Domains</h5>
<button type="button" class="btn-close" data-action="hide-modal"
data-modal="domainsModal<%= form.uuid %>"
aria-label="Close allowed domains modal">×</button>
</div>
<form action="/admin/update-allowed-domains/<%= form.uuid %>" method="POST">
<div class="modal-body">
<div class="form-group">
<label for="allowedDomains<%= form.uuid %>" class="form-label">Allowed
Domains (comma-separated)</label>
<input type="text" id="allowedDomains<%= form.uuid %>"
name="allowedDomains"
placeholder="example.com, subdomain.example.com"
value="<%= form.allowed_domains ? form.allowed_domains.join(', ') : '' %>"
aria-describedby="domainsHelp<%= form.uuid %>" />
<small class="form-text" id="domainsHelp<%= form.uuid %>">Leave empty to
allow submissions from any domain.</small>
</div>
</div>
<div class="modal-footer">
<button type="button" class="button button-secondary"
data-action="hide-modal"
data-modal="domainsModal<%= form.uuid %>">Cancel</button>
<button type="submit" class="button">Save Changes</button>
</div>
</form>
</div>
</div>
<% }); %>
<% } %>
</section>
</main>
</div>
<script src="/main.js"></script>
</body>
</html>

View File

@ -1,239 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Login - Formies</title>
<style>
body {
font-family: sans-serif;
margin: 0;
background-color: #f4f7f6;
min-height: 100vh;
display: flex;
flex-direction: column;
}
.navbar {
background-color: #333;
color: white;
padding: 1rem;
display: flex;
justify-content: space-between;
align-items: center;
}
.navbar a {
color: white;
text-decoration: none;
}
.navbar .logo {
font-size: 1.5rem;
font-weight: bold;
}
.container {
flex: 1;
display: flex;
justify-content: center;
align-items: center;
padding: 2rem;
}
.login-card {
background-color: white;
padding: 2rem;
border-radius: 0.5rem;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
width: 100%;
max-width: 400px;
}
.login-header {
text-align: center;
margin-bottom: 2rem;
}
.login-header h1 {
margin: 0;
color: #333;
font-size: 1.8rem;
}
.form-group {
margin-bottom: 1.5rem;
}
.form-group label {
display: block;
margin-bottom: 0.5rem;
color: #555;
}
.form-group input {
width: 100%;
padding: 0.75rem;
border: 1px solid #ddd;
border-radius: 0.25rem;
font-size: 1rem;
box-sizing: border-box;
}
.form-group input:focus {
outline: none;
border-color: #007bff;
box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.25);
}
.btn {
background-color: #007bff;
color: white;
padding: 0.75rem 1.5rem;
border: none;
border-radius: 0.25rem;
font-size: 1rem;
cursor: pointer;
width: 100%;
transition: background-color 0.2s;
}
.btn:hover {
background-color: #0056b3;
}
.error-message {
background-color: #f8d7da;
color: #721c24;
padding: 0.75rem;
border-radius: 0.25rem;
margin-bottom: 1rem;
text-align: center;
}
.success-message {
background-color: #d4edda;
color: #155724;
padding: 0.75rem;
border-radius: 0.25rem;
margin-bottom: 1rem;
text-align: center;
}
.links {
text-align: center;
margin-top: 1rem;
}
.links a {
color: #007bff;
text-decoration: none;
font-size: 0.9rem;
}
.links a:hover {
text-decoration: underline;
}
</style>
</head>
<body>
<nav class="navbar">
<div class="logo"><a href="/">Formies</a></div>
<div>
<a href="/register">Register</a>
</div>
</nav>
<div class="container">
<div class="login-card">
<div class="login-header">
<h1>Welcome Back</h1>
<p>Please sign in to continue</p>
</div>
<% if (typeof error !== 'undefined' && error) { %>
<div class="error-message"><%= error %></div>
<% } %>
<% if (typeof success !== 'undefined' && success) { %>
<div class="success-message"><%= success %></div>
<% } %>
<form action="/api/auth/login" method="POST">
<div class="form-group">
<label for="email">Email Address</label>
<input
type="email"
id="email"
name="email"
required
placeholder="Enter your email"
value="<%= typeof email !== 'undefined' ? email : '' %>"
/>
</div>
<div class="form-group">
<label for="password">Password</label>
<input
type="password"
id="password"
name="password"
required
placeholder="Enter your password"
/>
</div>
<button type="submit" class="btn">Sign In</button>
<div class="links">
<a href="/forgot-password">Forgot your password?</a>
<br>
<a href="/register">Don't have an account? Register</a>
</div>
</form>
</div>
</div>
<script>
document.querySelector('form').addEventListener('submit', async (e) => {
e.preventDefault();
const email = document.getElementById('email').value;
const password = document.getElementById('password').value;
try {
const response = await fetch('/api/auth/login', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ email, password })
});
const data = await response.json();
if (response.ok) {
// Store tokens
localStorage.setItem('accessToken', data.data.accessToken);
localStorage.setItem('refreshToken', data.data.refreshToken);
// Redirect to dashboard
window.location.href = '/dashboard';
} else {
// Show error message
const errorDiv = document.createElement('div');
errorDiv.className = 'error-message';
errorDiv.textContent = data.message || 'Login failed';
const existingError = document.querySelector('.error-message');
if (existingError) {
existingError.remove();
}
document.querySelector('.login-card').insertBefore(
errorDiv,
document.querySelector('form')
);
}
} catch (error) {
console.error('Login error:', error);
const errorDiv = document.createElement('div');
errorDiv.className = 'error-message';
errorDiv.textContent = 'An error occurred. Please try again.';
const existingError = document.querySelector('.error-message');
if (existingError) {
existingError.remove();
}
document.querySelector('.login-card').insertBefore(
errorDiv,
document.querySelector('form')
);
}
});
</script>
</body>
</html>

71
views/main.js Normal file
View File

@ -0,0 +1,71 @@
// Dropdown functionality
function toggleDropdown(button) {
// Find the closest .dropdown and then the .dropdown-menu inside it
const dropdown = button.closest('.dropdown');
if (!dropdown) return;
const dropdownMenu = dropdown.querySelector('.dropdown-menu');
if (dropdownMenu) {
dropdownMenu.classList.toggle('show');
}
}
// Modal functionality
function showModal(modalId) {
document.getElementById(modalId).classList.add('show');
}
function hideModal(modalId) {
document.getElementById(modalId).classList.remove('show');
}
// Initialize all event listeners
document.addEventListener('DOMContentLoaded', function () {
// Handle dropdown toggles
document.querySelectorAll('[data-action="toggle-dropdown"]').forEach(button => {
button.addEventListener('click', function () {
toggleDropdown(this);
});
});
// Handle modal show buttons
document.querySelectorAll('[data-action="show-modal"]').forEach(button => {
button.addEventListener('click', function () {
const modalId = this.dataset.modal;
showModal(modalId);
});
});
// Handle modal hide buttons
document.querySelectorAll('[data-action="hide-modal"]').forEach(button => {
button.addEventListener('click', function () {
const modalId = this.dataset.modal;
hideModal(modalId);
});
});
// Handle test notification buttons
document.querySelectorAll('[data-action="test-notification"]').forEach(button => {
button.addEventListener('click', function () {
const formId = this.dataset.formId;
// Implement test notification functionality here
console.log('Testing notification for form:', formId);
});
});
// Close dropdowns when clicking outside
document.addEventListener('click', function (event) {
// Only close if click is outside any .dropdown
if (!event.target.closest('.dropdown')) {
document.querySelectorAll('.dropdown-menu.show').forEach(menu => {
menu.classList.remove('show');
});
}
});
// Close modals when clicking outside
document.addEventListener('click', function (event) {
if (event.target.classList.contains('modal')) {
event.target.classList.remove('show');
}
});
});

View File

@ -1,51 +0,0 @@
<% if (forms && forms.length > 0) { %>
<table style="width: 100%; border-collapse: collapse;">
<thead>
<tr style="background-color: #eee;">
<th style="padding: 0.5rem; border: 1px solid #ddd; text-align: left;">Form Name</th>
<th style="padding: 0.5rem; border: 1px solid #ddd; text-align: left;">Submissions</th>
<th style="padding: 0.5rem; border: 1px solid #ddd; text-align: left;">Endpoint URL</th>
<th style="padding: 0.5rem; border: 1px solid #ddd; text-align: left;">Created Date</th>
<th style="padding: 0.5rem; border: 1px solid #ddd; text-align: left;">Status</th>
<th style="padding: 0.5rem; border: 1px solid #ddd; text-align: left;">Actions</th>
</tr>
</thead>
<tbody>
<% forms.forEach(form => { %>
<tr>
<td style="padding: 0.5rem; border: 1px solid #ddd;"><%= form.name %></td>
<td style="padding: 0.5rem; border: 1px solid #ddd;"><%= form.submission_count %></td>
<td style="padding: 0.5rem; border: 1px solid #ddd;">
<code><%= appUrl %>/submit/<%= form.uuid %></code>
<button onclick="copyToClipboard('<%= appUrl %>/submit/<%= form.uuid %>')">Copy</button>
</td>
<td style="padding: 0.5rem; border: 1px solid #ddd;"><%= new Date(form.created_at).toLocaleDateString() %></td>
<td style="padding: 0.5rem; border: 1px solid #ddd;"><%= form.is_archived ? 'Archived' : 'Active' %></td>
<td style="padding: 0.5rem; border: 1px solid #ddd;">
<a href="/dashboard/submissions/<%= form.uuid %>" class="btn btn-secondary">View Submissions</a>
<a href="/dashboard/forms/<%= form.uuid %>/settings" class="btn btn-secondary">Settings</a>
<!-- Add Archive/Delete buttons/forms here -->
<form action="/dashboard/forms/<%= form.is_archived ? 'unarchive' : 'archive' %>/<%= form.uuid %>" method="POST" style="display: inline;">
<button type="submit" class="btn btn-secondary"><%= form.is_archived ? 'Unarchive' : 'Archive' %></button>
</form>
<form action="/dashboard/forms/delete/<%= form.uuid %>" method="POST" style="display: inline;" onsubmit="return confirm('Are you sure you want to permanently delete this form and all its submissions?');">
<button type_submit" class="btn btn-danger">Delete</button>
</form>
</td>
</tr>
<% }); %>
</tbody>
</table>
<% } else { %>
<p>You haven't created any forms yet. <a href="/dashboard/create-form">Create one now!</a></p>
<% } %>
<script>
function copyToClipboard(text) {
navigator.clipboard.writeText(text).then(function() {
alert('Endpoint URL copied to clipboard!');
}, function(err) {
alert('Could not copy text: ', err);
});
}
</script>

View File

@ -1,176 +0,0 @@
<div class="header-bar">
<h1>
Submissions for <span style="font-weight: normal"><%= formName %></span>
</h1>
<div>
<a href="/dashboard" class="btn btn-secondary">Back to My Forms</a>
<a href="/dashboard/submissions/<%= formUuid %>/export" class="btn"
>Export CSV</a
>
</div>
</div>
<% if (submissions.length === 0) { %>
<div
style="
padding: 1rem;
background-color: #e9ecef;
border-radius: 0.25rem;
text-align: center;
">
No submissions yet for this form.
</div>
<% } else { %> <% submissions.forEach(submission => { %>
<div
style="
border: 1px solid #ddd;
border-radius: 0.25rem;
margin-bottom: 1rem;
background-color: white;
">
<div style="padding: 1rem">
<div
style="
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 0.5rem;
">
<div>
<h6 style="margin: 0 0 0.25rem 0; color: #6c757d">
Submitted: <%= new Date(submission.submitted_at).toLocaleString() %>
</h6>
<h6 style="margin: 0; color: #6c757d">
IP: <%= submission.ip_address %>
</h6>
</div>
<form
action="/dashboard/submissions/delete/<%= submission.id %>"
method="POST"
style="display: inline"
onsubmit="return confirm('Are you sure you want to delete this submission?');">
<input
type="hidden"
name="formUuidForRedirect"
value="<%= formUuid %>" />
<button
type="submit"
class="btn btn-secondary"
style="background-color: #dc3545; border-color: #dc3545">
Delete
</button>
</form>
</div>
<hr style="margin-top: 0.5rem; margin-bottom: 1rem" />
<div>
<% let data = {}; try { data = JSON.parse(submission.data); } catch (e) {
console.error("Failed to parse submission data:", submission.data); data =
{ "error": "Could not parse submission data" }; } %> <%
Object.entries(data).forEach(([key, value]) => { %> <% if (key !==
'honeypot_field' && key !== '_thankyou') { %>
<div style="margin-bottom: 0.5rem">
<strong
style="display: inline-block; min-width: 120px; vertical-align: top"
><%= key %>:</strong
>
<span style="white-space: pre-wrap; word-break: break-all"
><%= value %></span
>
</div>
<% } %> <% }); %>
</div>
</div>
</div>
<% }); %>
<!-- Pagination -->
<% if (pagination.totalPages > 1) { %>
<nav
aria-label="Submissions pagination"
style="margin-top: 2rem; margin-bottom: 2rem">
<ul
style="
display: flex;
justify-content: center;
padding-left: 0;
list-style: none;
">
<% if (pagination.currentPage > 1) { %>
<li style="margin: 0 0.25rem">
<a
href="/dashboard/submissions/<%= formUuid %>?page=<%= pagination.currentPage - 1 %>&limit=<%= pagination.limit %>"
class="btn btn-secondary"
>Previous</a
>
</li>
<% } else { %>
<li style="margin: 0 0.25rem">
<span
class="btn btn-secondary"
style="opacity: 0.65; pointer-events: none"
>Previous</span
>
</li>
<% } %> <% const maxPagesToShow = 5; let startPage = Math.max(1,
pagination.currentPage - Math.floor(maxPagesToShow / 2)); let endPage =
Math.min(pagination.totalPages, startPage + maxPagesToShow - 1); if (endPage
- startPage + 1 < maxPagesToShow) { startPage = Math.max(1, endPage -
maxPagesToShow + 1); } %> <% if (startPage > 1) { %>
<li style="margin: 0 0.25rem">
<a
href="/dashboard/submissions/<%= formUuid %>?page=1&limit=<%= pagination.limit %>"
class="btn btn-secondary"
>1</a
>
</li>
<% if (startPage > 2) { %>
<li style="margin: 0 0.25rem">
<span class="btn btn-secondary" style="pointer-events: none">...</span>
</li>
<% } %> <% } %> <% for(let i = startPage; i <= endPage; i++) { %>
<li style="margin: 0 0.25rem">
<a
href="/dashboard/submissions/<%= formUuid %>?page=<%= i %>&limit=<%= pagination.limit %>"
class="btn <%= i === pagination.currentPage ? '' : 'btn-secondary' %>"
><%= i %></a
>
</li>
<% } %> <% if (endPage < pagination.totalPages) { %> <% if (endPage <
pagination.totalPages - 1) { %>
<li style="margin: 0 0.25rem">
<span class="btn btn-secondary" style="pointer-events: none">...</span>
</li>
<% } %>
<li style="margin: 0 0.25rem">
<a
href="/dashboard/submissions/<%= formUuid %>?page=<%= pagination.totalPages %>&limit=<%= pagination.limit %>"
class="btn btn-secondary"
><%= pagination.totalPages %></a
>
</li>
<% } %> <% if (pagination.currentPage < pagination.totalPages) { %>
<li style="margin: 0 0.25rem">
<a
href="/dashboard/submissions/<%= formUuid %>?page=<%= pagination.currentPage + 1 %>&limit=<%= pagination.limit %>"
class="btn btn-secondary"
>Next</a
>
</li>
<% } else { %>
<li style="margin: 0 0.25rem">
<span
class="btn btn-secondary"
style="opacity: 0.65; pointer-events: none"
>Next</span
>
</li>
<% } %>
</ul>
</nav>
<div style="text-align: center; color: #6c757d">
Showing <%= (pagination.currentPage - 1) * pagination.limit + 1 %> to <%=
Math.min(pagination.currentPage * pagination.limit,
pagination.totalSubmissions) %> of <%= pagination.totalSubmissions %>
submissions
</div>
<% } %> <% } %>

View File

@ -1,313 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Register - Formies</title>
<style>
body {
font-family: sans-serif;
margin: 0;
background-color: #f4f7f6;
min-height: 100vh;
display: flex;
flex-direction: column;
}
.navbar {
background-color: #333;
color: white;
padding: 1rem;
display: flex;
justify-content: space-between;
align-items: center;
}
.navbar a {
color: white;
text-decoration: none;
}
.navbar .logo {
font-size: 1.5rem;
font-weight: bold;
}
.container {
flex: 1;
display: flex;
justify-content: center;
align-items: center;
padding: 2rem;
}
.register-card {
background-color: white;
padding: 2rem;
border-radius: 0.5rem;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
width: 100%;
max-width: 400px;
}
.register-header {
text-align: center;
margin-bottom: 2rem;
}
.register-header h1 {
margin: 0;
color: #333;
font-size: 1.8rem;
}
.form-group {
margin-bottom: 1.5rem;
}
.form-group label {
display: block;
margin-bottom: 0.5rem;
color: #555;
}
.form-group input {
width: 100%;
padding: 0.75rem;
border: 1px solid #ddd;
border-radius: 0.25rem;
font-size: 1rem;
box-sizing: border-box;
}
.form-group input:focus {
outline: none;
border-color: #007bff;
box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.25);
}
.btn {
background-color: #007bff;
color: white;
padding: 0.75rem 1.5rem;
border: none;
border-radius: 0.25rem;
font-size: 1rem;
cursor: pointer;
width: 100%;
transition: background-color 0.2s;
}
.btn:hover {
background-color: #0056b3;
}
.error-message {
background-color: #f8d7da;
color: #721c24;
padding: 0.75rem;
border-radius: 0.25rem;
margin-bottom: 1rem;
text-align: center;
}
.success-message {
background-color: #d4edda;
color: #155724;
padding: 0.75rem;
border-radius: 0.25rem;
margin-bottom: 1rem;
text-align: center;
}
.links {
text-align: center;
margin-top: 1rem;
}
.links a {
color: #007bff;
text-decoration: none;
font-size: 0.9rem;
}
.links a:hover {
text-decoration: underline;
}
.password-requirements {
font-size: 0.8rem;
color: #666;
margin-top: 0.5rem;
}
</style>
</head>
<body>
<nav class="navbar">
<div class="logo"><a href="/">Formies</a></div>
<div>
<a href="/login">Login</a>
</div>
</nav>
<div class="container">
<div class="register-card">
<div class="register-header">
<h1>Create Account</h1>
<p>Join Formies to start creating forms</p>
</div>
<% if (typeof error !== 'undefined' && error) { %>
<div class="error-message"><%= error %></div>
<% } %>
<% if (typeof success !== 'undefined' && success) { %>
<div class="success-message"><%= success %></div>
<% } %>
<form id="registerForm">
<div class="form-group">
<label for="firstName">First Name</label>
<input
type="text"
id="firstName"
name="first_name"
required
placeholder="Enter your first name"
value="<%= typeof first_name !== 'undefined' ? first_name : '' %>"
/>
</div>
<div class="form-group">
<label for="lastName">Last Name</label>
<input
type="text"
id="lastName"
name="last_name"
required
placeholder="Enter your last name"
value="<%= typeof last_name !== 'undefined' ? last_name : '' %>"
/>
</div>
<div class="form-group">
<label for="email">Email Address</label>
<input
type="email"
id="email"
name="email"
required
placeholder="Enter your email"
value="<%= typeof email !== 'undefined' ? email : '' %>"
/>
</div>
<div class="form-group">
<label for="password">Password</label>
<input
type="password"
id="password"
name="password"
required
placeholder="Create a password"
/>
<div class="password-requirements">
Password must be at least 8 characters long and include:
<ul>
<li>At least one uppercase letter</li>
<li>At least one lowercase letter</li>
<li>At least one number</li>
<li>At least one special character</li>
</ul>
</div>
</div>
<div class="form-group">
<label for="confirmPassword">Confirm Password</label>
<input
type="password"
id="confirmPassword"
name="confirmPassword"
required
placeholder="Confirm your password"
/>
</div>
<button type="submit" class="btn">Create Account</button>
<div class="links">
<a href="/login">Already have an account? Sign in</a>
</div>
</form>
</div>
</div>
<script>
document.getElementById('registerForm').addEventListener('submit', async (e) => {
e.preventDefault();
const firstName = document.getElementById('firstName').value;
const lastName = document.getElementById('lastName').value;
const email = document.getElementById('email').value;
const password = document.getElementById('password').value;
const confirmPassword = document.getElementById('confirmPassword').value;
// Client-side validation
if (password !== confirmPassword) {
showError('Passwords do not match');
return;
}
// Password strength validation
const passwordRegex = /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]{8,}$/;
if (!passwordRegex.test(password)) {
showError('Password does not meet the requirements');
return;
}
try {
const response = await fetch('/api/auth/register', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
first_name: firstName,
last_name: lastName,
email,
password
})
});
const data = await response.json();
if (response.ok) {
// Show success message and redirect to login
showSuccess('Registration successful! Please check your email to verify your account.');
setTimeout(() => {
window.location.href = '/login?success=Registration successful! Please check your email to verify your account.';
}, 3000);
} else {
showError(data.message || 'Registration failed');
}
} catch (error) {
console.error('Registration error:', error);
showError('An error occurred. Please try again.');
}
});
function showError(message) {
const errorDiv = document.createElement('div');
errorDiv.className = 'error-message';
errorDiv.textContent = message;
const existingError = document.querySelector('.error-message');
if (existingError) {
existingError.remove();
}
document.querySelector('.register-card').insertBefore(
errorDiv,
document.querySelector('form')
);
}
function showSuccess(message) {
const successDiv = document.createElement('div');
successDiv.className = 'success-message';
successDiv.textContent = message;
const existingSuccess = document.querySelector('.success-message');
if (existingSuccess) {
existingSuccess.remove();
}
document.querySelector('.register-card').insertBefore(
successDiv,
document.querySelector('form')
);
}
</script>
</body>
</html>

177
views/submissions.ejs Normal file
View File

@ -0,0 +1,177 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Submissions - <%= formName %>
</title>
<link rel="stylesheet" href="/global.css">
</head>
<body>
<a href="#main-content" class="skip-link">Skip to main content</a>
<div class="container">
<main id="main-content">
<div class="dashboard-header">
<h1 class="dashboard-title">Submissions for <%= formName %>
</h1>
<div>
<a href="/admin" class="button button-secondary me-2">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
aria-hidden="true" focusable="false">
<line x1="19" y1="12" x2="5" y2="12"></line>
<polyline points="12 19 5 12 12 5"></polyline>
</svg>
Back to Forms
</a>
<a href="/admin/submissions/<%= formUuid %>/export" class="button">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
aria-hidden="true" focusable="false">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
<polyline points="7 10 12 15 17 10"></polyline>
<line x1="12" y1="15" x2="12" y2="3"></line>
</svg>
Export CSV
</a>
<form action="/admin/clear-submissions/<%= formUuid %>" method="POST"
style="display: inline-block; margin-left: 0.5rem;">
<button type="submit" class="button button-danger"
onclick="return confirm('Are you sure you want to delete ALL submissions for this form? This action cannot be undone.')">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24"
fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"
stroke-linejoin="round" aria-hidden="true" focusable="false">
<polyline points="3 6 5 6 21 6"></polyline>
<path
d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2">
</path>
<line x1="10" y1="11" x2="10" y2="17"></line>
<line x1="14" y1="11" x2="14" y2="17"></line>
</svg>
Clear All Submissions
</button>
</form>
</div>
</div>
<% if (submissions.length===0) { %>
<div class="alert-info-custom">No submissions yet for this form.</div>
<% } else { %>
<div class="submissions-table-wrapper">
<table class="submissions-table">
<caption class="visually-hidden">List of submissions for the form named <%= formName %>.
</caption>
<thead>
<tr>
<th scope="col">Submitted At</th>
<th scope="col">IP Address</th>
<th scope="col">Data</th>
<th scope="col" style="text-align: right;">Actions</th>
</tr>
</thead>
<tbody>
<% submissions.forEach(submission=> { %>
<tr>
<td>
<%= new Date(submission.submitted_at).toLocaleString() %>
</td>
<td>
<%= submission.ip_address %>
</td>
<td>
<% const data=JSON.parse(submission.data); %>
<% Object.entries(data).forEach(([key, value])=> { %>
<% if (key !=='honeypot_field' && key !=='_thankyou' ) { %>
<div class="submission-data-item">
<strong>
<%= key %>:
</strong>
<span>
<%= typeof value==='object' ? JSON.stringify(value) :
value %>
</span>
</div>
<% } %>
<% }); %>
</td>
<td>
<div class="table-actions">
<form action="/admin/delete-submission/<%= submission.id %>"
method="POST" style="display: inline;">
<button type="submit" class="table-action delete"
title="Delete Submission"
onclick="return confirm('Are you sure you want to delete this submission?')"
aria-label="Delete submission from <%= submission.ip_address %> at <%= new Date(submission.submitted_at).toLocaleString() %>">
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18"
viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2" stroke-linecap="round"
stroke-linejoin="round" aria-hidden="true"
focusable="false">
<polyline points="3 6 5 6 21 6"></polyline>
<path
d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2">
</path>
<line x1="10" y1="11" x2="10" y2="17"></line>
<line x1="14" y1="11" x2="14" y2="17"></line>
</svg>
</button>
</form>
</div>
</td>
</tr>
<% }); %>
</tbody>
</table>
</div>
<!-- Pagination -->
<% if (pagination.totalPages> 1) { %>
<nav aria-label="Submissions pagination">
<ul class="pagination">
<% if (pagination.currentPage> 1) { %>
<li class="page-item">
<a class="page-link"
href="/admin/submissions/<%= formUuid %>?page=<%= pagination.currentPage - 1 %>&limit=<%= pagination.limit %>">Previous</a>
</li>
<% } else { %>
<li class="page-item disabled"><span class="page-link"
aria-disabled="true">Previous</span></li>
<% } %>
<% for(let i=1; i <=pagination.totalPages; i++) { %>
<li
class="page-item <%= i === pagination.currentPage ? 'active' : '' %>">
<a class="page-link"
href="/admin/submissions/<%= formUuid %>?page=<%= i %>&limit=<%= pagination.limit %>"
<% if (i===pagination.currentPage) { %>
aria-current="page" <% } %>>
<%= i %>
</a>
</li>
<% } %>
<% if (pagination.currentPage < pagination.totalPages) { %>
<li class="page-item">
<a class="page-link"
href="/admin/submissions/<%= formUuid %>?page=<%= pagination.currentPage + 1 %>&limit=<%= pagination.limit %>">Next</a>
</li>
<% } else { %>
<li class="page-item disabled"><span class="page-link"
aria-disabled="true">Next</span></li>
<% } %>
</ul>
</nav>
<div class="pagination-info" role="status" aria-live="polite">
Showing <%= (pagination.currentPage - 1) * pagination.limit + 1 %> to <%=
Math.min(pagination.currentPage * pagination.limit, pagination.totalSubmissions) %>
of <%= pagination.totalSubmissions %> submissions
</div>
<% } %>
<% } %>
</main>
</div>
<script src="/main.js"></script>
</body>
</html>